Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cabe7d6
Add config-driven dashboard widget pilot (agents_pilot, 3 widgets)
rksk Aug 1, 2026
f4f3c56
Add frontend for the config-driven dashboard widget pilot
rksk Aug 1, 2026
cdd0e79
Isolate dashboard widget failures to the widget that failed
rksk Aug 1, 2026
6b452f0
feat(csm-dashboard): make pilot widgets the default engineer dashboard
rksk Aug 1, 2026
40e6763
fix(csm-dashboard): return widget filter criteria instead of resolved…
rksk Aug 1, 2026
7cd7958
fix(csm-dashboard): resolve each widget's count from its own /cases/s…
rksk Aug 1, 2026
8a424dc
fix(csm-dashboard): address review findings on filter resolution and …
rksk Aug 1, 2026
aed6cb0
feat(csm-dashboard): add dashboard list endpoint, combine widgets int…
rksk Aug 1, 2026
bf96224
feat(csm-dashboard): source the dashboard switcher and default select…
rksk Aug 1, 2026
8de309b
feat(csm-dashboard): generalize widget schema to any resource type, r…
rksk Aug 1, 2026
e3bccb4
feat(csm-dashboard): generic resource-driven widget rendering, click-…
rksk Aug 1, 2026
fccd871
feat(csm-dashboard): load dashboard config from DASHBOARDS_CONFIG env…
rksk Aug 1, 2026
d927912
feat(csm-dashboard): remove the My ABT / All customers toggle
rksk Aug 1, 2026
3711ae7
feat(csm-dashboard): add isTeamBased flag and a team selector skeleton
rksk Aug 1, 2026
d003475
fix(csm-dashboard): address CodeRabbit review on cs-tools#1316
rksk Aug 1, 2026
5c126a5
fix(csm-dashboard): resolve __current_user__ via entity /users/me, no…
rksk Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/csm-portal/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ SCIM_SCOPES=
# (e.g. http://localhost:3001 for local dev)
# CSM_PORTAL_WEB_BASE_URL=

# Dashboard widget registry. Optional — a JSON array of Dashboard objects
# (see internal/dashboard/widgets.go's Dashboard/WidgetTemplate json tags for
# the exact shape). Loaded once at process startup only: there is no
# file-watching, hot-reload, or admin endpoint — changing this value requires
# restarting the backend process. Left unset or malformed, GET /dashboards
# returns an empty list and GET /dashboards/{id} 404s for every id; startup
# and every other endpoint work normally (an error is logged, not a crash).
# Example (the pilot's 5 dashboards):
# DASHBOARDS_CONFIG='[{"id":"agents_pilot","displayName":"Engineer overview","isDefault":true,"targetTeam":"cs_engineers","widgets":[{"id":"my_patches","displayName":"My Patches","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"tags":["patch"],"states":["open","work_in_progress","waiting_on_wso2","reopened","awaiting_info"]}},{"id":"my_reminders","displayName":"My Reminders","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"states":["awaiting_info","solution_proposed"]}},{"id":"open_incident_team","displayName":"Open Incident (Team)","resourceType":"case","shape":"count","gridWidth":3,"filters":{"tags":["s_dip"],"states":["work_in_progress","open","waiting_on_wso2","reopened"]}},{"id":"my_critical_open","displayName":"My Critical & High Cases","resourceType":"case","shape":"list","gridWidth":3,"listLimit":5,"filters":{"assignedUserIds":["__current_user__"],"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]},{"id":"operations","displayName":"Operations","targetTeam":"cs_operations","widgets":[{"id":"p0_p1_open","displayName":"P0/P1 Open","resourceType":"case","shape":"count","gridWidth":4,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}},{"id":"open_critical_incidents","displayName":"Open Critical Incidents","resourceType":"incident","shape":"count","gridWidth":4,"filters":{"priorities":["CRITICAL","HIGH"]}},{"id":"crs_awaiting_approval","displayName":"CRs Awaiting Approval","resourceType":"change_request","shape":"count","gridWidth":4,"filters":{"states":["customer_approval"]}}]},{"id":"iam","displayName":"IAM CS","targetTeam":"iam_cs","widgets":[{"id":"iam_open_cases","displayName":"IAM Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["iam"],"states":["open","work_in_progress","awaiting_info"]}},{"id":"asgardeo_open_cases","displayName":"Asgardeo Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["asgardeo"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"security","displayName":"Security center","targetTeam":"security","widgets":[{"id":"critical_vulns","displayName":"Critical Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"critical"}},{"id":"high_vulns","displayName":"High Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"high"}},{"id":"sra_cases_open","displayName":"Open SRAs","resourceType":"case","shape":"count","gridWidth":4,"filters":{"types":["security_report_analysis"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"team_performance","displayName":"Team performance","targetTeam":"cs_team_leads","isTeamBased":true,"widgets":[{"id":"time_cards_pending_approval","displayName":"Time Cards Pending Approval","resourceType":"time_card","shape":"count","gridWidth":6,"filters":{"states":["pending"]}},{"id":"team_open_cases","displayName":"Team Open P0/P1","resourceType":"case","shape":"count","gridWidth":6,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]}]'

