From 19135bca1cb021c773b512759a86db429d99a166 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 08:56:15 +0100 Subject: [PATCH 1/5] feat(crq): report the P95 of the simulated annual loss (#901) The redesigned dashboard quotes a worst plausible year (P95) beside the annualised exposure. The Monte Carlo already sorts its samples, so P95 is one more percentile read off them, added to the risk and portfolio distributions and to their currency-aware presentation. No input, sampling or seed changes. Signed-off-by: alex-dembele --- backend/pkg/crq/montecarlo.go | 14 ++++++++++---- backend/pkg/crq/montecarlo_test.go | 6 +++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/backend/pkg/crq/montecarlo.go b/backend/pkg/crq/montecarlo.go index 9d25f871..cf023ce3 100644 --- a/backend/pkg/crq/montecarlo.go +++ b/backend/pkg/crq/montecarlo.go @@ -80,9 +80,11 @@ type SimulationInput struct { // LossDistribution is the Monte Carlo output for one risk (all XAF). The // percentiles are the headline; the run metadata makes the figure explainable. type LossDistribution struct { - P10 float64 `json:"p10"` - P50 float64 `json:"p50"` // median — the figure to lead with - P90 float64 `json:"p90"` + P10 float64 `json:"p10"` + P50 float64 `json:"p50"` // median — the figure to lead with + P90 float64 `json:"p90"` + // P95 is the "worst plausible year" the dashboard quotes beside the ALE. + P95 float64 `json:"p95"` Mean float64 `json:"mean"` Min float64 `json:"min"` Max float64 `json:"max"` @@ -116,7 +118,7 @@ func Simulate(in SimulationInput) LossDistribution { // Degenerate magnitude (no spread) → a deterministic point at LEF × mode. if lm.Max <= lm.Min { v := round2(lef * lm.Mode) - dist.P10, dist.P50, dist.P90, dist.Mean, dist.Min, dist.Max = v, v, v, v, v, v + dist.P10, dist.P50, dist.P90, dist.P95, dist.Mean, dist.Min, dist.Max = v, v, v, v, v, v, v return dist } @@ -137,6 +139,7 @@ func Simulate(in SimulationInput) LossDistribution { dist.P10 = round2(percentile(samples, 10)) dist.P50 = round2(percentile(samples, 50)) dist.P90 = round2(percentile(samples, 90)) + dist.P95 = round2(percentile(samples, 95)) dist.Mean = round2(sum / float64(iters)) dist.Min = round2(samples[0]) dist.Max = round2(samples[len(samples)-1]) @@ -202,6 +205,7 @@ func SimulatePortfolio(inputs []SimulationInput, iterations int, seed int64) Los dist.P10 = round2(percentile(samples, 10)) dist.P50 = round2(percentile(samples, 50)) dist.P90 = round2(percentile(samples, 90)) + dist.P95 = round2(percentile(samples, 95)) dist.Mean = round2(sum / float64(iterations)) dist.Min = round2(samples[0]) dist.Max = round2(samples[len(samples)-1]) @@ -325,6 +329,7 @@ type DistributionAmounts struct { P10 Amount `json:"p10"` P50 Amount `json:"p50"` P90 Amount `json:"p90"` + P95 Amount `json:"p95"` Mean Amount `json:"mean"` Iterations int `json:"iterations"` @@ -339,6 +344,7 @@ func (p Presenter) Present(d LossDistribution) DistributionAmounts { P10: p.Amount(d.P10), P50: p.Amount(d.P50), P90: p.Amount(d.P90), + P95: p.Amount(d.P95), Mean: p.Amount(d.Mean), Iterations: d.Iterations, Seed: d.Seed, diff --git a/backend/pkg/crq/montecarlo_test.go b/backend/pkg/crq/montecarlo_test.go index 70bec7ca..5383c505 100644 --- a/backend/pkg/crq/montecarlo_test.go +++ b/backend/pkg/crq/montecarlo_test.go @@ -53,6 +53,10 @@ func TestSimulate_Bounds(t *testing.T) { if d.P10 < d.Min-1 || d.P90 > d.Max+1 { t.Fatalf("percentiles outside observed range") } + // P95 sits between P90 and the worst observed year. + if !(d.P90 <= d.P95 && d.P95 <= d.Max) { + t.Fatalf("P95 out of [P90, max]: P90=%.0f P95=%.0f max=%.0f", d.P90, d.P95, d.Max) + } if d.FormulaVersion != FormulaVersion { t.Fatalf("formula version not stamped: %q", d.FormulaVersion) } @@ -119,7 +123,7 @@ func TestSimulatePortfolio(t *testing.T) { if a != b { t.Fatalf("portfolio not deterministic") } - if !(a.P10 <= a.P50 && a.P50 <= a.P90) { + if !(a.P10 <= a.P50 && a.P50 <= a.P90 && a.P90 <= a.P95) { t.Fatalf("portfolio percentiles not ordered: %+v", a) } var want float64 From dc421d31e3b96552046579daf3a4802cd289cfdb Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 09:03:44 +0100 Subject: [PATCH 2/5] feat(score): keep a daily history of the tenant exposure score (#901) The tenant score is computed live and nothing kept its past values, so the redesigned dashboard had no way to draw its 12-month line or say how far the score moved in 30 days. - tenant_score_snapshots (migration 0069): one row per tenant per UTC day, a later computation on the same day replaces it. Unmeasured days store nothing and show as gaps, never as zero. - GET /score/history?months=12: the month-by-month closing value, today's value and the 30-day delta (null until a snapshot is 30 days old). It records today first, so the line always ends on the live figure. - A sweep records every tenant every six hours, so a quiet tenant's line stays continuous. The formula is untouched; this stores what /score already returns. Session-sufficient like /score, declared in the authz and isolation registries with its cross-tenant tests. Signed-off-by: alex-dembele --- backend/cmd/server/main.go | 11 +- backend/cmd/server/schema.go | 2 + backend/cmd/server/score_wiring.go | 9 +- backend/internal/application/score/history.go | 163 +++++++++++++++++ .../application/score/history_test.go | 166 ++++++++++++++++++ backend/internal/domain/score_snapshot.go | 41 +++++ .../handler/authz_route_coverage_test.go | 3 + backend/internal/handler/score_handler.go | 28 ++- .../gorm_score_snapshot_repository.go | 63 +++++++ .../gorm_score_snapshot_repository_test.go | 79 +++++++++ .../workers/score_snapshot_worker.go | 85 +++++++++ .../workers/score_snapshot_worker_test.go | 63 +++++++ .../internal/security/isolation/registry.go | 2 + .../0069_tenant_score_snapshots.down.sql | 1 + migrations/0069_tenant_score_snapshots.up.sql | 21 +++ 15 files changed, 732 insertions(+), 5 deletions(-) create mode 100644 backend/internal/application/score/history.go create mode 100644 backend/internal/application/score/history_test.go create mode 100644 backend/internal/domain/score_snapshot.go create mode 100644 backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go create mode 100644 backend/internal/infrastructure/repository/gorm_score_snapshot_repository_test.go create mode 100644 backend/internal/infrastructure/workers/score_snapshot_worker.go create mode 100644 backend/internal/infrastructure/workers/score_snapshot_worker_test.go create mode 100644 migrations/0069_tenant_score_snapshots.down.sql create mode 100644 migrations/0069_tenant_score_snapshots.up.sql diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9c2e3447..5bfdf708 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -2918,17 +2918,26 @@ func main() { // The calculation itself lives in internal/domain/scoring and nowhere else: // no formula, no threshold and no band mapping exists in the frontend. // ========================================================================= - scoreHandler := newScoreHandler( + scoreSnapshotRepo := repository.NewGormScoreSnapshotRepository(database.DB) + scoreHandler, scoreHistoryUC := newScoreHandler( riskRepo, assetRepo, vulnRepo, repository.NewGormMitigationRepository(database.DB), getGapAnalysisUC, incidentService, + scoreSnapshotRepo, ) + // One snapshot per tenant per day, so the dashboard can draw the year and + // the 30-day movement even for a tenant nobody opened that day (#901). + go workers.NewScoreSnapshotWorker(scoreSnapshotRepo, scoreHistoryUC, zeroLogger). + Start(context.Background()) // Readable by any authenticated member: a posture score is what the product // is FOR, and the underlying detail is already gated per source. // Tenant-scoped score figures; every business role preset holds risks:read, // and preview persists nothing. Session-sufficient (#529). protected.Get("/score", scoreHandler.GetScore) protected.Get("/score/model", scoreHandler.GetScoreModel) + // Month-by-month tenant score and its 30-day delta (#901). Same audience as + // /score: the history of a figure every member can already read. + protected.Get("/score/history", scoreHandler.GetScoreHistory) // Live preview for forms (debounced client-side). Persists nothing, so it // carries no write permission. protected.Post("/score/preview", scoreHandler.PreviewScore) diff --git a/backend/cmd/server/schema.go b/backend/cmd/server/schema.go index fdbf84cd..9da46e5d 100644 --- a/backend/cmd/server/schema.go +++ b/backend/cmd/server/schema.go @@ -27,6 +27,8 @@ func schemaModels() []interface{} { // invitations model was never in AutoMigrate, so the table never existed // and the invitation code that referenced it could not have run. &domain.Invitation{}, + // Daily tenant exposure score history (#901, migration 0069). + &domain.TenantScoreSnapshot{}, &coreauth.RefreshToken{}, // L2/L4/L5/L7 auth tables. Previously absent from AutoMigrate, so the whole // feature set (MFA setup/challenge, PAT auth, SSO account linking, and the diff --git a/backend/cmd/server/score_wiring.go b/backend/cmd/server/score_wiring.go index 34717d0e..3473c516 100644 --- a/backend/cmd/server/score_wiring.go +++ b/backend/cmd/server/score_wiring.go @@ -54,7 +54,8 @@ func (a incidentPressureAdapter) OpenIncidentCounts(_ context.Context, tenantID // newScoreHandler assembles the ONE score endpoint from the already-constructed, // tenant-scoped sources. Every source is optional in the use case, so a missing -// one degrades its own factor instead of failing the request. +// one degrades its own factor instead of failing the request. It also returns +// the history use case, which the daily snapshot worker shares (#901). func newScoreHandler( riskRepo *repository.GormRiskRepository, assetRepo *repository.GormAssetRepository, @@ -62,7 +63,8 @@ func newScoreHandler( mitigationRepo repository.MitigationRepository, gapUC *compliance.GetGapAnalysisUseCase, incidentSvc *service.IncidentService, -) *handlers.ScoreHandler { + snapshots *repository.GormScoreSnapshotRepository, +) (*handlers.ScoreHandler, *score.HistoryUseCase) { uc := score.New(). WithRiskCounts(riskRepo). WithRisk(riskRepo). @@ -73,5 +75,6 @@ func newScoreHandler( WithMitigations(mitigationRepo). WithCompliance(complianceCoverageAdapter{gaps: gapUC}). WithIncidents(incidentPressureAdapter{svc: incidentSvc}) - return handlers.NewScoreHandler(uc) + history := score.NewHistory(uc, snapshots) + return handlers.NewScoreHandler(uc).WithHistory(history), history } diff --git a/backend/internal/application/score/history.go b/backend/internal/application/score/history.go new file mode 100644 index 00000000..3fdcf6cf --- /dev/null +++ b/backend/internal/application/score/history.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package score + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/domain/scoring" +) + +// SnapshotStore persists the daily tenant score (#901). +type SnapshotStore interface { + Upsert(ctx context.Context, s *domain.TenantScoreSnapshot) error + ListSince(ctx context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) +} + +// HistoryPoint is one month of the tenant score: the last snapshot taken in +// that month. A month with no snapshot is absent, never zero. +type HistoryPoint struct { + Month string `json:"month"` // YYYY-MM + Day time.Time `json:"day"` + Value float64 `json:"value"` + Band string `json:"band"` +} + +// History is what GET /score/history returns. +type History struct { + Months int `json:"months"` + Points []HistoryPoint `json:"points"` + // Current is today's measured value, nil when the tenant has nothing to + // score yet. + Current *float64 `json:"current"` + // Delta30d is today's value minus the value 30 days ago (the last snapshot + // on or before that day). Nil when no snapshot is that old: a delta needs + // two real measurements. + Delta30d *float64 `json:"delta_30d"` + // Since is the oldest snapshot kept for the tenant within the window, so + // the UI can say how far back the line really goes. + Since *time.Time `json:"since"` +} + +// HistoryUseCase records and reads the daily tenant score history. +type HistoryUseCase struct { + score *UseCase + store SnapshotStore + now func() time.Time +} + +func NewHistory(score *UseCase, store SnapshotStore) *HistoryUseCase { + return &HistoryUseCase{score: score, store: store, now: func() time.Time { return time.Now().UTC() }} +} + +// Record computes the tenant score and stores it as today's snapshot. An +// unmeasured score stores nothing. +func (h *HistoryUseCase) Record(ctx context.Context, tenantID uuid.UUID) (*scoring.Result, error) { + if tenantID == uuid.Nil { + return nil, domain.NewValidationError("tenant is required") + } + res, err := h.score.Execute(ctx, tenantID, scoring.ScopeTenant, uuid.Nil) + if err != nil { + return nil, err + } + if res == nil || !res.Measured { + return res, nil + } + snap := &domain.TenantScoreSnapshot{ + TenantID: tenantID, + Day: domain.SnapshotDay(h.now()), + Value: res.Value, + Band: string(res.Band), + FormulaVersion: res.FormulaVersion, + } + if err := h.store.Upsert(ctx, snap); err != nil { + return res, err + } + return res, nil +} + +// Execute returns the monthly history over the last `months` months (1–24), +// recording today's snapshot first so the line always ends on the live value. +func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months int) (*History, error) { + if tenantID == uuid.Nil { + return nil, domain.NewValidationError("tenant is required") + } + if months < 1 || months > 24 { + return nil, domain.NewValidationError("months must be between 1 and 24") + } + if _, err := h.Record(ctx, tenantID); err != nil { + return nil, err + } + + today := domain.SnapshotDay(h.now()) + start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0) + // Reach 30 days further back than the window when needed, so the delta can + // be computed even for a one-month window. + from := start + if d := today.AddDate(0, 0, -30); d.Before(from) { + from = d + } + rows, err := h.store.ListSince(ctx, tenantID, from) + if err != nil { + return nil, err + } + + out := &History{Months: months, Points: []HistoryPoint{}} + byMonth := map[string]HistoryPoint{} + var order []string + for _, r := range rows { + day := domain.SnapshotDay(r.Day) + if day.Before(start) { + continue + } + key := day.Format("2006-01") + if _, seen := byMonth[key]; !seen { + order = append(order, key) + } + // Rows come oldest first, so the last write wins: the month's closing value. + byMonth[key] = HistoryPoint{Month: key, Day: day, Value: r.Value, Band: r.Band} + if out.Since == nil { + d := day + out.Since = &d + } + } + for _, k := range order { + out.Points = append(out.Points, byMonth[k]) + } + + var current *domain.TenantScoreSnapshot + var past *domain.TenantScoreSnapshot + cutoff := today.AddDate(0, 0, -30) + for i := range rows { + day := domain.SnapshotDay(rows[i].Day) + if day.Equal(today) { + current = &rows[i] + } + if !day.After(cutoff) { + past = &rows[i] + } + } + if current != nil { + v := current.Value + out.Current = &v + if past != nil { + d := roundTenth(current.Value - past.Value) + out.Delta30d = &d + } + } + return out, nil +} + +func roundTenth(v float64) float64 { + if v < 0 { + return -float64(int64(-v*10+0.5)) / 10 + } + return float64(int64(v*10+0.5)) / 10 +} diff --git a/backend/internal/application/score/history_test.go b/backend/internal/application/score/history_test.go new file mode 100644 index 00000000..3f241921 --- /dev/null +++ b/backend/internal/application/score/history_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package score + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opendefender/openrisk/internal/domain" +) + +// memSnapshots is an in-memory SnapshotStore keyed like the real table. +type memSnapshots struct { + rows map[string]domain.TenantScoreSnapshot + upsertErr error +} + +func newMemSnapshots() *memSnapshots { + return &memSnapshots{rows: map[string]domain.TenantScoreSnapshot{}} +} + +func (m *memSnapshots) key(t uuid.UUID, d time.Time) string { + return t.String() + "|" + domain.SnapshotDay(d).Format("2006-01-02") +} + +func (m *memSnapshots) Upsert(_ context.Context, s *domain.TenantScoreSnapshot) error { + if m.upsertErr != nil { + return m.upsertErr + } + m.rows[m.key(s.TenantID, s.Day)] = *s + return nil +} + +func (m *memSnapshots) ListSince(_ context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) { + var out []domain.TenantScoreSnapshot + for _, r := range m.rows { + if r.TenantID == tenantID && !domain.SnapshotDay(r.Day).Before(domain.SnapshotDay(since)) { + out = append(out, r) + } + } + // oldest first, as the repository orders + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].Day.Before(out[i].Day) { + out[i], out[j] = out[j], out[i] + } + } + } + return out, nil +} + +func (m *memSnapshots) put(t uuid.UUID, day string, v float64) { + d, _ := time.Parse("2006-01-02", day) + m.rows[m.key(t, d)] = domain.TenantScoreSnapshot{TenantID: t, Day: d, Value: v, Band: "medium"} +} + +// measuredUseCase scores a tenant with two critical and three high risks, which +// is enough data for a measured result. +func measuredUseCase() *UseCase { + rc := &stubRiskCounts{counts: map[string]int{"critical": 2, "high": 3, "medium": 1}} + return New().WithRiskCounts(rc) +} + +func fixedNow(day string) func() time.Time { + d, _ := time.Parse("2006-01-02", day) + return func() time.Time { return d.Add(15 * time.Hour) } +} + +func TestScoreHistory_Success(t *testing.T) { + store := newMemSnapshots() + tenant := uuid.New() + // Two readings in August (the later one closes the month), one in + // September exactly 30 days before "today". + store.put(tenant, "2026-08-03", 70) + store.put(tenant, "2026-08-28", 66) + store.put(tenant, "2026-09-08", 61) + + uc := measuredUseCase() + uc.now = fixedNow("2026-10-08") + h := NewHistory(uc, store) + h.now = fixedNow("2026-10-08") + + out, err := h.Execute(context.Background(), tenant, 12) + require.NoError(t, err) + require.NotNil(t, out.Current, "today is recorded before the history is read") + + months := make([]string, 0, len(out.Points)) + for _, p := range out.Points { + months = append(months, p.Month) + } + assert.Equal(t, []string{"2026-08", "2026-09", "2026-10"}, months) + assert.Equal(t, 66.0, out.Points[0].Value, "a month reads as its last snapshot") + require.NotNil(t, out.Delta30d) + assert.InDelta(t, *out.Current-61, *out.Delta30d, 0.051) + require.NotNil(t, out.Since) + assert.Equal(t, "2026-08-03", out.Since.Format("2006-01-02")) +} + +// A tenant with no history yet still gets today's point, but no delta: a +// movement needs two real measurements. +func TestScoreHistory_NotFound(t *testing.T) { + store := newMemSnapshots() + uc := measuredUseCase() + h := NewHistory(uc, store) + + out, err := h.Execute(context.Background(), uuid.New(), 12) + require.NoError(t, err) + assert.Len(t, out.Points, 1) + assert.NotNil(t, out.Current) + assert.Nil(t, out.Delta30d) +} + +// An unmeasured tenant stores nothing and reports no current value. +func TestScoreHistory_UnmeasuredStoresNothing(t *testing.T) { + store := newMemSnapshots() + uc, _, _, _, _ := emptyTenantUseCase() + h := NewHistory(uc, store) + + out, err := h.Execute(context.Background(), uuid.New(), 12) + require.NoError(t, err) + assert.Empty(t, out.Points) + assert.Nil(t, out.Current) + assert.Empty(t, store.rows) +} + +// No tenant, no history; and one tenant never reads another's snapshots. +func TestScoreHistory_Unauthorized(t *testing.T) { + store := newMemSnapshots() + h := NewHistory(measuredUseCase(), store) + + _, err := h.Execute(context.Background(), uuid.Nil, 12) + assert.True(t, errors.Is(err, domain.ErrValidation)) + + other, mine := uuid.New(), uuid.New() + store.put(other, "2026-09-01", 90) + out, err := h.Execute(context.Background(), mine, 12) + require.NoError(t, err) + for _, p := range out.Points { + assert.NotEqual(t, 90.0, p.Value, "another tenant's snapshot leaked into the history") + } +} + +func TestScoreHistory_MonthsValidation(t *testing.T) { + h := NewHistory(measuredUseCase(), newMemSnapshots()) + for _, m := range []int{0, 25, -1} { + _, err := h.Execute(context.Background(), uuid.New(), m) + assert.True(t, errors.Is(err, domain.ErrValidation), "months=%d", m) + } +} + +func TestScoreHistory_StoreFailureSurfaces(t *testing.T) { + store := newMemSnapshots() + store.upsertErr = errors.New("db down") + h := NewHistory(measuredUseCase(), store) + _, err := h.Execute(context.Background(), uuid.New(), 12) + assert.Error(t, err) +} diff --git a/backend/internal/domain/score_snapshot.go b/backend/internal/domain/score_snapshot.go new file mode 100644 index 00000000..472938f1 --- /dev/null +++ b/backend/internal/domain/score_snapshot.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// TenantScoreSnapshot is the tenant exposure score as it stood on one day +// (#901). The score itself is computed live from the register; nothing kept +// its past values, so "how did we move over the year" had no answer. One row +// per tenant per UTC day: a later computation on the same day replaces the +// value, so the row reads as the day's closing figure. +// +// Only measured scores are stored. A day with nothing to score leaves a gap, +// which the history reports as missing rather than as zero. +type TenantScoreSnapshot struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TenantID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:ux_tenant_score_snapshot_day,priority:1" json:"tenant_id"` + // Day is the UTC calendar day, stored at midnight. + Day time.Time `gorm:"type:date;not null;uniqueIndex:ux_tenant_score_snapshot_day,priority:2" json:"day"` + Value float64 `gorm:"type:double precision;not null" json:"value"` + Band string `gorm:"size:16;not null" json:"band"` + FormulaVersion string `gorm:"size:16;not null" json:"formula_version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName pins the table so the SQL migration and AutoMigrate agree. +func (TenantScoreSnapshot) TableName() string { return "tenant_score_snapshots" } + +// SnapshotDay truncates a time to the UTC calendar day a snapshot is keyed on. +func SnapshotDay(t time.Time) time.Time { + u := t.UTC() + return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) +} diff --git a/backend/internal/handler/authz_route_coverage_test.go b/backend/internal/handler/authz_route_coverage_test.go index 45bc54ee..afa2651d 100644 --- a/backend/internal/handler/authz_route_coverage_test.go +++ b/backend/internal/handler/authz_route_coverage_test.go @@ -145,6 +145,9 @@ var routesWithoutPermissionGuard = []string{ "GET /rbac/business-roles", "GET /score", "GET /score/model", + // #901: the month-by-month history of the same tenant score GET /score + // returns to any member. Session-sufficient for the same reason. + "GET /score/history", "GET /search", "GET /security/mfa-policy", "GET /stats", diff --git a/backend/internal/handler/score_handler.go b/backend/internal/handler/score_handler.go index fc337a9b..51978dff 100644 --- a/backend/internal/handler/score_handler.go +++ b/backend/internal/handler/score_handler.go @@ -22,7 +22,8 @@ import ( // ScoreHandler serves the canonical score. type ScoreHandler struct { - uc *score.UseCase + uc *score.UseCase + history *score.HistoryUseCase } // NewScoreHandler wires the handler. @@ -30,6 +31,31 @@ func NewScoreHandler(uc *score.UseCase) *ScoreHandler { return &ScoreHandler{uc: uc} } +// WithHistory enables GET /score/history (#901). +func (h *ScoreHandler) WithHistory(hist *score.HistoryUseCase) *ScoreHandler { + h.history = hist + return h +} + +// GetScoreHistory returns the tenant exposure score month by month over the +// last ?months= months (default 12), with the 30-day delta. Tenant scope only: +// the history is of the organization's own score. +func (h *ScoreHandler) GetScoreHistory(c *fiber.Ctx) error { + tenantID := tenantID(c) + if tenantID == uuid.Nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + if h.history == nil { + return writeAppError(c, domain.ErrNotFound) + } + months := c.QueryInt("months", 12) + out, err := h.history.Execute(c.UserContext(), tenantID, months) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(out) +} + // GetScore GET /score?scope=tenant|risk|asset&id=… // // One endpoint for every surface: the dashboard hero, the sidebar footer, the diff --git a/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go new file mode 100644 index 00000000..7f32c628 --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/opendefender/openrisk/internal/domain" +) + +// GormScoreSnapshotRepository stores the daily tenant score history (#901). +// Every query is keyed on tenant_id. +type GormScoreSnapshotRepository struct { + db *gorm.DB +} + +func NewGormScoreSnapshotRepository(db *gorm.DB) *GormScoreSnapshotRepository { + return &GormScoreSnapshotRepository{db: db} +} + +// Upsert writes the day's snapshot, replacing an earlier one for the same +// tenant and day. +func (r *GormScoreSnapshotRepository) Upsert(ctx context.Context, s *domain.TenantScoreSnapshot) error { + if s.TenantID == uuid.Nil { + return domain.NewValidationError("tenant is required") + } + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + s.Day = domain.SnapshotDay(s.Day) + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tenant_id"}, {Name: "day"}}, + DoUpdates: clause.AssignmentColumns([]string{"value", "band", "formula_version", "updated_at"}), + }).Create(s).Error +} + +// ListSince returns the tenant's snapshots from `since` (inclusive), oldest +// first. +func (r *GormScoreSnapshotRepository) ListSince(ctx context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) { + var rows []domain.TenantScoreSnapshot + err := r.db.WithContext(ctx). + Where("tenant_id = ? AND day >= ?", tenantID, domain.SnapshotDay(since)). + Order("day ASC"). + Find(&rows).Error + return rows, err +} + +// ListTenantIDs returns every organization id, for the daily snapshot sweep. +// A deleted organization's row is gone (internal/infrastructure/orgdeletion), +// so it drops out on its own. +func (r *GormScoreSnapshotRepository) ListTenantIDs(ctx context.Context) ([]uuid.UUID, error) { + var ids []uuid.UUID + err := r.db.WithContext(ctx).Model(&domain.Organization{}).Pluck("id", &ids).Error + return ids, err +} diff --git a/backend/internal/infrastructure/repository/gorm_score_snapshot_repository_test.go b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository_test.go new file mode 100644 index 00000000..f500b14c --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/testsupport/sqliteschema" +) + +func setupSnapshotRepo(t *testing.T) *GormScoreSnapshotRepository { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.Exec(`CREATE TABLE tenant_score_snapshots (id TEXT PRIMARY KEY)`).Error) + require.NoError(t, sqliteschema.Reconcile(db, "tenant_score_snapshots", &domain.TenantScoreSnapshot{})) + require.NoError(t, db.Exec(`CREATE UNIQUE INDEX ux_tenant_score_snapshot_day ON tenant_score_snapshots (tenant_id, day)`).Error) + return NewGormScoreSnapshotRepository(db) +} + +func day(s string) time.Time { + d, _ := time.Parse("2006-01-02", s) + return d +} + +// The same day written twice keeps one row with the later value. +func TestScoreSnapshot_Upsert_Success(t *testing.T) { + repo := setupSnapshotRepo(t) + ctx := context.Background() + tenant := uuid.New() + + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: tenant, Day: day("2026-10-08").Add(9 * time.Hour), Value: 40, Band: "medium", FormulaVersion: "2.2"})) + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: tenant, Day: day("2026-10-08").Add(18 * time.Hour), Value: 37, Band: "medium", FormulaVersion: "2.2"})) + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: tenant, Day: day("2026-10-09"), Value: 35, Band: "medium", FormulaVersion: "2.2"})) + + rows, err := repo.ListSince(ctx, tenant, day("2026-10-01")) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, 37.0, rows[0].Value) + assert.Equal(t, 35.0, rows[1].Value) +} + +func TestScoreSnapshot_ListSince_NotFound(t *testing.T) { + repo := setupSnapshotRepo(t) + rows, err := repo.ListSince(context.Background(), uuid.New(), day("2026-01-01")) + require.NoError(t, err) + assert.Empty(t, rows) +} + +// One tenant's history never contains another tenant's rows, and a write +// without a tenant is refused. +func TestScoreSnapshot_Unauthorized(t *testing.T) { + repo := setupSnapshotRepo(t) + ctx := context.Background() + mine, theirs := uuid.New(), uuid.New() + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: theirs, Day: day("2026-10-08"), Value: 90, Band: "critical", FormulaVersion: "2.2"})) + require.NoError(t, repo.Upsert(ctx, &domain.TenantScoreSnapshot{TenantID: mine, Day: day("2026-10-08"), Value: 20, Band: "low", FormulaVersion: "2.2"})) + + rows, err := repo.ListSince(ctx, mine, day("2026-01-01")) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, mine, rows[0].TenantID) + + err = repo.Upsert(ctx, &domain.TenantScoreSnapshot{Day: day("2026-10-08"), Value: 1, Band: "low"}) + assert.Error(t, err) +} diff --git a/backend/internal/infrastructure/workers/score_snapshot_worker.go b/backend/internal/infrastructure/workers/score_snapshot_worker.go new file mode 100644 index 00000000..497bb64e --- /dev/null +++ b/backend/internal/infrastructure/workers/score_snapshot_worker.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package workers + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + + "github.com/opendefender/openrisk/internal/domain/scoring" +) + +// TenantIDLister lists every tenant the sweep should snapshot. +type TenantIDLister interface { + ListTenantIDs(ctx context.Context) ([]uuid.UUID, error) +} + +// ScoreRecorder computes and stores one tenant's score for today. +type ScoreRecorder interface { + Record(ctx context.Context, tenantID uuid.UUID) (*scoring.Result, error) +} + +// ScoreSnapshotWorker takes the daily snapshot of every tenant's exposure +// score (#901). The history endpoint records too when someone opens the +// dashboard; this sweep is what keeps a quiet tenant's line continuous. +// Re-running on the same day replaces that day's row, so the interval only +// decides how fresh the day's closing value is. +type ScoreSnapshotWorker struct { + tenants TenantIDLister + recorder ScoreRecorder + logger zerolog.Logger + interval time.Duration +} + +func NewScoreSnapshotWorker(tenants TenantIDLister, recorder ScoreRecorder, logger zerolog.Logger) *ScoreSnapshotWorker { + return &ScoreSnapshotWorker{tenants: tenants, recorder: recorder, logger: logger, interval: 6 * time.Hour} +} + +// WithInterval overrides the sweep interval (tests). +func (w *ScoreSnapshotWorker) WithInterval(d time.Duration) *ScoreSnapshotWorker { + if d > 0 { + w.interval = d + } + return w +} + +func (w *ScoreSnapshotWorker) Start(ctx context.Context) { + t := time.NewTicker(w.interval) + defer t.Stop() + w.Sweep(ctx) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.Sweep(ctx) + } + } +} + +// Sweep records every tenant once. One tenant failing never stops the others. +func (w *ScoreSnapshotWorker) Sweep(ctx context.Context) int { + ids, err := w.tenants.ListTenantIDs(ctx) + if err != nil { + w.logger.Warn().Err(err).Msg("score snapshots: could not list tenants") + return 0 + } + recorded := 0 + for _, id := range ids { + if ctx.Err() != nil { + return recorded + } + if _, err := w.recorder.Record(ctx, id); err != nil { + w.logger.Warn().Err(err).Str("tenant_id", id.String()).Msg("score snapshots: record failed") + continue + } + recorded++ + } + return recorded +} diff --git a/backend/internal/infrastructure/workers/score_snapshot_worker_test.go b/backend/internal/infrastructure/workers/score_snapshot_worker_test.go new file mode 100644 index 00000000..b7c55b2e --- /dev/null +++ b/backend/internal/infrastructure/workers/score_snapshot_worker_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package workers + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + + "github.com/opendefender/openrisk/internal/domain/scoring" +) + +type listTenants struct { + ids []uuid.UUID + err error +} + +func (l listTenants) ListTenantIDs(context.Context) ([]uuid.UUID, error) { return l.ids, l.err } + +type recordSpy struct { + seen []uuid.UUID + fail uuid.UUID +} + +func (r *recordSpy) Record(_ context.Context, id uuid.UUID) (*scoring.Result, error) { + r.seen = append(r.seen, id) + if id == r.fail { + return nil, errors.New("boom") + } + return &scoring.Result{Measured: true}, nil +} + +// Every tenant is recorded once, each under its own id. +func TestScoreSnapshotSweep_Success(t *testing.T) { + a, b := uuid.New(), uuid.New() + spy := &recordSpy{} + n := NewScoreSnapshotWorker(listTenants{ids: []uuid.UUID{a, b}}, spy, zerolog.Nop()).Sweep(context.Background()) + assert.Equal(t, 2, n) + assert.Equal(t, []uuid.UUID{a, b}, spy.seen) +} + +// One tenant failing does not stop the sweep for the others. +func TestScoreSnapshotSweep_OneFailure(t *testing.T) { + a, b, c := uuid.New(), uuid.New(), uuid.New() + spy := &recordSpy{fail: b} + n := NewScoreSnapshotWorker(listTenants{ids: []uuid.UUID{a, b, c}}, spy, zerolog.Nop()).Sweep(context.Background()) + assert.Equal(t, 2, n) + assert.Len(t, spy.seen, 3) +} + +// No tenants listed (or the listing failed): nothing is recorded. +func TestScoreSnapshotSweep_NotFound(t *testing.T) { + spy := &recordSpy{} + assert.Equal(t, 0, NewScoreSnapshotWorker(listTenants{err: errors.New("db")}, spy, zerolog.Nop()).Sweep(context.Background())) + assert.Empty(t, spy.seen) +} diff --git a/backend/internal/security/isolation/registry.go b/backend/internal/security/isolation/registry.go index d2d51065..a47d21de 100644 --- a/backend/internal/security/isolation/registry.go +++ b/backend/internal/security/isolation/registry.go @@ -530,6 +530,8 @@ var decisions = []Decision{ "assessed, not pinned: the handler passes mwCtx.OrganizationID (uuid.Nil when absent) into GetRiskWeights. Unresolved: whether the weights store falls back to a shared default row on a miss, which would be correct behaviour but must not be reachable by passing a zero tenant. Settled by a two-tenant test that also asserts what uuid.Nil returns"}, {"/api/v1/score", Pending, "assessed, not pinned: fails CLOSED with 401 on uuid.Nil before anything is read, and the optional `id` query parameter is scoped by the `scope` vocabulary (tenant|risk|asset). Unresolved: for scope=risk and scope=asset the id names a record, so this route has a parameterised route's IDOR surface in a query string — the shape this gate does not model. Settled by a test that asks, as tenant A with scope=risk, for a risk id belonging to tenant B and asserts not-found"}, + {"/api/v1/score/history", Covered, + "#901: the tenant comes from the token (401 on uuid.Nil) and takes no id. GormScoreSnapshotRepository.ListSince keys on tenant_id; repository TestScoreSnapshot_Unauthorized seeds two tenants and asserts each reads only its own rows, and application/score TestScoreHistory_Unauthorized asserts another tenant's snapshot never enters the history"}, {"/api/v1/score/model", PublicByDesign, "returns scoring.Describe() — the frozen formula, its bands and its factor names. Requires a session (401 on a zero tenant) but reads no table, so there is nothing tenant-owned in the response"}, diff --git a/migrations/0069_tenant_score_snapshots.down.sql b/migrations/0069_tenant_score_snapshots.down.sql new file mode 100644 index 00000000..73f79712 --- /dev/null +++ b/migrations/0069_tenant_score_snapshots.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tenant_score_snapshots; diff --git a/migrations/0069_tenant_score_snapshots.up.sql b/migrations/0069_tenant_score_snapshots.up.sql new file mode 100644 index 00000000..70d9f9a7 --- /dev/null +++ b/migrations/0069_tenant_score_snapshots.up.sql @@ -0,0 +1,21 @@ +-- #901 — daily history of the tenant exposure score. +-- +-- The tenant score is computed live and nothing kept its past values, so the +-- dashboard could not draw a 12-month line or say how much the score moved in +-- 30 days. One row per tenant per UTC day; a later computation on the same day +-- updates the row. AutoMigrate builds the same table on boot, so every +-- statement is idempotent. + +CREATE TABLE IF NOT EXISTS tenant_score_snapshots ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL, + day date NOT NULL, + value double precision NOT NULL, + band varchar(16) NOT NULL, + formula_version varchar(16) NOT NULL, + created_at timestamptz, + updated_at timestamptz +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_score_snapshot_day + ON tenant_score_snapshots (tenant_id, day); From a3b7f346bf0393c5115afca1537adfcaa3905e83 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 09:08:40 +0100 Subject: [PATCH 3/5] feat(dashboard): live risk counters in /stats (#901) The redesigned dashboard counts the risks still alive, which the stock counters cannot answer because they include closed risks so they reconcile with the full register. /stats gains four fields, the existing ones are unchanged: - live_risks: not closed - critical_live: critical band, still open or in treatment - without_mitigation: live risks with no plan other than cancelled ones, matched on the risk's own tenant - reviews_due_30d: next review within 30 days, overdue included Signed-off-by: alex-dembele --- backend/internal/handler/dashboard_handler.go | 49 +++++++++++++++++++ .../internal/handler/dashboard_stats_test.go | 49 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/backend/internal/handler/dashboard_handler.go b/backend/internal/handler/dashboard_handler.go index 700cb217..98778fb5 100644 --- a/backend/internal/handler/dashboard_handler.go +++ b/backend/internal/handler/dashboard_handler.go @@ -112,6 +112,22 @@ type DashboardStats struct { // links to — which is precisely the reconciliation this wave has to hold. OpenedInPeriod int64 `json:"opened_in_period"` + // Live counters for the redesigned dashboard (#901). "Live" leaves out + // closed risks, which the stock counters above include so they reconcile + // with the full register. + // + // LiveRisks every risk not closed + // CriticalLive critical band, still open or in treatment + // (not closed, mitigated or accepted) + // WithoutMitigation live risks with no mitigation plan other than + // cancelled ones + // ReviewsDue30d live risks whose next review falls within 30 days, + // overdue reviews included + LiveRisks int64 `json:"live_risks"` + CriticalLive int64 `json:"critical_live"` + WithoutMitigation int64 `json:"without_mitigation"` + ReviewsDue30d int64 `json:"reviews_due_30d"` + RiskTrend RiskTrend `json:"risk_trend"` GeneratedAt string `json:"generated_at"` @@ -225,6 +241,39 @@ func ComputeDashboardStats(ctx context.Context, db *gorm.DB, tenantID uuid.UUID, GeneratedAt: time.Now().UTC().Format(time.RFC3339), } + // --- live counters (#901) ---------------------------------------------- + // The mitigation sub-query is keyed on the risk's own tenant as well as its + // id, so a plan can never be credited across tenants. + var live struct { + Live int64 + CriticalLive int64 + WithoutMitigation int64 + ReviewsDue int64 + } + err = tx.Raw(` + SELECT + COUNT(*) FILTER (WHERE COALESCE(LOWER(status), 'open') <> 'closed') AS live, + COUNT(*) FILTER (WHERE score >= 7.0 + AND COALESCE(LOWER(status), 'open') NOT IN ('closed', 'mitigated', 'accepted')) AS critical_live, + COUNT(*) FILTER (WHERE COALESCE(LOWER(status), 'open') <> 'closed' + AND NOT EXISTS ( + SELECT 1 FROM mitigations m + WHERE m.risk_id = risks.id AND m.tenant_id = risks.tenant_id + AND m.deleted_at IS NULL AND UPPER(COALESCE(m.status, '')) <> 'CANCELLED' + )) AS without_mitigation, + COUNT(*) FILTER (WHERE COALESCE(LOWER(status), 'open') <> 'closed' + AND next_review_at IS NOT NULL AND next_review_at <= ?) AS reviews_due + FROM risks + WHERE tenant_id = ? AND deleted_at IS NULL + `, time.Now().UTC().AddDate(0, 0, 30), tenantID).Scan(&live).Error + if err != nil { + return nil, fmt.Errorf("failed to aggregate live risk counters: %w", err) + } + stats.LiveRisks = live.Live + stats.CriticalLive = live.CriticalLive + stats.WithoutMitigation = live.WithoutMitigation + stats.ReviewsDue30d = live.ReviewsDue + // --- 5x5 heatmap -------------------------------------------------------- // probability [0,1] -> band 1-5, impact [0,10] -> band 1-5. A risk at exactly // 0 still lands in band 1 rather than vanishing from the matrix. diff --git a/backend/internal/handler/dashboard_stats_test.go b/backend/internal/handler/dashboard_stats_test.go index 96404f50..b3e9799f 100644 --- a/backend/internal/handler/dashboard_stats_test.go +++ b/backend/internal/handler/dashboard_stats_test.go @@ -42,9 +42,18 @@ func newStatsDB(t *testing.T) *gorm.DB { other_direct_cost_xaf REAL, downtime_hours REAL, hourly_downtime_cost_xaf REAL, + next_review_at DATETIME, created_at DATETIME, deleted_at DATETIME );`).Error) + require.NoError(t, db.Exec(` + CREATE TABLE mitigations ( + id TEXT PRIMARY KEY, + tenant_id TEXT, + risk_id TEXT, + status TEXT, + deleted_at DATETIME + );`).Error) return db } @@ -303,3 +312,43 @@ func TestDashboardStats_RefusesWithoutTenant(t *testing.T) { _, err := ComputeDashboardStats(context.Background(), db, uuid.Nil, windowFor(t, "all")) require.Error(t, err) } + +// The live counters behind the redesigned dashboard (#901): closed risks drop +// out, a cancelled plan does not count as a plan, a review is due within 30 +// days or already overdue, and another tenant's mitigation never covers this +// tenant's risk. +func TestDashboardStats_LiveCounters(t *testing.T) { + db := newStatsDB(t) + tenant, other := uuid.New(), uuid.New() + add := func(tn uuid.UUID, score float64, status string, review *time.Time) string { + id := uuid.NewString() + require.NoError(t, db.Exec( + `INSERT INTO risks (id, tenant_id, score, status, next_review_at, created_at) VALUES (?,?,?,?,?,?)`, + id, tn.String(), score, status, review, statsNow).Error) + return id + } + plan := func(tn uuid.UUID, riskID, status string) { + require.NoError(t, db.Exec(`INSERT INTO mitigations (id, tenant_id, risk_id, status) VALUES (?,?,?,?)`, + uuid.NewString(), tn.String(), riskID, status).Error) + } + soon := time.Now().UTC().AddDate(0, 0, 10) + overdue := time.Now().UTC().AddDate(0, 0, -3) + far := time.Now().UTC().AddDate(0, 0, 90) + + a := add(tenant, 9, "open", &soon) // critical, live, planned + b := add(tenant, 8, "in_progress", &far) // critical, live, cancelled plan only + _ = add(tenant, 7.5, "accepted", &overdue) + _ = add(tenant, 9, "closed", &soon) // closed: out of every live counter + c := add(tenant, 3, "open", nil) + plan(tenant, a, "IN_PROGRESS") + plan(tenant, b, "CANCELLED") + // Another tenant's plan on this tenant's risk id must not count. + plan(other, c, "PLANNED") + + stats, err := ComputeDashboardStats(context.Background(), db, tenant, windowFor(t, "all")) + require.NoError(t, err) + assert.Equal(t, int64(4), stats.LiveRisks) + assert.Equal(t, int64(2), stats.CriticalLive, "open and in-treatment criticals; accepted and closed out") + assert.Equal(t, int64(3), stats.WithoutMitigation, "b (cancelled only), the accepted one, c (foreign plan)") + assert.Equal(t, int64(2), stats.ReviewsDue30d, "due in 10 days and overdue") +} From 1684bdfaa4e7d0d480743333542a995b632116cf Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 13:22:01 +0100 Subject: [PATCH 4/5] feat(dashboard): rebuild the dashboard on the October 2026 redesign (#901) One layout for every member: the open critical or high incident with its running time and the way into its war room, the exposure score with its four factors and 30-day movement, the annualised exposure split by band with the residual and the simulated P95, four counters, the score over twelve months beside the 5x5 matrix, then the top priorities and the recent activity. The business role picks which four counters and which priorities show (RSSI, risk manager, auditor, executive), as the design's four views do; there is no switch on screen, per the owner. Each block fetches its own tenant-scoped source and renders its own loading, error and empty state. The score keeps its exposure direction (owner decision): it is labelled "Score d'exposition", a falling value shows green, factor bars are pressures coloured from the served band table, no threshold in the client. The matrix tint is a grid legend; the binning is the server's CEIL(p x 5), CEIL(i / 2). The period control is gone: no block of the redesigned page is period-scoped, so it would filter nothing. The five persona dashboards, the action-center panel and their helpers are removed; the panel's link safety (no unresolved or off-origin row) now guards the priority list, with its tests ported. The register opens on its matrix with ?view=matrix, which the dashboard links to. Signed-off-by: alex-dembele --- frontend/.lint-ceiling.json | 4 +- .../action-center/ActionCenterPanel.tsx | 125 --- .../__tests__/actionCenter.test.tsx | 468 --------- .../__tests__/actionCenterPage.test.tsx | 57 - .../features/dashboard/AnalystDashboard.tsx | 260 ----- .../src/features/dashboard/AuditDashboard.tsx | 234 ----- .../features/dashboard/ConsoleDashboard.tsx | 80 ++ .../src/features/dashboard/DashboardPage.tsx | 992 +----------------- .../features/dashboard/EstateDashboard.tsx | 300 ------ .../src/features/dashboard/ExecDashboard.tsx | 169 --- .../src/features/dashboard/PeriodControl.tsx | 149 --- .../features/dashboard/ViewerDashboard.tsx | 170 --- .../src/features/dashboard/WidgetState.tsx | 290 ----- .../dashboard/__tests__/TrendCard.test.tsx | 139 --- .../dashboard/__tests__/WidgetState.test.tsx | 233 ---- .../__tests__/consoleDashboard.test.tsx | 170 +++ .../dashboard/__tests__/priorityList.test.tsx | 227 ++++ .../__tests__/useScoreCardTilt.test.tsx | 159 --- .../dashboard/console/CounterList.tsx | 300 ++++++ .../dashboard/console/ExposureCard.tsx | 141 +++ .../dashboard/console/IncidentBanner.tsx | 69 ++ .../src/features/dashboard/console/Panel.tsx | 105 ++ .../dashboard/console/PriorityList.tsx | 130 +++ .../dashboard/console/RecentActivity.tsx | 65 ++ .../dashboard/console/RiskMatrixCard.tsx | 106 ++ .../features/dashboard/console/ScoreCard.tsx | 185 ++++ .../dashboard/console/ScoreTrendCard.tsx | 145 +++ .../src/features/dashboard/console/dates.ts | 12 + .../dashboard/console/incidentPick.ts | 17 + .../features/dashboard/console/scoreBands.ts | 15 + .../features/dashboard/dashboardPersona.ts | 34 - .../features/dashboard/dashboardVariant.ts | 32 + frontend/src/features/dashboard/shared.tsx | 208 ---- .../features/dashboard/useCommandCenter.ts | 5 + .../features/dashboard/useScoreCardTilt.ts | 103 -- .../src/features/dashboard/useScoreHistory.ts | 34 + .../src/features/dashboard/widgetError.ts | 29 - .../features/financial/financialService.ts | 2 + frontend/src/features/financial/money.ts | 29 + .../src/features/incidents/incidentMeta.ts | 15 + .../src/features/risks/RiskRegisterPage.tsx | 6 +- .../__tests__/emptyTenantSurfaces.test.tsx | 19 +- frontend/src/locales/en.json | 148 +++ frontend/src/locales/fr.json | 148 +++ 44 files changed, 2208 insertions(+), 4120 deletions(-) delete mode 100644 frontend/src/features/action-center/ActionCenterPanel.tsx delete mode 100644 frontend/src/features/action-center/__tests__/actionCenter.test.tsx delete mode 100644 frontend/src/features/dashboard/AnalystDashboard.tsx delete mode 100644 frontend/src/features/dashboard/AuditDashboard.tsx create mode 100644 frontend/src/features/dashboard/ConsoleDashboard.tsx delete mode 100644 frontend/src/features/dashboard/EstateDashboard.tsx delete mode 100644 frontend/src/features/dashboard/ExecDashboard.tsx delete mode 100644 frontend/src/features/dashboard/PeriodControl.tsx delete mode 100644 frontend/src/features/dashboard/ViewerDashboard.tsx delete mode 100644 frontend/src/features/dashboard/WidgetState.tsx delete mode 100644 frontend/src/features/dashboard/__tests__/TrendCard.test.tsx delete mode 100644 frontend/src/features/dashboard/__tests__/WidgetState.test.tsx create mode 100644 frontend/src/features/dashboard/__tests__/consoleDashboard.test.tsx create mode 100644 frontend/src/features/dashboard/__tests__/priorityList.test.tsx delete mode 100644 frontend/src/features/dashboard/__tests__/useScoreCardTilt.test.tsx create mode 100644 frontend/src/features/dashboard/console/CounterList.tsx create mode 100644 frontend/src/features/dashboard/console/ExposureCard.tsx create mode 100644 frontend/src/features/dashboard/console/IncidentBanner.tsx create mode 100644 frontend/src/features/dashboard/console/Panel.tsx create mode 100644 frontend/src/features/dashboard/console/PriorityList.tsx create mode 100644 frontend/src/features/dashboard/console/RecentActivity.tsx create mode 100644 frontend/src/features/dashboard/console/RiskMatrixCard.tsx create mode 100644 frontend/src/features/dashboard/console/ScoreCard.tsx create mode 100644 frontend/src/features/dashboard/console/ScoreTrendCard.tsx create mode 100644 frontend/src/features/dashboard/console/dates.ts create mode 100644 frontend/src/features/dashboard/console/incidentPick.ts create mode 100644 frontend/src/features/dashboard/console/scoreBands.ts delete mode 100644 frontend/src/features/dashboard/dashboardPersona.ts create mode 100644 frontend/src/features/dashboard/dashboardVariant.ts delete mode 100644 frontend/src/features/dashboard/shared.tsx delete mode 100644 frontend/src/features/dashboard/useScoreCardTilt.ts create mode 100644 frontend/src/features/dashboard/useScoreHistory.ts delete mode 100644 frontend/src/features/dashboard/widgetError.ts create mode 100644 frontend/src/features/financial/money.ts diff --git a/frontend/.lint-ceiling.json b/frontend/.lint-ceiling.json index 05c13880..b42c6d36 100644 --- a/frontend/.lint-ceiling.json +++ b/frontend/.lint-ceiling.json @@ -1,11 +1,11 @@ { - "total": 97, + "total": 96, "rules": { "@typescript-eslint/no-unused-vars": 27, "@typescript-eslint/no-explicit-any": 21, "react-hooks/preserve-manual-memoization": 12, "react-hooks/set-state-in-effect": 12, - "react-refresh/only-export-components": 9, + "react-refresh/only-export-components": 8, "react-hooks/static-components": 6, "react-hooks/purity": 3, "prefer-const": 2, diff --git a/frontend/src/features/action-center/ActionCenterPanel.tsx b/frontend/src/features/action-center/ActionCenterPanel.tsx deleted file mode 100644 index 311549e8..00000000 --- a/frontend/src/features/action-center/ActionCenterPanel.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// The Action Center panel (#430). -// -// One list answering one question: what is mine to act on right now, in order, -// and how do I get straight to it. Every row is a real anchor to a real route — -// there is no row here that does not go anywhere (CLAUDE.md rule 12). -// -// This is NOT the notification bell. The bell is chronological, "what happened", -// and is read then dismissed. This is a work queue: an item appears because a -// record is outstanding and disappears when that work is done. There is no -// dismiss and no read state, on purpose — see the epic's non-goals. -// -// Two things it deliberately does not do: -// -// - it does not sort. The server has already applied category rank, then each -// category's own secondary key, then id, and it pages on that same order. -// Re-sorting here would agree with the server on page one and contradict it -// on page two. -// - it does not invent an empty state's meaning. An empty Action Center is the -// GOOD outcome — nothing is outstanding — so it reads as an all-clear rather -// than as "no data found". - -import { useMemo } from 'react'; -import { Link } from 'react-router'; -import { CircleCheck } from 'lucide-react'; - -import { useI18n, interpolate } from '../../hooks/useI18n'; -import { EmptyState, ErrorState, Skeleton } from '../../shared/ui'; -import { useActionItems, PANEL_LIMIT } from './useActionItems'; -import { linkableItems } from './actionLinks'; -import { ActionItemRow } from './ActionItemRow'; - -const SKELETON_ROWS = 4; - -export function ActionCenterPanel({ limit = PANEL_LIMIT }: { limit?: number }) { - const { t } = useI18n(); - const { items, total, isLoading, isError, refetch } = useActionItems({ limit }); - - // Filtering, not sorting: an item whose deep_link does not resolve to a real - // route is dropped (and logged) rather than rendered as a dead row. Order is - // the server's throughout. - const rows = useMemo(() => linkableItems(items), [items]); - - return ( -
-
-
-

- {t('actionCenter.title')} -

-

{t('actionCenter.subtitle')}

-
- {!isLoading && !isError && total > 0 && ( - - {interpolate(t('actionCenter.showing'), { shown: rows.length, total })} - - )} -
- - {isLoading && ( -
- {t('actionCenter.loading')} - {Array.from({ length: SKELETON_ROWS }).map((_, index) => ( - - ))} -
- )} - - {!isLoading && isError && ( -
- -
- )} - - {!isLoading && !isError && rows.length === 0 && ( -
- -
- )} - - {!isLoading && !isError && rows.length > 0 && ( - <> -
    - {rows.map(({ item, href }) => ( - - ))} -
- {total > rows.length && ( -
- {/* #433 — this was a dead line of text saying how much was not on - screen, because the page it wanted to link to did not exist - yet. It does now, so the count is a real link rather than a - statement the user cannot act on. */} - - {interpolate(t('actionCenter.viewAll'), { count: total - rows.length })} - -
- )} - - )} -
- ); -} - -export default ActionCenterPanel; diff --git a/frontend/src/features/action-center/__tests__/actionCenter.test.tsx b/frontend/src/features/action-center/__tests__/actionCenter.test.tsx deleted file mode 100644 index cf865621..00000000 --- a/frontend/src/features/action-center/__tests__/actionCenter.test.tsx +++ /dev/null @@ -1,468 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// The Action Center's rendering contract (#430, acceptance criteria 1-6 and 9). -// -// What is asserted here is that the panel tells the truth about the server: -// -// - it renders the server's order, and does not impose one of its own; -// - loading, error and empty are three different screens, and empty reads as -// an all-clear rather than as a failure; -// - every one of the six categories produces the exact deep link the #429 -// contract documents, resolving to a route that exists in routeModel; -// - an item whose link goes nowhere is dropped and logged, never drawn as a -// dead row; -// - the panel has no serious or critical axe violations. -// -// The server is mocked at the service boundary — the only place a fixture is -// allowed to exist. Nothing built here ships in a production route. - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import axe from 'axe-core'; - -import { useUIStore } from '../../../store/uiStore'; -import { ActionCenterPanel } from '../ActionCenterPanel'; -import { safeDeepLink } from '../actionLinks'; -import type { ActionCenterResponse, ActionItem } from '../actionCenterService'; - -vi.mock('../actionCenterService', async () => { - const actual = - await vi.importActual('../actionCenterService'); - return { ...actual, actionCenterService: { list: vi.fn() } }; -}); - -import { actionCenterService } from '../actionCenterService'; - -const list = vi.mocked(actionCenterService.list); - -const TENANT = '3f1b0f9e-5a4c-4c7e-9a1d-8b2c6d5e4f30'; - -function item(overrides: Partial & Pick): ActionItem { - return { - title: 'Untitled', - subject_resource_type: 'risk', - subject_resource_id: '8f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', - deep_link: '/risks', - due_at: null, - category_rank: 1, - tenant_id: TENANT, - ...overrides, - }; -} - -function page(items: ActionItem[], total = items.length): ActionCenterResponse { - return { - data: items, - generated_at: '2026-08-31T09:00:00Z', - limit: 20, - offset: 0, - total, - }; -} - -/** - * The six categories with the deep links the backend contract commits to - * (#429 issue comment, and docs/openapi.yaml). Read as a table on purpose: if - * one of these ever stops resolving, the test names which one. - */ -const CONTRACT_LINKS: Array<{ type: ActionItem['type']; rank: number; deepLink: string }> = [ - { - type: 'overdue_mitigation', - rank: 1, - deepLink: '/risks/mitigations/8f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', - }, - { - type: 'critical_risk', - rank: 2, - deepLink: '/risks?drawer=risk&entity=1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f', - }, - { type: 'pending_approval', rank: 3, deepLink: '/governance' }, - { type: 'open_incident', rank: 4, deepLink: '/incidents/412/war-room' }, - { - type: 'expiring_evidence', - rank: 5, - deepLink: '/compliance/evidence?drawer=evidence&entity=aa11bb22-cc33-dd44-ee55-ff6677889900', - }, - { - type: 'overdue_remediation', - rank: 6, - deepLink: '/compliance/remediation/9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d', - }, -]; - -function LocationProbe() { - const location = useLocation(); - return
{`${location.pathname}${location.search}`}
; -} - -function renderPanel(initialPath = '/') { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, refetchInterval: false } }, - }); - return render( - - - - - - - - } - /> - - - , - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - useUIStore.setState({ lang: 'en' }); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('ActionCenterPanel', () => { - /* --- AC1: the server's order is the rendered order ---------------------- */ - - it('renders items in the exact order the API returned, across categories', async () => { - // Deliberately NOT in category_rank order: the server owns the ordering and - // may page on a secondary key the client cannot see, so a client that - // "helpfully" re-sorts by rank would disagree with the server on page two. - // Rendering this array unchanged is what proves nothing re-sorts. - const returned = [ - item({ - id: 'incident:412', - type: 'open_incident', - title: 'Ransomware on the payroll server', - deep_link: '/incidents/412/war-room', - category_rank: 4, - }), - item({ - id: 'mitigation:1', - type: 'overdue_mitigation', - title: 'Patch the perimeter VPN', - deep_link: '/risks/mitigations/8f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', - category_rank: 1, - }), - item({ - id: 'approval:2', - type: 'pending_approval', - title: 'Risk acceptance for legacy SFTP', - deep_link: '/governance', - category_rank: 3, - }), - ]; - list.mockResolvedValue(page(returned)); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-list')).toBeInTheDocument()); - - const rendered = screen.getAllByTestId('action-center-item'); - expect(rendered).toHaveLength(3); - expect(rendered.map((node) => node.getAttribute('href'))).toEqual( - returned.map((entry) => entry.deep_link), - ); - expect(rendered.map((node) => node.getAttribute('data-action-type'))).toEqual([ - 'open_incident', - 'overdue_mitigation', - 'pending_approval', - ]); - }); - - /* --- AC2: empty is an all-clear, not a blank div ------------------------ */ - - it('renders an explicit empty state, not a blank div or a hanging skeleton', async () => { - list.mockResolvedValue(page([])); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-empty')).toBeInTheDocument()); - expect(screen.getByText('You are all clear')).toBeInTheDocument(); - expect(screen.queryByTestId('action-center-skeleton')).not.toBeInTheDocument(); - expect(screen.queryByTestId('action-center-list')).not.toBeInTheDocument(); - }); - - /* --- AC3: a skeleton while in flight, never a full-page spinner ---------- */ - - it('renders a skeleton before the query resolves', async () => { - let resolve: ((value: ActionCenterResponse) => void) | undefined; - list.mockReturnValue( - new Promise((r) => { - resolve = r; - }), - ); - - renderPanel(); - - expect(screen.getByTestId('action-center-skeleton')).toBeInTheDocument(); - expect(screen.queryByTestId('action-center-list')).not.toBeInTheDocument(); - expect(screen.queryByTestId('action-center-empty')).not.toBeInTheDocument(); - - resolve?.( - page([ - item({ - id: 'approval:2', - type: 'pending_approval', - title: 'Risk acceptance', - deep_link: '/governance', - category_rank: 3, - }), - ]), - ); - - await waitFor(() => expect(screen.getByTestId('action-center-list')).toBeInTheDocument()); - expect(screen.queryByTestId('action-center-skeleton')).not.toBeInTheDocument(); - }); - - /* --- AC4: a failure shows a failure, and fabricates nothing -------------- */ - - it('renders an error state with no items when the request fails', async () => { - list.mockRejectedValue(new Error('Network Error')); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-error')).toBeInTheDocument()); - expect(screen.queryByTestId('action-center-item')).not.toBeInTheDocument(); - expect(screen.queryByTestId('action-center-empty')).not.toBeInTheDocument(); - expect(screen.getByText('The action list could not be loaded')).toBeInTheDocument(); - }); - - /* --- AC5: the six deep links, and real navigation ------------------------ */ - - it('renders the documented deep link for each of the six item types', async () => { - list.mockResolvedValue( - page( - CONTRACT_LINKS.map(({ type, rank, deepLink }) => - item({ - id: `${type}:x`, - type, - title: `A ${type}`, - deep_link: deepLink, - category_rank: rank, - }), - ), - ), - ); - - renderPanel(); - - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(6)); - - const byType = new Map( - screen - .getAllByTestId('action-center-item') - .map((node) => [node.getAttribute('data-action-type'), node.getAttribute('href')]), - ); - for (const { type, deepLink } of CONTRACT_LINKS) { - expect(byType.get(type)).toBe(deepLink); - } - }); - - it('every documented deep link resolves to a route that exists in routeModel', () => { - // The links above are only worth asserting because the route tree says they - // are reachable. This is the half of criterion 5 that a rendered href alone - // cannot prove. - for (const { type, deepLink } of CONTRACT_LINKS) { - expect(safeDeepLink(deepLink), `${type} -> ${deepLink}`).toBe(deepLink); - } - }); - - it('navigates to the deep link on click and on Enter and Space from the keyboard', async () => { - list.mockResolvedValue( - page([ - item({ - id: 'incident:412', - type: 'open_incident', - title: 'Ransomware on the payroll server', - deep_link: '/incidents/412/war-room', - category_rank: 4, - }), - ]), - ); - - const { unmount } = renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - - fireEvent.click(screen.getByTestId('action-center-item')); - await waitFor(() => - expect(screen.getByTestId('location')).toHaveTextContent('/incidents/412/war-room'), - ); - unmount(); - - // Enter — the anchor's own default activation. - renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - const link = screen.getByTestId('action-center-item'); - link.focus(); - fireEvent.keyDown(link, { key: 'Enter' }); - fireEvent.click(link); - await waitFor(() => - expect(screen.getByTestId('location')).toHaveTextContent('/incidents/412/war-room'), - ); - screen.getByTestId('location'); - }); - - it('activates a row with the Space key', async () => { - list.mockResolvedValue( - page([ - item({ - id: 'remediation:9', - type: 'overdue_remediation', - title: 'Close the access-review gap', - deep_link: '/compliance/remediation/9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d', - category_rank: 6, - }), - ]), - ); - - renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - - fireEvent.keyDown(screen.getByTestId('action-center-item'), { key: ' ' }); - await waitFor(() => - expect(screen.getByTestId('location')).toHaveTextContent( - '/compliance/remediation/9b8a7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d', - ), - ); - }); - - /* --- AC6: no row ever goes nowhere -------------------------------------- */ - - it('drops an item whose deep link does not resolve, and logs why', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - list.mockResolvedValue( - page( - [ - item({ - id: 'approval:2', - type: 'pending_approval', - title: 'Risk acceptance', - deep_link: '/governance', - category_rank: 3, - }), - item({ - id: 'ghost:1', - type: 'critical_risk', - title: 'Points at a route that does not exist', - deep_link: '/risks/00000000-0000-0000-0000-000000000000', - category_rank: 2, - }), - ], - 2, - ), - ); - - renderPanel(); - - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(1)); - expect(screen.getByTestId('action-center-item').getAttribute('href')).toBe('/governance'); - expect(screen.queryByText('Points at a route that does not exist')).not.toBeInTheDocument(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('ghost:1')); - }); - - it('refuses a deep link that leaves the origin', () => { - // deep_link is server data that ends up in an href, so it is checked like - // any other untrusted string. None of these may become a link. - expect(safeDeepLink('https://evil.example/risks')).toBeNull(); - expect(safeDeepLink('//evil.example/risks')).toBeNull(); - expect(safeDeepLink('javascript:alert(1)')).toBeNull(); - expect(safeDeepLink('risks')).toBeNull(); - expect(safeDeepLink('')).toBeNull(); - expect(safeDeepLink(undefined)).toBeNull(); - }); - - it('renders an unrecognised item type under a generic label rather than hiding real work', async () => { - // A frontend can be one release behind the API. An unknown category is not a - // reason to hide work the user has to do — but it still may not produce a - // dead link, which is why its deep_link goes through the same check. - list.mockResolvedValue( - page([ - item({ - id: 'newthing:1', - // Cast: the point of the test is a value outside the generated union, - // which is exactly what a newer server can send. - type: 'audit_finding' as ActionItem['type'], - title: 'Something this build has never heard of', - deep_link: '/governance', - category_rank: 7, - }), - ]), - ); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-item')).toBeInTheDocument()); - expect(screen.getByText('Something this build has never heard of')).toBeInTheDocument(); - expect(screen.getByTestId('action-center-item').getAttribute('href')).toBe('/governance'); - expect(screen.getByText(/Action required/)).toBeInTheDocument(); - }); - - /* --- i18n: no hardcoded literal survives a language switch --------------- */ - - it('renders its own strings in French when the language is French', async () => { - useUIStore.setState({ lang: 'fr' }); - list.mockResolvedValue(page([])); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-empty')).toBeInTheDocument()); - expect(screen.getByText("Centre d'actions")).toBeInTheDocument(); - expect(screen.getByText('Vous êtes à jour')).toBeInTheDocument(); - }); - - /* --- AC9: axe ------------------------------------------------------------ */ - - it('has no serious or critical axe violations with items rendered', async () => { - list.mockResolvedValue( - page( - CONTRACT_LINKS.map(({ type, rank, deepLink }) => - item({ - id: `${type}:x`, - type, - title: `A ${type.replace(/_/g, ' ')}`, - deep_link: deepLink, - category_rank: rank, - due_at: rank % 2 === 0 ? null : '2026-07-01T00:00:00Z', - }), - ), - ), - ); - - const { container } = renderPanel(); - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(6)); - - const results = await axe.run(container, { - // jsdom has no layout engine, so colour contrast cannot be evaluated here. - // It is asserted for real against the running app by check-contrast.mjs and - // the Playwright axe pass. - rules: { 'color-contrast': { enabled: false } }, - }); - const blocking = results.violations.filter( - (violation) => violation.impact === 'serious' || violation.impact === 'critical', - ); - expect(blocking.map((violation) => `${violation.id}: ${violation.help}`)).toEqual([]); - }); - - it('has no serious or critical axe violations in the empty state', async () => { - list.mockResolvedValue(page([])); - - const { container } = renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-empty')).toBeInTheDocument()); - - const results = await axe.run(container, { rules: { 'color-contrast': { enabled: false } } }); - const blocking = results.violations.filter( - (violation) => violation.impact === 'serious' || violation.impact === 'critical', - ); - expect(blocking.map((violation) => `${violation.id}: ${violation.help}`)).toEqual([]); - }); -}); diff --git a/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx b/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx index badbc8c3..104b26a0 100644 --- a/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx +++ b/frontend/src/features/action-center/__tests__/actionCenterPage.test.tsx @@ -27,7 +27,6 @@ import { ROUTES, resolveRoute, parentHref } from '../../../shared/routeModel'; import { NAV_GROUPS } from '../../../shared/navModel'; import { ActionCenterPage } from '../ActionCenterPage'; import { pageFromParam } from '../paging'; -import { ActionCenterPanel } from '../ActionCenterPanel'; import { PAGE_LIMIT } from '../useActionItems'; import type { ActionCenterResponse, ActionItem } from '../actionCenterService'; @@ -413,59 +412,3 @@ describe('ActionCenterPage', () => { }); }); -/* --- AC2: the dashboard panel's way in ------------------------------------ */ - -describe('the dashboard panel', () => { - function renderPanel() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, refetchInterval: false } }, - }); - return render( - - - - - - - - } - /> - - - , - ); - } - - it('offers a real link to the full page when more is outstanding than it shows', async () => { - list.mockResolvedValue(envelope(approvals(1, 8), 25, 0)); - - renderPanel(); - - await waitFor(() => expect(screen.getByTestId('action-center-view-all')).toBeInTheDocument()); - expect(screen.getByTestId('action-center-view-all').getAttribute('href')).toBe( - '/action-center', - ); - }); - - it('does not offer it when the panel already shows everything', async () => { - list.mockResolvedValue(envelope(approvals(1, 3), 3, 0)); - - renderPanel(); - - await waitFor(() => expect(screen.getAllByTestId('action-center-item')).toHaveLength(3)); - expect(screen.queryByTestId('action-center-view-all')).not.toBeInTheDocument(); - }); - - it('navigates to the full page when the link is activated', async () => { - list.mockResolvedValue(envelope(approvals(1, 8), 25, 0)); - - renderPanel(); - await waitFor(() => expect(screen.getByTestId('action-center-view-all')).toBeInTheDocument()); - - fireEvent.click(screen.getByTestId('action-center-view-all')); - await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/action-center')); - }); -}); diff --git a/frontend/src/features/dashboard/AnalystDashboard.tsx b/frontend/src/features/dashboard/AnalystDashboard.tsx deleted file mode 100644 index b6d4d282..00000000 --- a/frontend/src/features/dashboard/AnalystDashboard.tsx +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Security-analyst dashboard persona (UX-2). Leads with vulnerabilities: the -// prioritised KPI band (open / P1 / KEV / exploitable) and the top-priority queue, -// each row deep-linking into the vulnerability drawer. Real data from the same -// endpoints the Vulnerabilities screen uses. -// -// W0-06: the KPI band used to render `stats?.total ?? 0` and friends, so a failed -// /vulnerabilities/stats produced a tidy row of zeroes — an analyst's dashboard -// reporting no open vulnerabilities and no KEV, which is the most reassuring -// thing this screen can say and the least verifiable. It now renders an error -// state. Each tile also carries its own filter into the register: clicking -// "P1 — 4" opens those four, not the whole list. - -import { useNavigate } from 'react-router'; -import { Bug, ShieldAlert, Flame, Zap } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useVulnerabilities, useVulnStats } from '../vulnerabilities/useVulnerabilities'; -import { deepLink } from './deepLinks'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -const SEV_COLOR: Record = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', - info: 'var(--fg-muted)', -}; -const TIER_COLOR: Record = { - P1: 'var(--critical)', - P2: 'var(--high)', - P3: 'var(--medium)', - P4: 'var(--low)', -}; -const soft = (c: string, p = 15) => `color-mix(in srgb, ${c} ${p}%, transparent)`; - -export function AnalystDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - - const statsQuery = useVulnStats(); - const stats = statsQuery.data; - const listQuery = useVulnerabilities({ limit: 6, sort_by: 'priority_score', sort_dir: 'desc' }); - const { isLoading } = listQuery; - const top = listQuery.data?.items ?? []; - - // The queue is sorted by priority, so every link carries that sort: the rows - // the tile counted are the rows at the top of the page it opens. - const sort = { key: 'priority_score', dir: 'desc' } as const; - // Every tile here reads the same query, so they share one "did this - // genuinely fetch during this mount" signal. - const fresh = statsQuery.isFetchedAfterMount; - const kpis: KpiSpec[] = [ - { - label: tr('Vulnérabilités', 'Vulnerabilities'), - val: stats?.total ?? 0, - icon: Bug, - col: 'var(--accent)', - onClick: () => navigate(deepLink('vulnerabilities', { sort })), - fresh, - }, - { - label: tr('Ouvertes', 'Open'), - val: stats?.open ?? 0, - icon: ShieldAlert, - col: 'var(--high)', - onClick: () => navigate(deepLink('vulnerabilities', { filters: { status: 'open' }, sort })), - fresh, - }, - { - label: tr('Priorité P1', 'Priority P1'), - val: stats?.by_tier?.P1 ?? 0, - icon: Flame, - col: 'var(--critical)', - onClick: () => navigate(deepLink('vulnerabilities', { filters: { tier: 'P1' }, sort })), - fresh, - }, - { - label: 'KEV', - val: stats?.kev_count ?? 0, - icon: Zap, - col: 'var(--critical)', - onClick: () => navigate(deepLink('vulnerabilities', { filters: { kev: 'true' }, sort })), - fresh, - }, - ]; - - const sevCounts = stats?.by_severity ?? {}; - const sevOrder = ['critical', 'high', 'medium', 'low', 'info']; - const sevMax = Math.max(1, ...sevOrder.map((s) => sevCounts[s] ?? 0)); - - return ( - - navigate('/vulnerabilities')} - /> - -
- void statsQuery.refetch()} - > - - -
- -
- {/* top-priority queue */} - -
-
- {tr('File de priorité', 'Priority queue')} -
- -
- void listQuery.refetch()} - isEmpty={top.length === 0} - emptyIcon={Bug} - emptyTitle={tr('Aucune vulnérabilité', 'No vulnerabilities')} - emptyDescription={tr( - 'Importez un scan ou branchez un connecteur : les vulnérabilités priorisées apparaîtront ici, P1 et KEV en tête.', - 'Import a scan or connect a source: prioritised vulnerabilities appear here, P1 and KEV first.', - )} - emptyAction={ - - } - > - <> - {top.map((v) => { - const tierCol = TIER_COLOR[v.priority_tier] ?? 'var(--fg-muted)'; - return ( - - ); - })} - - -
- - {/* severity distribution */} - -
- {tr('Par sévérité', 'By severity')} -
- void statsQuery.refetch()} - > - <> -
- {sevOrder.map((s) => { - const c = sevCounts[s] ?? 0; - const col = SEV_COLOR[s]; - return ( - - ); - })} -
-
- {tr('Exploitables', 'Exploitable')} - {stats?.exploit_count ?? 0} -
- -
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/AuditDashboard.tsx b/frontend/src/features/dashboard/AuditDashboard.tsx deleted file mode 100644 index ee92d7a6..00000000 --- a/frontend/src/features/dashboard/AuditDashboard.tsx +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Auditor / compliance dashboard persona (UX-2). Leads with controls: coverage per -// framework, the gap count, and shortcuts to audits & gap analysis. Real data from -// the same compliance overview the Compliance screen uses. -// -// W0-06: a failed compliance fetch rendered "No framework — import one to start", -// with all four KPIs at zero. That is not a milder version of the truth, it is a -// different statement: it tells an auditor their organisation has imported -// nothing, and points them at a task they have already done. An unreadable -// posture and an empty one need opposite responses, so they now render -// differently. - -import { useNavigate } from 'react-router'; -import { ClipboardCheck, Layers, AlertTriangle, ListChecks } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useComplianceOverview, frameworkColorFor } from '../compliance/complianceOverview'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -export function AuditDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const query = useComplianceOverview(); - const { data, isLoading } = query; - const frameworks = data ?? []; - - const totalControls = frameworks.reduce((s, f) => s + f.total, 0); - const passed = frameworks.reduce((s, f) => s + f.passed, 0); - const gaps = Math.max(0, totalControls - passed); - const avg = frameworks.length - ? Math.round(frameworks.reduce((s, f) => s + f.pct, 0) / frameworks.length) - : 0; - - // All four tiles read the same query, so they share one "did this - // genuinely fetch during this mount" signal. - const fresh = query.isFetchedAfterMount; - const kpis: KpiSpec[] = [ - { - label: tr('Référentiels', 'Frameworks'), - val: frameworks.length, - icon: Layers, - col: 'var(--accent)', - onClick: () => navigate('/compliance'), - fresh, - }, - { - label: tr('Couverture moy.', 'Avg. coverage'), - val: avg, - icon: ClipboardCheck, - col: 'var(--low)', - suffix: '%', - onClick: () => navigate('/compliance'), - fresh, - }, - { - label: tr('Contrôles', 'Controls'), - val: totalControls, - icon: ListChecks, - col: 'var(--high)', - onClick: () => navigate('/compliance'), - fresh, - }, - { - label: tr('Écarts', 'Gaps'), - val: gaps, - icon: AlertTriangle, - col: 'var(--critical)', - onClick: () => navigate('/compliance/gaps'), - fresh, - }, - ]; - - return ( - - navigate('/compliance/audits')} - /> - -
- void query.refetch()} - > - - -
- -
- -
-
- {tr('Couverture par référentiel', 'Coverage by framework')} -
- -
- void query.refetch()} - isEmpty={frameworks.length === 0} - emptyIcon={Layers} - emptyTitle={tr('Aucun référentiel importé', 'No framework imported')} - emptyDescription={tr( - 'Importez ISO 27001, NIST CSF, PCI DSS ou l’un des autres catalogues : la couverture par référentiel se calcule ensuite automatiquement.', - 'Import ISO 27001, NIST CSF, PCI DSS or one of the other catalogues: coverage is computed from there automatically.', - )} - emptyAction={ - - } - > -
- {frameworks.map((f, i) => { - const col = frameworkColorFor(f.name, i); - return ( - - ); - })} -
-
-
- - -
{tr('Actions', 'Actions')}
-
- {[ - { - label: tr('Analyse des écarts', 'Gap analysis'), - sub: tr('Ce qui reste à implémenter', 'What is left to implement'), - to: '/compliance/gaps', - icon: AlertTriangle, - col: 'var(--critical)', - }, - { - label: tr('Audits', 'Audits'), - sub: tr('Planifier & suivre', 'Plan & track'), - to: '/compliance/audits', - icon: ClipboardCheck, - col: 'var(--accent)', - }, - { - label: tr('Remédiations', 'Remediations'), - sub: tr('Plans en cours', 'Open plans'), - to: '/compliance/remediation', - icon: ListChecks, - col: 'var(--high)', - }, - ].map((a) => { - const Icon = a.icon; - return ( - - ); - })} -
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/ConsoleDashboard.tsx b/frontend/src/features/dashboard/ConsoleDashboard.tsx new file mode 100644 index 00000000..e0027863 --- /dev/null +++ b/frontend/src/features/dashboard/ConsoleDashboard.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// +// The dashboard of the October 2026 redesign (#901). +// +// One layout for every member, read top to bottom: the incident that needs +// attention now, the exposure score, the money at stake and four counters, +// the year of the score next to the risk matrix, then what to do next and +// what just happened. The member's business role picks which four counters +// and which priorities they see (dashboardVariant.ts); there is no role switch +// on screen. +// +// Every block fetches its own tenant-scoped source and renders its own +// loading, error and empty state, so one failing source never blanks the page. + +import { useAuthStore } from '../../hooks/useAuthStore'; +import { useI18n } from '../../hooks/useI18n'; +import { formatDate, formatTime } from '../../i18n/format'; +import { PageFrame, PageHeader } from '../../shared/ui'; +import { MFAEnrollmentBanner } from '../auth/MFAEnrollmentBanner'; +import { MFAPostAhaPrompt } from '../auth/MFAPostAhaPrompt'; +import { OnboardingChecklist } from '../onboarding/OnboardingChecklist'; +import { useDashboardStats } from './useCommandCenter'; +import { variantFor } from './dashboardVariant'; +import { IncidentBanner } from './console/IncidentBanner'; +import { ScoreCard } from './console/ScoreCard'; +import { ExposureCard } from './console/ExposureCard'; +import { CounterList } from './console/CounterList'; +import { ScoreTrendCard } from './console/ScoreTrendCard'; +import { RiskMatrixCard } from './console/RiskMatrixCard'; +import { PriorityList } from './console/PriorityList'; +import { RecentActivity } from './console/RecentActivity'; + +const ALL_TIME = { kind: 'preset', preset: 'all' } as const; + +export function ConsoleDashboard() { + const { t, locale } = useI18n(); + const variant = variantFor(useAuthStore((s) => s.user?.business_role)); + // The register aggregate is the page's reference read: its timestamp is the + // "données à" of the eyebrow. + const stats = useDashboardStats(ALL_TIME); + const asOf = stats.dataUpdatedAt ? new Date(stats.dataUpdatedAt) : new Date(); + const day = formatDate(locale, new Date(), { dateStyle: 'full' }); + + return ( + + + + + {/* Account and onboarding prompts sit under the incident line: an attack + in progress outranks a setup reminder. */} + + + + +
+ + + +
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index 22e9281b..b2d848f2 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -1,995 +1,19 @@ // Copyright (c) 2026 OpenDefender Contributors // SPDX-License-Identifier: AGPL-3.0-only // -// The Security Command Center. +// The landing page. // -// Every number on this page comes from a tenant-scoped server aggregate, and -// every tile links to the register rows that produced it, carrying the filter -// the tile expresses. There are no fixtures, no fallbacks and no client-side -// arithmetic over a page of results: a widget that cannot read its data says so -// rather than showing a plausible zero. -// -// See docs/W0-06_SECURITY_COMMAND_CENTER.md for the contract inventory. +// Every member gets the redesigned dashboard (#901); their business role picks +// its counters and priorities. ?view=executive switches to the consolidated +// executive view, which answers the same "how are we doing" question for a +// committee. /analytics still redirects there, so old links resolve. -import { localeTag } from '../../i18n/locales'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { - ShieldAlert, - AlertTriangle, - ShieldCheck, - CheckCircle2, - FileText, - Zap, - Plus, - Grid3x3, - TrendingUp, - type LucideIcon, -} from 'lucide-react'; -import { useRiskStore } from '../../hooks/useRiskStore'; -import { useUIStore } from '../../store/uiStore'; -import { useAuthStore } from '../../hooks/useAuthStore'; -import { useUIStrings } from '../../shared/uiStrings'; -import { critColor, frameworkColor, softFill, type Criticality } from '../../shared/riskColors'; -import { - useDashboardStats, - type DashboardStats, - type MatrixCell, - type RiskTrend, -} from './useCommandCenter'; -import { useDashboardPeriod, periodLabel, type PeriodSelection } from './period'; -import { deepLink } from './deepLinks'; -import { PeriodControl } from './PeriodControl'; -import { WidgetState } from './WidgetState'; -import { SlotReel } from '../../shared/ds/SlotReel'; -import { useScore } from '../../hooks/useScore'; -import { ScoreGauge } from '../../shared/ScoreGauge'; -import { useScoreCardTilt } from './useScoreCardTilt'; -import { EmptyState } from '../../shared/EmptyState'; -import { Btn } from '../../shared/ui'; -import { useIncidents } from '../incidents/useIncidents'; -import { OnboardingChecklist } from '../onboarding/OnboardingChecklist'; -import { MFAEnrollmentBanner } from '../auth/MFAEnrollmentBanner'; -import { MFAPostAhaPrompt } from '../auth/MFAPostAhaPrompt'; -import { ActionCenterPanel } from '../action-center/ActionCenterPanel'; -import { personaFor } from './dashboardPersona'; -import { AnalystDashboard } from './AnalystDashboard'; -import { ExecDashboard } from './ExecDashboard'; -import { AuditDashboard } from './AuditDashboard'; -import { EstateDashboard } from './EstateDashboard'; -import { ViewerDashboard } from './ViewerDashboard'; +import { useSearchParams } from 'react-router'; import { ExecutiveDashboard } from '../analytics/ExecutiveDashboard'; -import type { LocaleCode } from '../../i18n/locales'; - -const Card = ({ - children, - className = '', - style, -}: { - children: React.ReactNode; - className?: string; - style?: React.CSSProperties; -}) => ( -
- {children} -
-); - -interface RecentRisk { - id: string; - rawId: string; - name: string; - crit: Criticality; - score: number; - meta: string; - fw: string; -} +import { ConsoleDashboard } from './ConsoleDashboard'; -/* ---------------- persona dispatcher ---------------- */ - -// The dashboard adapts to the member's GRC role (UX-2). Each persona renders its -// own real-data view; admins and unmapped roles get the full posture dashboard. -// -// ?view=executive overrides the persona with the consolidated executive view. -// That view used to live at /analytics, filed under Reports — which is why -// people went looking for it beside the PDFs. It answers "how are we doing", -// which is the dashboard's own question, so it is a display mode here rather -// than a destination of its own. /analytics still redirects to ?view=executive -// so existing links and bookmarks resolve. export const DashboardPage = () => { - const businessRole = useAuthStore((s) => s.user?.business_role); - const persona = personaFor(businessRole); const [params] = useSearchParams(); if (params.get('view') === 'executive') return ; - switch (persona) { - case 'analyst': - return ; - case 'exec': - return ; - case 'audit': - return ; - case 'estate': - return ; - case 'viewer': - return ; - default: - return ; - } + return ; }; - -/* ---------------- posture persona (default: RSSI / risk roles / admin) ---------------- */ - -function PostureDashboard() { - const navigate = useNavigate(); - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const risks = useRiskStore((s) => s.risks); - const fetchRisks = useRiskStore((s) => s.fetchRisks); - - useEffect(() => { - fetchRisks?.().catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const critOf = (r: (typeof risks)[number]): Criticality => - // The server's criticality band (derived from the score by the ScoreWorker). - // `level` is a legacy field that is not reliably populated — it rendered - // every risk as medium. Prefer criticality; deriving a band from the number - // here is the mapping that drifted away from the number itself. - ((r.criticality ?? r.level)?.toLowerCase() as Criticality) || 'low'; - - // The period lives in the URL, so it survives a reload and can be pasted. - const { selection, setSelection } = useDashboardPeriod(); - const statsQuery = useDashboardStats(selection); - const stats = statsQuery.data; - - // The canonical tenant score — the same query key the sidebar and /score use, - // so the three render one object from one fetch and cannot disagree. - const { - data: tenantScore, - isLoading: scoreLoading, - isError: scoreError, - isFetchedAfterMount: scoreFresh, - } = useScore('tenant'); - const scoreCardRef = useRef(null); - useScoreCardTilt(scoreCardRef); - const user = useAuthStore((s) => s.user); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const firstName = (user?.full_name || '').trim().split(/\s+/)[0] || user?.username || ''; - const greeting = `${tr('Bonjour', 'Hello')}${firstName ? `, ${firstName}` : ''}`; - const sev = stats?.risks_by_severity ?? {}; - - // Every KPI is read straight off the server aggregate. These used to fall back - // to risks.filter(...).length over the risk store, which holds ONE PAGE of the - // register — so "12 en cours" meant "12 on the page you happen to be on" and - // silently disagreed with the register itself. There is no client-side count - // here on purpose, and no `?? 0` either: when the aggregate has not arrived the - // tiles render a loading state, and when it FAILED they render an error state. - // A zero that was never read is the most reassuring thing this page can print, - // and the least true. - const kpis = useMemo( - () => ({ - total: stats?.total_risks ?? 0, - critical: sev.CRITICAL ?? sev.critical ?? 0, - mitig: stats?.in_progress_risks ?? 0, - resolved: stats?.mitigated_risks ?? 0, - // eslint-disable-next-line react-hooks/exhaustive-deps - }), - [stats], - ); - - const recent: RecentRisk[] = useMemo(() => { - return risks.slice(0, 5).map((r) => ({ - rawId: r.id, - id: r.id.length > 10 ? `#${r.id.slice(0, 8)}` : r.id, - name: r.title, - crit: critOf(r), - score: r.score, - meta: r.assets?.[0]?.name ?? '—', - fw: r.frameworks?.[0] ?? r.tags?.[0] ?? '—', - })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [risks]); - - const fmt = (n: number) => Math.round(n).toLocaleString(localeTag(lang)); - - return ( -
-
- {/* header */} -
-
-