# Auth — set to false for local testing (skips JWT signature verification)
AUTH_JWKS_ENDPOINT=
AUTH_ISSUER=
Expand Down
10 changes: 10 additions & 0 deletions apps/csm-portal/backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"syscall"
"time"

"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/dashboard"
"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/entity"
"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/handler"
"github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware"
Expand All @@ -43,6 +44,12 @@ func main() {
loadDotEnv(".env")
middleware.ConfigureLogger()

// The dashboard registry is config-driven and loaded once at startup; a
// missing or malformed DASHBOARDS_CONFIG logs an error (see
// ParseDashboardsConfig) and leaves it empty rather than failing
// startup, since no other endpoint depends on it.
dashboard.Dashboards = dashboard.ParseDashboardsConfig(os.Getenv("DASHBOARDS_CONFIG"))

// All upstream service clients (entity, updates, SCIM, and future notification
// channels) authenticate as the same OAuth2 client-credentials app; only the
// base URL and scopes differ per service.
Expand All @@ -61,6 +68,7 @@ func main() {

customerEntityClient := entity.NewCustomerEntityClient(customerEntityCfg)
caseHandler := handler.NewCaseHandler(customerEntityClient)
dashboardHandler := handler.NewDashboardHandler(customerEntityClient)
accountHandler := handler.NewAccountHandler(customerEntityClient)
projectHandler := handler.NewProjectHandler(customerEntityClient)
productHandler := handler.NewProductHandler(customerEntityClient)
Expand Down Expand Up @@ -139,6 +147,8 @@ func main() {
mux.HandleFunc("DELETE /cases/{id}/tags/{tagId}", caseHandler.RemoveCaseTag)
mux.HandleFunc("GET /tags/search", caseHandler.SearchTags)
mux.HandleFunc("POST /cases/search", caseHandler.SearchCases)
mux.HandleFunc("GET /dashboards", dashboardHandler.GetDashboards)
mux.HandleFunc("GET /dashboards/{dashboardId}", dashboardHandler.GetDashboardDetail)
mux.HandleFunc("GET /updates/product-update-levels", updatesHandler.GetProductUpdateLevels)
mux.HandleFunc("POST /updates/levels/search", updatesHandler.SearchUpdatesBetweenUpdateLevels)
mux.HandleFunc("GET /users/me", usersHandler.GetMe)
Expand Down
172 changes: 172 additions & 0 deletions apps/csm-portal/backend/internal/dashboard/widgets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

// Package dashboard holds the pilot's config-driven dashboard widget
// templates. Each widget resolves to a search against that ResourceType's own
// /search endpoint (every resource's search payload shape is
// {filters: {...}, pagination: {...}}) — there is no generic filter DSL and
// no database backing this; the registry itself is loaded from the
// DASHBOARDS_CONFIG environment variable at process startup (see
// ParseDashboardsConfig and cmd/server/main.go).
package dashboard

import (
"encoding/json"
"log/slog"
)

// CurrentUserPlaceholder marks a filter value that must be resolved to the
// requesting user's id before the filters are sent upstream. It never
// reaches the entity service: ResolveFilters always substitutes it.
const CurrentUserPlaceholder = "__current_user__"

// ResourceType identifies which resource a widget's filters search against.
type ResourceType string

const (
ResourceCase ResourceType = "case"
ResourceIncident ResourceType = "incident"
ResourceChangeRequest ResourceType = "change_request"
ResourceAccount ResourceType = "account"
ResourceProject ResourceType = "project"
ResourceUser ResourceType = "user"
ResourceTimeCard ResourceType = "time_card"
ResourceProblem ResourceType = "problem"
ResourceProductVulnerability ResourceType = "product_vulnerability"
)

// Shape is how a widget's resolved data should be rendered.
type Shape string

const (
ShapeCount Shape = "count" // single resolved number
ShapeList Shape = "list" // top-N matching records
ShapePie Shape = "pie" // grouped counts — NOT resolvable by any /search endpoint today (no aggregate endpoint exists anywhere in the stack); keep the const so a future dashboard doesn't need a schema migration, but do not wire any rendering logic for it beyond accepting the value
ShapeBar Shape = "bar" // same caveat as ShapePie
)

// WidgetTemplate is resource-agnostic: Filters is opaque JSON, forwarded
// verbatim (after __current_user__ substitution) as the filters object of
// that ResourceType's own /search payload (every resource's search payload
// shape is {filters: {...}, pagination: {...}}). The BE never interprets
// filter contents beyond substituting the current-user placeholder.
type WidgetTemplate struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
ResourceType ResourceType `json:"resourceType"`
Shape Shape `json:"shape"`
GridWidth int `json:"gridWidth"` // 1-12, CSS grid columns out of 12
Filters map[string]any `json:"filters"`
GroupBy string `json:"groupBy,omitempty"` // only meaningful for Shape pie/bar — see the caveat on those consts; unused by every widget below
ListLimit int `json:"listLimit,omitempty"` // only meaningful for Shape list; how many records to show
}

// Dashboard is a single dashboard's metadata plus its widget templates.
type Dashboard struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
IsDefault bool `json:"isDefault"`
// TargetTeam is purely descriptive metadata (e.g. for a future FE team
// picker); it is not enforced anywhere. GET /dashboards still returns
// every dashboard to every caller regardless of team membership.
TargetTeam string `json:"targetTeam"`
// IsTeamBased marks a dashboard whose FE view should offer a team
// selector (populated from POST /teams/search) alongside the dashboard
// switcher. This is currently UI skeleton only: selecting a team does
// not yet scope any widget's data. Wiring a selected team into widget
// filters (e.g. resolving its member user IDs into a case widget's
// assignedUserIds) is deliberately deferred to a later increment.
IsTeamBased bool `json:"isTeamBased"`
Widgets []WidgetTemplate `json:"widgets"`
}

// Dashboards is the ordered registry of dashboards, populated once at process
// startup from the DASHBOARDS_CONFIG environment variable (see
// ParseDashboardsConfig, called from cmd/server/main.go). It is empty (nil)
// until main() populates it; there is no file-watching or hot-reload — a
// config change requires restarting the process. Order is deterministic and
// is what the frontend's dashboard picker displays.
var Dashboards []Dashboard

// ParseDashboardsConfig decodes DASHBOARDS_CONFIG, a JSON array of Dashboard
// objects (see the Dashboard and WidgetTemplate json tags for the expected
// shape). A missing or malformed value logs an error and yields no
// dashboards rather than failing startup, since callers always check
// dashboard.Dashboards for emptiness (GET /dashboards simply returns an empty
// list; GET /dashboards/{id} 404s) instead of crashing the process.
func ParseDashboardsConfig(raw string) []Dashboard {
if raw == "" {
return nil
}
var dashboards []Dashboard
if err := json.Unmarshal([]byte(raw), &dashboards); err != nil {
slog.Error("failed to parse DASHBOARDS_CONFIG; no dashboards will be available", "err", err)
return nil
}
return dashboards
}

// DashboardByID looks up a dashboard by id, returning ok=false if the id
// isn't in the registry.
func DashboardByID(id string) (Dashboard, bool) {
for _, d := range Dashboards {
if d.ID == id {
return d, true
}
}
return Dashboard{}, false
}

// ResolveFilters returns tpl's filters with CurrentUserPlaceholder substituted
// by currentUserID wherever it appears as a string inside a []any (the only
// place a per-user value belongs in a filters object — e.g. assignedUserIds,
// userIds). It does not mutate tpl.Filters.
func ResolveFilters(tpl WidgetTemplate, currentUserID string) map[string]any {
return substituteCurrentUser(tpl.Filters, currentUserID).(map[string]any)
}

func substituteCurrentUser(v any, currentUserID string) any {
switch val := v.(type) {
case map[string]any:
out := make(map[string]any, len(val))
for k, sub := range val {
out[k] = substituteCurrentUser(sub, currentUserID)
}
return out
case []string:
out := make([]string, len(val))
for i, s := range val {
if s == CurrentUserPlaceholder {
s = currentUserID
}
out[i] = s
}
return out
case []any:
out := make([]any, len(val))
for i, sub := range val {
out[i] = substituteCurrentUser(sub, currentUserID)
}
return out
case string:
if val == CurrentUserPlaceholder {
return currentUserID
}
return val
default:
return val
}
}
128 changes: 128 additions & 0 deletions apps/csm-portal/backend/internal/dashboard/widgets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package dashboard

import "testing"

func TestParseDashboardsConfig_Empty(t *testing.T) {
got := ParseDashboardsConfig("")
if got != nil {
t.Errorf("ParseDashboardsConfig(\"\") = %v, want nil", got)
}
}

func TestParseDashboardsConfig_Malformed(t *testing.T) {
got := ParseDashboardsConfig("{not valid json")
if got != nil {
t.Errorf("ParseDashboardsConfig(malformed) = %v, want nil", got)
}
}

func TestParseDashboardsConfig_MalformedShape(t *testing.T) {
// Valid JSON, but not an array of Dashboard objects — must not panic and
// must return nil, not a zero-value slice with garbage entries.
got := ParseDashboardsConfig(`{"id":"not-an-array"}`)
if got != nil {
t.Errorf("ParseDashboardsConfig(wrong shape) = %v, want nil", got)
}
}

func TestParseDashboardsConfig_ValidRoundTrip(t *testing.T) {
const raw = `[
{
"id": "agents_pilot",
"displayName": "Engineer overview",
"isDefault": true,
"targetTeam": "cs_engineers",
"widgets": [
{
"id": "my_patches",
"displayName": "My Patches",
"resourceType": "case",
"shape": "count",
"gridWidth": 3,
"filters": {
"assignedUserIds": ["__current_user__"],
"tags": ["patch"],
"states": ["open", "work_in_progress"]
}
}
]
}
]`

got := ParseDashboardsConfig(raw)
if len(got) != 1 {
t.Fatalf("len(ParseDashboardsConfig(raw)) = %d, want 1", len(got))
}

d := got[0]
if d.ID != "agents_pilot" {
t.Errorf("Dashboard.ID = %q, want %q", d.ID, "agents_pilot")
}
if d.DisplayName != "Engineer overview" {
t.Errorf("Dashboard.DisplayName = %q, want %q", d.DisplayName, "Engineer overview")
}
if !d.IsDefault {
t.Errorf("Dashboard.IsDefault = false, want true")
}
if d.TargetTeam != "cs_engineers" {
t.Errorf("Dashboard.TargetTeam = %q, want %q", d.TargetTeam, "cs_engineers")
}
if len(d.Widgets) != 1 {
t.Fatalf("len(Dashboard.Widgets) = %d, want 1", len(d.Widgets))
}

w := d.Widgets[0]
if w.ID != "my_patches" {
t.Errorf("WidgetTemplate.ID = %q, want %q", w.ID, "my_patches")
}
if w.ResourceType != ResourceCase {
t.Errorf("WidgetTemplate.ResourceType = %q, want %q", w.ResourceType, ResourceCase)
}
if w.Shape != ShapeCount {
t.Errorf("WidgetTemplate.Shape = %q, want %q", w.Shape, ShapeCount)
}
if w.GridWidth != 3 {
t.Errorf("WidgetTemplate.GridWidth = %d, want 3", w.GridWidth)
}

// The detail that matters for ResolveFilters' substitution logic
// downstream: a JSON array value unmarshals into map[string]any as
// []any, not []string — assert the actual runtime type, not just
// presence, since substituteCurrentUser's []any and []string cases
// behave identically but are reached via different type switches.
assignedRaw, present := w.Filters["assignedUserIds"]
if !present {
t.Fatalf("Filters has no assignedUserIds key")
}
assigned, ok := assignedRaw.([]any)
if !ok {
t.Fatalf("Filters[assignedUserIds] is %T, want []any", assignedRaw)
}
if len(assigned) != 1 || assigned[0] != CurrentUserPlaceholder {
t.Errorf("Filters[assignedUserIds] = %v, want [%q]", assigned, CurrentUserPlaceholder)
}

// End-to-end: resolving through the real substitution path yields a
// concrete user id in place of the placeholder.
resolved := ResolveFilters(w, "user-123")
resolvedAssigned, ok := resolved["assignedUserIds"].([]any)
if !ok || len(resolvedAssigned) != 1 || resolvedAssigned[0] != "user-123" {
t.Errorf("ResolveFilters(...)[assignedUserIds] = %v, want [\"user-123\"]", resolved["assignedUserIds"])
}
}
Loading