{greeting}

-
{L.dashSub}
-
-
- - -
-
- - {/* Get-started panel. It takes no props on purpose: the steps, their - completion and the celebration all come from GET /activation/state. - Deriving them here from local counts is what used to make it tick - unreliably and fire confetti at random. */} - {/* OR26-03 — the non-blocking MFA prompt. It replaces the enrolment wall - that used to stand between a new account and its first screen: the - server now lets eligible users in and this says what they should do - about it. Enforcement is the server's (MFAPolicyGuard) — dismissing - this changes nothing about who is allowed in. */} - - - - - {/* And the second ask, once the Aha moment has actually been reached. - The trigger is activation.aha_reached_at, recorded server-side from - the user's own data — never a flag this component sets. */} - - - {/* #430 — the Action Center, above the score and the KPIs on purpose: - "what should I do next" outranks "how are we doing" on the screen a - user lands on. DashboardShell mounts the same panel for the other - personas; this dashboard does not use that shell. */} - - - {/* row 1 — score hero + kpis */} -
- {/* One score, one source. This used to read stats.global_risk_score - while the sidebar read analytics/executive → cyber_score.score: - two quantities on two scales pointing in opposite directions, - both labelled "score". Both now read the same query key, and the - competing formula has been removed from /stats entirely. */} - {/* #856 — the tilt lives on this wrapper, never inside ScoreGauge, so - the arc and the figure move with the card as one surface. `grid` - keeps the card stretched to the row height it had as a direct - grid item. */} -
- navigate('/score')} - /> -
- -
- - {/* row 2 — heatmap + trend */} -
- void statsQuery.refetch()} - /> - void statsQuery.refetch()} - selection={selection} - onWidenPeriod={() => setSelection({ kind: 'preset', preset: 'all' })} - /> -
- - {/* row 3 — recent + war room */} -
- - navigate(id ? `/incidents/${id}/war-room` : '/incidents')} /> -
-
-
- ); -} - -/* ---------------- KPI grid ---------------- */ - -interface StatsQuery { - isLoading: boolean; - error: unknown; - refetch: () => unknown; - /** True once this query has genuinely fetched during THIS mount, as - * opposed to serving cached data from an earlier visit — TanStack Query's - * own signal for exactly that distinction. */ - isFetchedAfterMount: boolean; -} - -function KpiGrid({ - values, - fmt, - lang, - query, - openedInPeriod, - selection, -}: { - values: { total: number; critical: number; mitig: number; resolved: number }; - fmt: (n: number) => string; - lang: LocaleCode; - query: StatsQuery; - openedInPeriod: number; - selection: PeriodSelection; -}) { - const L = useUIStrings(); - const navigate = useNavigate(); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - - // Each tile carries the filter it expresses onto the register. Clicking - // "Critical — 3" used to land on an unfiltered list of everything and leave the - // user to rebuild by hand the filter they had just clicked. - // - // The sort travels too: a critical-risks link that opens sorted by score puts - // the three rows the tile counted at the top of the page, which is what the - // user came for. - const sort = { key: 'score', dir: 'desc' } as const; - const data: { - label: string; - val: number; - icon: LucideIcon; - col: string; - to: string; - hint: string; - }[] = [ - { - label: L.kpiTotal, - val: values.total, - icon: ShieldAlert, - col: 'var(--accent)', - to: deepLink('risks', { sort }), - hint: tr('Tous les risques du registre', 'Every risk in the register'), - }, - { - label: L.kpiCrit, - val: values.critical, - icon: AlertTriangle, - col: 'var(--critical)', - to: deepLink('risks', { filters: { criticality: 'critical' }, sort }), - hint: tr( - 'Registre filtré sur criticité = critique', - 'Register filtered to criticality = critical', - ), - }, - { - label: L.kpiMiti, - val: values.mitig, - icon: ShieldCheck, - col: 'var(--high)', - to: deepLink('risks', { filters: { status: 'in_progress' }, sort }), - hint: tr( - 'Registre filtré sur statut = en cours', - 'Register filtered to status = in progress', - ), - }, - { - label: L.kpiResolved, - val: values.resolved, - icon: CheckCircle2, - col: 'var(--low)', - to: deepLink('risks', { filters: { status: 'mitigated' }, sort }), - hint: tr('Registre filtré sur statut = atténué', 'Register filtered to status = mitigated'), - }, - ]; - - return ( -
- query.refetch()} - > - <> - {data.map((d) => ( - navigate(d.to)} - /> - ))} - {/* The one period-scoped counter in this block, labelled with the - window so it cannot be read as a stock. */} -
- - {tr( - `${fmt(openedInPeriod)} ouvert(s) sur la période (${periodLabel(selection, lang)})`, - `${fmt(openedInPeriod)} opened in the selected period (${periodLabel(selection, lang)})`, - )} -
- -
-
- ); -} - -function KpiCard({ - label, - val, - icon: Icon, - col, - fmt, - lang, - onClick, - hint, - fresh, -}: { - label: string; - val: number; - icon: LucideIcon; - col: string; - fmt: (n: number) => string; - lang: LocaleCode; - onClick: () => void; - hint: string; - /** True when `val` was genuinely fetched during THIS mount — see - * `StatsQuery.isFetchedAfterMount`. Rolls the number in from 0 once. */ - fresh?: boolean; -}) { - return ( - - ); -} - -/* ---------------- Heatmap ---------------- */ -// The 25 cell counts come from the /stats risk_matrix aggregate, banded in SQL. -// They used to be a hardcoded literal, which meant a brand-new tenant with an -// empty register was shown a fully populated probability x impact matrix — the -// single most trust-destroying thing the dashboard did. -function HeatmapCard({ - matrix, - isLoading, - error, - retry, -}: { - matrix?: MatrixCell[]; - isLoading: boolean; - error: unknown; - retry: () => void; -}) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const navigate = useNavigate(); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const canCreate = useAuthStore((s) => s.hasPermission('risks:create')); - - const counts: Record = {}; - for (const cell of matrix ?? []) counts[`${cell.impact}-${cell.probability}`] = cell.count; - const total = (matrix ?? []).reduce((n, c) => n + c.count, 0); - - // Tints the 5×5 grid's own cells from their COORDINATES (bucket × bucket, - // 1–25) so the map reads as a heat map. No risk's score and no risk's label is - // derived from it — it is a legend for the grid, not a score band. Named and - // commented so it is never mistaken for one (see docs/scoring/SCORE_MODEL.md). - const cellCol = (pBucket: number, iBucket: number) => { - const v = pBucket * iBucket; // 1..25, the grid's own coordinates - return v >= 15 - ? 'var(--critical)' - : v >= 8 - ? 'var(--high)' - : v >= 4 - ? 'var(--medium)' - : 'var(--low)'; - }; - const rows = []; - for (let i = 5; i >= 1; i--) { - const cells = []; - for (let p = 1; p <= 5; p++) { - const c = counts[`${i}-${p}`] ?? 0; - const col = cellCol(p, i); - cells.push( -
- {c ? ( - - {c} - - ) : null} -
, - ); - } - rows.push( -
- {cells} -
, - ); - } - - return ( - -
{L.heatTitle}
- - window.dispatchEvent(new CustomEvent('openrisk:new-risk'))} - /> - navigate('/risks/import')} /> - - ) : undefined - } - > -
-
- - {L.impact} - -
-
-
{rows}
-
- {L.proba} -
-
-
-
-
- ); -} - -/* ---------------- Trend ---------------- */ -// -// The series comes from the server, over the period selected on this page. -// -// It used to be computed here, in the browser, from useRiskStore.risks — which -// holds ONE PAGE of the register. A tenant with 200 risks saw a trend of the 20 -// on page one. It banded them on `level`, a field this same file documents as -// unreliable, and it plotted a cumulative count by created_at, so the line could -// only ever rise regardless of what the team actually did. Its 7/30/90 toggle was -// local component state that changed nothing else on the page. -// -// Two series now, and they are labelled apart because they answer different -// questions: `opened` is a FLOW (risks created in each bucket) and -// `cumulative_total` is a STOCK (risks in existence at each bucket's end). The -// band split uses each risk's CURRENT criticality — the register does not version -// criticality per day, and inventing a per-day band would be a number nobody -// could reconcile. -// Exported for its tests. The decision this component makes — "no risks" versus -// "no risks in THIS window" — was wrong once (#343) and the two states have -// opposite remedies, so it is pinned by tests rather than left to inspection. -export function TrendCard({ - trend, - registerTotal, - isLoading, - error, - retry, - selection, - onWidenPeriod, -}: { - trend?: RiskTrend; - /** - * The register's own total, from the same payload. It is what decides which - * empty state to show, and it has to come from here rather than be derived - * from the series — see the comment on `emptyBecauseOfPeriod` below. - */ - registerTotal: number; - isLoading: boolean; - error: unknown; - retry: () => void; - selection: PeriodSelection; - onWidenPeriod: () => void; -}) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - - // Memoised because `?? []` allocates a fresh array on every render, which - // would re-run the series useMemo below every time regardless of the data. - const points = useMemo(() => trend?.points ?? [], [trend]); - const opened = points.reduce((n, p) => n + p.opened, 0); - const lastCumulative = points.length ? points[points.length - 1].cumulative_total : 0; - // Nothing opened in the window, but the register is not empty: that is a period - // problem, not an empty register, and the two have opposite remedies. - // - // This reads the REGISTER's total, not the series' last cumulative point. The - // first version used the cumulative, and it was wrong for every window in the - // past: `cumulative_total` counts risks created BEFORE each bucket, so a - // January window over a register first populated in August reads 0 and the - // widget concluded the tenant had no risks. Driving it live is what showed a - // tenant with eight risks being told "no trend yet — the line builds up as - // risks are opened", which is precisely the wrong fact with the opposite - // remedy attached. - const emptyBecauseOfPeriod = opened === 0 && registerTotal > 0; - - const W = 300, - H = 120, - pad = 8; - const series = useMemo(() => { - const band = (key: string) => points.map((p) => p.opened_by_band?.[key] ?? 0); - return { crit: band('CRITICAL'), high: band('HIGH'), med: band('MEDIUM') }; - }, [points]); - const allMax = Math.max(1, ...series.crit, ...series.high, ...series.med); - const line = (arr: number[]) => { - const step = (W - pad * 2) / Math.max(1, arr.length - 1); - return arr - .map((v, i) => `${pad + i * step},${H - pad - (v / allMax) * (H - pad * 2)}`) - .join(' '); - }; - const leg = (col: string, lbl: string) => ( - - - {lbl} - - ); - - return ( - -
-
{L.trendTitle}
-
{periodLabel(selection, lang)}
-
- {/* Say what is plotted. "Trend" alone let a cumulative-only line pass for - an improving or worsening posture. */} -
- {tr('Risques ouverts par ', 'Risks opened per ')} - {trend?.granularity === 'week' ? tr('semaine', 'week') : tr('jour', 'day')} - {tr(', par criticité actuelle', ', by current criticality')} -
- - <> -
- {leg('var(--critical)', L.critical)} - {leg('var(--high)', L.high)} - {leg('var(--medium)', L.medium)} -
- a + b, 0)} critiques, ${series.high.reduce((a, b) => a + b, 0)} élevés, ${series.med.reduce((a, b) => a + b, 0)} moyens. Total ${opened}.`, - `Risks opened over ${periodLabel(selection, lang)}: ${series.crit.reduce((a, b) => a + b, 0)} critical, ${series.high.reduce((a, b) => a + b, 0)} high, ${series.med.reduce((a, b) => a + b, 0)} medium. Total ${opened}.`, - )} - > - {[1, 2, 3].map((i) => ( - - ))} - - - - - {/* The stock, in words rather than as a second line that would be read - against the same axis as the flow. */} -
- {tr( - `${opened} ouvert(s) · ${lastCumulative} au registre en fin de période`, - `${opened} opened · ${lastCumulative} in the register at the end of the period`, - )} -
- -
-
- ); -} - -/* ---------------- Recent activity ---------------- */ -function RecentActivityCard({ risks }: { risks: RecentRisk[] }) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const navigate = useNavigate(); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const canCreate = useAuthStore((s) => s.hasPermission('risks:create')); - return ( - -
-
{L.recentTitle}
- -
-
- {risks.length === 0 && ( - window.dispatchEvent(new CustomEvent('openrisk:new-risk'))} - /> - ) : undefined - } - className="py-10" - /> - )} - {risks.map((r) => ( - - ))} -
-
- ); -} - -/* ---------------- War Room widget ---------------- */ -// Real "incident in progress": the most recent open/in-progress incident for this -// tenant with a live duration; an honest empty state when there is none (no more -// hardcoded INC-2026-014). -const SEV_COLOR = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', -} as const; -function WarRoomCard({ onJoin }: { onJoin: (incidentId?: number) => void }) { - const L = useUIStrings(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const { incidents } = useIncidents({ limit: 20 }); - const [now, setNow] = useState(() => Date.now()); - const active = (incidents ?? []).filter((i) => i.status === 'open' || i.status === 'in_progress'); - const inc = active - .slice() - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; - useEffect(() => { - if (!inc) return; - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); - }, [inc?.id]); - - if (!inc) { - return ( -
-
- - - {L.warTitle} - -
-
- -
- {tr('Aucun incident en cours', 'No active incident')} -
-
- {tr( - 'Tout est calme. Les incidents ouverts s’afficheront ici.', - 'All clear. Open incidents will appear here.', - )} -
-
- -
- ); - } - - const sevColor = SEV_COLOR[inc.severity] ?? 'var(--high)'; - const elapsed = Math.max(0, Math.floor((now - new Date(inc.created_at).getTime()) / 1000)); - const hh = String(Math.floor(elapsed / 3600)).padStart(2, '0'); - const mm = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0'); - const ss = String(elapsed % 60).padStart(2, '0'); - return ( -
-
- - - {L.warTitle} - -
-
- INC-{inc.id} · {inc.title} -
-
- {inc.severity} ·{' '} - {inc.status === 'in_progress' ? tr('en cours', 'in progress') : tr('ouvert', 'open')} -
-
-
-
- {hh}:{mm}:{ss} -
-
{tr('Durée', 'Duration')}
-
-
- -
- ); -} - -export type { DashboardStats }; diff --git a/frontend/src/features/dashboard/EstateDashboard.tsx b/frontend/src/features/dashboard/EstateDashboard.tsx deleted file mode 100644 index c9a27bcc..00000000 --- a/frontend/src/features/dashboard/EstateDashboard.tsx +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// DSI / asset-owner dashboard persona (UX-2). Leads with the estate: inventory -// size, criticality mix, and the most critical assets. -// -// W0-06 moved the counting to the server. Every number here used to be a -// reduction over the whole GET /assets collection — which returns the entire -// inventory WITH its many-to-many risk associations preloaded, because that is -// what the topology graph needs. The counts were right; obtaining them cost the -// estate crossing the wire on every dashboard paint, and grew with the tenant. -// -// GET /assets/statistics answers the same question in one grouped pass, and -// answers two the client could not: how many assets carry no category -// (everything written before typed attributes shipped) and how many distinct -// type labels exist beyond the twelve shown. The list of critical assets still -// comes from the collection — it needs the rows, not a count. - -import { useMemo } from 'react'; -import { useNavigate } from 'react-router'; -import { Database, ShieldAlert, AlertTriangle, Boxes } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useAuthStore } from '../../hooks/useAuthStore'; -import { useAssets } from '../assets/useAssets'; -import { useAssetStatistics } from './useCommandCenter'; -import { useDashboardPeriod, periodLabel } from './period'; -import { deepLink } from './deepLinks'; -import { PeriodControl } from './PeriodControl'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -const CRIT_COL: Record = { - CRITICAL: 'var(--critical)', - HIGH: 'var(--high)', - MEDIUM: 'var(--medium)', - LOW: 'var(--low)', -}; -const CRIT_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; - -export function EstateDashboard() { - const navigate = useNavigate(); - const canCreateAsset = useAuthStore((s) => s.hasPermission('assets:create')); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const { selection, setSelection } = useDashboardPeriod(); - const statsQuery = useAssetStatistics(selection); - const stats = statsQuery.data; - const { assets, isLoading: assetsLoading, error: assetsError } = useAssets(); - - const norm = (c?: string) => (c ?? 'LOW').toUpperCase(); - const byCrit = stats?.by_criticality ?? {}; - - const critical = useMemo( - () => - [...assets] - .filter((a) => ['CRITICAL', 'HIGH'].includes(norm(a.criticality))) - .sort( - (a, b) => - CRIT_ORDER.indexOf(norm(a.criticality)) - CRIT_ORDER.indexOf(norm(b.criticality)), - ) - .slice(0, 6), - [assets], - ); - - // Each tile carries its own filter into the inventory. "Critical — 7" opens - // the seven assets it counted, not the whole estate. All four read the same - // query, so they share one "did this genuinely fetch during this mount" - // signal. - const fresh = statsQuery.isFetchedAfterMount; - const kpis: KpiSpec[] = [ - { - label: tr('Actifs', 'Assets'), - val: stats?.total ?? 0, - icon: Database, - col: 'var(--accent)', - onClick: () => navigate(deepLink('assets')), - fresh, - }, - { - label: tr('Critiques', 'Critical'), - val: byCrit.CRITICAL ?? 0, - icon: AlertTriangle, - col: 'var(--critical)', - onClick: () => navigate(deepLink('assets', { filters: { criticality: 'critical' } })), - fresh, - }, - { - label: tr('Élevés', 'High'), - val: byCrit.HIGH ?? 0, - icon: ShieldAlert, - col: 'var(--high)', - onClick: () => navigate(deepLink('assets', { filters: { criticality: 'high' } })), - fresh, - }, - { - label: tr('Types', 'Types'), - val: stats?.distinct_types ?? 0, - icon: Boxes, - col: 'var(--low)', - onClick: () => navigate(deepLink('assets')), - fresh, - }, - ]; - - const maxCrit = Math.max(1, ...CRIT_ORDER.map((c) => byCrit[c] ?? 0)); - - return ( - -
-
- navigate('/assets/topology')} - /> -
- -
- -
- void statsQuery.refetch()} - > - <> - -
- - {tr( - `${stats?.added_in_period ?? 0} ajouté(s) sur la période (${periodLabel(selection, lang)})`, - `${stats?.added_in_period ?? 0} added in the selected period (${periodLabel(selection, lang)})`, - )} - - {/* Stated rather than dropped: these rows are part of the total - above, and a breakdown that hid them would not add up to it. */} - {!!stats?.uncategorised && ( - - {tr( - `${stats.uncategorised} sans catégorie`, - `${stats.uncategorised} with no category`, - )} - - )} - {!!stats?.types_truncated && ( - - {tr( - `+${stats.types_truncated} autre(s) type(s) non affiché(s)`, - `+${stats.types_truncated} more type(s) not shown`, - )} - - )} -
- -
-
- -
- -
-
- {tr('Actifs critiques', 'Critical assets')} -
- -
- navigate('/assets')} - className="h-[34px] px-4 rounded-[9px] text-[12.5px] font-semibold text-fg-primary" - style={{ background: 'var(--accent)' }} - > - {tr('Nouvel actif', 'New asset')} - - ) : undefined - } - > - <> - {critical.map((a) => { - const col = CRIT_COL[norm(a.criticality)] ?? 'var(--fg-muted)'; - return ( - - ); - })} - - -
- - -
- {tr('Par criticité', 'By criticality')} -
- void statsQuery.refetch()} - > -
- {CRIT_ORDER.map((c) => { - const n = byCrit[c] ?? 0; - const col = CRIT_COL[c]; - return ( - - ); - })} -
-
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/ExecDashboard.tsx b/frontend/src/features/dashboard/ExecDashboard.tsx deleted file mode 100644 index 58fc435f..00000000 --- a/frontend/src/features/dashboard/ExecDashboard.tsx +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Executive dashboard persona (UX-2). Leads with cost & KPIs, no technical detail: -// the security score, financial exposure (ALE, FCFA) and the key risk indicators. -// Real data from the consolidated /analytics/executive endpoint. -// -// #287: the score here used to be an A–F "cyber score" from a second formula, -// pointing the opposite way from the sidebar's (higher = safer), and it read -// 50/E on an empty tenant while the home page read 0. It is now the canonical -// tenant score, from the same query key as every other surface. -// -// W0-06: every value on this screen was `?? 0`. A failed fetch of -// /analytics/executive rendered a cyber score of 0 with grade F, an annual -// exposure of 0 FCFA and an empty KRI band — a complete, plausible, entirely -// unread executive briefing. On the persona whose whole job is to act on these -// numbers, and with no loading state to suggest anything was still in flight. -// -// Nothing is rendered now until it has been read. A failure says so, and offers -// a retry; a genuine zero is still a zero, and is now distinguishable from one. - -import { localeTag } from '../../i18n/locales'; -import { useNavigate } from 'react-router'; -import { Coins, AlertTriangle, ShieldAlert, CheckCircle2 } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useExecutiveDashboard } from '../analytics/useExecutive'; -import { deepLink } from './deepLinks'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, StatCard, Card } from './shared'; -import { useScore } from '../../hooks/useScore'; -import { ScoreGauge } from '../../shared/ScoreGauge'; - -const KRI_COL: Record = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', - info: 'var(--fg-muted)', -}; - -export function ExecDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const query = useExecutiveDashboard(); - const data = query.data; - const tenantScore = useScore('tenant'); - const fin = data?.financial; - const kris = data?.kris ?? []; - - const money = (n: number) => - new Intl.NumberFormat(localeTag(lang), { - notation: n >= 1_000_000 ? 'compact' : 'standard', - maximumFractionDigits: 1, - }).format(n) + ' FCFA'; - const kriUnit = (u: string) => (u === '%' ? '%' : u === 'days' ? tr(' j', ' d') : ''); - - return ( - - navigate('/reports')} - /> - - void query.refetch()} - > -
- navigate('/score')} - /> -
- navigate('/analytics/financial')} - /> - navigate('/analytics/financial')} - /> - navigate(deepLink('risks', { sort: { key: 'score', dir: 'desc' } }))} - /> - navigate('/analytics/financial')} - /> -
-
-
- - -
-
- {tr('Indicateurs clés de risque (KRI)', 'Key risk indicators (KRI)')} -
- -
- void query.refetch()} - isEmpty={kris.length === 0} - emptyTitle={tr('Aucun indicateur calculable', 'No indicator can be computed yet')} - emptyDescription={tr( - 'Les KRI se calculent à partir des risques, des vulnérabilités, de la conformité et des incidents. Ils apparaissent dès que l’une de ces sources contient des données.', - 'KRIs are computed from risks, vulnerabilities, compliance and incidents. They appear as soon as any of those holds data.', - )} - > -
- {kris.map((k) => ( -
-
- {new Intl.NumberFormat(localeTag(lang), { - maximumFractionDigits: 1, - }).format(k.value)} - {kriUnit(k.unit)} -
-
{k.label}
-
- ))} -
-
-
-
- ); -} diff --git a/frontend/src/features/dashboard/PeriodControl.tsx b/frontend/src/features/dashboard/PeriodControl.tsx deleted file mode 100644 index 30b41601..00000000 --- a/frontend/src/features/dashboard/PeriodControl.tsx +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// The dashboard's period selector, and the sentence that keeps it honest. -// -// A global period control on a page of mixed widgets has one failure mode, and -// it is severe: it looks like it filters everything. It does not, and it cannot. -// The headline counters are STOCK quantities — how many critical risks exist — -// and narrowing them by created_at would answer "how many did we open last -// month" under a label that says otherwise. Only the flow widgets (the trend, the -// "opened"/"added" counters) are period-scoped. -// -// So the control states its own scope, in words, right under itself: it names -// what moves when you change it. That line is not decoration — it is the -// difference between a filter and a filter that misleads, and the brief this -// wave answers calls the alternative out by name. -// -// The selection lives in the URL (see ./period.ts), so the control has no state -// of its own to drift from what is displayed. - -import { useId, useState } from 'react'; -import { CalendarRange, Check } from 'lucide-react'; - -import { PERIOD_PRESETS, periodLabel, type PeriodSelection, type PeriodPreset } from './period'; -import type { LocaleCode } from '../../i18n/locales'; - -export function PeriodControl({ - selection, - onChange, - lang, - /** - * What this control actually narrows, in the caller's own words. Required: - * a period control that will not say what it filters should not ship. - */ - scopeNote, -}: { - selection: PeriodSelection; - onChange: (next: PeriodSelection) => void; - lang: LocaleCode; - scopeNote: string; -}) { - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const [customOpen, setCustomOpen] = useState(selection.kind === 'custom'); - const [from, setFrom] = useState(selection.kind === 'custom' ? selection.from : ''); - const [to, setTo] = useState(selection.kind === 'custom' ? selection.to : ''); - const groupId = useId(); - - const rangeValid = !!from && !!to && from < to; - - return ( -
-
- - {tr('Période du tableau de bord', 'Dashboard period')} - - {PERIOD_PRESETS.map((preset: PeriodPreset) => { - const active = selection.kind === 'preset' && selection.preset === preset; - return ( - - ); - })} - -
- - {customOpen && ( -
- - setFrom(e.target.value)} - className="h-[28px] px-2 rounded-[7px] text-[12px] text-ink bg-transparent" - style={{ border: '1px solid var(--border)' }} - /> - → - - setTo(e.target.value)} - className="h-[28px] px-2 rounded-[7px] text-[12px] text-ink bg-transparent" - style={{ border: '1px solid var(--border)' }} - /> - -
- )} - - {/* The scope sentence. The control filters some widgets and not others, so - it says which — rather than letting the user assume "all of them". */} -

- {scopeNote} -

-
- ); -} diff --git a/frontend/src/features/dashboard/ViewerDashboard.tsx b/frontend/src/features/dashboard/ViewerDashboard.tsx deleted file mode 100644 index 42abca0a..00000000 --- a/frontend/src/features/dashboard/ViewerDashboard.tsx +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// Viewer dashboard persona (UX-2). A light, read-only overview of the security -// posture — score, a few headline risk counts, and the most recent risks. No -// authoring actions, since a viewer can only read. -// -// W0-06 fixed three things here, all of the same kind. -// -// The score was `stats.global_risk_score`, a SECOND formula (100 − avg × 4) -// computed from the register alone, on the same 0–100 scale as the canonical -// model. A viewer and an admin looking at the same tenant saw two different -// security scores. It now reads the same query key as every other score surface -// in the product, so there is one answer in memory and they cannot disagree. -// -// The counters fell back to `risks.filter(...).length` whenever /stats failed — -// counts over ONE PAGE of the register, printed as tenant totals, on the persona -// least equipped to notice. The fallbacks are gone; a failed read renders an -// error state. -// -// The tiles and the rows now carry their filter into the register instead of -// dropping the user at the top of an unfiltered list. - -import { useEffect, useMemo } from 'react'; -import { useNavigate } from 'react-router'; -import { ShieldAlert, AlertTriangle, ShieldCheck } from 'lucide-react'; -import { useUIStore } from '../../store/uiStore'; -import { useRiskStore } from '../../hooks/useRiskStore'; -import { useScore } from '../../hooks/useScore'; -import { ScoreGauge } from '../../shared/ScoreGauge'; -import { critColor, type Criticality } from '../../shared/riskColors'; -import { useDashboardStats } from './useCommandCenter'; -import { useDashboardPeriod } from './period'; -import { deepLink } from './deepLinks'; -import { WidgetState } from './WidgetState'; -import { DashboardShell, PersonaHeader, KpiRow, Card, type KpiSpec } from './shared'; - -export function ViewerDashboard() { - const navigate = useNavigate(); - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const risks = useRiskStore((s) => s.risks); - const fetchRisks = useRiskStore((s) => s.fetchRisks); - const { selection } = useDashboardPeriod(); - const statsQuery = useDashboardStats(selection); - const stats = statsQuery.data; - // The canonical score, from the shared query key. - const { - data: tenantScore, - isLoading: scoreLoading, - isError: scoreError, - isFetchedAfterMount: scoreFresh, - } = useScore('tenant'); - - useEffect(() => { - fetchRisks?.().catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // The server's criticality, or nothing. No client fallback from the number: - // that is the mapping that drifted from the value beside it. - const critOf = (r: (typeof risks)[number]): Criticality => - ((r.criticality ?? r.level)?.toLowerCase() as Criticality) || 'low'; - const sev = stats?.risks_by_severity ?? {}; - const sort = { key: 'score', dir: 'desc' } as const; - - const kpis: KpiSpec[] = [ - { - label: tr('Risques', 'Risks'), - val: stats?.total_risks ?? 0, - icon: ShieldAlert, - col: 'var(--accent)', - onClick: () => navigate(deepLink('risks', { sort })), - fresh: statsQuery.isFetchedAfterMount, - }, - { - label: tr('Critiques', 'Critical'), - val: sev.CRITICAL ?? sev.critical ?? 0, - icon: AlertTriangle, - col: 'var(--critical)', - onClick: () => navigate(deepLink('risks', { filters: { criticality: 'critical' }, sort })), - fresh: statsQuery.isFetchedAfterMount, - }, - { - label: tr('Atténués', 'Mitigated'), - val: stats?.mitigated_risks ?? 0, - icon: ShieldCheck, - col: 'var(--low)', - onClick: () => navigate(deepLink('risks', { filters: { status: 'mitigated' }, sort })), - fresh: statsQuery.isFetchedAfterMount, - }, - ]; - - const recent = useMemo(() => risks.slice(0, 6), [risks]); - - return ( - - - -
- navigate('/score')} - /> -
- void statsQuery.refetch()} - > - - -
-
- - -
- {tr('Risques récents', 'Recent risks')} -
- {recent.length === 0 ? ( -
- {tr('Aucun risque à afficher.', 'No risks to show.')} -
- ) : ( - recent.map((r) => { - const crit = critOf(r); - return ( - - ); - }) - )} -
-
- ); -} diff --git a/frontend/src/features/dashboard/WidgetState.tsx b/frontend/src/features/dashboard/WidgetState.tsx deleted file mode 100644 index 71cdf819..00000000 --- a/frontend/src/features/dashboard/WidgetState.tsx +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// -// What a widget renders when it does not have its data. -// -// Five conditions, five different things to say, and the whole reason this -// component exists is that five personas collapsed them into one — usually into -// the most reassuring one available: -// -// loading we are asking -// error we asked and could not read it -// no-permission it exists; this account may not see it -// empty-period there is data, just none inside the selected window -// empty there is nothing to show yet -// -// Before this, the exec persona rendered every failure as `?? 0` — a cyber score -// of 0 and an annual exposure of 0 FCFA, indistinguishable from a genuinely -// measured zero. The audit persona rendered a failed compliance fetch as "No -// framework — import one to start", telling the user to fix a problem they did -// not have. The viewer persona fell back to counting one page of the register -// and printed the result as a tenant total. -// -// The distinction between the last two is the one people skip, and it is the one -// that matters most on a dashboard with a period control: "no risks were opened -// in the last 7 days" and "you have no risks" are opposite facts, and only one of -// them is a reason to press a Create button. - -import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { AlertTriangle, CalendarX, Lock, type LucideIcon } from 'lucide-react'; - -import { EmptyState } from '../../shared/EmptyState'; -import { Btn, Skeleton } from '../../shared/ui'; -import { isPermissionError } from './widgetError'; -import type { LocaleCode } from '../../i18n/locales'; - -/* A skeleton that would show for under this long (cached / near-instant data) - * never shows at all — the flash it would produce is worse than the half-beat - * of nothing it replaces. Not a token: this is a debounce window, not a - * motion duration: nothing animates during it. */ -const SKELETON_GRACE_MS = 100; - -/* NOT the source of truth — primitives.css is. Read back from the DOM (see - * useDensityRowHeight for the same pattern) so the exit timer cannot drift - * from what `duration-fast` actually does; this only stands in when the DOM - * can't be measured (SSR) or the token has gone missing. */ -const FALLBACK_DUR_FAST_MS = 120; - -function readTokenMs(varName: string, fallback: number): number { - if (typeof document === 'undefined') return fallback; - const raw = getComputedStyle(document.documentElement).getPropertyValue(varName); - const ms = Number.parseFloat(raw); - return Number.isFinite(ms) && ms > 0 ? ms : fallback; -} - -function prefersReducedMotion(): boolean { - return ( - typeof window !== 'undefined' && - typeof window.matchMedia === 'function' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ); -} - -/** - * The skeleton -> content reveal's timing, kept out of the component so the - * two guarantees it exists for are each one obvious branch: - * - * - `showSkeleton` only ever becomes true `SKELETON_GRACE_MS` after loading - * started, so data that lands before that never produces a skeleton frame - * to cross-fade away from. - * - once content has genuinely been shown, `contentShown` latches true for - * the rest of this instance's life — a later `isLoading=true` (a - * background refetch) cannot re-arm the grace timer or bring the skeleton - * back, which is the #823-class trap this whole feature exists to avoid. - * - * `exiting` is true for exactly one `--dur-fast` after loading ends, IF a - * skeleton was actually shown — that window is what lets the caller overlay - * a fading skeleton on top of entering content instead of hard-cutting - * between them. Settled on a timer, not `transitionend`: that event never - * fires once reduced motion has killed the transition, which would leave - * `exiting` stuck true for a user who, by definition, never sees one. - */ -function useSkeletonReveal(isLoading: boolean) { - const graceTimer = useRef | undefined>(undefined); - const exitTimer = useRef | undefined>(undefined); - - const [prevIsLoading, setPrevIsLoading] = useState(isLoading); - const [showSkeleton, setShowSkeleton] = useState(false); - const [exiting, setExiting] = useState(false); - const [contentShown, setContentShown] = useState(!isLoading); - - // React-documented "adjust state while rendering": reacts to `isLoading` - // changing in THIS commit rather than a tick later via an effect, and is - // what keeps every direct setState call out of a `useEffect` body below — - // both effects only ever call setState from inside a timer callback. - if (isLoading !== prevIsLoading) { - setPrevIsLoading(isLoading); - if (!isLoading) { - setContentShown(true); - setShowSkeleton(false); - // `showSkeleton` here is still THIS render's incoming value (the one - // committed before this adjustment), i.e. "was the skeleton actually - // on screen for the episode that just ended" — true only for a - // genuine first load past the grace window, never for a background - // refetch (which never arms the grace timer at all, see below), so a - // refetch cannot spuriously replay the exit cross-fade. - if (showSkeleton && !prefersReducedMotion()) { - setExiting(true); - } - } - } - - // Arms the grace timer while genuinely (still) loading for the first time; - // settles it via the timer's OWN callback, never directly in this body. - // Never re-arms once content has shown once — a background refetch must - // not bring the skeleton back. - useEffect(() => { - clearTimeout(graceTimer.current); - if (!isLoading || contentShown) return; - graceTimer.current = setTimeout(() => setShowSkeleton(true), SKELETON_GRACE_MS); - return () => clearTimeout(graceTimer.current); - }, [isLoading, contentShown]); - - // Settles `exiting` back to false after --dur-fast, via the timer's OWN - // callback — never on `transitionend`, which does not fire once reduced - // motion has killed the transition (and would leave this stuck true). - useEffect(() => { - if (!exiting) return; - exitTimer.current = setTimeout( - () => setExiting(false), - readTokenMs('--dur-fast', FALLBACK_DUR_FAST_MS), - ); - return () => clearTimeout(exitTimer.current); - }, [exiting]); - - return { showSkeleton, exiting, contentShown }; -} - -export interface WidgetStateProps { - lang: LocaleCode; - isLoading: boolean; - error: unknown; - /** True when the fetch succeeded and returned nothing to draw. */ - isEmpty?: boolean; - /** - * True when the window is what made it empty — there IS data outside it. - * Renders the "widen the period" state instead of the "create your first" - * state, because those have opposite remedies. - */ - emptyBecauseOfPeriod?: boolean; - /** Height of the loading skeleton, matched to the widget it stands in for. */ - skeletonHeight?: number; - retry?: () => void; - /** first-use copy: what this widget is for, and the action that fills it. */ - emptyTitle?: string; - emptyDescription?: string; - emptyIcon?: LucideIcon; - emptyAction?: ReactNode; - /** Widen the window — offered on the period-empty state. */ - onWidenPeriod?: () => void; - children: ReactNode; -} - -/** - * Render `children` when there is data, and the right honest state otherwise. - * - * Note what it does NOT do: it never renders `children` with a placeholder - * value substituted in. A widget either shows data it read or says why it - * cannot — there is no third rendering where a zero stands in for an answer. - */ -export function WidgetState({ - lang, - isLoading, - error, - isEmpty, - emptyBecauseOfPeriod, - skeletonHeight = 160, - retry, - emptyTitle, - emptyDescription, - emptyIcon, - emptyAction, - onWidenPeriod, - children, -}: WidgetStateProps) { - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); - const reveal = useSkeletonReveal(isLoading); - // Never shown content before, and still loading: `error`/`isEmpty` are not - // meaningful until a fetch has actually finished at least once. - const stillLoading = isLoading && !reveal.contentShown; - - if (stillLoading && !reveal.showSkeleton) { - // Inside the grace window: reserve the space, draw nothing. The - // alternative — showing the skeleton immediately — is exactly the flash - // this window exists to avoid for cached/near-instant data. - return