diff --git a/CLAUDE.md b/CLAUDE.md index 8cbddeb07..cc6c5b1b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Traceway is an error tracking and monitoring platform consisting of: - **No pointless comments**: Do not add comments that simply describe what the code does. The code should be self-explanatory. Only add comments when explaining non-obvious "why" decisions. - **No `py-4` in dialog form content**: Do not add `py-4` on the content wrapper inside `AlertDialog` or `Dialog` components — it creates too much blank space between the form and the action buttons. -- **Dialog button labels & toasts**: For form dialogs, use descriptive button labels with icons instead of generic "Create"/"Update". The `{Entity}` is always a platform entity, capitalized (Project, Widget, Widget Group, Channel, Rule, Token, Invitation, ...). Create actions: ` New {Entity}` with `variant="success"`. Update actions: ` Update {Entity}` with the default (primary) variant. Delete/revoke/remove confirm buttons: ` Delete {Entity}` (or `Revoke {Entity}` / `Remove {Entity}`) with `variant="destructive"`. After success, show `toast.success('Successfully created the {Entity}', { position: 'top-center' })` for creates and `'Successfully updated the {Entity}'` for updates. The button should only be `disabled` during the loading state — never disable it to enforce validation; let the backend return 422 and show the error in the dialog instead. +- **Dialog button labels & toasts**: For form dialogs, use descriptive button labels with icons instead of generic "Create"/"Update". The `{Entity}` is always a platform entity, capitalized (Project, Widget, Dashboard, Channel, Rule, Token, Invitation, ...). Create actions: ` New {Entity}` with `variant="success"`. Update actions: ` Update {Entity}` with the default (primary) variant. Delete/revoke/remove confirm buttons: ` Delete {Entity}` (or `Revoke {Entity}` / `Remove {Entity}`) with `variant="destructive"`. After success, show `toast.success('Successfully created the {Entity}', { position: 'top-center' })` for creates and `'Successfully updated the {Entity}'` for updates. The button should only be `disabled` during the loading state — never disable it to enforce validation; let the backend return 422 and show the error in the dialog instead. --- @@ -484,7 +484,7 @@ const handleClick = createRowClickHandler('/issues/abc123', 'preset', 'from', 't /endpoints/[endpoint] Single endpoint details /tasks Background tasks list /tasks/[task] Single task details -/metrics System metrics dashboard (CPU, memory, etc.) +/dashboards Dashboards page (tabs of org dashboards; /metrics redirects here) /connection SDK integration guide ``` @@ -616,22 +616,40 @@ backend/ | GET | `/api/metrics/discover/tags` | App | Discover metric tags | | PUT | `/api/metrics/registry` | App+Write | Update metric registry entry | -**Widget Groups & Widgets** +**Dashboards & Templates** + +Dashboards are org-owned JSON documents (`{schemaVersion, widgets: [{id, title, widgetType, config}]}`, widget ids are server-generated `w_xxxxxxxx` strings, array order = display order) applied to projects via `project_dashboards`. Dashboard mutations require org role above `readonly` (checked in-handler); project-scoped routes (list/star/reorder/populate) use the standard middleware chains; apply/unapply also check the effective role of each affected project. The old per-project widget-group tables are converted once at startup by `backfill.RunDashboards` (advisory-locked on PG) and retained for rollback. + | Method | Endpoint | Auth | Purpose | |--------|----------|------|---------| -| GET | `/api/widget-groups` | App | List widget groups | -| POST | `/api/widget-groups` | App+Write | Create widget group | -| GET | `/api/widget-groups/:id` | App | Get group with widgets | -| PUT | `/api/widget-groups/:id` | App+Write | Update widget group | -| DELETE | `/api/widget-groups/:id` | App+Write | Delete widget group | -| POST | `/api/widget-groups/:id/widgets` | App+Write | Add widget | -| PUT | `/api/widget-groups/:id/widgets/:wid` | App+Write | Update widget | -| PUT | `/api/widget-groups/:id/reorder` | App+Write | Reorder widgets (explicit id order) | -| PUT | `/api/widget-groups/:id/widgets/:wid/star` | App+Write | Star/unstar widget for the homepage | -| DELETE | `/api/widget-groups/:id/widgets/:wid` | App+Write | Delete widget | -| GET | `/api/widget-groups/starred` | App | List starred widgets with homepage layout | -| PUT | `/api/starred-widgets/reorder` | App+Write | Reorder homepage starred widgets (explicit id order) | -| PUT | `/api/starred-widgets/:wid` | App+Write | Update homepage layout (colSpan/size) of a starred widget | +| GET | `/api/dashboards` | App | Dashboards applied to the project (tab order) | +| GET | `/api/dashboards/library` | App | All dashboards across the user's orgs with applied project ids | +| POST | `/api/dashboards` | App | Create in org (auto-applies to current project unless `applyToProjectIds` given) | +| GET | `/api/dashboards/:id` | App | Meta + widgets (+ per-project `isStarred`, `appliedProjectIds`) | +| PUT | `/api/dashboards/:id` | App | Update name/description and/or full `definition` (the as-code path) | +| DELETE | `/api/dashboards/:id` | App | Delete everywhere (assignments + stars cascade) | +| PUT | `/api/dashboards/:id/apply` | App | Set the full project assignment list | +| DELETE | `/api/dashboards/:id/apply/:projectId` | App | Unassign from one project | +| POST | `/api/dashboards/:id/copy` | App | Copy (also cross-org) with optional apply | +| PUT | `/api/dashboards/reorder` | App+Write | Tab order for a project (explicit id order) | +| POST | `/api/dashboards/:id/widgets` | App | Add widget | +| PUT | `/api/dashboards/:id/widgets/reorder` | App | Reorder widgets (explicit id order) | +| PUT | `/api/dashboards/:id/widgets/:wid` | App | Update widget | +| DELETE | `/api/dashboards/:id/widgets/:wid` | App | Delete widget (+ its stars) | +| PUT | `/api/dashboards/:id/widgets/:wid/star` | App+Write | Star/unstar for the project homepage | +| GET | `/api/dashboards/starred` | App | Starred widgets with homepage layout | +| PUT | `/api/starred-widgets/reorder` | App+Write | Reorder homepage starred widgets (`{ids}` = starred row ids) | +| PUT | `/api/starred-widgets/:id` | App+Write | Update homepage layout (colSpan/size) | +| GET | `/api/dashboards/:id/export` | App | Export one dashboard as JSON | +| GET | `/api/dashboards/export?organizationId=` | App | Export the org bundle | +| POST | `/api/dashboards/import` | App | Import doc/bundle (`mode: create\|upsert`, upsert matches by name) | +| POST | `/api/dashboards/import/grafana` | App | Convert a Grafana export (best-effort, returns `warnings[]`) | +| GET | `/api/dashboard-templates` | App | Marketplace list/search (`search`, `category` params) | +| POST | `/api/dashboard-templates/:key/install` | App | Copy a template into the org and apply | +| POST | `/api/dashboards/populate-defaults` | App+Write | Install the framework-default template set for an empty project | +| GET | `/api/metrics/discover/org` | App | Metric names across all org projects (command palette) | + +Templates are DB rows seeded by migrations (`traceway-otel-agent` for the OTel host agent, `golang` for Go SDK apps, `traceway-clickhouse`/`traceway-duckdb` for the telemetry stores of a monitored Traceway instance; SQLite emits no store-specific metrics so it has no template); cloud can insert more rows without a release. The OTLP metric ingest allowlists per-resource grouping tags (`container.name`, `k8s.pod.name`, `k8s.node.name`, `postgresql.database.name`, ...) in `otelcontrollers/metric_converter.go` for custom widgets. **Endpoints** | Method | Endpoint | Auth | Purpose | @@ -737,9 +755,11 @@ func (c *ReportController) Report(ctx *gin.Context) { | `invitations` | Team invitations with token, role, expiry | | `source_maps` | Uploaded source map files (project, version, storage key) | | `metric_registry` | Custom metric definitions (type, unit, description) | -| `widget_groups` | Dashboard widget groups (name, default flag) | -| `widget_group_widgets` | Individual widgets within groups (type, config, position) | -| `starred_widgets` | Homepage layout for starred widgets (position, col_span, size per widget) | +| `dashboards` | Org-owned dashboards (name, JSONB definition with widgets, template provenance) | +| `project_dashboards` | Which projects show a dashboard, and tab order | +| `dashboard_templates` | Marketplace templates (key, category, definition), seeded by migrations | +| `starred_dashboard_widgets` | Homepage layout per project (dashboard id + widget id, position, col_span, size) | +| `widget_groups` / `widget_group_widgets` / `starred_widgets` | Legacy pre-dashboards tables, retained read-only for rollback until a follow-up drop | #### ClickHouse vs PostgreSQL Decision Guide - **PostgreSQL**: Relational/config data needing ACID, frequent updates, JOINs, low volume (users, organizations, projects, invitations, widgets, source maps, metric registry) @@ -757,7 +777,7 @@ In SQLite mode (`DB_TYPE=sqlite`), the backend uses **two separate SQLite databa **Main DB tables** (`db.DB` — transactional, uses lit with `*sql.Tx`): - `users`, `organizations`, `organization_users`, `projects`, `invitations` -- `source_maps`, `metric_registry`, `widget_groups`, `widget_group_widgets`, `starred_widgets` +- `source_maps`, `metric_registry`, `dashboards`, `project_dashboards`, `dashboard_templates`, `starred_dashboard_widgets` (plus the legacy `widget_groups`/`widget_group_widgets`/`starred_widgets`) - `notification_channels`, `notification_rules` **Telemetry DB tables** (`db.TelemetryDB` — non-transactional, uses lit with `db.TelemetryDB` directly): diff --git a/backend/app/backfill/dashboards.go b/backend/app/backfill/dashboards.go new file mode 100644 index 000000000..02237452d --- /dev/null +++ b/backend/app/backfill/dashboards.go @@ -0,0 +1,263 @@ +package backfill + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/tracewayapp/lit/v2" + "github.com/tracewayapp/traceway/backend/app/config" + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/repositories/transactional" + dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards" + + "github.com/google/uuid" +) + +const dashboardsBackfillName = "dashboards" + +const maxDashboardNameRunes = 100 + +type widgetGroupWithOrg struct { + Id int `lit:"id"` + ProjectId uuid.UUID `lit:"project_id"` + Name string `lit:"name"` + Description string `lit:"description"` + IsDefault bool `lit:"is_default"` + CreatedBy *int `lit:"created_by"` + CreatedAt time.Time `lit:"created_at"` + UpdatedAt time.Time `lit:"updated_at"` + OrganizationId int `lit:"organization_id"` + ProjectName string `lit:"project_name"` +} + +func init() { + models.ExtensionModelRegistrations = append(models.ExtensionModelRegistrations, func(driver lit.Driver) { + lit.RegisterModel[widgetGroupWithOrg](driver) + }) +} + +func RunDashboards() error { + converted, err := db.ExecuteTransaction(func(tx *sql.Tx) (int, error) { + if !db.IsSQLite() { + if _, err := tx.Exec("SELECT pg_advisory_xact_lock(824737001)"); err != nil { + return 0, fmt.Errorf("failed to take backfill lock: %w", err) + } + } + + marker, err := lit.SelectSingleNamed[models.CountResult]( + tx, + "SELECT COUNT(*) as count FROM backfill_runs WHERE name = :name", + lit.P{"name": dashboardsBackfillName}, + ) + if err != nil { + return 0, fmt.Errorf("failed to check backfill marker: %w", err) + } + if marker != nil && marker.Count > 0 { + return 0, nil + } + + existing, err := lit.SelectSingleNamed[models.CountResult](tx, "SELECT COUNT(*) as count FROM dashboards", lit.P{}) + if err != nil { + return 0, fmt.Errorf("failed to count dashboards: %w", err) + } + + converted := 0 + if existing == nil || existing.Count == 0 { + converted, err = convertWidgetGroups(tx) + if err != nil { + return 0, err + } + } + + query, args, err := lit.ParseNamedQuery( + db.Driver, + "INSERT INTO backfill_runs (name, ran_at) VALUES (:name, :ran_at)", + lit.P{"name": dashboardsBackfillName, "ran_at": time.Now().UTC()}, + ) + if err != nil { + return 0, err + } + if err := lit.UpdateNative(tx, query, args...); err != nil { + return 0, fmt.Errorf("failed to record backfill marker: %w", err) + } + + return converted, nil + }) + if err != nil { + return err + } + if converted > 0 { + config.Logf("dashboards backfill: converted %d widget group(s) into dashboards", converted) + } + return nil +} + +func convertWidgetGroups(tx *sql.Tx) (int, error) { + skipped, err := lit.SelectSingleNamed[models.CountResult]( + tx, + "SELECT COUNT(*) as count FROM widget_groups wg JOIN projects p ON p.id = wg.project_id WHERE p.organization_id IS NULL", + lit.P{}, + ) + if err != nil { + return 0, fmt.Errorf("failed to count orphaned widget groups: %w", err) + } + if skipped != nil && skipped.Count > 0 { + config.Logf("dashboards backfill: skipping %d widget group(s) whose project has no organization; the widget_groups table keeps their data", skipped.Count) + } + + groups, err := lit.SelectNamed[widgetGroupWithOrg]( + tx, + `SELECT wg.id, wg.project_id, wg.name, COALESCE(wg.description, '') AS description, wg.is_default, wg.created_by, wg.created_at, wg.updated_at, p.organization_id, p.name AS project_name + FROM widget_groups wg + JOIN projects p ON p.id = wg.project_id + WHERE p.organization_id IS NOT NULL + ORDER BY wg.project_id ASC, wg.is_default DESC, wg.name ASC`, + lit.P{}, + ) + if err != nil { + return 0, fmt.Errorf("failed to list widget groups: %w", err) + } + if len(groups) == 0 { + return 0, nil + } + + positions := map[uuid.UUID]int{} + usedNames := map[int]map[string]bool{} + for _, group := range groups { + widgets, err := lit.SelectNamed[models.WidgetGroupWidget]( + tx, + "SELECT id, widget_group_id, title, widget_type, config, position, created_at, updated_at FROM widget_group_widgets WHERE widget_group_id = :wg_id ORDER BY position ASC", + lit.P{"wg_id": group.Id}, + ) + if err != nil { + return 0, fmt.Errorf("failed to list widgets for group %d: %w", group.Id, err) + } + + def := &models.DashboardDefinition{SchemaVersion: dashboardsvc.SchemaVersion} + widgetIds := make(map[int]string, len(widgets)) + usedIds := make(map[string]bool, len(widgets)) + for _, w := range widgets { + uid := dashboardsvc.NewWidgetId() + for usedIds[uid] { + uid = dashboardsvc.NewWidgetId() + } + usedIds[uid] = true + widgetIds[w.Id] = uid + widgetConfig := json.RawMessage(w.Config) + if len(widgetConfig) == 0 { + widgetConfig = json.RawMessage(`{}`) + } + def.Widgets = append(def.Widgets, models.DashboardWidget{ + Id: uid, + Title: w.Title, + WidgetType: w.WidgetType, + Config: widgetConfig, + }) + } + definition, err := dashboardsvc.MarshalDefinition(def) + if err != nil { + return 0, fmt.Errorf("failed to marshal definition for group %d: %w", group.Id, err) + } + + orgNames := usedNames[group.OrganizationId] + if orgNames == nil { + orgNames = map[string]bool{} + usedNames[group.OrganizationId] = orgNames + } + name := uniqueDashboardName(orgNames, group.Name, group.ProjectName) + orgNames[strings.ToLower(name)] = true + + dashboard := &models.Dashboard{ + OrganizationId: group.OrganizationId, + Name: name, + Description: group.Description, + Definition: definition, + CreatedBy: group.CreatedBy, + CreatedAt: group.CreatedAt, + UpdatedAt: group.UpdatedAt, + } + dashboardId, err := transactional.DashboardRepository.Create(tx, dashboard) + if err != nil { + return 0, fmt.Errorf("failed to create dashboard for group %d: %w", group.Id, err) + } + + position := positions[group.ProjectId] + positions[group.ProjectId] = position + 1 + if err := transactional.DashboardRepository.CreateAssignment(tx, &models.ProjectDashboard{ + ProjectId: group.ProjectId, + DashboardId: dashboardId, + Position: position, + CreatedAt: group.CreatedAt, + }); err != nil { + return 0, fmt.Errorf("failed to assign dashboard for group %d: %w", group.Id, err) + } + + starred, err := lit.SelectNamed[models.StarredWidget]( + tx, + `SELECT sw.id, sw.widget_id, sw.position, sw.col_span, sw.size, sw.created_at + FROM starred_widgets sw + JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id + WHERE wgw.widget_group_id = :wg_id + ORDER BY sw.position ASC, sw.id ASC`, + lit.P{"wg_id": group.Id}, + ) + if err != nil { + return 0, fmt.Errorf("failed to list starred widgets for group %d: %w", group.Id, err) + } + for _, s := range starred { + uid := widgetIds[s.WidgetId] + if uid == "" { + continue + } + if err := transactional.DashboardRepository.CreateStarred(tx, &models.StarredDashboardWidget{ + ProjectId: group.ProjectId, + DashboardId: dashboardId, + WidgetId: uid, + Position: s.Position, + ColSpan: s.ColSpan, + Size: s.Size, + CreatedAt: s.CreatedAt, + }); err != nil { + return 0, fmt.Errorf("failed to migrate starred widget %d for group %d: %w", s.Id, group.Id, err) + } + } + } + + return len(groups), nil +} + +func uniqueDashboardName(used map[string]bool, groupName string, projectName string) string { + base := dashboardsvc.TruncateName(groupName, maxDashboardNameRunes) + if !used[strings.ToLower(base)] { + return base + } + candidate := suffixDashboardName(base, projectName, 1) + if !used[strings.ToLower(candidate)] { + return candidate + } + for n := 2; ; n++ { + candidate = suffixDashboardName(base, projectName, n) + if !used[strings.ToLower(candidate)] { + return candidate + } + } +} + +func suffixDashboardName(base string, projectName string, n int) string { + numeral := "" + if n > 1 { + numeral = fmt.Sprintf(" %d", n) + } + maxProjectRunes := maxDashboardNameRunes - 4 - utf8.RuneCountInString(numeral) + suffix := " (" + dashboardsvc.TruncateName(projectName, maxProjectRunes) + ")" + numeral + limit := maxDashboardNameRunes - utf8.RuneCountInString(suffix) + if limit < 1 { + limit = 1 + } + return dashboardsvc.TruncateName(base, limit) + suffix +} diff --git a/backend/app/backfill/dashboards_test.go b/backend/app/backfill/dashboards_test.go new file mode 100644 index 000000000..79c3c2ce5 --- /dev/null +++ b/backend/app/backfill/dashboards_test.go @@ -0,0 +1,349 @@ +//go:build !telemetry_ch + +package backfill + +import ( + "database/sql" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/tracewayapp/lit/v2" + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/models" + _ "modernc.org/sqlite" +) + +func setup(t *testing.T) { + t.Helper() + + mainDB, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open main db: %v", err) + } + mainDB.SetMaxOpenConns(1) + + ddl := []string{ + `CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT NOT NULL, organization_id INTEGER)`, + `CREATE TABLE widget_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, name TEXT NOT NULL, description TEXT DEFAULT '', is_default INTEGER NOT NULL DEFAULT 0, created_by INTEGER, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`, + `CREATE TABLE widget_group_widgets (id INTEGER PRIMARY KEY AUTOINCREMENT, widget_group_id INTEGER NOT NULL, title TEXT NOT NULL, widget_type TEXT NOT NULL, config TEXT NOT NULL DEFAULT '{}', position INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`, + `CREATE TABLE starred_widgets (id INTEGER PRIMARY KEY AUTOINCREMENT, widget_id INTEGER NOT NULL UNIQUE, position INTEGER NOT NULL DEFAULT 0, col_span INTEGER NOT NULL DEFAULT 1, size TEXT NOT NULL DEFAULT 'sm', created_at DATETIME NOT NULL)`, + `CREATE TABLE dashboards (id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id INTEGER NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', definition TEXT NOT NULL DEFAULT '{}', template_key TEXT, created_by INTEGER, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`, + `CREATE TABLE project_dashboards (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, dashboard_id INTEGER NOT NULL, position INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, UNIQUE(project_id, dashboard_id))`, + `CREATE TABLE starred_dashboard_widgets (id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, dashboard_id INTEGER NOT NULL, widget_id TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, col_span INTEGER NOT NULL DEFAULT 1, size TEXT NOT NULL DEFAULT 'sm', created_at DATETIME NOT NULL, UNIQUE(project_id, dashboard_id, widget_id))`, + `CREATE TABLE backfill_runs (name TEXT PRIMARY KEY, ran_at DATETIME NOT NULL)`, + `CREATE UNIQUE INDEX idx_dashboards_org_lower_name ON dashboards(organization_id, LOWER(name))`, + } + for _, stmt := range ddl { + if _, err := mainDB.Exec(stmt); err != nil { + t.Fatalf("ddl: %v", err) + } + } + + prevDB, prevDriver := db.DB, db.Driver + db.DB = mainDB + db.Driver = lit.SQLite + models.Init(db.Driver) + + t.Cleanup(func() { + mainDB.Close() + db.DB = prevDB + db.Driver = prevDriver + }) +} + +func seedGroup(t *testing.T, projectId uuid.UUID, name string, isDefault bool, widgetTitles []string) []int { + t.Helper() + now := time.Now().UTC() + group := models.WidgetGroup{ + ProjectId: projectId, + Name: name, + IsDefault: isDefault, + CreatedAt: now, + UpdatedAt: now, + } + groupId, err := lit.Insert(db.DB, &group) + if err != nil { + t.Fatalf("seed widget_group: %v", err) + } + widgetIds := make([]int, 0, len(widgetTitles)) + for i, title := range widgetTitles { + w := models.WidgetGroupWidget{ + WidgetGroupId: groupId, + Title: title, + WidgetType: "line_chart", + Config: models.JSONText(`{"sources":[{"type":"metric","name":"cpu.used_pcnt","aggregation":"avg"}]}`), + Position: i, + CreatedAt: now, + UpdatedAt: now, + } + id, err := lit.Insert(db.DB, &w) + if err != nil { + t.Fatalf("seed widget: %v", err) + } + widgetIds = append(widgetIds, id) + } + return widgetIds +} + +func TestBackfillConvertsGroupsToDashboards(t *testing.T) { + setup(t) + + projectId := uuid.New() + if _, err := db.DB.Exec(`INSERT INTO projects (id, name, organization_id) VALUES (?, 'p1', 7)`, projectId.String()); err != nil { + t.Fatalf("seed project: %v", err) + } + + customIds := seedGroup(t, projectId, "Custom", false, []string{"A", "B"}) + seedGroup(t, projectId, "CPU / Mem", true, []string{"CPU Usage"}) + + star := models.StarredWidget{WidgetId: customIds[1], Position: 3, ColSpan: 2, Size: "md", CreatedAt: time.Now().UTC()} + if err := db.DB.QueryRow(`INSERT INTO starred_widgets (widget_id, position, col_span, size, created_at) VALUES (?, ?, ?, ?, ?) RETURNING id`, + star.WidgetId, star.Position, star.ColSpan, star.Size, star.CreatedAt).Scan(&star.Id); err != nil { + t.Fatalf("seed starred: %v", err) + } + + if err := RunDashboards(); err != nil { + t.Fatalf("RunDashboards: %v", err) + } + + dashboards, err := lit.SelectNamed[models.Dashboard](db.DB, "SELECT id, organization_id, name, description, definition, template_key, created_by, created_at, updated_at FROM dashboards ORDER BY id ASC", lit.P{}) + if err != nil { + t.Fatalf("select dashboards: %v", err) + } + if len(dashboards) != 2 { + t.Fatalf("expected 2 dashboards, got %d", len(dashboards)) + } + for _, d := range dashboards { + if d.OrganizationId != 7 { + t.Errorf("dashboard %q has org %d, want 7", d.Name, d.OrganizationId) + } + } + + assignments, err := lit.SelectNamed[models.ProjectDashboard](db.DB, "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards ORDER BY position ASC", lit.P{}) + if err != nil { + t.Fatalf("select assignments: %v", err) + } + if len(assignments) != 2 { + t.Fatalf("expected 2 assignments, got %d", len(assignments)) + } + + byId := map[int]*models.Dashboard{} + for _, d := range dashboards { + byId[d.Id] = d + } + if byId[assignments[0].DashboardId].Name != "CPU / Mem" { + t.Errorf("first tab should be the default group, got %q", byId[assignments[0].DashboardId].Name) + } + if byId[assignments[1].DashboardId].Name != "Custom" { + t.Errorf("second tab should be Custom, got %q", byId[assignments[1].DashboardId].Name) + } + + var customDef models.DashboardDefinition + customDashboardId := assignments[1].DashboardId + if err := json.Unmarshal(byId[customDashboardId].Definition, &customDef); err != nil { + t.Fatalf("unmarshal definition: %v", err) + } + if len(customDef.Widgets) != 2 || customDef.Widgets[0].Title != "A" || customDef.Widgets[1].Title != "B" { + t.Fatalf("unexpected widgets in Custom definition: %+v", customDef.Widgets) + } + for _, w := range customDef.Widgets { + if len(w.Id) != 10 || w.Id[:2] != "w_" { + t.Errorf("widget id %q not in expected format", w.Id) + } + } + + starred, err := lit.SelectNamed[models.StarredDashboardWidget](db.DB, "SELECT id, project_id, dashboard_id, widget_id, position, col_span, size, created_at FROM starred_dashboard_widgets", lit.P{}) + if err != nil { + t.Fatalf("select starred: %v", err) + } + if len(starred) != 1 { + t.Fatalf("expected 1 starred widget, got %d", len(starred)) + } + if starred[0].DashboardId != customDashboardId || starred[0].WidgetId != customDef.Widgets[1].Id { + t.Errorf("star should reference widget B of Custom, got dashboard %d widget %s", starred[0].DashboardId, starred[0].WidgetId) + } + if starred[0].Position != 3 || starred[0].ColSpan != 2 || starred[0].Size != "md" { + t.Errorf("star layout not preserved: %+v", starred[0]) + } + + if err := RunDashboards(); err != nil { + t.Fatalf("second RunDashboards: %v", err) + } + again, err := lit.SelectNamed[models.Dashboard](db.DB, "SELECT id, organization_id, name, description, definition, template_key, created_by, created_at, updated_at FROM dashboards", lit.P{}) + if err != nil { + t.Fatalf("select dashboards after rerun: %v", err) + } + if len(again) != 2 { + t.Fatalf("backfill is not idempotent: got %d dashboards after rerun", len(again)) + } +} + +func TestBackfillSurvivesLegacyNullsAndLongNames(t *testing.T) { + setup(t) + + projectId := uuid.New() + if _, err := db.DB.Exec(`INSERT INTO projects (id, name, organization_id) VALUES (?, 'p1', 3)`, projectId.String()); err != nil { + t.Fatalf("seed project: %v", err) + } + + longName := strings.Repeat("x", 150) + now := time.Now().UTC() + if _, err := db.DB.Exec( + `INSERT INTO widget_groups (project_id, name, description, is_default, created_at, updated_at) VALUES (?, ?, NULL, 0, ?, ?)`, + projectId.String(), longName, now, now, + ); err != nil { + t.Fatalf("seed legacy group: %v", err) + } + var groupId int + if err := db.DB.QueryRow(`SELECT id FROM widget_groups`).Scan(&groupId); err != nil { + t.Fatalf("read group id: %v", err) + } + if _, err := db.DB.Exec( + `INSERT INTO widget_group_widgets (widget_group_id, title, widget_type, config, position, created_at, updated_at) VALUES (?, 'W', 'line_chart', '{"sources":[{"type":"metric","name":"m","aggregation":"avg"}]}', 0, ?, ?)`, + groupId, now, now, + ); err != nil { + t.Fatalf("seed widget: %v", err) + } + + if err := RunDashboards(); err != nil { + t.Fatalf("RunDashboards: %v", err) + } + + dashboards, err := lit.SelectNamed[models.Dashboard](db.DB, "SELECT id, organization_id, name, description, definition, template_key, created_by, created_at, updated_at FROM dashboards", lit.P{}) + if err != nil { + t.Fatalf("select dashboards: %v", err) + } + if len(dashboards) != 1 { + t.Fatalf("expected 1 dashboard, got %d", len(dashboards)) + } + if len(dashboards[0].Name) != 100 { + t.Errorf("name should be truncated to 100 chars, got %d", len(dashboards[0].Name)) + } + if dashboards[0].Description != "" { + t.Errorf("NULL description should become empty string, got %q", dashboards[0].Description) + } +} + +func TestBackfillSkipsWhenNoGroups(t *testing.T) { + setup(t) + if err := RunDashboards(); err != nil { + t.Fatalf("RunDashboards: %v", err) + } + var n int + if err := db.DB.QueryRow("SELECT count(*) FROM dashboards").Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatalf("expected no dashboards, got %d", n) + } + if err := db.DB.QueryRow("SELECT count(*) FROM backfill_runs WHERE name = 'dashboards'").Scan(&n); err != nil { + t.Fatalf("count marker: %v", err) + } + if n != 1 { + t.Fatalf("expected marker row even with no groups, got %d", n) + } +} + +func TestBackfillMarkerPreventsResurrectionAfterDeletes(t *testing.T) { + setup(t) + + projectId := uuid.New() + if _, err := db.DB.Exec(`INSERT INTO projects (id, name, organization_id) VALUES (?, 'p1', 7)`, projectId.String()); err != nil { + t.Fatalf("seed project: %v", err) + } + seedGroup(t, projectId, "Custom", false, []string{"A"}) + + if err := RunDashboards(); err != nil { + t.Fatalf("RunDashboards: %v", err) + } + var n int + if err := db.DB.QueryRow("SELECT count(*) FROM dashboards").Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 dashboard after first run, got %d", n) + } + + if _, err := db.DB.Exec("DELETE FROM starred_dashboard_widgets"); err != nil { + t.Fatalf("delete starred: %v", err) + } + if _, err := db.DB.Exec("DELETE FROM project_dashboards"); err != nil { + t.Fatalf("delete assignments: %v", err) + } + if _, err := db.DB.Exec("DELETE FROM dashboards"); err != nil { + t.Fatalf("delete dashboards: %v", err) + } + + if err := RunDashboards(); err != nil { + t.Fatalf("second RunDashboards: %v", err) + } + if err := db.DB.QueryRow("SELECT count(*) FROM dashboards").Scan(&n); err != nil { + t.Fatalf("count after rerun: %v", err) + } + if n != 0 { + t.Fatalf("deleted dashboards were resurrected: got %d", n) + } + if err := db.DB.QueryRow("SELECT count(*) FROM backfill_runs WHERE name = 'dashboards'").Scan(&n); err != nil { + t.Fatalf("count marker: %v", err) + } + if n != 1 { + t.Fatalf("expected exactly 1 marker row, got %d", n) + } +} + +func TestBackfillDedupesNamesWithinOrg(t *testing.T) { + setup(t) + + p1 := uuid.MustParse("00000000-0000-0000-0000-000000000001") + p2 := uuid.MustParse("00000000-0000-0000-0000-000000000002") + p3 := uuid.MustParse("00000000-0000-0000-0000-000000000003") + for _, p := range []struct { + id uuid.UUID + name string + }{{p1, "Alpha"}, {p2, "Beta"}, {p3, "Beta"}} { + if _, err := db.DB.Exec(`INSERT INTO projects (id, name, organization_id) VALUES (?, ?, 9)`, p.id.String(), p.name); err != nil { + t.Fatalf("seed project: %v", err) + } + } + seedGroup(t, p1, "CPU / Mem", false, []string{"A"}) + seedGroup(t, p2, "cpu / mem", false, []string{"B"}) + seedGroup(t, p3, "CPU / Mem", false, []string{"C"}) + + if err := RunDashboards(); err != nil { + t.Fatalf("RunDashboards: %v", err) + } + + assignments, err := lit.SelectNamed[models.ProjectDashboard](db.DB, "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards", lit.P{}) + if err != nil { + t.Fatalf("select assignments: %v", err) + } + dashboards, err := lit.SelectNamed[models.Dashboard](db.DB, "SELECT id, organization_id, name, description, definition, template_key, created_by, created_at, updated_at FROM dashboards", lit.P{}) + if err != nil { + t.Fatalf("select dashboards: %v", err) + } + if len(dashboards) != 3 { + t.Fatalf("expected 3 dashboards, got %d", len(dashboards)) + } + + nameById := map[int]string{} + for _, d := range dashboards { + nameById[d.Id] = d.Name + } + nameByProject := map[uuid.UUID]string{} + for _, a := range assignments { + nameByProject[a.ProjectId] = nameById[a.DashboardId] + } + + if nameByProject[p1] != "CPU / Mem" { + t.Errorf("first project should keep the plain name, got %q", nameByProject[p1]) + } + if nameByProject[p2] != "cpu / mem (Beta)" { + t.Errorf("case-insensitive collision should get the project suffix, got %q", nameByProject[p2]) + } + if nameByProject[p3] != "CPU / Mem (Beta) 2" { + t.Errorf("repeat collision on the same project name should get a numeral, got %q", nameByProject[p3]) + } +} diff --git a/backend/app/controllers/dashboard_io.controller.go b/backend/app/controllers/dashboard_io.controller.go new file mode 100644 index 000000000..a0371fd49 --- /dev/null +++ b/backend/app/controllers/dashboard_io.controller.go @@ -0,0 +1,386 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/middleware" + "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/repositories/transactional" + dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards" + "github.com/tracewayapp/traceway/backend/app/services/grafana" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + traceway "go.tracewayapp.com" +) + +func exportDashboard(ctx *gin.Context, dashboard *models.Dashboard) (*models.DashboardExport, bool) { + def := dashboardDefinitionOrError(ctx, dashboard) + if def == nil { + return nil, false + } + return &models.DashboardExport{ + SchemaVersion: dashboardsvc.SchemaVersion, + Name: dashboard.Name, + Description: dashboard.Description, + Widgets: def.Widgets, + }, true +} + +func (c *dashboardsController) Export(ctx *gin.Context) { + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, false) + if dashboard == nil { + return + } + + export, ok := exportDashboard(ctx, dashboard) + if !ok { + return + } + ctx.JSON(http.StatusOK, export) +} + +func (c *dashboardsController) ExportOrganization(ctx *gin.Context) { + organizationId, err := strconv.Atoi(ctx.Query("organizationId")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "organizationId query param is required"}) + return + } + + tx := db.GetTx(ctx) + + userId := middleware.GetUserId(ctx) + role, err := transactional.OrganizationRepository.GetUserRole(tx, organizationId, userId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to resolve organization role: %w", err)) + return + } + if role == "" { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"}) + return + } + + dashboards, err := transactional.DashboardRepository.FindByOrganization(tx, organizationId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboards: %w", err)) + return + } + + bundle := models.DashboardBundleExport{ + SchemaVersion: dashboardsvc.SchemaVersion, + Dashboards: []models.DashboardExport{}, + } + for _, d := range dashboards { + export, ok := exportDashboard(ctx, d) + if !ok { + return + } + bundle.Dashboards = append(bundle.Dashboards, *export) + } + + ctx.JSON(http.StatusOK, bundle) +} + +type ImportDashboardsRequest struct { + OrganizationId *int `json:"organizationId"` + Mode string `json:"mode"` + Data json.RawMessage `json:"data" binding:"required"` + ApplyToProjectIds []uuid.UUID `json:"applyToProjectIds"` +} + +func parseImportedDashboards(raw json.RawMessage) ([]models.DashboardExport, string) { + var bundle models.DashboardBundleExport + if err := json.Unmarshal(raw, &bundle); err == nil && len(bundle.Dashboards) > 0 { + if !dashboardsvc.SupportedSchemaVersion(bundle.SchemaVersion) { + return nil, unsupportedSchemaVersionMessage() + } + return bundle.Dashboards, "" + } + + var doc models.DashboardExport + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, "The file is not valid dashboard JSON." + } + if len(doc.Widgets) == 0 && strings.TrimSpace(doc.Name) == "" { + return nil, "The file contains no dashboards." + } + return []models.DashboardExport{doc}, "" +} + +func (c *dashboardsController) Import(ctx *gin.Context) { + limitRequestBody(ctx) + var req ImportDashboardsRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.Mode == "" { + req.Mode = "create" + } + if req.Mode != "create" && req.Mode != "upsert" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "mode must be \"create\" or \"upsert\"."}) + return + } + + docs, msg := parseImportedDashboards(req.Data) + if msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) + return + } + + tx := db.GetTx(ctx) + + organizationId, ok := resolveTargetOrganization(ctx, tx, req.OrganizationId) + if !ok { + return + } + if !requireOrgWrite(ctx, tx, organizationId) { + return + } + + applyTo := req.ApplyToProjectIds + if applyTo == nil { + if projectIdStr := ctx.Query("projectId"); projectIdStr != "" { + if projectId, err := uuid.Parse(projectIdStr); err == nil { + applyTo = []uuid.UUID{projectId} + } + } + } + + userId := middleware.GetUserId(ctx) + var createdBy *int + if userId > 0 { + createdBy = &userId + } + + created := 0 + updated := 0 + items := []DashboardListItem{} + for i := range docs { + doc := &docs[i] + if !dashboardsvc.SupportedSchemaVersion(doc.SchemaVersion) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("Dashboard %d: %s", i+1, unsupportedSchemaVersionMessage())}) + return + } + name, msg := validateDashboardName(doc.Name) + if msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("Dashboard %d: %s", i+1, msg)}) + return + } + + def := &models.DashboardDefinition{SchemaVersion: dashboardsvc.SchemaVersion, Widgets: doc.Widgets} + if msg := validateDefinitionWidgets(def); msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("Dashboard %q: %s", name, msg)}) + return + } + dashboardsvc.EnsureWidgetIds(def) + definition, ok := marshalDashboardDefinition(ctx, def) + if !ok { + return + } + + existing, err := transactional.DashboardRepository.FindByOrganization(tx, organizationId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboards: %w", err)) + return + } + + var target *models.Dashboard + if req.Mode == "upsert" { + for _, d := range existing { + if strings.EqualFold(d.Name, name) { + target = d + break + } + } + } + + now := time.Now().UTC() + if target != nil { + oldDef := dashboardDefinitionOrError(ctx, target) + if oldDef == nil { + return + } + kept := map[string]bool{} + for _, w := range def.Widgets { + kept[w.Id] = true + } + for _, w := range oldDef.Widgets { + if !kept[w.Id] { + if err := transactional.DashboardRepository.DeleteStarredByWidget(tx, target.Id, w.Id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to clean up starred widgets: %w", err)) + return + } + } + } + target.Description = doc.Description + target.Definition = definition + target.UpdatedAt = now + if err := transactional.DashboardRepository.Update(tx, target); err != nil { + if db.IsUniqueViolation(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("A dashboard named %q already exists.", name)}) + return + } + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update dashboard: %w", err)) + return + } + updated++ + if !applyDashboardToProjects(ctx, tx, target, applyTo) { + return + } + items = append(items, dashboardListItem(target)) + continue + } + + importName := name + for dashboardNameTaken(existing, importName, 0) { + suffixed := importName + " (imported)" + if len(suffixed) > maxDashboardNameLength { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("A dashboard named %q already exists.", name)}) + return + } + importName = suffixed + } + + dashboard := &models.Dashboard{ + OrganizationId: organizationId, + Name: importName, + Description: doc.Description, + Definition: definition, + CreatedBy: createdBy, + CreatedAt: now, + UpdatedAt: now, + } + id, err := transactional.DashboardRepository.Create(tx, dashboard) + if err != nil { + if db.IsUniqueViolation(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("A dashboard named %q already exists.", importName)}) + return + } + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to import dashboard: %w", err)) + return + } + dashboard.Id = id + created++ + if !applyDashboardToProjects(ctx, tx, dashboard, applyTo) { + return + } + items = append(items, dashboardListItem(dashboard)) + } + + ctx.JSON(http.StatusCreated, gin.H{"dashboards": items, "created": created, "updated": updated}) +} + +type ImportGrafanaRequest struct { + OrganizationId *int `json:"organizationId"` + Grafana json.RawMessage `json:"grafana" binding:"required"` + ApplyToProjectIds []uuid.UUID `json:"applyToProjectIds"` +} + +func (c *dashboardsController) ImportGrafana(ctx *gin.Context) { + limitRequestBody(ctx) + var req ImportGrafanaRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + organizationId, ok := resolveTargetOrganization(ctx, tx, req.OrganizationId) + if !ok { + return + } + if !requireOrgWrite(ctx, tx, organizationId) { + return + } + + result, err := grafana.Convert(req.Grafana) + if err != nil { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The file is not a valid Grafana dashboard export."}) + return + } + if len(result.Definition.Widgets) == 0 { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": "No panels could be converted from this Grafana dashboard.", + "warnings": result.Warnings, + }) + return + } + if msg := validateDefinitionWidgets(result.Definition); msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg, "warnings": result.Warnings}) + return + } + + definition, ok := marshalDashboardDefinition(ctx, result.Definition) + if !ok { + return + } + + existing, err := transactional.DashboardRepository.FindByOrganization(tx, organizationId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboards: %w", err)) + return + } + name := dashboardsvc.TruncateName(result.Name, maxDashboardNameLength) + for dashboardNameTaken(existing, name, 0) { + suffixed := name + " (imported)" + if len(suffixed) > maxDashboardNameLength { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": fmt.Sprintf("A dashboard named %q already exists.", result.Name)}) + return + } + name = suffixed + } + + userId := middleware.GetUserId(ctx) + var createdBy *int + if userId > 0 { + createdBy = &userId + } + + now := time.Now().UTC() + dashboard := &models.Dashboard{ + OrganizationId: organizationId, + Name: name, + Description: result.Description, + Definition: definition, + CreatedBy: createdBy, + CreatedAt: now, + UpdatedAt: now, + } + id, err := transactional.DashboardRepository.Create(tx, dashboard) + if err != nil { + if db.IsUniqueViolation(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return + } + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to import Grafana dashboard: %w", err)) + return + } + dashboard.Id = id + + applyTo := req.ApplyToProjectIds + if applyTo == nil { + if projectIdStr := ctx.Query("projectId"); projectIdStr != "" { + if projectId, err := uuid.Parse(projectIdStr); err == nil { + applyTo = []uuid.UUID{projectId} + } + } + } + if !applyDashboardToProjects(ctx, tx, dashboard, applyTo) { + return + } + + ctx.JSON(http.StatusCreated, gin.H{ + "dashboard": dashboardListItem(dashboard), + "warnings": result.Warnings, + }) +} diff --git a/backend/app/controllers/dashboard_template.controller.go b/backend/app/controllers/dashboard_template.controller.go new file mode 100644 index 000000000..e08b17c51 --- /dev/null +++ b/backend/app/controllers/dashboard_template.controller.go @@ -0,0 +1,288 @@ +package controllers + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/middleware" + "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/repositories/transactional" + dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + traceway "go.tracewayapp.com" +) + +type dashboardTemplateController struct{} + +type DashboardTemplateListItem struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + WidgetCount int `json:"widgetCount"` +} + +func (c *dashboardTemplateController) List(ctx *gin.Context) { + tx := db.GetTx(ctx) + + templates, err := transactional.DashboardTemplateRepository.FindAll(tx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboard templates: %w", err)) + return + } + + search := strings.ToLower(strings.TrimSpace(ctx.Query("search"))) + category := strings.TrimSpace(ctx.Query("category")) + + items := []DashboardTemplateListItem{} + for _, t := range templates { + if category != "" && t.Category != category { + continue + } + if search != "" { + haystack := strings.ToLower(t.Key + " " + t.Name + " " + t.Description + " " + t.Category) + if !strings.Contains(haystack, search) { + continue + } + } + widgetCount := 0 + def, err := dashboardsvc.ParseDefinition(t.Definition) + if err != nil { + traceway.CaptureException(traceway.NewStackTraceErrorf("failed to parse definition of dashboard template %s: %w", t.Key, err)) + } else { + widgetCount = len(def.Widgets) + } + items = append(items, DashboardTemplateListItem{ + Key: t.Key, + Name: t.Name, + Description: t.Description, + Category: t.Category, + WidgetCount: widgetCount, + }) + } + + ctx.JSON(http.StatusOK, gin.H{"templates": items}) +} + +func uniqueDashboardName(existing []*models.Dashboard, base string) string { + base = dashboardsvc.TruncateName(strings.TrimSpace(base), maxDashboardNameLength) + if !dashboardNameTaken(existing, base, 0) { + return base + } + for i := 2; ; i++ { + suffix := " " + strconv.Itoa(i) + candidate := dashboardsvc.TruncateName(base, maxDashboardNameLength-utf8.RuneCountInString(suffix)) + suffix + if !dashboardNameTaken(existing, candidate, 0) { + return candidate + } + } +} + +func installDashboardTemplate(ctx *gin.Context, tx *sql.Tx, template *models.DashboardTemplate, organizationId int) *models.Dashboard { + def, err := dashboardsvc.ParseDefinition(template.Definition) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to parse template %s definition: %w", template.Key, err)) + return nil + } + for i := range def.Widgets { + def.Widgets[i].WidgetType = strings.TrimSpace(def.Widgets[i].WidgetType) + if def.Widgets[i].WidgetType == "" { + def.Widgets[i].WidgetType = "line_chart" + } + if !dashboardsvc.IsAllowedWidgetType(def.Widgets[i].WidgetType) { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("template %s contains unsupported widget type %s", template.Key, def.Widgets[i].WidgetType)) + return nil + } + } + dashboardsvc.EnsureWidgetIds(def) + definition, err := dashboardsvc.MarshalDefinition(def) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to marshal template %s definition: %w", template.Key, err)) + return nil + } + + existing, err := transactional.DashboardRepository.FindByOrganization(tx, organizationId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboards: %w", err)) + return nil + } + name := uniqueDashboardName(existing, template.Name) + + userId := middleware.GetUserId(ctx) + var createdBy *int + if userId > 0 { + createdBy = &userId + } + + templateKey := template.Key + now := time.Now().UTC() + dashboard := &models.Dashboard{ + OrganizationId: organizationId, + Name: name, + Description: template.Description, + Definition: definition, + TemplateKey: &templateKey, + CreatedBy: createdBy, + CreatedAt: now, + UpdatedAt: now, + } + id, err := transactional.DashboardRepository.Create(tx, dashboard) + if err != nil { + if db.IsUniqueViolation(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return nil + } + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to install template %s: %w", template.Key, err)) + return nil + } + dashboard.Id = id + return dashboard +} + +type InstallTemplateRequest struct { + OrganizationId *int `json:"organizationId"` + ProjectIds []uuid.UUID `json:"projectIds"` +} + +func (c *dashboardTemplateController) Install(ctx *gin.Context) { + var req InstallTemplateRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + template, err := transactional.DashboardTemplateRepository.FindByKey(tx, ctx.Param("key")) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to find dashboard template: %w", err)) + return + } + if template == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Template not found"}) + return + } + + organizationId, ok := resolveTargetOrganization(ctx, tx, req.OrganizationId) + if !ok { + return + } + if !requireOrgWrite(ctx, tx, organizationId) { + return + } + + dashboard := installDashboardTemplate(ctx, tx, template, organizationId) + if dashboard == nil { + return + } + + applyTo := req.ProjectIds + if applyTo == nil { + if projectIdStr := ctx.Query("projectId"); projectIdStr != "" { + if projectId, err := uuid.Parse(projectIdStr); err == nil { + applyTo = []uuid.UUID{projectId} + } + } + } + if !applyDashboardToProjects(ctx, tx, dashboard, applyTo) { + return + } + + ctx.JSON(http.StatusCreated, dashboardListItem(dashboard)) +} + +func defaultTemplateKeysForFramework(framework string) []string { + goFrameworks := map[string]bool{ + "gin": true, "fiber": true, "chi": true, + "fasthttp": true, "stdlib": true, "custom": true, + } + switch { + case framework == "opentelemetry": + return []string{"traceway-otel-agent"} + case goFrameworks[framework]: + return []string{"golang"} + default: + return nil + } +} + +func (c *dashboardsController) PopulateDefaults(ctx *gin.Context) { + projectId, err := middleware.GetProjectId(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + tx := db.GetTx(ctx) + + existing, err := transactional.DashboardRepository.FindAssignmentsByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboard assignments: %w", err)) + return + } + if len(existing) > 0 { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Project already has dashboards."}) + return + } + + project, err := transactional.ProjectRepository.FindById(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to find project: %w", err)) + return + } + if project == nil || project.OrganizationId == nil { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Project has no organization."}) + return + } + if !requireOrgWrite(ctx, tx, *project.OrganizationId) { + return + } + + keys := defaultTemplateKeysForFramework(project.Framework) + if len(keys) == 0 { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "No default dashboards are available for this project's framework."}) + return + } + + position := 0 + items := []DashboardListItem{} + for _, key := range keys { + template, err := transactional.DashboardTemplateRepository.FindByKey(tx, key) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to find dashboard template %s: %w", key, err)) + return + } + if template == nil { + traceway.CaptureException(fmt.Errorf("default dashboard template %s is not seeded", key)) + continue + } + + dashboard := installDashboardTemplate(ctx, tx, template, *project.OrganizationId) + if dashboard == nil { + return + } + if err := transactional.DashboardRepository.CreateAssignment(tx, &models.ProjectDashboard{ + ProjectId: projectId, + DashboardId: dashboard.Id, + Position: position, + CreatedAt: time.Now().UTC(), + }); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to assign default dashboard: %w", err)) + return + } + position++ + items = append(items, dashboardListItem(dashboard)) + } + + ctx.JSON(http.StatusCreated, gin.H{"dashboards": items}) +} + +var DashboardTemplateController = dashboardTemplateController{} diff --git a/backend/app/controllers/dashboard_template_seed_test.go b/backend/app/controllers/dashboard_template_seed_test.go new file mode 100644 index 000000000..63fcaf477 --- /dev/null +++ b/backend/app/controllers/dashboard_template_seed_test.go @@ -0,0 +1,94 @@ +//go:build !telemetry_ch && !transactional_pg + +package controllers + +import ( + "database/sql" + "testing" + + "github.com/tracewayapp/lit/v2" + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/migrations" + "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/repositories/transactional" + dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards" + _ "modernc.org/sqlite" +) + +func TestSeededDashboardTemplatesAreValid(t *testing.T) { + mainDB, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open main sqlite: %v", err) + } + mainDB.SetMaxOpenConns(1) + + telemetryDB, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open telemetry sqlite: %v", err) + } + telemetryDB.SetMaxOpenConns(1) + + prevDB, prevTelemetry, prevDriver := db.DB, db.TelemetryDB, db.Driver + db.DB = mainDB + db.TelemetryDB = telemetryDB + db.Driver = lit.SQLite + models.Init(db.Driver) + t.Cleanup(func() { + mainDB.Close() + telemetryDB.Close() + db.DB = prevDB + db.TelemetryDB = prevTelemetry + db.Driver = prevDriver + }) + + if err := migrations.Run("sqlite"); err != nil { + t.Fatalf("run migrations: %v", err) + } + + tx, err := mainDB.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + templates, err := transactional.DashboardTemplateRepository.FindAll(tx) + if err != nil { + t.Fatalf("list templates: %v", err) + } + + expectedKeys := []string{ + "traceway-clickhouse", "traceway-duckdb", + "golang", "traceway-otel-agent", + } + byKey := map[string]*models.DashboardTemplate{} + for _, tpl := range templates { + byKey[tpl.Key] = tpl + } + for _, key := range expectedKeys { + if byKey[key] == nil { + t.Errorf("template %s is not seeded", key) + } + } + + for _, tpl := range templates { + def, err := dashboardsvc.ParseDefinition(tpl.Definition) + if err != nil { + t.Errorf("template %s definition does not parse: %v", tpl.Key, err) + continue + } + if len(def.Widgets) == 0 { + t.Errorf("template %s has no widgets", tpl.Key) + } + if msg := validateDefinitionWidgets(def); msg != "" { + t.Errorf("template %s definition invalid: %s", tpl.Key, msg) + } + } + + for _, framework := range []string{"opentelemetry", "react", "gin", ""} { + for _, key := range defaultTemplateKeysForFramework(framework) { + if byKey[key] == nil { + t.Errorf("default template %s for framework %q is not seeded", key, framework) + } + } + } +} diff --git a/backend/app/controllers/dashboard_widgets.controller.go b/backend/app/controllers/dashboard_widgets.controller.go new file mode 100644 index 000000000..d8ca4e45b --- /dev/null +++ b/backend/app/controllers/dashboard_widgets.controller.go @@ -0,0 +1,524 @@ +package controllers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/middleware" + "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/repositories/transactional" + dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards" + + "github.com/gin-gonic/gin" + traceway "go.tracewayapp.com" +) + +type AddWidgetRequest struct { + Title string `json:"title"` + WidgetType string `json:"widgetType" binding:"required"` + Config json.RawMessage `json:"config"` +} + +func (c *dashboardsController) AddWidget(ctx *gin.Context) { + limitRequestBody(ctx) + var req AddWidgetRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.Config == nil { + req.Config = json.RawMessage(`{}`) + } + if len(req.Config) > dashboardsvc.MaxWidgetConfigBytes { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Widget configuration is too large."}) + return + } + if msg := validateWidgetConfig(req.Config); msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) + return + } + req.Title = strings.TrimSpace(req.Title) + if req.Title == "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Title is required."}) + return + } + req.WidgetType = strings.TrimSpace(req.WidgetType) + if !dashboardsvc.IsAllowedWidgetType(req.WidgetType) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": widgetTypeErrorMessage}) + return + } + + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, true) + if dashboard == nil { + return + } + def := dashboardDefinitionOrError(ctx, dashboard) + if def == nil { + return + } + if len(def.Widgets) >= dashboardsvc.MaxWidgetsPerDashboard { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard can have at most 100 widgets."}) + return + } + + used := make(map[string]bool, len(def.Widgets)) + for _, w := range def.Widgets { + used[w.Id] = true + } + id := dashboardsvc.NewWidgetId() + for used[id] { + id = dashboardsvc.NewWidgetId() + } + + widget := models.DashboardWidget{ + Id: id, + Title: req.Title, + WidgetType: req.WidgetType, + Config: req.Config, + } + def.Widgets = append(def.Widgets, widget) + + if !saveDashboardDefinition(ctx, tx, dashboard, def) { + return + } + + ctx.JSON(http.StatusCreated, widget) +} + +func (c *dashboardsController) UpdateWidget(ctx *gin.Context) { + limitRequestBody(ctx) + var req AddWidgetRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.Config != nil { + if len(req.Config) > dashboardsvc.MaxWidgetConfigBytes { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Widget configuration is too large."}) + return + } + if msg := validateWidgetConfig(req.Config); msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) + return + } + } + req.Title = strings.TrimSpace(req.Title) + if req.Title == "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Title is required."}) + return + } + req.WidgetType = strings.TrimSpace(req.WidgetType) + if !dashboardsvc.IsAllowedWidgetType(req.WidgetType) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": widgetTypeErrorMessage}) + return + } + + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, true) + if dashboard == nil { + return + } + def := dashboardDefinitionOrError(ctx, dashboard) + if def == nil { + return + } + + index := dashboardsvc.FindWidget(def, ctx.Param("wid")) + if index < 0 { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget not found"}) + return + } + + def.Widgets[index].Title = req.Title + def.Widgets[index].WidgetType = req.WidgetType + if req.Config != nil { + def.Widgets[index].Config = req.Config + } + + if !saveDashboardDefinition(ctx, tx, dashboard, def) { + return + } + + ctx.JSON(http.StatusOK, def.Widgets[index]) +} + +func (c *dashboardsController) DeleteWidget(ctx *gin.Context) { + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, true) + if dashboard == nil { + return + } + def := dashboardDefinitionOrError(ctx, dashboard) + if def == nil { + return + } + + widgetId := ctx.Param("wid") + index := dashboardsvc.FindWidget(def, widgetId) + if index < 0 { + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) + return + } + + def.Widgets = append(def.Widgets[:index], def.Widgets[index+1:]...) + + if err := transactional.DashboardRepository.DeleteStarredByWidget(tx, dashboard.Id, widgetId); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete starred widget: %w", err)) + return + } + if !saveDashboardDefinition(ctx, tx, dashboard, def) { + return + } + + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +type ReorderWidgetsRequest struct { + WidgetIds []string `json:"widgetIds" binding:"required,min=1"` +} + +func (c *dashboardsController) ReorderWidgets(ctx *gin.Context) { + var req ReorderWidgetsRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, true) + if dashboard == nil { + return + } + def := dashboardDefinitionOrError(ctx, dashboard) + if def == nil { + return + } + + byId := make(map[string]models.DashboardWidget, len(def.Widgets)) + for _, w := range def.Widgets { + byId[w.Id] = w + } + + if len(req.WidgetIds) != len(def.Widgets) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) + return + } + seen := make(map[string]bool, len(req.WidgetIds)) + reordered := make([]models.DashboardWidget, 0, len(def.Widgets)) + for _, id := range req.WidgetIds { + w, ok := byId[id] + if !ok || seen[id] { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) + return + } + seen[id] = true + reordered = append(reordered, w) + } + def.Widgets = reordered + + if !saveDashboardDefinition(ctx, tx, dashboard, def) { + return + } + + ctx.JSON(http.StatusOK, gin.H{"reordered": true}) +} + +func saveDashboardDefinition(ctx *gin.Context, tx *sql.Tx, dashboard *models.Dashboard, def *models.DashboardDefinition) bool { + definition, ok := marshalDashboardDefinition(ctx, def) + if !ok { + return false + } + dashboard.Definition = definition + dashboard.UpdatedAt = time.Now().UTC() + if err := transactional.DashboardRepository.Update(tx, dashboard); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update dashboard definition: %w", err)) + return false + } + return true +} + +func (c *dashboardsController) ToggleStar(ctx *gin.Context) { + projectId, err := middleware.GetProjectId(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, false) + if dashboard == nil { + return + } + + assignment, err := transactional.DashboardRepository.FindAssignment(tx, projectId, dashboard.Id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to check dashboard assignment: %w", err)) + return + } + if assignment == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Dashboard is not applied to this project"}) + return + } + + def := dashboardDefinitionOrError(ctx, dashboard) + if def == nil { + return + } + widgetId := ctx.Param("wid") + index := dashboardsvc.FindWidget(def, widgetId) + if index < 0 { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget not found"}) + return + } + + starred, err := transactional.DashboardRepository.FindStarred(tx, projectId, dashboard.Id, widgetId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) + return + } + + if starred != nil { + if err := transactional.DashboardRepository.DeleteStarred(tx, projectId, dashboard.Id, widgetId); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) + return + } + ctx.JSON(http.StatusOK, gin.H{"id": widgetId, "isStarred": false}) + return + } + + allStarred, err := transactional.DashboardRepository.FindStarredByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) + return + } + + var cfg struct { + ColSpan int `json:"colSpan"` + Size string `json:"size"` + } + _ = json.Unmarshal(def.Widgets[index].Config, &cfg) + if cfg.ColSpan < 1 || cfg.ColSpan > 3 { + cfg.ColSpan = 1 + } + if cfg.Size != "sm" && cfg.Size != "md" && cfg.Size != "lg" { + cfg.Size = "sm" + } + + nextPosition := 0 + for _, s := range allStarred { + if s.Position >= nextPosition { + nextPosition = s.Position + 1 + } + } + + if err := transactional.DashboardRepository.CreateStarred(tx, &models.StarredDashboardWidget{ + ProjectId: projectId, + DashboardId: dashboard.Id, + WidgetId: widgetId, + Position: nextPosition, + ColSpan: cfg.ColSpan, + Size: cfg.Size, + CreatedAt: time.Now().UTC(), + }); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) + return + } + + ctx.JSON(http.StatusOK, gin.H{"id": widgetId, "isStarred": true}) +} + +type StarredWidgetResponse struct { + Id int `json:"id"` + DashboardId int `json:"dashboardId"` + WidgetId string `json:"widgetId"` + Title string `json:"title"` + WidgetType string `json:"widgetType"` + Config json.RawMessage `json:"config"` + HomePosition int `json:"homePosition"` + HomeColSpan int `json:"homeColSpan"` + HomeSize string `json:"homeSize"` +} + +func (c *dashboardsController) ListStarred(ctx *gin.Context) { + projectId, err := middleware.GetProjectId(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + tx := db.GetTx(ctx) + + starred, err := transactional.DashboardRepository.FindStarredByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list starred widgets: %w", err)) + return + } + + dashboards, err := transactional.DashboardRepository.FindStarredDashboardsByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to load starred dashboards: %w", err)) + return + } + + widgetsByDashboard := map[int]map[string]models.DashboardWidget{} + for _, d := range dashboards { + def, err := dashboardsvc.ParseDefinition(d.Definition) + if err != nil { + traceway.CaptureException(traceway.NewStackTraceErrorf("failed to parse definition of dashboard %d: %w", d.Id, err)) + continue + } + widgets := make(map[string]models.DashboardWidget, len(def.Widgets)) + for _, w := range def.Widgets { + widgets[w.Id] = w + } + widgetsByDashboard[d.Id] = widgets + } + + response := []StarredWidgetResponse{} + for _, s := range starred { + widget, ok := widgetsByDashboard[s.DashboardId][s.WidgetId] + if !ok { + continue + } + response = append(response, StarredWidgetResponse{ + Id: s.Id, + DashboardId: s.DashboardId, + WidgetId: s.WidgetId, + Title: widget.Title, + WidgetType: widget.WidgetType, + Config: widget.Config, + HomePosition: s.Position, + HomeColSpan: s.ColSpan, + HomeSize: s.Size, + }) + } + + ctx.JSON(http.StatusOK, response) +} + +type ReorderStarredRequest struct { + Ids []int `json:"ids" binding:"required,min=1"` +} + +func (c *dashboardsController) ReorderStarred(ctx *gin.Context) { + projectId, err := middleware.GetProjectId(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + var req ReorderStarredRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + starred, err := transactional.DashboardRepository.FindStarredByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder starred widgets: %w", err)) + return + } + + byId := make(map[int]*models.StarredDashboardWidget, len(starred)) + for _, s := range starred { + byId[s.Id] = s + } + + if len(req.Ids) != len(starred) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) + return + } + seen := make(map[int]bool, len(req.Ids)) + for _, id := range req.Ids { + if byId[id] == nil || seen[id] { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) + return + } + seen[id] = true + } + + for position, id := range req.Ids { + s := byId[id] + if s.Position == position { + continue + } + s.Position = position + if err := transactional.DashboardRepository.UpdateStarred(tx, s); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder starred widgets: %w", err)) + return + } + } + + ctx.JSON(http.StatusOK, gin.H{"reordered": true}) +} + +type UpdateStarredLayoutRequest struct { + ColSpan int `json:"colSpan"` + Size string `json:"size"` +} + +func (c *dashboardsController) UpdateStarredLayout(ctx *gin.Context) { + projectId, err := middleware.GetProjectId(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + idStr := ctx.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid starred widget id"}) + return + } + + var req UpdateStarredLayoutRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.ColSpan < 1 || req.ColSpan > 3 { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Width must be between 1 and 3 columns."}) + return + } + if req.Size != "sm" && req.Size != "md" && req.Size != "lg" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Height must be one of: sm, md, lg."}) + return + } + + tx := db.GetTx(ctx) + + starred, err := transactional.DashboardRepository.FindStarredById(tx, projectId, id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update starred widget layout: %w", err)) + return + } + if starred == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Starred widget not found"}) + return + } + + starred.ColSpan = req.ColSpan + starred.Size = req.Size + if err := transactional.DashboardRepository.UpdateStarred(tx, starred); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update starred widget layout: %w", err)) + return + } + + ctx.JSON(http.StatusOK, starred) +} diff --git a/backend/app/controllers/dashboards.controller.go b/backend/app/controllers/dashboards.controller.go new file mode 100644 index 000000000..0843f1866 --- /dev/null +++ b/backend/app/controllers/dashboards.controller.go @@ -0,0 +1,972 @@ +package controllers + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/middleware" + "github.com/tracewayapp/traceway/backend/app/models" + "github.com/tracewayapp/traceway/backend/app/repositories/transactional" + dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + traceway "go.tracewayapp.com" +) + +type dashboardsController struct{} + +const maxDashboardNameLength = 100 + +const maxDashboardBodyBytes = 5 << 20 + +func limitRequestBody(ctx *gin.Context) { + ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxDashboardBodyBytes) +} + +func definitionProvided(raw json.RawMessage) bool { + trimmed := strings.TrimSpace(string(raw)) + return trimmed != "" && trimmed != "null" +} + +var widgetTypeErrorMessage = "Widget type must be one of: " + strings.Join(dashboardsvc.AllowedWidgetTypes, ", ") + "." + +func unsupportedSchemaVersionMessage() string { + return fmt.Sprintf("Unsupported dashboard schemaVersion. This server supports schemaVersion %d.", dashboardsvc.SchemaVersion) +} + +type DashboardListItem struct { + Id int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OrganizationId int `json:"organizationId"` + TemplateKey *string `json:"templateKey"` +} + +type DashboardWidgetWithStar struct { + Id string `json:"id"` + Title string `json:"title"` + WidgetType string `json:"widgetType"` + Config json.RawMessage `json:"config"` + IsStarred bool `json:"isStarred"` +} + +type DashboardResponse struct { + Id int `json:"id"` + OrganizationId int `json:"organizationId"` + Name string `json:"name"` + Description string `json:"description"` + TemplateKey *string `json:"templateKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + AppliedProjectIds []uuid.UUID `json:"appliedProjectIds"` + Widgets []DashboardWidgetWithStar `json:"widgets"` +} + +func dashboardListItem(d *models.Dashboard) DashboardListItem { + return DashboardListItem{ + Id: d.Id, + Name: d.Name, + Description: d.Description, + OrganizationId: d.OrganizationId, + TemplateKey: d.TemplateKey, + } +} + +func validateDashboardName(name string) (string, string) { + name = strings.TrimSpace(name) + if name == "" { + return "", "Name is required." + } + if utf8.RuneCountInString(name) > maxDashboardNameLength { + return "", "Name must be 100 characters or fewer." + } + return name, "" +} + +func validateWidgetConfig(raw json.RawMessage) string { + var cfg struct { + Sources []struct { + Name string `json:"name"` + } `json:"sources"` + } + if err := json.Unmarshal(raw, &cfg); err != nil { + return "Invalid widget configuration." + } + hasMetric := false + for _, s := range cfg.Sources { + if strings.TrimSpace(s.Name) != "" { + hasMetric = true + break + } + } + if !hasMetric { + return "Please select a Metric." + } + return "" +} + +func validateDefinitionWidgets(def *models.DashboardDefinition) string { + if len(def.Widgets) > dashboardsvc.MaxWidgetsPerDashboard { + return "A dashboard can have at most 100 widgets." + } + for i := range def.Widgets { + w := &def.Widgets[i] + w.Title = strings.TrimSpace(w.Title) + if w.Title == "" { + return "Every widget needs a title." + } + w.WidgetType = strings.TrimSpace(w.WidgetType) + if w.WidgetType == "" { + w.WidgetType = "line_chart" + } + if !dashboardsvc.IsAllowedWidgetType(w.WidgetType) { + return widgetTypeErrorMessage + } + if len(w.Config) == 0 { + w.Config = json.RawMessage(`{}`) + } + if len(w.Config) > dashboardsvc.MaxWidgetConfigBytes { + return "Widget configuration is too large." + } + if msg := validateWidgetConfig(w.Config); msg != "" { + return msg + } + } + return "" +} + +func loadDashboardForUser(ctx *gin.Context, tx *sql.Tx, requireWrite bool) *models.Dashboard { + idStr := ctx.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid dashboard id"}) + return nil + } + + dashboard, err := transactional.DashboardRepository.FindById(tx, id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to load dashboard: %w", err)) + return nil + } + if dashboard == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Dashboard not found"}) + return nil + } + + userId := middleware.GetUserId(ctx) + role, err := transactional.OrganizationRepository.GetUserRole(tx, dashboard.OrganizationId, userId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to resolve organization role: %w", err)) + return nil + } + if role == "" { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Dashboard not found"}) + return nil + } + if requireWrite && role == "readonly" { + ctx.JSON(http.StatusForbidden, gin.H{"error": "You have read-only access to this organization"}) + return nil + } + return dashboard +} + +func requireOrgWrite(ctx *gin.Context, tx *sql.Tx, organizationId int) bool { + userId := middleware.GetUserId(ctx) + role, err := transactional.OrganizationRepository.GetUserRole(tx, organizationId, userId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to resolve organization role: %w", err)) + return false + } + if role == "" { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"}) + return false + } + if role == "readonly" { + ctx.JSON(http.StatusForbidden, gin.H{"error": "You have read-only access to this organization"}) + return false + } + return true +} + +func requireProjectWrite(ctx *gin.Context, tx *sql.Tx, projectId uuid.UUID) bool { + userId := middleware.GetUserId(ctx) + role, err := transactional.ProjectRepository.GetEffectiveRole(tx, projectId, userId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to resolve project role: %w", err)) + return false + } + if role == "" || role == "readonly" { + ctx.JSON(http.StatusForbidden, gin.H{"error": "You do not have write access to this project"}) + return false + } + return true +} + +func parseDashboardDefinition(ctx *gin.Context, raw json.RawMessage) *models.DashboardDefinition { + def, err := dashboardsvc.ParseDefinition(raw) + if err != nil { + if errors.Is(err, dashboardsvc.ErrUnsupportedSchemaVersion) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": unsupportedSchemaVersionMessage()}) + return nil + } + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Invalid dashboard definition JSON."}) + return nil + } + if msg := validateDefinitionWidgets(def); msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) + return nil + } + dashboardsvc.EnsureWidgetIds(def) + return def +} + +func marshalDashboardDefinition(ctx *gin.Context, def *models.DashboardDefinition) ([]byte, bool) { + raw, err := dashboardsvc.MarshalDefinition(def) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to marshal dashboard definition: %w", err)) + return nil, false + } + return raw, true +} + +func dashboardDefinitionOrError(ctx *gin.Context, dashboard *models.Dashboard) *models.DashboardDefinition { + def, err := dashboardsvc.ParseDefinition(dashboard.Definition) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to parse definition of dashboard %d: %w", dashboard.Id, err)) + return nil + } + return def +} + +func (c *dashboardsController) List(ctx *gin.Context) { + projectId, err := middleware.GetProjectId(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + tx := db.GetTx(ctx) + + dashboards, err := transactional.DashboardRepository.FindByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboards: %w", err)) + return + } + + project, err := transactional.ProjectRepository.FindById(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to find project: %w", err)) + return + } + + framework := "" + if project != nil { + framework = project.Framework + } + + items := []DashboardListItem{} + for _, d := range dashboards { + items = append(items, dashboardListItem(d)) + } + + ctx.JSON(http.StatusOK, gin.H{ + "dashboards": items, + "framework": framework, + "canPopulateDefaults": len(items) == 0 && len(defaultTemplateKeysForFramework(framework)) > 0, + }) +} + +type LibraryDashboard struct { + Id int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TemplateKey *string `json:"templateKey"` + WidgetCount int `json:"widgetCount"` + AppliedProjectIds []uuid.UUID `json:"appliedProjectIds"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type LibraryOrganization struct { + Id int `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Dashboards []LibraryDashboard `json:"dashboards"` +} + +func (c *dashboardsController) Library(ctx *gin.Context) { + userId := middleware.GetUserId(ctx) + tx := db.GetTx(ctx) + + orgs, err := transactional.OrganizationRepository.FindByUserId(tx, userId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list organizations: %w", err)) + return + } + + result := []LibraryOrganization{} + for _, org := range orgs { + role, err := transactional.OrganizationRepository.GetUserRole(tx, org.Id, userId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to resolve organization role: %w", err)) + return + } + + dashboards, err := transactional.DashboardRepository.FindByOrganization(tx, org.Id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboards for organization %d: %w", org.Id, err)) + return + } + assignments, err := transactional.DashboardRepository.FindAssignmentsByOrganization(tx, org.Id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboard assignments for organization %d: %w", org.Id, err)) + return + } + + applied := map[int][]uuid.UUID{} + for _, a := range assignments { + applied[a.DashboardId] = append(applied[a.DashboardId], a.ProjectId) + } + + libOrg := LibraryOrganization{Id: org.Id, Name: org.Name, Role: role, Dashboards: []LibraryDashboard{}} + for _, d := range dashboards { + def, err := dashboardsvc.ParseDefinition(d.Definition) + widgetCount := 0 + if err != nil { + traceway.CaptureException(traceway.NewStackTraceErrorf("failed to parse definition of dashboard %d: %w", d.Id, err)) + } else { + widgetCount = len(def.Widgets) + } + projectIds := applied[d.Id] + if projectIds == nil { + projectIds = []uuid.UUID{} + } + libOrg.Dashboards = append(libOrg.Dashboards, LibraryDashboard{ + Id: d.Id, + Name: d.Name, + Description: d.Description, + TemplateKey: d.TemplateKey, + WidgetCount: widgetCount, + AppliedProjectIds: projectIds, + UpdatedAt: d.UpdatedAt, + }) + } + result = append(result, libOrg) + } + + ctx.JSON(http.StatusOK, gin.H{"organizations": result}) +} + +func resolveTargetOrganization(ctx *gin.Context, tx *sql.Tx, explicitOrgId *int) (int, bool) { + if explicitOrgId != nil { + return *explicitOrgId, true + } + projectIdStr := ctx.Query("projectId") + if projectIdStr != "" { + if projectId, err := uuid.Parse(projectIdStr); err == nil { + project, err := transactional.ProjectRepository.FindById(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to find project: %w", err)) + return 0, false + } + if project != nil && project.OrganizationId != nil { + return *project.OrganizationId, true + } + } + } + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "An organization is required."}) + return 0, false +} + +func dashboardNameTaken(dashboards []*models.Dashboard, name string, excludeId int) bool { + for _, d := range dashboards { + if d.Id != excludeId && strings.EqualFold(d.Name, name) { + return true + } + } + return false +} + +func applyDashboardToProjects(ctx *gin.Context, tx *sql.Tx, dashboard *models.Dashboard, projectIds []uuid.UUID) bool { + for _, projectId := range projectIds { + project, err := transactional.ProjectRepository.FindById(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to find project: %w", err)) + return false + } + if project == nil || project.OrganizationId == nil || *project.OrganizationId != dashboard.OrganizationId { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "All projects must belong to the dashboard's organization."}) + return false + } + if !requireProjectWrite(ctx, tx, projectId) { + return false + } + + assignments, err := transactional.DashboardRepository.FindAssignmentsByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboard assignments: %w", err)) + return false + } + nextPosition := 0 + for _, a := range assignments { + if a.DashboardId == dashboard.Id { + nextPosition = -1 + break + } + if a.Position >= nextPosition { + nextPosition = a.Position + 1 + } + } + if nextPosition < 0 { + continue + } + if err := transactional.DashboardRepository.CreateAssignment(tx, &models.ProjectDashboard{ + ProjectId: projectId, + DashboardId: dashboard.Id, + Position: nextPosition, + CreatedAt: time.Now().UTC(), + }); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to assign dashboard: %w", err)) + return false + } + } + return true +} + +type CreateDashboardRequest struct { + OrganizationId *int `json:"organizationId"` + Name string `json:"name"` + Description string `json:"description"` + Definition json.RawMessage `json:"definition"` + ApplyToProjectIds []uuid.UUID `json:"applyToProjectIds"` +} + +func (c *dashboardsController) Create(ctx *gin.Context) { + limitRequestBody(ctx) + var req CreateDashboardRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + organizationId, ok := resolveTargetOrganization(ctx, tx, req.OrganizationId) + if !ok { + return + } + if !requireOrgWrite(ctx, tx, organizationId) { + return + } + + name, msg := validateDashboardName(req.Name) + if msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) + return + } + + existing, err := transactional.DashboardRepository.FindByOrganization(tx, organizationId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to check duplicate dashboard name: %w", err)) + return + } + if dashboardNameTaken(existing, name, 0) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return + } + + def := parseDashboardDefinition(ctx, req.Definition) + if def == nil { + return + } + definition, ok := marshalDashboardDefinition(ctx, def) + if !ok { + return + } + + userId := middleware.GetUserId(ctx) + var createdBy *int + if userId > 0 { + createdBy = &userId + } + + now := time.Now().UTC() + dashboard := &models.Dashboard{ + OrganizationId: organizationId, + Name: name, + Description: req.Description, + Definition: definition, + CreatedBy: createdBy, + CreatedAt: now, + UpdatedAt: now, + } + id, err := transactional.DashboardRepository.Create(tx, dashboard) + if err != nil { + if db.IsUniqueViolation(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return + } + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to create dashboard: %w", err)) + return + } + dashboard.Id = id + + applyTo := req.ApplyToProjectIds + if applyTo == nil { + if projectIdStr := ctx.Query("projectId"); projectIdStr != "" { + if projectId, err := uuid.Parse(projectIdStr); err == nil { + applyTo = []uuid.UUID{projectId} + } + } + } + if !applyDashboardToProjects(ctx, tx, dashboard, applyTo) { + return + } + + ctx.JSON(http.StatusCreated, dashboardListItem(dashboard)) +} + +func (c *dashboardsController) Get(ctx *gin.Context) { + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, false) + if dashboard == nil { + return + } + + def := dashboardDefinitionOrError(ctx, dashboard) + if def == nil { + return + } + + starredIds := map[string]bool{} + if projectIdStr := ctx.Query("projectId"); projectIdStr != "" { + if projectId, err := uuid.Parse(projectIdStr); err == nil { + starred, err := transactional.DashboardRepository.FindStarredByProjectAndDashboard(tx, projectId, dashboard.Id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to load starred widgets: %w", err)) + return + } + for _, s := range starred { + starredIds[s.WidgetId] = true + } + } + } + + widgets := []DashboardWidgetWithStar{} + for _, w := range def.Widgets { + widgets = append(widgets, DashboardWidgetWithStar{ + Id: w.Id, + Title: w.Title, + WidgetType: w.WidgetType, + Config: w.Config, + IsStarred: starredIds[w.Id], + }) + } + + assignments, err := transactional.DashboardRepository.FindAssignmentsByDashboard(tx, dashboard.Id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboard assignments: %w", err)) + return + } + appliedProjectIds := []uuid.UUID{} + for _, a := range assignments { + appliedProjectIds = append(appliedProjectIds, a.ProjectId) + } + + ctx.JSON(http.StatusOK, DashboardResponse{ + Id: dashboard.Id, + OrganizationId: dashboard.OrganizationId, + Name: dashboard.Name, + Description: dashboard.Description, + TemplateKey: dashboard.TemplateKey, + CreatedAt: dashboard.CreatedAt, + UpdatedAt: dashboard.UpdatedAt, + AppliedProjectIds: appliedProjectIds, + Widgets: widgets, + }) +} + +type UpdateDashboardRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Definition json.RawMessage `json:"definition"` +} + +func (c *dashboardsController) Update(ctx *gin.Context) { + limitRequestBody(ctx) + var req UpdateDashboardRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, true) + if dashboard == nil { + return + } + + if req.Name != nil { + name, msg := validateDashboardName(*req.Name) + if msg != "" { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) + return + } + if !strings.EqualFold(name, dashboard.Name) { + existing, err := transactional.DashboardRepository.FindByOrganization(tx, dashboard.OrganizationId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to check duplicate dashboard name: %w", err)) + return + } + if dashboardNameTaken(existing, name, dashboard.Id) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return + } + } + dashboard.Name = name + } + if req.Description != nil { + dashboard.Description = *req.Description + } + + if definitionProvided(req.Definition) { + oldDef := dashboardDefinitionOrError(ctx, dashboard) + if oldDef == nil { + return + } + def := parseDashboardDefinition(ctx, req.Definition) + if def == nil { + return + } + definition, ok := marshalDashboardDefinition(ctx, def) + if !ok { + return + } + + kept := map[string]bool{} + for _, w := range def.Widgets { + kept[w.Id] = true + } + for _, w := range oldDef.Widgets { + if !kept[w.Id] { + if err := transactional.DashboardRepository.DeleteStarredByWidget(tx, dashboard.Id, w.Id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to clean up starred widgets: %w", err)) + return + } + } + } + dashboard.Definition = definition + } + + dashboard.UpdatedAt = time.Now().UTC() + if err := transactional.DashboardRepository.Update(tx, dashboard); err != nil { + if db.IsUniqueViolation(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return + } + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update dashboard: %w", err)) + return + } + + ctx.JSON(http.StatusOK, dashboardListItem(dashboard)) +} + +func (c *dashboardsController) Delete(ctx *gin.Context) { + tx := db.GetTx(ctx) + + idStr := ctx.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid dashboard id"}) + return + } + + dashboard, err := transactional.DashboardRepository.FindById(tx, id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete dashboard: %w", err)) + return + } + if dashboard == nil { + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) + return + } + + userId := middleware.GetUserId(ctx) + role, err := transactional.OrganizationRepository.GetUserRole(tx, dashboard.OrganizationId, userId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to resolve organization role: %w", err)) + return + } + if role == "" { + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) + return + } + if role == "readonly" { + ctx.JSON(http.StatusForbidden, gin.H{"error": "You have read-only access to this organization"}) + return + } + + if err := transactional.DashboardRepository.DeleteStarredByDashboard(tx, id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete dashboard starred widgets: %w", err)) + return + } + if err := transactional.DashboardRepository.DeleteAssignmentsByDashboard(tx, id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete dashboard assignments: %w", err)) + return + } + if err := transactional.DashboardRepository.Delete(tx, id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete dashboard: %w", err)) + return + } + + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +type ApplyDashboardRequest struct { + ProjectIds []uuid.UUID `json:"projectIds" binding:"required"` +} + +func (c *dashboardsController) Apply(ctx *gin.Context) { + var req ApplyDashboardRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, true) + if dashboard == nil { + return + } + + current, err := transactional.DashboardRepository.FindAssignmentsByDashboard(tx, dashboard.Id) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list dashboard assignments: %w", err)) + return + } + + desired := map[uuid.UUID]bool{} + for _, projectId := range req.ProjectIds { + desired[projectId] = true + } + + toAdd := []uuid.UUID{} + for projectId := range desired { + found := false + for _, a := range current { + if a.ProjectId == projectId { + found = true + break + } + } + if !found { + toAdd = append(toAdd, projectId) + } + } + + for _, a := range current { + if desired[a.ProjectId] { + continue + } + if !requireProjectWrite(ctx, tx, a.ProjectId) { + return + } + if err := transactional.DashboardRepository.DeleteStarredByProjectAndDashboard(tx, a.ProjectId, dashboard.Id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to clean up starred widgets: %w", err)) + return + } + if err := transactional.DashboardRepository.DeleteAssignment(tx, a.ProjectId, dashboard.Id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to unassign dashboard: %w", err)) + return + } + } + + if !applyDashboardToProjects(ctx, tx, dashboard, toAdd) { + return + } + + ctx.JSON(http.StatusOK, gin.H{"applied": true}) +} + +func (c *dashboardsController) Unapply(ctx *gin.Context) { + tx := db.GetTx(ctx) + + dashboard := loadDashboardForUser(ctx, tx, false) + if dashboard == nil { + return + } + + projectId, err := uuid.Parse(ctx.Param("projectId")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project id"}) + return + } + if !requireProjectWrite(ctx, tx, projectId) { + return + } + + if err := transactional.DashboardRepository.DeleteStarredByProjectAndDashboard(tx, projectId, dashboard.Id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to clean up starred widgets: %w", err)) + return + } + if err := transactional.DashboardRepository.DeleteAssignment(tx, projectId, dashboard.Id); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to unassign dashboard: %w", err)) + return + } + + ctx.JSON(http.StatusOK, gin.H{"removed": true}) +} + +type CopyDashboardRequest struct { + OrganizationId *int `json:"organizationId"` + Name string `json:"name"` + ApplyToProjectIds []uuid.UUID `json:"applyToProjectIds"` +} + +func (c *dashboardsController) Copy(ctx *gin.Context) { + var req CopyDashboardRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + source := loadDashboardForUser(ctx, tx, false) + if source == nil { + return + } + + targetOrgId := source.OrganizationId + if req.OrganizationId != nil { + targetOrgId = *req.OrganizationId + } + if !requireOrgWrite(ctx, tx, targetOrgId) { + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + name = source.Name + } + name = dashboardsvc.TruncateName(name, maxDashboardNameLength) + + existing, err := transactional.DashboardRepository.FindByOrganization(tx, targetOrgId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to check duplicate dashboard name: %w", err)) + return + } + for dashboardNameTaken(existing, name, 0) { + suffixed := name + " (copy)" + if len(suffixed) > maxDashboardNameLength { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return + } + name = suffixed + } + + userId := middleware.GetUserId(ctx) + var createdBy *int + if userId > 0 { + createdBy = &userId + } + + now := time.Now().UTC() + copyRow := &models.Dashboard{ + OrganizationId: targetOrgId, + Name: name, + Description: source.Description, + Definition: source.Definition, + TemplateKey: source.TemplateKey, + CreatedBy: createdBy, + CreatedAt: now, + UpdatedAt: now, + } + id, err := transactional.DashboardRepository.Create(tx, copyRow) + if err != nil { + if db.IsUniqueViolation(err) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A dashboard with this name already exists."}) + return + } + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to copy dashboard: %w", err)) + return + } + copyRow.Id = id + + if !applyDashboardToProjects(ctx, tx, copyRow, req.ApplyToProjectIds) { + return + } + + ctx.JSON(http.StatusCreated, dashboardListItem(copyRow)) +} + +type ReorderDashboardsRequest struct { + DashboardIds []int `json:"dashboardIds" binding:"required,min=1"` +} + +func (c *dashboardsController) Reorder(ctx *gin.Context) { + projectId, err := middleware.GetProjectId(ctx) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) + return + } + + var req ReorderDashboardsRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + tx := db.GetTx(ctx) + + assignments, err := transactional.DashboardRepository.FindAssignmentsByProject(tx, projectId) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder dashboards: %w", err)) + return + } + + byDashboardId := make(map[int]*models.ProjectDashboard, len(assignments)) + for _, a := range assignments { + byDashboardId[a.DashboardId] = a + } + + if len(req.DashboardIds) != len(assignments) { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The dashboard list is out of date. Please refresh and try again."}) + return + } + seen := make(map[int]bool, len(req.DashboardIds)) + for _, id := range req.DashboardIds { + if byDashboardId[id] == nil || seen[id] { + ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The dashboard list is out of date. Please refresh and try again."}) + return + } + seen[id] = true + } + + for position, id := range req.DashboardIds { + a := byDashboardId[id] + if a.Position == position { + continue + } + a.Position = position + if err := transactional.DashboardRepository.UpdateAssignment(tx, a); err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder dashboards: %w", err)) + return + } + } + + ctx.JSON(http.StatusOK, gin.H{"reordered": true}) +} + +var DashboardsController = dashboardsController{} diff --git a/backend/app/controllers/metric_query.controller.go b/backend/app/controllers/metric_query.controller.go index 88c222aa9..ec4d68ec3 100644 --- a/backend/app/controllers/metric_query.controller.go +++ b/backend/app/controllers/metric_query.controller.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "net/http" + "strconv" "time" "github.com/tracewayapp/traceway/backend/app/db" @@ -13,6 +14,7 @@ import ( "github.com/tracewayapp/traceway/backend/app/repositories/transactional" "github.com/gin-gonic/gin" + "github.com/google/uuid" traceway "go.tracewayapp.com" ) @@ -168,6 +170,86 @@ func (c *metricQueryController) Discover(ctx *gin.Context) { ctx.JSON(http.StatusOK, DiscoverResponse{Metrics: discovered}) } +const discoverOrgMaxProjects = 25 + +type OrgDiscoveredMetric struct { + Name string `json:"name"` + TagKeys []string `json:"tagKeys"` + MetricType string `json:"metricType,omitempty"` + Unit string `json:"unit,omitempty"` + ProjectIds []uuid.UUID `json:"projectIds"` +} + +func (c *metricQueryController) DiscoverOrg(ctx *gin.Context) { + organizationId, err := strconv.Atoi(ctx.Query("organizationId")) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "organizationId query param is required"}) + return + } + + userId := middleware.GetUserId(ctx) + projects, err := db.ExecuteTransaction(func(tx *sql.Tx) ([]*models.Project, error) { + role, err := transactional.OrganizationRepository.GetUserRole(tx, organizationId, userId) + if err != nil { + return nil, err + } + if role == "" { + return nil, nil + } + return transactional.ProjectRepository.FindByOrganizationId(tx, organizationId) + }) + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list organization projects: %w", err)) + return + } + if projects == nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Organization not found"}) + return + } + truncated := false + if len(projects) > discoverOrgMaxProjects { + projects = projects[:discoverOrgMaxProjects] + truncated = true + } + + from := time.Now().AddDate(0, 0, -7) + to := time.Now() + + merged := map[string]*OrgDiscoveredMetric{} + order := []string{} + for _, project := range projects { + span := traceway.StartSpan(ctx, fmt.Sprintf("discover metrics: project %s", project.Id)) + discovered, err := telemetry.MetricPointRepository.DiscoverMetrics(ctx, project.Id, from, to) + span.End() + if err != nil { + traceway.CaptureException(fmt.Errorf("failed to discover metrics for project %s: %w", project.Id, err)) + continue + } + for _, m := range discovered { + entry := merged[m.Name] + if entry == nil { + entry = &OrgDiscoveredMetric{Name: m.Name, TagKeys: m.TagKeys, MetricType: m.MetricType, Unit: m.Unit, ProjectIds: []uuid.UUID{}} + merged[m.Name] = entry + order = append(order, m.Name) + } + entry.ProjectIds = append(entry.ProjectIds, project.Id) + if entry.Unit == "" { + entry.Unit = m.Unit + } + if entry.MetricType == "" { + entry.MetricType = m.MetricType + } + } + } + + metrics := make([]OrgDiscoveredMetric, 0, len(order)) + for _, name := range order { + metrics = append(metrics, *merged[name]) + } + + ctx.JSON(http.StatusOK, gin.H{"metrics": metrics, "truncated": truncated}) +} + func (c *metricQueryController) DiscoverTags(ctx *gin.Context) { projectId, err := middleware.GetProjectId(ctx) if err != nil { diff --git a/backend/app/controllers/otelcontrollers/metric_converter.go b/backend/app/controllers/otelcontrollers/metric_converter.go index ec7de370b..d95c4fc28 100644 --- a/backend/app/controllers/otelcontrollers/metric_converter.go +++ b/backend/app/controllers/otelcontrollers/metric_converter.go @@ -19,14 +19,24 @@ type convertedMetrics struct { // onto each metric point's tags. Necessary because the hostmetrics process // scraper distinguishes per-process metrics via Resource attributes (one // ResourceMetrics per process), not data-point attributes — without this, -// every process.* point looks identical save for the value. The list is an -// allowlist rather than a passthrough so other receivers can't blow up +// every process.* point looks identical save for the value. The same applies +// to the docker_stats, kubeletstats and postgresql receivers, whose +// container/pod/node/database identity also lives on the Resource. The list +// is an allowlist rather than a passthrough so other receivers can't blow up // metric_points cardinality with arbitrary resource attrs. var processResourceAttrAllowlist = []string{ "process.pid", "process.executable.name", "process.command_line", "process.owner", + "container.name", + "container.image.name", + "k8s.pod.name", + "k8s.namespace.name", + "k8s.node.name", + "k8s.deployment.name", + "k8s.container.name", + "postgresql.database.name", } func convertMetricPoints(projectId uuid.UUID, req *colmetricspb.ExportMetricsServiceRequest) convertedMetrics { diff --git a/backend/app/controllers/routes.go b/backend/app/controllers/routes.go index ac4a5fe99..04c9464c9 100644 --- a/backend/app/controllers/routes.go +++ b/backend/app/controllers/routes.go @@ -68,24 +68,37 @@ func RegisterControllers(router *gin.RouterGroup) { router.POST("/metrics/query", middleware.UseAppAuth, middleware.RequireProjectAccess, MetricQueryController.Query) router.GET("/metrics/discover", middleware.UseAppAuth, middleware.RequireProjectAccess, MetricQueryController.Discover) router.GET("/metrics/discover/tags", middleware.UseAppAuth, middleware.RequireProjectAccess, MetricQueryController.DiscoverTags) + router.GET("/metrics/discover/org", middleware.UseAppAuth, MetricQueryController.DiscoverOrg) router.PUT("/metrics/registry", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, MetricQueryController.UpdateRegistry) - router.GET("/widget-groups", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.Transactional, WidgetGroupController.List) - router.POST("/widget-groups", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetGroupController.Create) - router.POST("/widget-groups/populate-defaults", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetGroupController.PopulateDefaults) - router.GET("/widget-groups/starred", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.Transactional, WidgetController.ListStarred) - router.GET("/widget-groups/:id", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.Transactional, WidgetGroupController.GetWithWidgets) - router.PUT("/widget-groups/:id", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetGroupController.Update) - router.DELETE("/widget-groups/:id", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetGroupController.Delete) - - router.POST("/widget-groups/:id/widgets", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetController.Add) - router.PUT("/widget-groups/:id/widgets/:wid", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetController.Update) - router.PUT("/widget-groups/:id/reorder", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetController.Reorder) - router.PUT("/widget-groups/:id/widgets/:wid/star", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetController.ToggleStar) - router.DELETE("/widget-groups/:id/widgets/:wid", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetController.Delete) - - router.PUT("/starred-widgets/reorder", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetController.ReorderStarred) - router.PUT("/starred-widgets/:wid", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, WidgetController.UpdateStarredLayout) + router.GET("/dashboards", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.Transactional, DashboardsController.List) + router.POST("/dashboards", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Create) + router.GET("/dashboards/library", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Library) + router.POST("/dashboards/populate-defaults", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, DashboardsController.PopulateDefaults) + router.GET("/dashboards/starred", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.Transactional, DashboardsController.ListStarred) + router.PUT("/dashboards/reorder", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, DashboardsController.Reorder) + router.GET("/dashboards/:id", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Get) + router.PUT("/dashboards/:id", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Update) + router.DELETE("/dashboards/:id", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Delete) + router.GET("/dashboards/export", middleware.UseAppAuth, middleware.Transactional, DashboardsController.ExportOrganization) + router.POST("/dashboards/import", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Import) + router.POST("/dashboards/import/grafana", middleware.UseAppAuth, middleware.Transactional, DashboardsController.ImportGrafana) + router.GET("/dashboards/:id/export", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Export) + router.PUT("/dashboards/:id/apply", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Apply) + router.DELETE("/dashboards/:id/apply/:projectId", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Unapply) + router.POST("/dashboards/:id/copy", middleware.UseAppAuth, middleware.Transactional, DashboardsController.Copy) + + router.POST("/dashboards/:id/widgets", middleware.UseAppAuth, middleware.Transactional, DashboardsController.AddWidget) + router.PUT("/dashboards/:id/widgets/reorder", middleware.UseAppAuth, middleware.Transactional, DashboardsController.ReorderWidgets) + router.PUT("/dashboards/:id/widgets/:wid", middleware.UseAppAuth, middleware.Transactional, DashboardsController.UpdateWidget) + router.DELETE("/dashboards/:id/widgets/:wid", middleware.UseAppAuth, middleware.Transactional, DashboardsController.DeleteWidget) + router.PUT("/dashboards/:id/widgets/:wid/star", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, DashboardsController.ToggleStar) + + router.PUT("/starred-widgets/reorder", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, DashboardsController.ReorderStarred) + router.PUT("/starred-widgets/:id", middleware.UseAppAuth, middleware.RequireProjectAccess, middleware.RequireWriteAccess, middleware.Transactional, DashboardsController.UpdateStarredLayout) + + router.GET("/dashboard-templates", middleware.UseAppAuth, middleware.Transactional, DashboardTemplateController.List) + router.POST("/dashboard-templates/:key/install", middleware.UseAppAuth, middleware.Transactional, DashboardTemplateController.Install) router.POST("/endpoints", middleware.UseAppAuth, middleware.RequireProjectAccess, EndpointController.FindAllEndpoints) router.POST("/endpoints/grouped", middleware.UseAppAuth, middleware.RequireProjectAccess, EndpointController.FindGroupedByEndpoint) diff --git a/backend/app/controllers/widget.controller.go b/backend/app/controllers/widget.controller.go deleted file mode 100644 index 1a27b0624..000000000 --- a/backend/app/controllers/widget.controller.go +++ /dev/null @@ -1,563 +0,0 @@ -package controllers - -import ( - "encoding/json" - "net/http" - "strconv" - "strings" - "time" - - "github.com/tracewayapp/traceway/backend/app/db" - "github.com/tracewayapp/traceway/backend/app/middleware" - "github.com/tracewayapp/traceway/backend/app/models" - "github.com/tracewayapp/traceway/backend/app/repositories/transactional" - - "github.com/gin-gonic/gin" - traceway "go.tracewayapp.com" -) - -type widgetController struct{} - -type AddWidgetRequest struct { - Title string `json:"title"` - WidgetType string `json:"widgetType" binding:"required"` - Config json.RawMessage `json:"config"` -} - -func (c *widgetController) Add(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - idStr := ctx.Param("id") - groupId, err := strconv.Atoi(idStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - var req AddWidgetRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - if req.Config == nil { - req.Config = json.RawMessage(`{}`) - } - if msg := validateWidgetConfig(req.Config); msg != "" { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) - return - } - - req.Title = strings.TrimSpace(req.Title) - if req.Title == "" { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Title is required."}) - return - } - - tx := db.GetTx(ctx) - - group, err := transactional.WidgetGroupRepository.FindById(tx, groupId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to add widget: %w", err)) - return - } - if group == nil || group.ProjectId != projectId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget group not found"}) - return - } - - existing, err := transactional.WidgetGroupRepository.FindWidgetsByGroup(tx, groupId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to add widget: %w", err)) - return - } - - w := &models.WidgetGroupWidget{ - WidgetGroupId: groupId, - Title: req.Title, - WidgetType: req.WidgetType, - Config: req.Config, - Position: len(existing), - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - id, err := transactional.WidgetGroupRepository.CreateWidget(tx, w) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to add widget: %w", err)) - return - } - w.Id = id - - ctx.JSON(http.StatusCreated, w) -} - -func (c *widgetController) Update(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - groupIdStr := ctx.Param("id") - groupId, err := strconv.Atoi(groupIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - widgetIdStr := ctx.Param("wid") - widgetId, err := strconv.Atoi(widgetIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget id"}) - return - } - - var req AddWidgetRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - if req.Config != nil { - if msg := validateWidgetConfig(req.Config); msg != "" { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": msg}) - return - } - } - - req.Title = strings.TrimSpace(req.Title) - if req.Title == "" { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Title is required."}) - return - } - - tx := db.GetTx(ctx) - - group, err := transactional.WidgetGroupRepository.FindById(tx, groupId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update widget: %w", err)) - return - } - if group == nil || group.ProjectId != projectId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget group not found"}) - return - } - - widget, err := transactional.WidgetGroupRepository.FindWidgetById(tx, widgetId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update widget: %w", err)) - return - } - if widget == nil || widget.WidgetGroupId != groupId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget not found"}) - return - } - - widget.Title = req.Title - widget.WidgetType = req.WidgetType - if req.Config != nil { - widget.Config = req.Config - } - widget.UpdatedAt = time.Now().UTC() - - if err := transactional.WidgetGroupRepository.UpdateWidget(tx, widget); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update widget: %w", err)) - return - } - - ctx.JSON(http.StatusOK, widget) -} - -type ReorderWidgetsRequest struct { - WidgetIds []int `json:"widgetIds" binding:"required,min=1"` -} - -func (c *widgetController) Reorder(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - groupIdStr := ctx.Param("id") - groupId, err := strconv.Atoi(groupIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - var req ReorderWidgetsRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - tx := db.GetTx(ctx) - - group, err := transactional.WidgetGroupRepository.FindById(tx, groupId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder widgets: %w", err)) - return - } - if group == nil || group.ProjectId != projectId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget group not found"}) - return - } - - allWidgets, err := transactional.WidgetGroupRepository.FindWidgetsByGroup(tx, groupId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder widgets: %w", err)) - return - } - - byId := make(map[int]*models.WidgetGroupWidget, len(allWidgets)) - for _, w := range allWidgets { - byId[w.Id] = w - } - - if len(req.WidgetIds) != len(allWidgets) { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) - return - } - - seen := make(map[int]bool, len(req.WidgetIds)) - for _, id := range req.WidgetIds { - if byId[id] == nil || seen[id] { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) - return - } - seen[id] = true - } - - now := time.Now().UTC() - for position, id := range req.WidgetIds { - w := byId[id] - if w.Position == position { - continue - } - w.Position = position - w.UpdatedAt = now - if err := transactional.WidgetGroupRepository.UpdateWidget(tx, w); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder widgets: %w", err)) - return - } - } - - ctx.JSON(http.StatusOK, gin.H{"reordered": true}) -} - -func (c *widgetController) Delete(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - groupIdStr := ctx.Param("id") - groupId, err := strconv.Atoi(groupIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - widgetIdStr := ctx.Param("wid") - widgetId, err := strconv.Atoi(widgetIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget id"}) - return - } - - tx := db.GetTx(ctx) - - group, err := transactional.WidgetGroupRepository.FindById(tx, groupId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget: %w", err)) - return - } - if group == nil || group.ProjectId != projectId { - ctx.JSON(http.StatusOK, gin.H{"deleted": true}) - return - } - - widget, err := transactional.WidgetGroupRepository.FindWidgetById(tx, widgetId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget: %w", err)) - return - } - if widget == nil || widget.WidgetGroupId != groupId { - ctx.JSON(http.StatusOK, gin.H{"deleted": true}) - return - } - - if err := transactional.WidgetGroupRepository.DeleteStarredByWidgetId(tx, widgetId); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget: %w", err)) - return - } - - if err := transactional.WidgetGroupRepository.DeleteWidget(tx, widgetId); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget: %w", err)) - return - } - - ctx.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -func (c *widgetController) ToggleStar(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - groupIdStr := ctx.Param("id") - groupId, err := strconv.Atoi(groupIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - widgetIdStr := ctx.Param("wid") - widgetId, err := strconv.Atoi(widgetIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget id"}) - return - } - - tx := db.GetTx(ctx) - - group, err := transactional.WidgetGroupRepository.FindById(tx, groupId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) - return - } - if group == nil || group.ProjectId != projectId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget group not found"}) - return - } - - widget, err := transactional.WidgetGroupRepository.FindWidgetById(tx, widgetId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) - return - } - if widget == nil || widget.WidgetGroupId != groupId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget not found"}) - return - } - - starred, err := transactional.WidgetGroupRepository.FindStarredByWidgetId(tx, projectId, widgetId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) - return - } - - if starred != nil { - if err := transactional.WidgetGroupRepository.DeleteStarredByWidgetId(tx, widgetId); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) - return - } - ctx.JSON(http.StatusOK, gin.H{"id": widgetId, "isStarred": false}) - return - } - - allStarred, err := transactional.WidgetGroupRepository.FindStarredByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) - return - } - - var cfg struct { - ColSpan int `json:"colSpan"` - Size string `json:"size"` - } - _ = json.Unmarshal(widget.Config, &cfg) - if cfg.ColSpan < 1 || cfg.ColSpan > 3 { - cfg.ColSpan = 1 - } - if cfg.Size != "sm" && cfg.Size != "md" && cfg.Size != "lg" { - cfg.Size = "sm" - } - - nextPosition := 0 - for _, s := range allStarred { - if s.Position >= nextPosition { - nextPosition = s.Position + 1 - } - } - - row := &models.StarredWidget{ - WidgetId: widgetId, - Position: nextPosition, - ColSpan: cfg.ColSpan, - Size: cfg.Size, - CreatedAt: time.Now().UTC(), - } - if err := transactional.WidgetGroupRepository.CreateStarred(tx, row); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to toggle star: %w", err)) - return - } - - ctx.JSON(http.StatusOK, gin.H{"id": widgetId, "isStarred": true}) -} - -func (c *widgetController) ListStarred(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - tx := db.GetTx(ctx) - - widgets, err := transactional.WidgetGroupRepository.FindStarredWidgetsByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list starred widgets: %w", err)) - return - } - - if widgets == nil { - widgets = []*models.StarredWidgetWithHome{} - } - - ctx.JSON(http.StatusOK, widgets) -} - -func (c *widgetController) ReorderStarred(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - var req ReorderWidgetsRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - tx := db.GetTx(ctx) - - starred, err := transactional.WidgetGroupRepository.FindStarredByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder starred widgets: %w", err)) - return - } - - byWidgetId := make(map[int]*models.StarredWidget, len(starred)) - for _, s := range starred { - byWidgetId[s.WidgetId] = s - } - - if len(req.WidgetIds) != len(starred) { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) - return - } - - seen := make(map[int]bool, len(req.WidgetIds)) - for _, id := range req.WidgetIds { - if byWidgetId[id] == nil || seen[id] { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "The widget list is out of date. Please refresh and try again."}) - return - } - seen[id] = true - } - - for position, id := range req.WidgetIds { - s := byWidgetId[id] - if s.Position == position { - continue - } - s.Position = position - if err := transactional.WidgetGroupRepository.UpdateStarred(tx, s); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to reorder starred widgets: %w", err)) - return - } - } - - ctx.JSON(http.StatusOK, gin.H{"reordered": true}) -} - -type UpdateStarredLayoutRequest struct { - ColSpan int `json:"colSpan"` - Size string `json:"size"` -} - -func (c *widgetController) UpdateStarredLayout(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - widgetIdStr := ctx.Param("wid") - widgetId, err := strconv.Atoi(widgetIdStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget id"}) - return - } - - var req UpdateStarredLayoutRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - if req.ColSpan < 1 || req.ColSpan > 3 { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Width must be between 1 and 3 columns."}) - return - } - if req.Size != "sm" && req.Size != "md" && req.Size != "lg" { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Height must be one of: sm, md, lg."}) - return - } - - tx := db.GetTx(ctx) - - starred, err := transactional.WidgetGroupRepository.FindStarredByWidgetId(tx, projectId, widgetId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update starred widget layout: %w", err)) - return - } - if starred == nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Starred widget not found"}) - return - } - - starred.ColSpan = req.ColSpan - starred.Size = req.Size - - if err := transactional.WidgetGroupRepository.UpdateStarred(tx, starred); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update starred widget layout: %w", err)) - return - } - - ctx.JSON(http.StatusOK, starred) -} - -func validateWidgetConfig(raw json.RawMessage) string { - var cfg struct { - Sources []struct { - Name string `json:"name"` - } `json:"sources"` - } - if err := json.Unmarshal(raw, &cfg); err != nil { - return "Invalid widget configuration." - } - hasMetric := false - for _, s := range cfg.Sources { - if strings.TrimSpace(s.Name) != "" { - hasMetric = true - break - } - } - if !hasMetric { - return "Please select a Metric." - } - return "" -} - -var WidgetController = widgetController{} diff --git a/backend/app/controllers/widget_group.controller.go b/backend/app/controllers/widget_group.controller.go deleted file mode 100644 index feafec45a..000000000 --- a/backend/app/controllers/widget_group.controller.go +++ /dev/null @@ -1,448 +0,0 @@ -package controllers - -import ( - "database/sql" - "encoding/json" - "net/http" - "strconv" - "strings" - "time" - - "github.com/tracewayapp/traceway/backend/app/db" - "github.com/tracewayapp/traceway/backend/app/middleware" - "github.com/tracewayapp/traceway/backend/app/models" - "github.com/tracewayapp/traceway/backend/app/repositories/transactional" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - traceway "go.tracewayapp.com" -) - -type widgetGroupController struct{} - -func (c *widgetGroupController) List(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - tx := db.GetTx(ctx) - - list, err := transactional.WidgetGroupRepository.FindByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list widget groups: %w", err)) - return - } - - project, err := transactional.ProjectRepository.FindById(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to find project: %w", err)) - return - } - - framework := "" - if project != nil { - framework = project.Framework - } - - ctx.JSON(http.StatusOK, gin.H{ - "widgetGroups": list, - "framework": framework, - "canPopulateDefaults": len(list) == 0, - }) -} - -func (c *widgetGroupController) PopulateDefaults(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - tx := db.GetTx(ctx) - - existing, err := transactional.WidgetGroupRepository.FindByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list widget groups: %w", err)) - return - } - if len(existing) > 0 { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Project already has widget groups."}) - return - } - - if err := ensureDefaultWidgetGroups(tx, projectId); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to create default widget groups: %w", err)) - return - } - - list, err := transactional.WidgetGroupRepository.FindByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to list widget groups: %w", err)) - return - } - - ctx.JSON(http.StatusCreated, gin.H{"widgetGroups": list}) -} - -type CreateWidgetGroupRequest struct { - Name string `json:"name"` - Description string `json:"description"` -} - -func (c *widgetGroupController) Create(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - var req CreateWidgetGroupRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Name is required."}) - return - } - if len(req.Name) > 12 { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Name must be 12 characters or fewer."}) - return - } - - tx := db.GetTx(ctx) - - existing, err := transactional.WidgetGroupRepository.FindByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to check duplicate widget group name: %w", err)) - return - } - for _, g := range existing { - if strings.EqualFold(g.Name, req.Name) { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A group with this name already exists."}) - return - } - } - - userId := middleware.GetUserId(ctx) - var createdBy *int - if userId > 0 { - createdBy = &userId - } - - g := &models.WidgetGroup{ - ProjectId: projectId, - Name: req.Name, - Description: req.Description, - IsDefault: false, - CreatedBy: createdBy, - CreatedAt: time.Now().UTC(), - UpdatedAt: time.Now().UTC(), - } - id, err := transactional.WidgetGroupRepository.Create(tx, g) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to create widget group: %w", err)) - return - } - g.Id = id - - ctx.JSON(http.StatusCreated, g) -} - -func (c *widgetGroupController) GetWithWidgets(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - idStr := ctx.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - tx := db.GetTx(ctx) - - group, err := transactional.WidgetGroupRepository.FindById(tx, id) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to get widget group: %w", err)) - return - } - if group == nil || group.ProjectId != projectId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget group not found"}) - return - } - - widgets, err := transactional.WidgetGroupRepository.FindWidgetsByGroupWithStar(tx, id) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to get widget group widgets: %w", err)) - return - } - - widgetSlice := []models.WidgetGroupWidgetWithStar{} - for _, w := range widgets { - widgetSlice = append(widgetSlice, *w) - } - - ctx.JSON(http.StatusOK, &models.WidgetGroupWithWidgets{ - WidgetGroup: *group, - Widgets: widgetSlice, - }) -} - -func (c *widgetGroupController) Update(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - idStr := ctx.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - var req CreateWidgetGroupRequest - if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) - return - } - - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Name is required."}) - return - } - if len(req.Name) > 12 { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Name must be 12 characters or fewer."}) - return - } - - tx := db.GetTx(ctx) - - existing, err := transactional.WidgetGroupRepository.FindById(tx, id) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update widget group: %w", err)) - return - } - if existing == nil || existing.ProjectId != projectId { - ctx.JSON(http.StatusNotFound, gin.H{"error": "Widget group not found"}) - return - } - - allGroups, err := transactional.WidgetGroupRepository.FindByProject(tx, projectId) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to check duplicate widget group name: %w", err)) - return - } - for _, g := range allGroups { - if g.Id != id && strings.EqualFold(g.Name, req.Name) { - ctx.JSON(http.StatusUnprocessableEntity, gin.H{"error": "A group with this name already exists."}) - return - } - } - - existing.Name = req.Name - existing.Description = req.Description - existing.UpdatedAt = time.Now().UTC() - - if err := transactional.WidgetGroupRepository.Update(tx, existing); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to update widget group: %w", err)) - return - } - - ctx.JSON(http.StatusOK, existing) -} - -func (c *widgetGroupController) Delete(ctx *gin.Context) { - projectId, err := middleware.GetProjectId(ctx) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err)) - return - } - - idStr := ctx.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid widget group id"}) - return - } - - tx := db.GetTx(ctx) - - existing, err := transactional.WidgetGroupRepository.FindById(tx, id) - if err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget group: %w", err)) - return - } - if existing == nil || existing.ProjectId != projectId { - ctx.JSON(http.StatusOK, gin.H{"deleted": true}) - return - } - - // Same transaction handles children + parent: delete child widgets first, - // then the group itself. Don't lean on the FK cascade — explicit deletes - // are easier to audit and survive future schema migrations that might - // drop or alter the cascade rule. - if err := transactional.WidgetGroupRepository.DeleteStarredByGroup(tx, id); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget group starred widgets: %w", err)) - return - } - - if err := transactional.WidgetGroupRepository.DeleteWidgetsByGroup(tx, id); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget group widgets: %w", err)) - return - } - - if err := transactional.WidgetGroupRepository.Delete(tx, id); err != nil { - ctx.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("failed to delete widget group: %w", err)) - return - } - - ctx.JSON(http.StatusOK, gin.H{"deleted": true}) -} - -func ensureDefaultWidgetGroups(tx *sql.Tx, projectId uuid.UUID) error { - project, err := transactional.ProjectRepository.FindById(tx, projectId) - if err != nil { - return err - } - - jsFrameworks := map[string]bool{ - "react": true, "svelte": true, "vuejs": true, - "nextjs": true, "nestjs": true, "express": true, "remix": true, - } - otelFrameworks := map[string]bool{ - "opentelemetry": true, - } - isJS := project != nil && jsFrameworks[project.Framework] - isOtel := project != nil && otelFrameworks[project.Framework] - - type widgetDef struct { - title string - name string - widgetType string // optional; empty string falls back to "line_chart" - } - type groupDef struct { - name string - widgets []widgetDef - } - - var groupDefs []groupDef - - if isOtel { - // Defaults track what `traceway-otel-agent` emits out-of-the-box. The - // `process` scraper is opt-in (TRACEWAY_PROCESS_NAMES) as of v0.5.0 - // and intentionally excluded here — seeding it produced empty tabs. - groupDefs = append(groupDefs, - groupDef{ - name: "System", - widgets: []widgetDef{ - {title: "CPU Utilization", name: "system.cpu.utilization"}, - {title: "Memory Utilization", name: "system.memory.utilization"}, - // area_chart: filled region reads naturally as "consumed memory". - {title: "Memory Usage", name: "system.memory.usage", widgetType: "area_chart"}, - {title: "Load Avg (1m)", name: "system.cpu.load_average.1m"}, - {title: "Load Avg (5m)", name: "system.cpu.load_average.5m"}, - {title: "Load Avg (15m)", name: "system.cpu.load_average.15m"}, - }, - }, - groupDef{ - name: "Storage", - widgets: []widgetDef{ - {title: "Filesystem Utilization", name: "system.filesystem.utilization"}, - // area_chart: same reasoning as Memory Usage — filled = used disk. - {title: "Filesystem Usage", name: "system.filesystem.usage", widgetType: "area_chart"}, - {title: "Disk I/O", name: "system.disk.io"}, - {title: "Disk IOPS", name: "system.disk.operations"}, - {title: "Disk I/O Time", name: "system.disk.io_time"}, - }, - }, - groupDef{ - name: "Network", - widgets: []widgetDef{ - {title: "Network I/O", name: "system.network.io"}, - {title: "Network Packets", name: "system.network.packets"}, - {title: "Network Errors", name: "system.network.errors"}, - {title: "Open Connections", name: "system.network.connections"}, - }, - }, - ) - } else { - if !isJS { - groupDefs = append(groupDefs, groupDef{ - name: "Application", - widgets: []widgetDef{ - {title: "Go Routines", name: "go.go_routines"}, - {title: "Heap Objects", name: "go.heap_objects"}, - {title: "GC Cycles", name: "go.num_gc"}, - {title: "GC Pause", name: "go.gc_pause"}, - }, - }) - } - - groupDefs = append(groupDefs, - groupDef{ - name: "Stats", - widgets: []widgetDef{ - {title: "Memory Usage", name: "mem.used"}, - {title: "Total Memory", name: "mem.total"}, - }, - }, - groupDef{ - name: "CPU / Mem", - widgets: []widgetDef{ - {title: "CPU Usage", name: "cpu.used_pcnt"}, - {title: "Memory Usage", name: "mem.used"}, - }, - }, - ) - } - - now := time.Now().UTC() - for _, gd := range groupDefs { - g := &models.WidgetGroup{ - ProjectId: projectId, - Name: gd.name, - IsDefault: true, - CreatedAt: now, - UpdatedAt: now, - } - groupId, err := transactional.WidgetGroupRepository.Create(tx, g) - if err != nil { - return err - } - - for i, w := range gd.widgets { - config := json.RawMessage(`{"sources":[{"type":"metric","name":"` + w.name + `","aggregation":"avg"}]}`) - widgetType := w.widgetType - if widgetType == "" { - widgetType = "line_chart" - } - widget := &models.WidgetGroupWidget{ - WidgetGroupId: groupId, - Title: w.title, - WidgetType: widgetType, - Config: config, - Position: i, - CreatedAt: now, - UpdatedAt: now, - } - if _, err := transactional.WidgetGroupRepository.CreateWidget(tx, widget); err != nil { - return err - } - } - } - - return nil -} - -var WidgetGroupController = widgetGroupController{} diff --git a/backend/app/db/db_unique_violation_pg.go b/backend/app/db/db_unique_violation_pg.go new file mode 100644 index 000000000..b5a1cc42c --- /dev/null +++ b/backend/app/db/db_unique_violation_pg.go @@ -0,0 +1,14 @@ +//go:build transactional_pg + +package db + +import ( + "errors" + + "github.com/lib/pq" +) + +func IsUniqueViolation(err error) bool { + var pqErr *pq.Error + return errors.As(err, &pqErr) && pqErr.Code == "23505" +} diff --git a/backend/app/db/db_unique_violation_sqlite.go b/backend/app/db/db_unique_violation_sqlite.go new file mode 100644 index 000000000..8bddb7a71 --- /dev/null +++ b/backend/app/db/db_unique_violation_sqlite.go @@ -0,0 +1,9 @@ +//go:build !transactional_pg + +package db + +import "strings" + +func IsUniqueViolation(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} diff --git a/backend/app/migrations/migrations.go b/backend/app/migrations/migrations.go index 99f1631bc..71416233c 100644 --- a/backend/app/migrations/migrations.go +++ b/backend/app/migrations/migrations.go @@ -59,7 +59,7 @@ func runMigrationsOn(target *sql.DB, fsys embed.FS, dir, trackingTable, createTr return fmt.Errorf("failed to read migration file %s: %w", file, err) } - statements := strings.Split(string(content), ";") + statements := splitStatements(string(content)) for _, stmt := range statements { stmt = strings.TrimSpace(stmt) if stmt == "" { @@ -77,3 +77,48 @@ func runMigrationsOn(target *sql.DB, fsys embed.FS, dir, trackingTable, createTr return nil } + +func splitStatements(content string) []string { + var statements []string + var current strings.Builder + inSingle := false + inDouble := false + for i := 0; i < len(content); i++ { + ch := content[i] + switch { + case inSingle: + current.WriteByte(ch) + if ch == '\'' { + if i+1 < len(content) && content[i+1] == '\'' { + current.WriteByte(content[i+1]) + i++ + } else { + inSingle = false + } + } + case inDouble: + current.WriteByte(ch) + if ch == '"' { + if i+1 < len(content) && content[i+1] == '"' { + current.WriteByte(content[i+1]) + i++ + } else { + inDouble = false + } + } + case ch == '\'': + inSingle = true + current.WriteByte(ch) + case ch == '"': + inDouble = true + current.WriteByte(ch) + case ch == ';': + statements = append(statements, current.String()) + current.Reset() + default: + current.WriteByte(ch) + } + } + statements = append(statements, current.String()) + return statements +} diff --git a/backend/app/migrations/pg/0054_create_dashboards.up.sql b/backend/app/migrations/pg/0054_create_dashboards.up.sql new file mode 100644 index 000000000..f60067265 --- /dev/null +++ b/backend/app/migrations/pg/0054_create_dashboards.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS dashboards ( + id SERIAL PRIMARY KEY, + organization_id INT NOT NULL REFERENCES organizations(id), + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + definition JSONB NOT NULL DEFAULT '{}', + template_key VARCHAR(100), + created_by INT REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) diff --git a/backend/app/migrations/pg/0055_create_dashboards_org_idx.up.sql b/backend/app/migrations/pg/0055_create_dashboards_org_idx.up.sql new file mode 100644 index 000000000..1e39cb60c --- /dev/null +++ b/backend/app/migrations/pg/0055_create_dashboards_org_idx.up.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_dashboards_organization_id ON dashboards(organization_id) diff --git a/backend/app/migrations/pg/0056_create_project_dashboards.up.sql b/backend/app/migrations/pg/0056_create_project_dashboards.up.sql new file mode 100644 index 000000000..a6b6fd351 --- /dev/null +++ b/backend/app/migrations/pg/0056_create_project_dashboards.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS project_dashboards ( + id SERIAL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + dashboard_id INT NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(project_id, dashboard_id) +) diff --git a/backend/app/migrations/pg/0057_create_project_dashboards_dashboard_idx.up.sql b/backend/app/migrations/pg/0057_create_project_dashboards_dashboard_idx.up.sql new file mode 100644 index 000000000..4f5a49e2c --- /dev/null +++ b/backend/app/migrations/pg/0057_create_project_dashboards_dashboard_idx.up.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_project_dashboards_dashboard_id ON project_dashboards(dashboard_id) diff --git a/backend/app/migrations/pg/0058_create_dashboard_templates.up.sql b/backend/app/migrations/pg/0058_create_dashboard_templates.up.sql new file mode 100644 index 000000000..6ffbb9aba --- /dev/null +++ b/backend/app/migrations/pg/0058_create_dashboard_templates.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS dashboard_templates ( + id SERIAL PRIMARY KEY, + key VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + category VARCHAR(50) NOT NULL, + definition JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) diff --git a/backend/app/migrations/pg/0059_create_starred_dashboard_widgets.up.sql b/backend/app/migrations/pg/0059_create_starred_dashboard_widgets.up.sql new file mode 100644 index 000000000..0ae6a66ab --- /dev/null +++ b/backend/app/migrations/pg/0059_create_starred_dashboard_widgets.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS starred_dashboard_widgets ( + id SERIAL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + dashboard_id INT NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE, + widget_id VARCHAR(20) NOT NULL, + position INT NOT NULL DEFAULT 0, + col_span INT NOT NULL DEFAULT 1, + size VARCHAR(10) NOT NULL DEFAULT 'sm', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(project_id, dashboard_id, widget_id) +) diff --git a/backend/app/migrations/pg/0060_seed_template_traceway_clickhouse.up.sql b/backend/app/migrations/pg/0060_seed_template_traceway_clickhouse.up.sql new file mode 100644 index 000000000..525dadb51 --- /dev/null +++ b/backend/app/migrations/pg/0060_seed_template_traceway_clickhouse.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('traceway-clickhouse', 'Traceway ClickHouse', 'ClickHouse health of a monitored Traceway instance', 'traceway', '{"schemaVersion":1,"widgets":[{"title":"Inserted Rows","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.inserted_rows.delta","aggregation":"sum"}]}},{"title":"Failed Inserts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.failed_inserts.delta","aggregation":"sum"}]}},{"title":"Delayed Inserts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.delayed_inserts.delta","aggregation":"sum"}]}},{"title":"Rejected Inserts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.rejected_inserts.delta","aggregation":"sum"}]}},{"title":"Active Parts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.parts.active","aggregation":"avg"}]}},{"title":"Running Merges","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.merges.running","aggregation":"avg"}]}},{"title":"Memory","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.memory.tracking_bytes","aggregation":"avg"}]}},{"title":"Disk Used %","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.disk.used_pcnt","aggregation":"avg"}]}}]}', NOW(), NOW()) +ON CONFLICT (key) DO NOTHING diff --git a/backend/app/migrations/pg/0061_seed_template_traceway_duckdb.up.sql b/backend/app/migrations/pg/0061_seed_template_traceway_duckdb.up.sql new file mode 100644 index 000000000..4a5110081 --- /dev/null +++ b/backend/app/migrations/pg/0061_seed_template_traceway_duckdb.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('traceway-duckdb', 'Traceway DuckDB', 'DuckDB health of a monitored Traceway instance', 'traceway', '{"schemaVersion":1,"widgets":[{"title":"Database Size (MB)","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.db_size_mb","aggregation":"avg"}]}},{"title":"WAL Size (MB)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.wal_size_mb","aggregation":"avg"}]}},{"title":"Memory Used (MB)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.memory_used_mb","aggregation":"avg"}]}},{"title":"Dropped Rows","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.rows_dropped.delta","aggregation":"sum"}]}},{"title":"Insert Failures","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.insert_failures.delta","aggregation":"sum"}]}},{"title":"Read Pool In Use","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.read_pool.in_use","aggregation":"avg"}]}},{"title":"Read Pool Waits","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.read_pool.wait_count.delta","aggregation":"sum"}]}},{"title":"Read Pool Wait Time (ms)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.read_pool.wait_ms.delta","aggregation":"sum"}]}}]}', NOW(), NOW()) +ON CONFLICT (key) DO NOTHING diff --git a/backend/app/migrations/pg/0062_seed_template_golang.up.sql b/backend/app/migrations/pg/0062_seed_template_golang.up.sql new file mode 100644 index 000000000..20181f3ca --- /dev/null +++ b/backend/app/migrations/pg/0062_seed_template_golang.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('golang', 'Go Application', 'Go runtime, CPU and memory metrics collected by the Traceway Go SDK', 'go', '{"schemaVersion":1,"widgets":[{"title":"Go Routines","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.go_routines","aggregation":"avg"}]}},{"title":"Heap Objects","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.heap_objects","aggregation":"avg"}]}},{"title":"GC Cycles","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.num_gc","aggregation":"avg"}]}},{"title":"GC Pause","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.gc_pause","aggregation":"avg"}]}},{"title":"CPU Usage %","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"cpu.used_pcnt","aggregation":"avg"}]}},{"title":"Memory Used (MB)","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"mem.used","aggregation":"avg"}]}},{"title":"Memory Usage %","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"mem.used_pcnt","aggregation":"avg"}]}}]}', NOW(), NOW()) +ON CONFLICT (key) DO NOTHING diff --git a/backend/app/migrations/pg/0063_seed_template_traceway_otel_agent.up.sql b/backend/app/migrations/pg/0063_seed_template_traceway_otel_agent.up.sql new file mode 100644 index 000000000..509f694b4 --- /dev/null +++ b/backend/app/migrations/pg/0063_seed_template_traceway_otel_agent.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('traceway-otel-agent', 'Server', 'Host CPU, memory, disk and network reported by the Traceway OTel Agent', 'server', '{"schemaVersion":1,"widgets":[{"title":"CPU Utilization","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.utilization","aggregation":"avg"}]}},{"title":"Memory Utilization","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.memory.utilization","aggregation":"avg"}]}},{"title":"Memory Usage","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"system.memory.usage","aggregation":"avg"}]}},{"title":"Load Avg (1m)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.load_average.1m","aggregation":"avg"}]}},{"title":"Load Avg (5m)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.load_average.5m","aggregation":"avg"}]}},{"title":"Load Avg (15m)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.load_average.15m","aggregation":"avg"}]}},{"title":"Filesystem Utilization","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.filesystem.utilization","aggregation":"avg"}]}},{"title":"Filesystem Usage","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"system.filesystem.usage","aggregation":"avg"}]}},{"title":"Disk I/O","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.disk.io","aggregation":"avg"}]}},{"title":"Disk IOPS","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.disk.operations","aggregation":"avg"}]}},{"title":"Disk I/O Time","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.disk.io_time","aggregation":"avg"}]}},{"title":"Network I/O","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.io","aggregation":"avg"}]}},{"title":"Network Packets","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.packets","aggregation":"avg"}]}},{"title":"Network Errors","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.errors","aggregation":"avg"}]}},{"title":"Open Connections","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.connections","aggregation":"avg"}]}}]}', NOW(), NOW()) +ON CONFLICT (key) DO NOTHING diff --git a/backend/app/migrations/pg/0064_dedupe_dashboard_names.up.sql b/backend/app/migrations/pg/0064_dedupe_dashboard_names.up.sql new file mode 100644 index 000000000..374ee6dd9 --- /dev/null +++ b/backend/app/migrations/pg/0064_dedupe_dashboard_names.up.sql @@ -0,0 +1,2 @@ +UPDATE dashboards SET name = LEFT(name, 100 - LENGTH(id::text) - 1) || ' ' || id::text +WHERE EXISTS (SELECT 1 FROM dashboards d2 WHERE d2.organization_id = dashboards.organization_id AND LOWER(d2.name) = LOWER(dashboards.name) AND d2.id < dashboards.id) diff --git a/backend/app/migrations/pg/0065_create_dashboards_org_name_unique.up.sql b/backend/app/migrations/pg/0065_create_dashboards_org_name_unique.up.sql new file mode 100644 index 000000000..0e2a98c48 --- /dev/null +++ b/backend/app/migrations/pg/0065_create_dashboards_org_name_unique.up.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS dashboards_org_name_unique ON dashboards (organization_id, LOWER(name)) diff --git a/backend/app/migrations/pg/0066_create_backfill_runs.up.sql b/backend/app/migrations/pg/0066_create_backfill_runs.up.sql new file mode 100644 index 000000000..8e566fea8 --- /dev/null +++ b/backend/app/migrations/pg/0066_create_backfill_runs.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS backfill_runs ( + name TEXT PRIMARY KEY, + ran_at TIMESTAMPTZ NOT NULL +) diff --git a/backend/app/migrations/pg/0067_rename_template_traceway_otel_agent.up.sql b/backend/app/migrations/pg/0067_rename_template_traceway_otel_agent.up.sql new file mode 100644 index 000000000..cdc42ea7a --- /dev/null +++ b/backend/app/migrations/pg/0067_rename_template_traceway_otel_agent.up.sql @@ -0,0 +1 @@ +UPDATE dashboard_templates SET name = 'OTelemetry Server Agent' WHERE key = 'traceway-otel-agent' diff --git a/backend/app/migrations/split_test.go b/backend/app/migrations/split_test.go new file mode 100644 index 000000000..04647abc1 --- /dev/null +++ b/backend/app/migrations/split_test.go @@ -0,0 +1,69 @@ +package migrations + +import ( + "strings" + "testing" +) + +func nonEmptyStatements(statements []string) []string { + var out []string + for _, s := range statements { + s = strings.TrimSpace(s) + if s != "" { + out = append(out, s) + } + } + return out +} + +func TestSplitStatementsKeepsSemicolonsInsideStringLiterals(t *testing.T) { + content := `INSERT INTO dashboard_templates (key, definition) VALUES ('demo', '{"widgets":[{"title":"a;b","config":{"query":"x;y;z"}}]}')` + statements := nonEmptyStatements(splitStatements(content)) + if len(statements) != 1 { + t.Fatalf("expected 1 statement, got %d: %#v", len(statements), statements) + } + if !strings.Contains(statements[0], `"x;y;z"`) { + t.Errorf("string literal was mangled: %q", statements[0]) + } +} + +func TestSplitStatementsHandlesEscapedSingleQuotes(t *testing.T) { + content := `INSERT INTO t (s) VALUES ('it''s; still one'); +UPDATE t SET s = 'done'` + statements := nonEmptyStatements(splitStatements(content)) + if len(statements) != 2 { + t.Fatalf("expected 2 statements, got %d: %#v", len(statements), statements) + } + if !strings.Contains(statements[0], "it''s; still one") { + t.Errorf("escaped quote literal was mangled: %q", statements[0]) + } + if !strings.HasPrefix(statements[1], "UPDATE") { + t.Errorf("second statement is wrong: %q", statements[1]) + } +} + +func TestSplitStatementsHandlesQuotedIdentifiers(t *testing.T) { + content := `CREATE TABLE "weird;name" (id INTEGER); +SELECT 1` + statements := nonEmptyStatements(splitStatements(content)) + if len(statements) != 2 { + t.Fatalf("expected 2 statements, got %d: %#v", len(statements), statements) + } + if !strings.Contains(statements[0], `"weird;name"`) { + t.Errorf("quoted identifier was mangled: %q", statements[0]) + } +} + +func TestSplitStatementsSplitsPlainStatements(t *testing.T) { + content := `CREATE TABLE a (id INTEGER PRIMARY KEY); + +CREATE INDEX idx_a ON a(id); +` + statements := nonEmptyStatements(splitStatements(content)) + if len(statements) != 2 { + t.Fatalf("expected 2 statements, got %d: %#v", len(statements), statements) + } + if !strings.HasPrefix(statements[0], "CREATE TABLE") || !strings.HasPrefix(statements[1], "CREATE INDEX") { + t.Errorf("statements split incorrectly: %#v", statements) + } +} diff --git a/backend/app/migrations/sqlite/0025_create_dashboards.up.sql b/backend/app/migrations/sqlite/0025_create_dashboards.up.sql new file mode 100644 index 000000000..11d8936e7 --- /dev/null +++ b/backend/app/migrations/sqlite/0025_create_dashboards.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS dashboards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id INTEGER NOT NULL REFERENCES organizations(id), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + definition TEXT NOT NULL DEFAULT '{}', + template_key TEXT, + created_by INTEGER REFERENCES users(id), + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_dashboards_organization_id ON dashboards(organization_id); diff --git a/backend/app/migrations/sqlite/0026_create_project_dashboards.up.sql b/backend/app/migrations/sqlite/0026_create_project_dashboards.up.sql new file mode 100644 index 000000000..11c2c955b --- /dev/null +++ b/backend/app/migrations/sqlite/0026_create_project_dashboards.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS project_dashboards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + dashboard_id INTEGER NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + UNIQUE(project_id, dashboard_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_dashboards_dashboard_id ON project_dashboards(dashboard_id); diff --git a/backend/app/migrations/sqlite/0027_create_dashboard_templates.up.sql b/backend/app/migrations/sqlite/0027_create_dashboard_templates.up.sql new file mode 100644 index 000000000..7931bbbe1 --- /dev/null +++ b/backend/app/migrations/sqlite/0027_create_dashboard_templates.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS dashboard_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + definition TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); diff --git a/backend/app/migrations/sqlite/0028_create_starred_dashboard_widgets.up.sql b/backend/app/migrations/sqlite/0028_create_starred_dashboard_widgets.up.sql new file mode 100644 index 000000000..aa130fd5e --- /dev/null +++ b/backend/app/migrations/sqlite/0028_create_starred_dashboard_widgets.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS starred_dashboard_widgets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + dashboard_id INTEGER NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE, + widget_id TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + col_span INTEGER NOT NULL DEFAULT 1, + size TEXT NOT NULL DEFAULT 'sm', + created_at DATETIME NOT NULL, + UNIQUE(project_id, dashboard_id, widget_id) +); diff --git a/backend/app/migrations/sqlite/0029_seed_template_traceway_clickhouse.up.sql b/backend/app/migrations/sqlite/0029_seed_template_traceway_clickhouse.up.sql new file mode 100644 index 000000000..42c3663da --- /dev/null +++ b/backend/app/migrations/sqlite/0029_seed_template_traceway_clickhouse.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('traceway-clickhouse', 'Traceway ClickHouse', 'ClickHouse health of a monitored Traceway instance', 'traceway', '{"schemaVersion":1,"widgets":[{"title":"Inserted Rows","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.inserted_rows.delta","aggregation":"sum"}]}},{"title":"Failed Inserts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.failed_inserts.delta","aggregation":"sum"}]}},{"title":"Delayed Inserts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.delayed_inserts.delta","aggregation":"sum"}]}},{"title":"Rejected Inserts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.rejected_inserts.delta","aggregation":"sum"}]}},{"title":"Active Parts","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.parts.active","aggregation":"avg"}]}},{"title":"Running Merges","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.merges.running","aggregation":"avg"}]}},{"title":"Memory","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.memory.tracking_bytes","aggregation":"avg"}]}},{"title":"Disk Used %","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.ch.disk.used_pcnt","aggregation":"avg"}]}}]}', datetime('now'), datetime('now')) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/app/migrations/sqlite/0030_seed_template_traceway_duckdb.up.sql b/backend/app/migrations/sqlite/0030_seed_template_traceway_duckdb.up.sql new file mode 100644 index 000000000..a74786448 --- /dev/null +++ b/backend/app/migrations/sqlite/0030_seed_template_traceway_duckdb.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('traceway-duckdb', 'Traceway DuckDB', 'DuckDB health of a monitored Traceway instance', 'traceway', '{"schemaVersion":1,"widgets":[{"title":"Database Size (MB)","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.db_size_mb","aggregation":"avg"}]}},{"title":"WAL Size (MB)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.wal_size_mb","aggregation":"avg"}]}},{"title":"Memory Used (MB)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.memory_used_mb","aggregation":"avg"}]}},{"title":"Dropped Rows","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.rows_dropped.delta","aggregation":"sum"}]}},{"title":"Insert Failures","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.insert_failures.delta","aggregation":"sum"}]}},{"title":"Read Pool In Use","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.read_pool.in_use","aggregation":"avg"}]}},{"title":"Read Pool Waits","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.read_pool.wait_count.delta","aggregation":"sum"}]}},{"title":"Read Pool Wait Time (ms)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"traceway.duckdb.read_pool.wait_ms.delta","aggregation":"sum"}]}}]}', datetime('now'), datetime('now')) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/app/migrations/sqlite/0031_seed_template_golang.up.sql b/backend/app/migrations/sqlite/0031_seed_template_golang.up.sql new file mode 100644 index 000000000..bb57f9d3f --- /dev/null +++ b/backend/app/migrations/sqlite/0031_seed_template_golang.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('golang', 'Go Application', 'Go runtime, CPU and memory metrics collected by the Traceway Go SDK', 'go', '{"schemaVersion":1,"widgets":[{"title":"Go Routines","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.go_routines","aggregation":"avg"}]}},{"title":"Heap Objects","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.heap_objects","aggregation":"avg"}]}},{"title":"GC Cycles","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.num_gc","aggregation":"avg"}]}},{"title":"GC Pause","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"go.gc_pause","aggregation":"avg"}]}},{"title":"CPU Usage %","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"cpu.used_pcnt","aggregation":"avg"}]}},{"title":"Memory Used (MB)","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"mem.used","aggregation":"avg"}]}},{"title":"Memory Usage %","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"mem.used_pcnt","aggregation":"avg"}]}}]}', datetime('now'), datetime('now')) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/app/migrations/sqlite/0032_seed_template_traceway_otel_agent.up.sql b/backend/app/migrations/sqlite/0032_seed_template_traceway_otel_agent.up.sql new file mode 100644 index 000000000..bd2cc6648 --- /dev/null +++ b/backend/app/migrations/sqlite/0032_seed_template_traceway_otel_agent.up.sql @@ -0,0 +1,3 @@ +INSERT INTO dashboard_templates (key, name, description, category, definition, created_at, updated_at) +VALUES ('traceway-otel-agent', 'Server', 'Host CPU, memory, disk and network reported by the Traceway OTel Agent', 'server', '{"schemaVersion":1,"widgets":[{"title":"CPU Utilization","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.utilization","aggregation":"avg"}]}},{"title":"Memory Utilization","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.memory.utilization","aggregation":"avg"}]}},{"title":"Memory Usage","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"system.memory.usage","aggregation":"avg"}]}},{"title":"Load Avg (1m)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.load_average.1m","aggregation":"avg"}]}},{"title":"Load Avg (5m)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.load_average.5m","aggregation":"avg"}]}},{"title":"Load Avg (15m)","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.cpu.load_average.15m","aggregation":"avg"}]}},{"title":"Filesystem Utilization","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.filesystem.utilization","aggregation":"avg"}]}},{"title":"Filesystem Usage","widgetType":"area_chart","config":{"sources":[{"type":"metric","name":"system.filesystem.usage","aggregation":"avg"}]}},{"title":"Disk I/O","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.disk.io","aggregation":"avg"}]}},{"title":"Disk IOPS","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.disk.operations","aggregation":"avg"}]}},{"title":"Disk I/O Time","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.disk.io_time","aggregation":"avg"}]}},{"title":"Network I/O","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.io","aggregation":"avg"}]}},{"title":"Network Packets","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.packets","aggregation":"avg"}]}},{"title":"Network Errors","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.errors","aggregation":"avg"}]}},{"title":"Open Connections","widgetType":"line_chart","config":{"sources":[{"type":"metric","name":"system.network.connections","aggregation":"avg"}]}}]}', datetime('now'), datetime('now')) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/app/migrations/sqlite/0033_dedupe_dashboard_names.up.sql b/backend/app/migrations/sqlite/0033_dedupe_dashboard_names.up.sql new file mode 100644 index 000000000..fc673d44f --- /dev/null +++ b/backend/app/migrations/sqlite/0033_dedupe_dashboard_names.up.sql @@ -0,0 +1,2 @@ +UPDATE dashboards SET name = substr(name, 1, 100 - length(CAST(id AS TEXT)) - 1) || ' ' || CAST(id AS TEXT) +WHERE EXISTS (SELECT 1 FROM dashboards d2 WHERE d2.organization_id = dashboards.organization_id AND LOWER(d2.name) = LOWER(dashboards.name) AND d2.id < dashboards.id) diff --git a/backend/app/migrations/sqlite/0034_create_dashboards_org_name_unique.up.sql b/backend/app/migrations/sqlite/0034_create_dashboards_org_name_unique.up.sql new file mode 100644 index 000000000..0e2a98c48 --- /dev/null +++ b/backend/app/migrations/sqlite/0034_create_dashboards_org_name_unique.up.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS dashboards_org_name_unique ON dashboards (organization_id, LOWER(name)) diff --git a/backend/app/migrations/sqlite/0035_create_backfill_runs.up.sql b/backend/app/migrations/sqlite/0035_create_backfill_runs.up.sql new file mode 100644 index 000000000..692f9f8e0 --- /dev/null +++ b/backend/app/migrations/sqlite/0035_create_backfill_runs.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS backfill_runs ( + name TEXT PRIMARY KEY, + ran_at DATETIME NOT NULL +); diff --git a/backend/app/migrations/sqlite/0036_rename_template_traceway_otel_agent.up.sql b/backend/app/migrations/sqlite/0036_rename_template_traceway_otel_agent.up.sql new file mode 100644 index 000000000..cdc42ea7a --- /dev/null +++ b/backend/app/migrations/sqlite/0036_rename_template_traceway_otel_agent.up.sql @@ -0,0 +1 @@ +UPDATE dashboard_templates SET name = 'OTelemetry Server Agent' WHERE key = 'traceway-otel-agent' diff --git a/backend/app/models/dashboards.model.go b/backend/app/models/dashboards.model.go new file mode 100644 index 000000000..f975d2bfa --- /dev/null +++ b/backend/app/models/dashboards.model.go @@ -0,0 +1,79 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type Dashboard struct { + Id int `json:"id" lit:"id"` + OrganizationId int `json:"organizationId" lit:"organization_id"` + Name string `json:"name" lit:"name"` + Description string `json:"description" lit:"description"` + Definition JSONText `json:"definition" lit:"definition"` + TemplateKey *string `json:"templateKey" lit:"template_key"` + CreatedBy *int `json:"createdBy" lit:"created_by"` + CreatedAt time.Time `json:"createdAt" lit:"created_at"` + UpdatedAt time.Time `json:"updatedAt" lit:"updated_at"` +} + +type ProjectDashboard struct { + Id int `json:"id" lit:"id"` + ProjectId uuid.UUID `json:"projectId" lit:"project_id"` + DashboardId int `json:"dashboardId" lit:"dashboard_id"` + Position int `json:"position" lit:"position"` + CreatedAt time.Time `json:"createdAt" lit:"created_at"` +} + +type DashboardAssignment struct { + DashboardId int `json:"dashboardId" lit:"dashboard_id"` + ProjectId uuid.UUID `json:"projectId" lit:"project_id"` +} + +type DashboardTemplate struct { + Id int `json:"id" lit:"id"` + Key string `json:"key" lit:"key"` + Name string `json:"name" lit:"name"` + Description string `json:"description" lit:"description"` + Category string `json:"category" lit:"category"` + Definition JSONText `json:"definition" lit:"definition"` + CreatedAt time.Time `json:"createdAt" lit:"created_at"` + UpdatedAt time.Time `json:"updatedAt" lit:"updated_at"` +} + +type StarredDashboardWidget struct { + Id int `json:"id" lit:"id"` + ProjectId uuid.UUID `json:"projectId" lit:"project_id"` + DashboardId int `json:"dashboardId" lit:"dashboard_id"` + WidgetId string `json:"widgetId" lit:"widget_id"` + Position int `json:"position" lit:"position"` + ColSpan int `json:"colSpan" lit:"col_span"` + Size string `json:"size" lit:"size"` + CreatedAt time.Time `json:"createdAt" lit:"created_at"` +} + +type DashboardDefinition struct { + SchemaVersion int `json:"schemaVersion"` + Widgets []DashboardWidget `json:"widgets"` +} + +type DashboardWidget struct { + Id string `json:"id"` + Title string `json:"title"` + WidgetType string `json:"widgetType"` + Config json.RawMessage `json:"config"` +} + +type DashboardExport struct { + SchemaVersion int `json:"schemaVersion"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Widgets []DashboardWidget `json:"widgets"` +} + +type DashboardBundleExport struct { + SchemaVersion int `json:"schemaVersion"` + Dashboards []DashboardExport `json:"dashboards"` +} diff --git a/backend/app/models/jsontext.go b/backend/app/models/jsontext.go new file mode 100644 index 000000000..6d352a65c --- /dev/null +++ b/backend/app/models/jsontext.go @@ -0,0 +1,45 @@ +package models + +import ( + "database/sql/driver" + "errors" + "fmt" +) + +type JSONText []byte + +func (j JSONText) MarshalJSON() ([]byte, error) { + if len(j) == 0 { + return []byte("null"), nil + } + return j, nil +} + +func (j *JSONText) UnmarshalJSON(data []byte) error { + if j == nil { + return errors.New("models.JSONText: UnmarshalJSON on nil pointer") + } + *j = append((*j)[0:0], data...) + return nil +} + +func (j *JSONText) Scan(value any) error { + switch v := value.(type) { + case nil: + *j = nil + case []byte: + *j = append((*j)[0:0], v...) + case string: + *j = JSONText(v) + default: + return fmt.Errorf("models.JSONText: cannot scan %T", value) + } + return nil +} + +func (j JSONText) Value() (driver.Value, error) { + if len(j) == 0 { + return []byte("{}"), nil + } + return []byte(j), nil +} diff --git a/backend/app/models/models.go b/backend/app/models/models.go index d3c767b2f..e2b1984a0 100644 --- a/backend/app/models/models.go +++ b/backend/app/models/models.go @@ -32,6 +32,11 @@ func Init(driver lit.Driver) { lit.RegisterModel[WidgetGroupWidgetWithStar](driver) lit.RegisterModel[StarredWidget](driver) lit.RegisterModel[StarredWidgetWithHome](driver) + lit.RegisterModel[Dashboard](driver) + lit.RegisterModel[ProjectDashboard](driver) + lit.RegisterModel[DashboardAssignment](driver) + lit.RegisterModel[DashboardTemplate](driver) + lit.RegisterModel[StarredDashboardWidget](driver) lit.RegisterModel[NotificationChannel](driver) lit.RegisterModel[NotificationRule](driver) lit.RegisterModel[NotificationRuleWithChannel](driver) diff --git a/backend/app/models/widget_group.model.go b/backend/app/models/widget_group.model.go index b7053de57..6bb25323a 100644 --- a/backend/app/models/widget_group.model.go +++ b/backend/app/models/widget_group.model.go @@ -19,14 +19,14 @@ type WidgetGroup struct { } type WidgetGroupWidget struct { - Id int `json:"id" lit:"id"` - WidgetGroupId int `json:"widgetGroupId" lit:"widget_group_id"` - Title string `json:"title" lit:"title"` - WidgetType string `json:"widgetType" lit:"widget_type"` - Config json.RawMessage `json:"config" lit:"config"` - Position int `json:"position" lit:"position"` - CreatedAt time.Time `json:"createdAt" lit:"created_at"` - UpdatedAt time.Time `json:"updatedAt" lit:"updated_at"` + Id int `json:"id" lit:"id"` + WidgetGroupId int `json:"widgetGroupId" lit:"widget_group_id"` + Title string `json:"title" lit:"title"` + WidgetType string `json:"widgetType" lit:"widget_type"` + Config JSONText `json:"config" lit:"config"` + Position int `json:"position" lit:"position"` + CreatedAt time.Time `json:"createdAt" lit:"created_at"` + UpdatedAt time.Time `json:"updatedAt" lit:"updated_at"` } type WidgetGroupWidgetWithStar struct { diff --git a/backend/app/repositories/transactional/pg/dashboard.repository.go b/backend/app/repositories/transactional/pg/dashboard.repository.go new file mode 100644 index 000000000..13cd9e69c --- /dev/null +++ b/backend/app/repositories/transactional/pg/dashboard.repository.go @@ -0,0 +1,227 @@ +//go:build transactional_pg + +package pg + +import ( + "database/sql" + + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/models" + + "github.com/google/uuid" + "github.com/tracewayapp/lit/v2" +) + +type dashboardRepository struct{} + +const dashboardColumns = "id, organization_id, name, description, definition, template_key, created_by, created_at, updated_at" + +func (r *dashboardRepository) FindById(tx *sql.Tx, id int) (*models.Dashboard, error) { + return lit.SelectSingleNamed[models.Dashboard]( + tx, + "SELECT "+dashboardColumns+" FROM dashboards WHERE id = :id", + lit.P{"id": id}, + ) +} + +func (r *dashboardRepository) FindByOrganization(tx *sql.Tx, organizationId int) ([]*models.Dashboard, error) { + return lit.SelectNamed[models.Dashboard]( + tx, + "SELECT "+dashboardColumns+" FROM dashboards WHERE organization_id = :organization_id ORDER BY name ASC, id ASC", + lit.P{"organization_id": organizationId}, + ) +} + +func (r *dashboardRepository) FindByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.Dashboard, error) { + return lit.SelectNamed[models.Dashboard]( + tx, + `SELECT d.id, d.organization_id, d.name, d.description, d.definition, d.template_key, d.created_by, d.created_at, d.updated_at + FROM dashboards d + JOIN project_dashboards pd ON pd.dashboard_id = d.id + WHERE pd.project_id = :project_id + ORDER BY pd.position ASC, pd.id ASC`, + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindStarredDashboardsByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.Dashboard, error) { + return lit.SelectNamed[models.Dashboard]( + tx, + "SELECT "+dashboardColumns+" FROM dashboards WHERE id IN (SELECT dashboard_id FROM starred_dashboard_widgets WHERE project_id = :project_id)", + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) Create(tx *sql.Tx, dashboard *models.Dashboard) (int, error) { + return lit.Insert[models.Dashboard](tx, dashboard) +} + +func (r *dashboardRepository) Update(tx *sql.Tx, dashboard *models.Dashboard) error { + return lit.UpdateNamed(tx, dashboard, "id = :id", lit.P{"id": dashboard.Id}) +} + +func (r *dashboardRepository) Delete(tx *sql.Tx, id int) error { + return lit.DeleteNamed(db.Driver, tx, "DELETE FROM dashboards WHERE id = :id", lit.P{"id": id}) +} + +func (r *dashboardRepository) FindAssignmentsByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.ProjectDashboard, error) { + return lit.SelectNamed[models.ProjectDashboard]( + tx, + "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards WHERE project_id = :project_id ORDER BY position ASC, id ASC", + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindAssignmentsByDashboard(tx *sql.Tx, dashboardId int) ([]*models.ProjectDashboard, error) { + return lit.SelectNamed[models.ProjectDashboard]( + tx, + "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards WHERE dashboard_id = :dashboard_id ORDER BY id ASC", + lit.P{"dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) FindAssignmentsByOrganization(tx *sql.Tx, organizationId int) ([]*models.DashboardAssignment, error) { + return lit.SelectNamed[models.DashboardAssignment]( + tx, + `SELECT pd.dashboard_id, pd.project_id + FROM project_dashboards pd + JOIN dashboards d ON d.id = pd.dashboard_id + WHERE d.organization_id = :organization_id`, + lit.P{"organization_id": organizationId}, + ) +} + +func (r *dashboardRepository) FindAssignment(tx *sql.Tx, projectId uuid.UUID, dashboardId int) (*models.ProjectDashboard, error) { + return lit.SelectSingleNamed[models.ProjectDashboard]( + tx, + "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards WHERE project_id = :project_id AND dashboard_id = :dashboard_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) CreateAssignment(tx *sql.Tx, assignment *models.ProjectDashboard) error { + query, args, err := lit.ParseNamedQuery( + db.Driver, + `INSERT INTO project_dashboards (project_id, dashboard_id, position, created_at) + VALUES (:project_id, :dashboard_id, :position, :created_at) + ON CONFLICT (project_id, dashboard_id) DO NOTHING`, + lit.P{ + "project_id": assignment.ProjectId, + "dashboard_id": assignment.DashboardId, + "position": assignment.Position, + "created_at": assignment.CreatedAt, + }, + ) + if err != nil { + return err + } + return lit.UpdateNative(tx, query, args...) +} + +func (r *dashboardRepository) UpdateAssignment(tx *sql.Tx, assignment *models.ProjectDashboard) error { + return lit.UpdateNamed(tx, assignment, "id = :id", lit.P{"id": assignment.Id}) +} + +func (r *dashboardRepository) DeleteAssignment(tx *sql.Tx, projectId uuid.UUID, dashboardId int) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM project_dashboards WHERE project_id = :project_id AND dashboard_id = :dashboard_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) DeleteAssignmentsByDashboard(tx *sql.Tx, dashboardId int) error { + return lit.DeleteNamed(db.Driver, tx, "DELETE FROM project_dashboards WHERE dashboard_id = :dashboard_id", lit.P{"dashboard_id": dashboardId}) +} + +func (r *dashboardRepository) FindStarredByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.StarredDashboardWidget, error) { + return lit.SelectNamed[models.StarredDashboardWidget]( + tx, + `SELECT s.id, s.project_id, s.dashboard_id, s.widget_id, s.position, s.col_span, s.size, s.created_at + FROM starred_dashboard_widgets s + JOIN project_dashboards pd ON pd.dashboard_id = s.dashboard_id AND pd.project_id = s.project_id + WHERE s.project_id = :project_id + ORDER BY s.position ASC, s.id ASC`, + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindStarredByProjectAndDashboard(tx *sql.Tx, projectId uuid.UUID, dashboardId int) ([]*models.StarredDashboardWidget, error) { + return lit.SelectNamed[models.StarredDashboardWidget]( + tx, + "SELECT id, project_id, dashboard_id, widget_id, position, col_span, size, created_at FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id ORDER BY position ASC, id ASC", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) FindStarredById(tx *sql.Tx, projectId uuid.UUID, id int) (*models.StarredDashboardWidget, error) { + return lit.SelectSingleNamed[models.StarredDashboardWidget]( + tx, + "SELECT id, project_id, dashboard_id, widget_id, position, col_span, size, created_at FROM starred_dashboard_widgets WHERE id = :id AND project_id = :project_id", + lit.P{"id": id, "project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindStarred(tx *sql.Tx, projectId uuid.UUID, dashboardId int, widgetId string) (*models.StarredDashboardWidget, error) { + return lit.SelectSingleNamed[models.StarredDashboardWidget]( + tx, + "SELECT id, project_id, dashboard_id, widget_id, position, col_span, size, created_at FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id AND widget_id = :widget_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId, "widget_id": widgetId}, + ) +} + +func (r *dashboardRepository) CreateStarred(tx *sql.Tx, starred *models.StarredDashboardWidget) error { + query, args, err := lit.ParseNamedQuery( + db.Driver, + `INSERT INTO starred_dashboard_widgets (project_id, dashboard_id, widget_id, position, col_span, size, created_at) + VALUES (:project_id, :dashboard_id, :widget_id, :position, :col_span, :size, :created_at) + ON CONFLICT (project_id, dashboard_id, widget_id) DO NOTHING`, + lit.P{ + "project_id": starred.ProjectId, + "dashboard_id": starred.DashboardId, + "widget_id": starred.WidgetId, + "position": starred.Position, + "col_span": starred.ColSpan, + "size": starred.Size, + "created_at": starred.CreatedAt, + }, + ) + if err != nil { + return err + } + return lit.UpdateNative(tx, query, args...) +} + +func (r *dashboardRepository) UpdateStarred(tx *sql.Tx, starred *models.StarredDashboardWidget) error { + return lit.UpdateNamed(tx, starred, "id = :id", lit.P{"id": starred.Id}) +} + +func (r *dashboardRepository) DeleteStarred(tx *sql.Tx, projectId uuid.UUID, dashboardId int, widgetId string) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id AND widget_id = :widget_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId, "widget_id": widgetId}, + ) +} + +func (r *dashboardRepository) DeleteStarredByProjectAndDashboard(tx *sql.Tx, projectId uuid.UUID, dashboardId int) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) DeleteStarredByDashboard(tx *sql.Tx, dashboardId int) error { + return lit.DeleteNamed(db.Driver, tx, "DELETE FROM starred_dashboard_widgets WHERE dashboard_id = :dashboard_id", lit.P{"dashboard_id": dashboardId}) +} + +func (r *dashboardRepository) DeleteStarredByWidget(tx *sql.Tx, dashboardId int, widgetId string) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM starred_dashboard_widgets WHERE dashboard_id = :dashboard_id AND widget_id = :widget_id", + lit.P{"dashboard_id": dashboardId, "widget_id": widgetId}, + ) +} + +var DashboardRepository = dashboardRepository{} diff --git a/backend/app/repositories/transactional/pg/dashboard_template.repository.go b/backend/app/repositories/transactional/pg/dashboard_template.repository.go new file mode 100644 index 000000000..9de96a3e4 --- /dev/null +++ b/backend/app/repositories/transactional/pg/dashboard_template.repository.go @@ -0,0 +1,37 @@ +//go:build transactional_pg + +package pg + +import ( + "database/sql" + + "github.com/tracewayapp/traceway/backend/app/models" + + "github.com/tracewayapp/lit/v2" +) + +type dashboardTemplateRepository struct{} + +const dashboardTemplateColumns = "id, key, name, description, category, definition, created_at, updated_at" + +func (r *dashboardTemplateRepository) FindAll(tx *sql.Tx) ([]*models.DashboardTemplate, error) { + return lit.SelectNamed[models.DashboardTemplate]( + tx, + "SELECT "+dashboardTemplateColumns+" FROM dashboard_templates ORDER BY category ASC, name ASC", + lit.P{}, + ) +} + +func (r *dashboardTemplateRepository) FindByKey(tx *sql.Tx, key string) (*models.DashboardTemplate, error) { + return lit.SelectSingleNamed[models.DashboardTemplate]( + tx, + "SELECT "+dashboardTemplateColumns+" FROM dashboard_templates WHERE key = :key", + lit.P{"key": key}, + ) +} + +func (r *dashboardTemplateRepository) Create(tx *sql.Tx, template *models.DashboardTemplate) (int, error) { + return lit.Insert[models.DashboardTemplate](tx, template) +} + +var DashboardTemplateRepository = dashboardTemplateRepository{} diff --git a/backend/app/repositories/transactional/pg/project.repository.go b/backend/app/repositories/transactional/pg/project.repository.go index ae4a283ed..9373aa754 100644 --- a/backend/app/repositories/transactional/pg/project.repository.go +++ b/backend/app/repositories/transactional/pg/project.repository.go @@ -272,6 +272,8 @@ func (p *projectRepository) Delete(tx *sql.Tx, id uuid.UUID) error { "notification_rules", "notification_channels", "widget_groups", + "project_dashboards", + "starred_dashboard_widgets", "source_maps", "metric_registry", "project_user_roles", diff --git a/backend/app/repositories/transactional/pg/widget_group.repository.go b/backend/app/repositories/transactional/pg/widget_group.repository.go deleted file mode 100644 index 7f4848ebe..000000000 --- a/backend/app/repositories/transactional/pg/widget_group.repository.go +++ /dev/null @@ -1,175 +0,0 @@ -//go:build transactional_pg - -package pg - -import ( - "database/sql" - - "github.com/tracewayapp/traceway/backend/app/db" - "github.com/tracewayapp/traceway/backend/app/models" - - "github.com/google/uuid" - "github.com/tracewayapp/lit/v2" -) - -type widgetGroupRepository struct{} - -func (r *widgetGroupRepository) FindByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.WidgetGroup, error) { - return lit.SelectNamed[models.WidgetGroup]( - tx, - "SELECT id, project_id, name, description, is_default, created_by, created_at, updated_at FROM widget_groups WHERE project_id = :project_id ORDER BY is_default DESC, name ASC", - lit.P{"project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) FindById(tx *sql.Tx, id int) (*models.WidgetGroup, error) { - return lit.SelectSingleNamed[models.WidgetGroup]( - tx, - "SELECT id, project_id, name, description, is_default, created_by, created_at, updated_at FROM widget_groups WHERE id = :id", - lit.P{"id": id}, - ) -} - -func (r *widgetGroupRepository) Create(tx *sql.Tx, group *models.WidgetGroup) (int, error) { - return lit.Insert[models.WidgetGroup](tx, group) -} - -func (r *widgetGroupRepository) Update(tx *sql.Tx, group *models.WidgetGroup) error { - return lit.UpdateNamed(tx, group, "id = :id", lit.P{"id": group.Id}) -} - -func (r *widgetGroupRepository) Delete(tx *sql.Tx, id int) error { - return lit.DeleteNamed(db.Driver, tx, "DELETE FROM widget_groups WHERE id = :id", lit.P{"id": id}) -} - -func (r *widgetGroupRepository) FindWidgetsByGroup(tx *sql.Tx, widgetGroupId int) ([]*models.WidgetGroupWidget, error) { - return lit.SelectNamed[models.WidgetGroupWidget]( - tx, - "SELECT id, widget_group_id, title, widget_type, config, position, created_at, updated_at FROM widget_group_widgets WHERE widget_group_id = :wg_id ORDER BY position ASC", - lit.P{"wg_id": widgetGroupId}, - ) -} - -func (r *widgetGroupRepository) FindWidgetsByGroupWithStar(tx *sql.Tx, widgetGroupId int) ([]*models.WidgetGroupWidgetWithStar, error) { - return lit.SelectNamed[models.WidgetGroupWidgetWithStar]( - tx, - `SELECT wgw.id, wgw.widget_group_id, wgw.title, wgw.widget_type, wgw.config, wgw.position, - (sw.id IS NOT NULL) AS is_starred, - wgw.created_at, wgw.updated_at - FROM widget_group_widgets wgw - LEFT JOIN starred_widgets sw ON sw.widget_id = wgw.id - WHERE wgw.widget_group_id = :wg_id - ORDER BY wgw.position ASC`, - lit.P{"wg_id": widgetGroupId}, - ) -} - -func (r *widgetGroupRepository) FindWidgetById(tx *sql.Tx, id int) (*models.WidgetGroupWidget, error) { - return lit.SelectSingleNamed[models.WidgetGroupWidget]( - tx, - "SELECT id, widget_group_id, title, widget_type, config, position, created_at, updated_at FROM widget_group_widgets WHERE id = :id", - lit.P{"id": id}, - ) -} - -func (r *widgetGroupRepository) FindStarredWidgetsByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.StarredWidgetWithHome, error) { - return lit.SelectNamed[models.StarredWidgetWithHome]( - tx, - `SELECT wgw.id, wgw.widget_group_id, wgw.title, wgw.widget_type, wgw.config, wgw.position, - sw.position AS home_position, sw.col_span AS home_col_span, sw.size AS home_size, - wgw.created_at, wgw.updated_at - FROM starred_widgets sw - JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id - JOIN widget_groups wg ON wg.id = wgw.widget_group_id - WHERE wg.project_id = :project_id - ORDER BY sw.position ASC, sw.id ASC`, - lit.P{"project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) FindStarredByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.StarredWidget, error) { - return lit.SelectNamed[models.StarredWidget]( - tx, - `SELECT sw.id, sw.widget_id, sw.position, sw.col_span, sw.size, sw.created_at - FROM starred_widgets sw - JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id - JOIN widget_groups wg ON wg.id = wgw.widget_group_id - WHERE wg.project_id = :project_id - ORDER BY sw.position ASC, sw.id ASC`, - lit.P{"project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) FindStarredByWidgetId(tx *sql.Tx, projectId uuid.UUID, widgetId int) (*models.StarredWidget, error) { - return lit.SelectSingleNamed[models.StarredWidget]( - tx, - `SELECT sw.id, sw.widget_id, sw.position, sw.col_span, sw.size, sw.created_at - FROM starred_widgets sw - JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id - JOIN widget_groups wg ON wg.id = wgw.widget_group_id - WHERE sw.widget_id = :widget_id AND wg.project_id = :project_id`, - lit.P{"widget_id": widgetId, "project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) CreateStarred(tx *sql.Tx, starred *models.StarredWidget) error { - query, args, err := lit.ParseNamedQuery( - db.Driver, - `INSERT INTO starred_widgets (widget_id, position, col_span, size, created_at) - VALUES (:widget_id, :position, :col_span, :size, :created_at) - ON CONFLICT (widget_id) DO NOTHING`, - lit.P{ - "widget_id": starred.WidgetId, - "position": starred.Position, - "col_span": starred.ColSpan, - "size": starred.Size, - "created_at": starred.CreatedAt, - }, - ) - if err != nil { - return err - } - return lit.UpdateNative(tx, query, args...) -} - -func (r *widgetGroupRepository) UpdateStarred(tx *sql.Tx, starred *models.StarredWidget) error { - return lit.UpdateNamed(tx, starred, "id = :id", lit.P{"id": starred.Id}) -} - -func (r *widgetGroupRepository) DeleteStarredByWidgetId(tx *sql.Tx, widgetId int) error { - return lit.DeleteNamed(db.Driver, tx, "DELETE FROM starred_widgets WHERE widget_id = :widget_id", lit.P{"widget_id": widgetId}) -} - -func (r *widgetGroupRepository) DeleteStarredByGroup(tx *sql.Tx, widgetGroupId int) error { - return lit.DeleteNamed( - db.Driver, tx, - "DELETE FROM starred_widgets WHERE widget_id IN (SELECT id FROM widget_group_widgets WHERE widget_group_id = :wg_id)", - lit.P{"wg_id": widgetGroupId}, - ) -} - -func (r *widgetGroupRepository) CreateWidget(tx *sql.Tx, widget *models.WidgetGroupWidget) (int, error) { - return lit.Insert[models.WidgetGroupWidget](tx, widget) -} - -func (r *widgetGroupRepository) UpdateWidget(tx *sql.Tx, widget *models.WidgetGroupWidget) error { - return lit.UpdateNamed(tx, widget, "id = :id", lit.P{"id": widget.Id}) -} - -func (r *widgetGroupRepository) DeleteWidget(tx *sql.Tx, id int) error { - return lit.DeleteNamed(db.Driver, tx, "DELETE FROM widget_group_widgets WHERE id = :id", lit.P{"id": id}) -} - -// DeleteWidgetsByGroup removes every widget belonging to the group. Use this -// before WidgetGroupRepository.Delete so the group + child widgets disappear -// together within the same transaction (rather than relying on the FK cascade -// — explicit is cheaper to audit and makes the SQL log self-explanatory). -func (r *widgetGroupRepository) DeleteWidgetsByGroup(tx *sql.Tx, widgetGroupId int) error { - return lit.DeleteNamed( - db.Driver, tx, - "DELETE FROM widget_group_widgets WHERE widget_group_id = :wg_id", - lit.P{"wg_id": widgetGroupId}, - ) -} - -var WidgetGroupRepository = widgetGroupRepository{} diff --git a/backend/app/repositories/transactional/sqlite/dashboard.repository.go b/backend/app/repositories/transactional/sqlite/dashboard.repository.go new file mode 100644 index 000000000..d3f9fe114 --- /dev/null +++ b/backend/app/repositories/transactional/sqlite/dashboard.repository.go @@ -0,0 +1,227 @@ +//go:build !transactional_pg + +package sqlite + +import ( + "database/sql" + + "github.com/tracewayapp/traceway/backend/app/db" + "github.com/tracewayapp/traceway/backend/app/models" + + "github.com/google/uuid" + "github.com/tracewayapp/lit/v2" +) + +type dashboardRepository struct{} + +const dashboardColumns = "id, organization_id, name, description, definition, template_key, created_by, created_at, updated_at" + +func (r *dashboardRepository) FindById(tx *sql.Tx, id int) (*models.Dashboard, error) { + return lit.SelectSingleNamed[models.Dashboard]( + tx, + "SELECT "+dashboardColumns+" FROM dashboards WHERE id = :id", + lit.P{"id": id}, + ) +} + +func (r *dashboardRepository) FindByOrganization(tx *sql.Tx, organizationId int) ([]*models.Dashboard, error) { + return lit.SelectNamed[models.Dashboard]( + tx, + "SELECT "+dashboardColumns+" FROM dashboards WHERE organization_id = :organization_id ORDER BY name ASC, id ASC", + lit.P{"organization_id": organizationId}, + ) +} + +func (r *dashboardRepository) FindByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.Dashboard, error) { + return lit.SelectNamed[models.Dashboard]( + tx, + `SELECT d.id, d.organization_id, d.name, d.description, d.definition, d.template_key, d.created_by, d.created_at, d.updated_at + FROM dashboards d + JOIN project_dashboards pd ON pd.dashboard_id = d.id + WHERE pd.project_id = :project_id + ORDER BY pd.position ASC, pd.id ASC`, + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindStarredDashboardsByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.Dashboard, error) { + return lit.SelectNamed[models.Dashboard]( + tx, + "SELECT "+dashboardColumns+" FROM dashboards WHERE id IN (SELECT dashboard_id FROM starred_dashboard_widgets WHERE project_id = :project_id)", + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) Create(tx *sql.Tx, dashboard *models.Dashboard) (int, error) { + return lit.Insert[models.Dashboard](tx, dashboard) +} + +func (r *dashboardRepository) Update(tx *sql.Tx, dashboard *models.Dashboard) error { + return lit.UpdateNamed(tx, dashboard, "id = :id", lit.P{"id": dashboard.Id}) +} + +func (r *dashboardRepository) Delete(tx *sql.Tx, id int) error { + return lit.DeleteNamed(db.Driver, tx, "DELETE FROM dashboards WHERE id = :id", lit.P{"id": id}) +} + +func (r *dashboardRepository) FindAssignmentsByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.ProjectDashboard, error) { + return lit.SelectNamed[models.ProjectDashboard]( + tx, + "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards WHERE project_id = :project_id ORDER BY position ASC, id ASC", + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindAssignmentsByDashboard(tx *sql.Tx, dashboardId int) ([]*models.ProjectDashboard, error) { + return lit.SelectNamed[models.ProjectDashboard]( + tx, + "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards WHERE dashboard_id = :dashboard_id ORDER BY id ASC", + lit.P{"dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) FindAssignmentsByOrganization(tx *sql.Tx, organizationId int) ([]*models.DashboardAssignment, error) { + return lit.SelectNamed[models.DashboardAssignment]( + tx, + `SELECT pd.dashboard_id, pd.project_id + FROM project_dashboards pd + JOIN dashboards d ON d.id = pd.dashboard_id + WHERE d.organization_id = :organization_id`, + lit.P{"organization_id": organizationId}, + ) +} + +func (r *dashboardRepository) FindAssignment(tx *sql.Tx, projectId uuid.UUID, dashboardId int) (*models.ProjectDashboard, error) { + return lit.SelectSingleNamed[models.ProjectDashboard]( + tx, + "SELECT id, project_id, dashboard_id, position, created_at FROM project_dashboards WHERE project_id = :project_id AND dashboard_id = :dashboard_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) CreateAssignment(tx *sql.Tx, assignment *models.ProjectDashboard) error { + query, args, err := lit.ParseNamedQuery( + db.Driver, + `INSERT INTO project_dashboards (project_id, dashboard_id, position, created_at) + VALUES (:project_id, :dashboard_id, :position, :created_at) + ON CONFLICT (project_id, dashboard_id) DO NOTHING`, + lit.P{ + "project_id": assignment.ProjectId, + "dashboard_id": assignment.DashboardId, + "position": assignment.Position, + "created_at": assignment.CreatedAt, + }, + ) + if err != nil { + return err + } + return lit.UpdateNative(tx, query, args...) +} + +func (r *dashboardRepository) UpdateAssignment(tx *sql.Tx, assignment *models.ProjectDashboard) error { + return lit.UpdateNamed(tx, assignment, "id = :id", lit.P{"id": assignment.Id}) +} + +func (r *dashboardRepository) DeleteAssignment(tx *sql.Tx, projectId uuid.UUID, dashboardId int) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM project_dashboards WHERE project_id = :project_id AND dashboard_id = :dashboard_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) DeleteAssignmentsByDashboard(tx *sql.Tx, dashboardId int) error { + return lit.DeleteNamed(db.Driver, tx, "DELETE FROM project_dashboards WHERE dashboard_id = :dashboard_id", lit.P{"dashboard_id": dashboardId}) +} + +func (r *dashboardRepository) FindStarredByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.StarredDashboardWidget, error) { + return lit.SelectNamed[models.StarredDashboardWidget]( + tx, + `SELECT s.id, s.project_id, s.dashboard_id, s.widget_id, s.position, s.col_span, s.size, s.created_at + FROM starred_dashboard_widgets s + JOIN project_dashboards pd ON pd.dashboard_id = s.dashboard_id AND pd.project_id = s.project_id + WHERE s.project_id = :project_id + ORDER BY s.position ASC, s.id ASC`, + lit.P{"project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindStarredByProjectAndDashboard(tx *sql.Tx, projectId uuid.UUID, dashboardId int) ([]*models.StarredDashboardWidget, error) { + return lit.SelectNamed[models.StarredDashboardWidget]( + tx, + "SELECT id, project_id, dashboard_id, widget_id, position, col_span, size, created_at FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id ORDER BY position ASC, id ASC", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) FindStarredById(tx *sql.Tx, projectId uuid.UUID, id int) (*models.StarredDashboardWidget, error) { + return lit.SelectSingleNamed[models.StarredDashboardWidget]( + tx, + "SELECT id, project_id, dashboard_id, widget_id, position, col_span, size, created_at FROM starred_dashboard_widgets WHERE id = :id AND project_id = :project_id", + lit.P{"id": id, "project_id": projectId}, + ) +} + +func (r *dashboardRepository) FindStarred(tx *sql.Tx, projectId uuid.UUID, dashboardId int, widgetId string) (*models.StarredDashboardWidget, error) { + return lit.SelectSingleNamed[models.StarredDashboardWidget]( + tx, + "SELECT id, project_id, dashboard_id, widget_id, position, col_span, size, created_at FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id AND widget_id = :widget_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId, "widget_id": widgetId}, + ) +} + +func (r *dashboardRepository) CreateStarred(tx *sql.Tx, starred *models.StarredDashboardWidget) error { + query, args, err := lit.ParseNamedQuery( + db.Driver, + `INSERT INTO starred_dashboard_widgets (project_id, dashboard_id, widget_id, position, col_span, size, created_at) + VALUES (:project_id, :dashboard_id, :widget_id, :position, :col_span, :size, :created_at) + ON CONFLICT (project_id, dashboard_id, widget_id) DO NOTHING`, + lit.P{ + "project_id": starred.ProjectId, + "dashboard_id": starred.DashboardId, + "widget_id": starred.WidgetId, + "position": starred.Position, + "col_span": starred.ColSpan, + "size": starred.Size, + "created_at": starred.CreatedAt, + }, + ) + if err != nil { + return err + } + return lit.UpdateNative(tx, query, args...) +} + +func (r *dashboardRepository) UpdateStarred(tx *sql.Tx, starred *models.StarredDashboardWidget) error { + return lit.UpdateNamed(tx, starred, "id = :id", lit.P{"id": starred.Id}) +} + +func (r *dashboardRepository) DeleteStarred(tx *sql.Tx, projectId uuid.UUID, dashboardId int, widgetId string) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id AND widget_id = :widget_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId, "widget_id": widgetId}, + ) +} + +func (r *dashboardRepository) DeleteStarredByProjectAndDashboard(tx *sql.Tx, projectId uuid.UUID, dashboardId int) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM starred_dashboard_widgets WHERE project_id = :project_id AND dashboard_id = :dashboard_id", + lit.P{"project_id": projectId, "dashboard_id": dashboardId}, + ) +} + +func (r *dashboardRepository) DeleteStarredByDashboard(tx *sql.Tx, dashboardId int) error { + return lit.DeleteNamed(db.Driver, tx, "DELETE FROM starred_dashboard_widgets WHERE dashboard_id = :dashboard_id", lit.P{"dashboard_id": dashboardId}) +} + +func (r *dashboardRepository) DeleteStarredByWidget(tx *sql.Tx, dashboardId int, widgetId string) error { + return lit.DeleteNamed( + db.Driver, tx, + "DELETE FROM starred_dashboard_widgets WHERE dashboard_id = :dashboard_id AND widget_id = :widget_id", + lit.P{"dashboard_id": dashboardId, "widget_id": widgetId}, + ) +} + +var DashboardRepository = dashboardRepository{} diff --git a/backend/app/repositories/transactional/sqlite/dashboard_template.repository.go b/backend/app/repositories/transactional/sqlite/dashboard_template.repository.go new file mode 100644 index 000000000..e35e785c0 --- /dev/null +++ b/backend/app/repositories/transactional/sqlite/dashboard_template.repository.go @@ -0,0 +1,37 @@ +//go:build !transactional_pg + +package sqlite + +import ( + "database/sql" + + "github.com/tracewayapp/traceway/backend/app/models" + + "github.com/tracewayapp/lit/v2" +) + +type dashboardTemplateRepository struct{} + +const dashboardTemplateColumns = "id, key, name, description, category, definition, created_at, updated_at" + +func (r *dashboardTemplateRepository) FindAll(tx *sql.Tx) ([]*models.DashboardTemplate, error) { + return lit.SelectNamed[models.DashboardTemplate]( + tx, + "SELECT "+dashboardTemplateColumns+" FROM dashboard_templates ORDER BY category ASC, name ASC", + lit.P{}, + ) +} + +func (r *dashboardTemplateRepository) FindByKey(tx *sql.Tx, key string) (*models.DashboardTemplate, error) { + return lit.SelectSingleNamed[models.DashboardTemplate]( + tx, + "SELECT "+dashboardTemplateColumns+" FROM dashboard_templates WHERE key = :key", + lit.P{"key": key}, + ) +} + +func (r *dashboardTemplateRepository) Create(tx *sql.Tx, template *models.DashboardTemplate) (int, error) { + return lit.Insert[models.DashboardTemplate](tx, template) +} + +var DashboardTemplateRepository = dashboardTemplateRepository{} diff --git a/backend/app/repositories/transactional/sqlite/project.repository.go b/backend/app/repositories/transactional/sqlite/project.repository.go index d849fad60..f463694ee 100644 --- a/backend/app/repositories/transactional/sqlite/project.repository.go +++ b/backend/app/repositories/transactional/sqlite/project.repository.go @@ -272,6 +272,8 @@ func (p *projectRepository) Delete(tx *sql.Tx, id uuid.UUID) error { "notification_rules", "notification_channels", "widget_groups", + "project_dashboards", + "starred_dashboard_widgets", "source_maps", "metric_registry", "project_user_roles", diff --git a/backend/app/repositories/transactional/sqlite/widget_group.repository.go b/backend/app/repositories/transactional/sqlite/widget_group.repository.go deleted file mode 100644 index dd4b7d890..000000000 --- a/backend/app/repositories/transactional/sqlite/widget_group.repository.go +++ /dev/null @@ -1,175 +0,0 @@ -//go:build !transactional_pg - -package sqlite - -import ( - "database/sql" - - "github.com/tracewayapp/traceway/backend/app/db" - "github.com/tracewayapp/traceway/backend/app/models" - - "github.com/google/uuid" - "github.com/tracewayapp/lit/v2" -) - -type widgetGroupRepository struct{} - -func (r *widgetGroupRepository) FindByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.WidgetGroup, error) { - return lit.SelectNamed[models.WidgetGroup]( - tx, - "SELECT id, project_id, name, description, is_default, created_by, created_at, updated_at FROM widget_groups WHERE project_id = :project_id ORDER BY is_default DESC, name ASC", - lit.P{"project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) FindById(tx *sql.Tx, id int) (*models.WidgetGroup, error) { - return lit.SelectSingleNamed[models.WidgetGroup]( - tx, - "SELECT id, project_id, name, description, is_default, created_by, created_at, updated_at FROM widget_groups WHERE id = :id", - lit.P{"id": id}, - ) -} - -func (r *widgetGroupRepository) Create(tx *sql.Tx, group *models.WidgetGroup) (int, error) { - return lit.Insert[models.WidgetGroup](tx, group) -} - -func (r *widgetGroupRepository) Update(tx *sql.Tx, group *models.WidgetGroup) error { - return lit.UpdateNamed(tx, group, "id = :id", lit.P{"id": group.Id}) -} - -func (r *widgetGroupRepository) Delete(tx *sql.Tx, id int) error { - return lit.DeleteNamed(db.Driver, tx, "DELETE FROM widget_groups WHERE id = :id", lit.P{"id": id}) -} - -func (r *widgetGroupRepository) FindWidgetsByGroup(tx *sql.Tx, widgetGroupId int) ([]*models.WidgetGroupWidget, error) { - return lit.SelectNamed[models.WidgetGroupWidget]( - tx, - "SELECT id, widget_group_id, title, widget_type, config, position, created_at, updated_at FROM widget_group_widgets WHERE widget_group_id = :wg_id ORDER BY position ASC", - lit.P{"wg_id": widgetGroupId}, - ) -} - -func (r *widgetGroupRepository) FindWidgetsByGroupWithStar(tx *sql.Tx, widgetGroupId int) ([]*models.WidgetGroupWidgetWithStar, error) { - return lit.SelectNamed[models.WidgetGroupWidgetWithStar]( - tx, - `SELECT wgw.id, wgw.widget_group_id, wgw.title, wgw.widget_type, wgw.config, wgw.position, - (sw.id IS NOT NULL) AS is_starred, - wgw.created_at, wgw.updated_at - FROM widget_group_widgets wgw - LEFT JOIN starred_widgets sw ON sw.widget_id = wgw.id - WHERE wgw.widget_group_id = :wg_id - ORDER BY wgw.position ASC`, - lit.P{"wg_id": widgetGroupId}, - ) -} - -func (r *widgetGroupRepository) FindWidgetById(tx *sql.Tx, id int) (*models.WidgetGroupWidget, error) { - return lit.SelectSingleNamed[models.WidgetGroupWidget]( - tx, - "SELECT id, widget_group_id, title, widget_type, config, position, created_at, updated_at FROM widget_group_widgets WHERE id = :id", - lit.P{"id": id}, - ) -} - -func (r *widgetGroupRepository) FindStarredWidgetsByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.StarredWidgetWithHome, error) { - return lit.SelectNamed[models.StarredWidgetWithHome]( - tx, - `SELECT wgw.id, wgw.widget_group_id, wgw.title, wgw.widget_type, wgw.config, wgw.position, - sw.position AS home_position, sw.col_span AS home_col_span, sw.size AS home_size, - wgw.created_at, wgw.updated_at - FROM starred_widgets sw - JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id - JOIN widget_groups wg ON wg.id = wgw.widget_group_id - WHERE wg.project_id = :project_id - ORDER BY sw.position ASC, sw.id ASC`, - lit.P{"project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) FindStarredByProject(tx *sql.Tx, projectId uuid.UUID) ([]*models.StarredWidget, error) { - return lit.SelectNamed[models.StarredWidget]( - tx, - `SELECT sw.id, sw.widget_id, sw.position, sw.col_span, sw.size, sw.created_at - FROM starred_widgets sw - JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id - JOIN widget_groups wg ON wg.id = wgw.widget_group_id - WHERE wg.project_id = :project_id - ORDER BY sw.position ASC, sw.id ASC`, - lit.P{"project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) FindStarredByWidgetId(tx *sql.Tx, projectId uuid.UUID, widgetId int) (*models.StarredWidget, error) { - return lit.SelectSingleNamed[models.StarredWidget]( - tx, - `SELECT sw.id, sw.widget_id, sw.position, sw.col_span, sw.size, sw.created_at - FROM starred_widgets sw - JOIN widget_group_widgets wgw ON wgw.id = sw.widget_id - JOIN widget_groups wg ON wg.id = wgw.widget_group_id - WHERE sw.widget_id = :widget_id AND wg.project_id = :project_id`, - lit.P{"widget_id": widgetId, "project_id": projectId}, - ) -} - -func (r *widgetGroupRepository) CreateStarred(tx *sql.Tx, starred *models.StarredWidget) error { - query, args, err := lit.ParseNamedQuery( - db.Driver, - `INSERT INTO starred_widgets (widget_id, position, col_span, size, created_at) - VALUES (:widget_id, :position, :col_span, :size, :created_at) - ON CONFLICT (widget_id) DO NOTHING`, - lit.P{ - "widget_id": starred.WidgetId, - "position": starred.Position, - "col_span": starred.ColSpan, - "size": starred.Size, - "created_at": starred.CreatedAt, - }, - ) - if err != nil { - return err - } - return lit.UpdateNative(tx, query, args...) -} - -func (r *widgetGroupRepository) UpdateStarred(tx *sql.Tx, starred *models.StarredWidget) error { - return lit.UpdateNamed(tx, starred, "id = :id", lit.P{"id": starred.Id}) -} - -func (r *widgetGroupRepository) DeleteStarredByWidgetId(tx *sql.Tx, widgetId int) error { - return lit.DeleteNamed(db.Driver, tx, "DELETE FROM starred_widgets WHERE widget_id = :widget_id", lit.P{"widget_id": widgetId}) -} - -func (r *widgetGroupRepository) DeleteStarredByGroup(tx *sql.Tx, widgetGroupId int) error { - return lit.DeleteNamed( - db.Driver, tx, - "DELETE FROM starred_widgets WHERE widget_id IN (SELECT id FROM widget_group_widgets WHERE widget_group_id = :wg_id)", - lit.P{"wg_id": widgetGroupId}, - ) -} - -func (r *widgetGroupRepository) CreateWidget(tx *sql.Tx, widget *models.WidgetGroupWidget) (int, error) { - return lit.Insert[models.WidgetGroupWidget](tx, widget) -} - -func (r *widgetGroupRepository) UpdateWidget(tx *sql.Tx, widget *models.WidgetGroupWidget) error { - return lit.UpdateNamed(tx, widget, "id = :id", lit.P{"id": widget.Id}) -} - -func (r *widgetGroupRepository) DeleteWidget(tx *sql.Tx, id int) error { - return lit.DeleteNamed(db.Driver, tx, "DELETE FROM widget_group_widgets WHERE id = :id", lit.P{"id": id}) -} - -// DeleteWidgetsByGroup removes every widget belonging to the group. Use this -// before WidgetGroupRepository.Delete so the group + child widgets disappear -// together within the same transaction (rather than relying on the FK cascade -// — explicit is cheaper to audit and makes the SQL log self-explanatory). -func (r *widgetGroupRepository) DeleteWidgetsByGroup(tx *sql.Tx, widgetGroupId int) error { - return lit.DeleteNamed( - db.Driver, tx, - "DELETE FROM widget_group_widgets WHERE widget_group_id = :wg_id", - lit.P{"wg_id": widgetGroupId}, - ) -} - -var WidgetGroupRepository = widgetGroupRepository{} diff --git a/backend/app/repositories/transactional/transactional_pg.go b/backend/app/repositories/transactional/transactional_pg.go index d82cdafa8..dfc69b463 100644 --- a/backend/app/repositories/transactional/transactional_pg.go +++ b/backend/app/repositories/transactional/transactional_pg.go @@ -6,6 +6,8 @@ import pgrepo "github.com/tracewayapp/traceway/backend/app/repositories/transact var ( AuthorizationCodeRepository = pgrepo.AuthorizationCodeRepository + DashboardRepository = pgrepo.DashboardRepository + DashboardTemplateRepository = pgrepo.DashboardTemplateRepository DeviceAuthorizationRepository = pgrepo.DeviceAuthorizationRepository InvitationRepository = pgrepo.InvitationRepository MetricRegistryRepository = pgrepo.MetricRegistryRepository @@ -19,5 +21,4 @@ var ( ProjectUserRoleRepository = pgrepo.ProjectUserRoleRepository RefreshTokenRepository = pgrepo.RefreshTokenRepository UserRepository = pgrepo.UserRepository - WidgetGroupRepository = pgrepo.WidgetGroupRepository ) diff --git a/backend/app/repositories/transactional/transactional_sqlite.go b/backend/app/repositories/transactional/transactional_sqlite.go index b02117b7a..ec70bc54f 100644 --- a/backend/app/repositories/transactional/transactional_sqlite.go +++ b/backend/app/repositories/transactional/transactional_sqlite.go @@ -6,6 +6,8 @@ import sqliterepo "github.com/tracewayapp/traceway/backend/app/repositories/tran var ( AuthorizationCodeRepository = sqliterepo.AuthorizationCodeRepository + DashboardRepository = sqliterepo.DashboardRepository + DashboardTemplateRepository = sqliterepo.DashboardTemplateRepository DeviceAuthorizationRepository = sqliterepo.DeviceAuthorizationRepository InvitationRepository = sqliterepo.InvitationRepository MetricRegistryRepository = sqliterepo.MetricRegistryRepository @@ -19,5 +21,4 @@ var ( ProjectUserRoleRepository = sqliterepo.ProjectUserRoleRepository RefreshTokenRepository = sqliterepo.RefreshTokenRepository UserRepository = sqliterepo.UserRepository - WidgetGroupRepository = sqliterepo.WidgetGroupRepository ) diff --git a/backend/app/services/dashboards/definition.go b/backend/app/services/dashboards/definition.go new file mode 100644 index 000000000..0c61379da --- /dev/null +++ b/backend/app/services/dashboards/definition.go @@ -0,0 +1,110 @@ +package dashboards + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "unicode/utf8" + + "github.com/tracewayapp/traceway/backend/app/models" +) + +const SchemaVersion = 1 + +const maxWidgetIdLength = 20 + +const MaxWidgetsPerDashboard = 100 + +const MaxWidgetConfigBytes = 32 * 1024 + +var widgetIdPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +var ErrUnsupportedSchemaVersion = errors.New("unsupported dashboard schemaVersion") + +var AllowedWidgetTypes = []string{"line_chart", "area_chart", "stacked_area", "bar_chart", "single_value", "gauge", "table"} + +var allowedWidgetTypeSet = func() map[string]bool { + set := make(map[string]bool, len(AllowedWidgetTypes)) + for _, t := range AllowedWidgetTypes { + set[t] = true + } + return set +}() + +func IsAllowedWidgetType(widgetType string) bool { + return allowedWidgetTypeSet[widgetType] +} + +func SupportedSchemaVersion(v int) bool { + return v == 0 || v == SchemaVersion +} + +func TruncateName(name string, maxRunes int) string { + if utf8.RuneCountInString(name) <= maxRunes { + return name + } + runes := []rune(name) + return string(runes[:maxRunes]) +} + +func NewWidgetId() string { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return "w_" + hex.EncodeToString(b) +} + +func ParseDefinition(raw []byte) (*models.DashboardDefinition, error) { + def := &models.DashboardDefinition{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, def); err != nil { + return nil, err + } + } + if !SupportedSchemaVersion(def.SchemaVersion) { + return nil, fmt.Errorf("%w %d, this server supports schemaVersion %d", ErrUnsupportedSchemaVersion, def.SchemaVersion, SchemaVersion) + } + if def.SchemaVersion == 0 { + def.SchemaVersion = SchemaVersion + } + if def.Widgets == nil { + def.Widgets = []models.DashboardWidget{} + } + return def, nil +} + +func MarshalDefinition(def *models.DashboardDefinition) ([]byte, error) { + def.SchemaVersion = SchemaVersion + if def.Widgets == nil { + def.Widgets = []models.DashboardWidget{} + } + return json.Marshal(def) +} + +func EnsureWidgetIds(def *models.DashboardDefinition) { + seen := make(map[string]bool, len(def.Widgets)) + for i := range def.Widgets { + id := def.Widgets[i].Id + if id == "" || id == "reorder" || len(id) > maxWidgetIdLength || !widgetIdPattern.MatchString(id) || seen[id] { + id = NewWidgetId() + for seen[id] { + id = NewWidgetId() + } + def.Widgets[i].Id = id + } + seen[id] = true + } +} + +func FindWidget(def *models.DashboardDefinition, widgetId string) int { + for i := range def.Widgets { + if def.Widgets[i].Id == widgetId { + return i + } + } + return -1 +} diff --git a/backend/app/services/grafana/convert.go b/backend/app/services/grafana/convert.go new file mode 100644 index 000000000..dac2b0e79 --- /dev/null +++ b/backend/app/services/grafana/convert.go @@ -0,0 +1,347 @@ +package grafana + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/tracewayapp/traceway/backend/app/models" + dashboardsvc "github.com/tracewayapp/traceway/backend/app/services/dashboards" +) + +type ConvertResult struct { + Name string + Description string + Definition *models.DashboardDefinition + Warnings []string +} + +type grafanaDashboard struct { + Dashboard *grafanaDashboard `json:"dashboard"` + Title string `json:"title"` + Description string `json:"description"` + Panels []grafanaPanel `json:"panels"` +} + +type grafanaPanel struct { + Type string `json:"type"` + Title string `json:"title"` + Description string `json:"description"` + GridPos grafanaGridPos `json:"gridPos"` + FieldConfig json.RawMessage `json:"fieldConfig"` + Targets []grafanaTarget `json:"targets"` + Panels []grafanaPanel `json:"panels"` +} + +type grafanaGridPos struct { + H int `json:"h"` + W int `json:"w"` +} + +type grafanaTarget struct { + Expr string `json:"expr"` + Hide bool `json:"hide"` + LegendFormat string `json:"legendFormat"` + Datasource json.RawMessage `json:"datasource"` +} + +var panelTypeMap = map[string]string{ + "timeseries": "line_chart", + "graph": "line_chart", + "stat": "single_value", + "singlestat": "single_value", + "gauge": "gauge", + "bargauge": "gauge", + "barchart": "bar_chart", + "bar chart": "bar_chart", + "table": "table", + "table-old": "table", + "piechart": "bar_chart", + "state-timeline": "line_chart", +} + +var unitMap = map[string]string{ + "percent": "%", + "percentunit": "%", + "bytes": "By", + "decbytes": "By", + "deckbytes": "KiBy", + "decmbytes": "MiBy", + "decgbytes": "GiBy", + "seconds": "s", + "s": "s", + "ms": "ms", + "ns": "ns", + "ops": "ops", + "reqps": "req/s", + "short": "", + "none": "", +} + +func Convert(raw []byte) (*ConvertResult, error) { + var doc grafanaDashboard + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("invalid Grafana dashboard JSON: %w", err) + } + root := &doc + if doc.Dashboard != nil { + root = doc.Dashboard + } + + result := &ConvertResult{ + Name: strings.TrimSpace(root.Title), + Description: strings.TrimSpace(root.Description), + Definition: &models.DashboardDefinition{SchemaVersion: dashboardsvc.SchemaVersion}, + Warnings: []string{}, + } + if result.Name == "" { + result.Name = "Imported dashboard" + } + + panels := flattenPanels(root.Panels) + if len(panels) == 0 { + result.Warnings = append(result.Warnings, "The dashboard has no panels.") + } + + for _, panel := range panels { + widget, warnings := convertPanel(panel) + result.Warnings = append(result.Warnings, warnings...) + if widget != nil { + result.Definition.Widgets = append(result.Definition.Widgets, *widget) + } + } + + dashboardsvc.EnsureWidgetIds(result.Definition) + return result, nil +} + +func flattenPanels(panels []grafanaPanel) []grafanaPanel { + flat := []grafanaPanel{} + for _, p := range panels { + if p.Type == "row" { + flat = append(flat, flattenPanels(p.Panels)...) + continue + } + flat = append(flat, p) + } + return flat +} + +func panelLabel(panel grafanaPanel) string { + title := strings.TrimSpace(panel.Title) + if title == "" { + return "untitled panel" + } + return fmt.Sprintf("panel %q", title) +} + +func convertPanel(panel grafanaPanel) (*models.DashboardWidget, []string) { + warnings := []string{} + + widgetType, ok := panelTypeMap[panel.Type] + if !ok { + return nil, []string{fmt.Sprintf("Skipped %s: unsupported panel type %q.", panelLabel(panel), panel.Type)} + } + if panel.Type == "piechart" { + warnings = append(warnings, fmt.Sprintf("Converted %s from a pie chart to a bar chart.", panelLabel(panel))) + } + + sources := []map[string]any{} + for _, target := range panel.Targets { + if target.Hide { + continue + } + expr := strings.TrimSpace(target.Expr) + if expr == "" { + continue + } + if !isPrometheusTarget(target) { + warnings = append(warnings, fmt.Sprintf("Skipped a query of %s: only Prometheus queries can be converted.", panelLabel(panel))) + continue + } + source, sourceWarnings := convertQuery(expr, target.LegendFormat, panelLabel(panel)) + warnings = append(warnings, sourceWarnings...) + if source != nil { + sources = append(sources, source) + } + } + + if len(sources) == 0 { + warnings = append(warnings, fmt.Sprintf("Skipped %s: no convertible queries.", panelLabel(panel))) + return nil, warnings + } + + if widgetType == "single_value" || widgetType == "gauge" { + for _, s := range sources { + if s["aggregation"] == "avg" { + s["aggregation"] = "last" + } + } + } + + config := map[string]any{ + "sources": sources, + "colSpan": colSpanFromWidth(panel.GridPos.W), + "size": sizeFromHeight(panel.GridPos.H), + } + if unit := extractUnit(panel.FieldConfig); unit != "" { + config["unit"] = unit + } + + configJSON, err := json.Marshal(config) + if err != nil { + return nil, append(warnings, fmt.Sprintf("Skipped %s: %v.", panelLabel(panel), err)) + } + + title := strings.TrimSpace(panel.Title) + if title == "" { + title = "Untitled" + } + + return &models.DashboardWidget{ + Title: title, + WidgetType: widgetType, + Config: configJSON, + }, warnings +} + +func isPrometheusTarget(target grafanaTarget) bool { + if len(target.Datasource) == 0 { + return true + } + var typed struct { + Type string `json:"type"` + } + if err := json.Unmarshal(target.Datasource, &typed); err == nil && typed.Type != "" { + return typed.Type == "prometheus" + } + return true +} + +var ( + aggregationPrefixRe = regexp.MustCompile(`^(sum|avg|max|min|count)\s*(by\s*\(([^)]*)\)\s*)?\(`) + aggregationByRe = regexp.MustCompile(`\)\s*by\s*\(([^)]*)\)\s*$`) + rateWrapperRe = regexp.MustCompile(`^(rate|irate|increase|delta|deriv)\s*\(`) + metricNameRe = regexp.MustCompile(`^([a-zA-Z_:][a-zA-Z0-9_:.]*)`) + labelMatcherRe = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)\s*(=~|!~|!=|=)\s*"((?:[^"\\]|\\.)*)"`) +) + +func convertQuery(expr, legendFormat, panelName string) (map[string]any, []string) { + warnings := []string{} + rest := expr + aggregation := "avg" + groupBy := "" + + for { + if m := aggregationPrefixRe.FindStringSubmatch(rest); m != nil { + aggregation = m[1] + if m[3] != "" { + groupBy = firstGroupByLabel(m[3]) + } + rest = strings.TrimSpace(rest[len(m[0]):]) + rest = strings.TrimSuffix(rest, ")") + if bm := aggregationByRe.FindStringSubmatch(expr); bm != nil && groupBy == "" { + groupBy = firstGroupByLabel(bm[1]) + } + continue + } + if m := rateWrapperRe.FindStringSubmatch(rest); m != nil { + warnings = append(warnings, fmt.Sprintf("Approximated %s() in %s with the %s aggregation over time buckets.", m[1], panelName, aggregation)) + rest = strings.TrimSpace(rest[len(m[0]):]) + rest = strings.TrimSuffix(rest, ")") + if idx := strings.LastIndex(rest, "["); idx >= 0 { + rest = rest[:idx] + } + continue + } + break + } + + rest = strings.TrimSpace(rest) + nameMatch := metricNameRe.FindStringSubmatch(rest) + if nameMatch == nil { + return nil, append(warnings, fmt.Sprintf("Skipped a query of %s: could not extract a metric name from %q.", panelName, expr)) + } + metricName := nameMatch[1] + + tagFilters := map[string]string{} + if start := strings.Index(rest, "{"); start >= 0 { + end := strings.Index(rest[start:], "}") + if end > 0 { + for _, m := range labelMatcherRe.FindAllStringSubmatch(rest[start:start+end+1], -1) { + if m[2] != "=" { + warnings = append(warnings, fmt.Sprintf("Dropped the %s%s\"%s\" matcher in %s: only exact matches are supported.", m[1], m[2], m[3], panelName)) + continue + } + tagFilters[m[1]] = m[3] + } + } + } + + source := map[string]any{ + "type": "metric", + "name": metricName, + "aggregation": aggregation, + } + if len(tagFilters) > 0 { + source["tagFilters"] = tagFilters + } + if groupBy != "" { + source["groupBy"] = groupBy + } + if label := strings.TrimSpace(legendFormat); label != "" && !strings.Contains(label, "{{") { + source["label"] = label + } + return source, warnings +} + +func firstGroupByLabel(list string) string { + for _, part := range strings.Split(list, ",") { + label := strings.TrimSpace(part) + if label != "" { + return label + } + } + return "" +} + +func extractUnit(fieldConfig json.RawMessage) string { + if len(fieldConfig) == 0 { + return "" + } + var cfg struct { + Defaults struct { + Unit string `json:"unit"` + } `json:"defaults"` + } + if err := json.Unmarshal(fieldConfig, &cfg); err != nil { + return "" + } + if mapped, ok := unitMap[cfg.Defaults.Unit]; ok { + return mapped + } + return cfg.Defaults.Unit +} + +func colSpanFromWidth(w int) int { + switch { + case w >= 16: + return 3 + case w >= 10: + return 2 + default: + return 1 + } +} + +func sizeFromHeight(h int) string { + switch { + case h >= 12: + return "lg" + case h >= 8: + return "md" + default: + return "sm" + } +} diff --git a/backend/app/services/grafana/convert_test.go b/backend/app/services/grafana/convert_test.go new file mode 100644 index 000000000..d475db1d6 --- /dev/null +++ b/backend/app/services/grafana/convert_test.go @@ -0,0 +1,212 @@ +package grafana + +import ( + "encoding/json" + "strings" + "testing" +) + +const nodeExporterFixture = `{ + "title": "Node Overview", + "description": "Host health", + "panels": [ + { + "type": "row", + "title": "Network", + "panels": [ + { + "type": "timeseries", + "title": "Receive Throughput", + "gridPos": {"h": 8, "w": 12}, + "fieldConfig": {"defaults": {"unit": "bytes"}}, + "targets": [ + { + "expr": "sum by (instance) (rate(node_network_receive_bytes_total{device!=\"lo\",job=\"node\"}[5m]))", + "datasource": {"type": "prometheus"} + } + ] + } + ] + }, + { + "type": "stat", + "title": "Load 1m", + "gridPos": {"h": 4, "w": 6}, + "targets": [ + {"expr": "node_load1{instance=\"web-1\"}"} + ] + }, + { + "type": "gauge", + "title": "Memory Available", + "gridPos": {"h": 8, "w": 18}, + "fieldConfig": {"defaults": {"unit": "percent"}}, + "targets": [ + {"expr": "avg(node_memory_MemAvailable_percent)"}, + {"expr": "node_hidden_metric", "hide": true} + ] + }, + { + "type": "table", + "title": "Recent Logs", + "targets": [ + {"expr": "{app=\"web\"}", "datasource": {"type": "loki"}} + ] + }, + { + "type": "text", + "title": "Notes" + } + ] +}` + +type sourceShape struct { + Type string `json:"type"` + Name string `json:"name"` + Aggregation string `json:"aggregation"` + TagFilters map[string]string `json:"tagFilters"` + GroupBy string `json:"groupBy"` +} + +type configShape struct { + Sources []sourceShape `json:"sources"` + Unit string `json:"unit"` + ColSpan int `json:"colSpan"` + Size string `json:"size"` +} + +func parseConfig(t *testing.T, raw json.RawMessage) configShape { + t.Helper() + var cfg configShape + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("config does not parse: %v", err) + } + return cfg +} + +func hasWarningContaining(warnings []string, substr string) bool { + for _, w := range warnings { + if strings.Contains(w, substr) { + return true + } + } + return false +} + +func TestConvertNodeExporterDashboard(t *testing.T) { + result, err := Convert([]byte(nodeExporterFixture)) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if result.Name != "Node Overview" || result.Description != "Host health" { + t.Errorf("unexpected name/description: %q / %q", result.Name, result.Description) + } + + if len(result.Definition.Widgets) != 3 { + names := []string{} + for _, w := range result.Definition.Widgets { + names = append(names, w.Title) + } + t.Fatalf("expected 3 widgets, got %d: %v", len(result.Definition.Widgets), names) + } + + network := result.Definition.Widgets[0] + if network.Title != "Receive Throughput" || network.WidgetType != "line_chart" { + t.Errorf("unexpected first widget: %+v", network) + } + cfg := parseConfig(t, network.Config) + if len(cfg.Sources) != 1 { + t.Fatalf("expected 1 source, got %d", len(cfg.Sources)) + } + src := cfg.Sources[0] + if src.Name != "node_network_receive_bytes_total" { + t.Errorf("metric name = %q", src.Name) + } + if src.Aggregation != "sum" { + t.Errorf("aggregation = %q", src.Aggregation) + } + if src.GroupBy != "instance" { + t.Errorf("groupBy = %q", src.GroupBy) + } + if src.TagFilters["job"] != "node" { + t.Errorf("tagFilters = %v", src.TagFilters) + } + if _, hasDevice := src.TagFilters["device"]; hasDevice { + t.Errorf("negative matcher should be dropped, got %v", src.TagFilters) + } + if cfg.Unit != "By" { + t.Errorf("unit = %q", cfg.Unit) + } + if cfg.ColSpan != 2 || cfg.Size != "md" { + t.Errorf("layout = colSpan %d size %q", cfg.ColSpan, cfg.Size) + } + + stat := result.Definition.Widgets[1] + if stat.WidgetType != "single_value" { + t.Errorf("stat widget type = %q", stat.WidgetType) + } + statCfg := parseConfig(t, stat.Config) + if statCfg.Sources[0].Name != "node_load1" || statCfg.Sources[0].Aggregation != "last" { + t.Errorf("stat source = %+v", statCfg.Sources[0]) + } + if statCfg.Sources[0].TagFilters["instance"] != "web-1" { + t.Errorf("stat tagFilters = %v", statCfg.Sources[0].TagFilters) + } + if statCfg.ColSpan != 1 || statCfg.Size != "sm" { + t.Errorf("stat layout = colSpan %d size %q", statCfg.ColSpan, statCfg.Size) + } + + gauge := result.Definition.Widgets[2] + if gauge.WidgetType != "gauge" { + t.Errorf("gauge widget type = %q", gauge.WidgetType) + } + gaugeCfg := parseConfig(t, gauge.Config) + if len(gaugeCfg.Sources) != 1 { + t.Fatalf("hidden target should be skipped, got %d sources", len(gaugeCfg.Sources)) + } + if gaugeCfg.Sources[0].Name != "node_memory_MemAvailable_percent" || gaugeCfg.Sources[0].Aggregation != "last" { + t.Errorf("gauge source = %+v", gaugeCfg.Sources[0]) + } + if gaugeCfg.Unit != "%" || gaugeCfg.ColSpan != 3 { + t.Errorf("gauge unit/colSpan = %q / %d", gaugeCfg.Unit, gaugeCfg.ColSpan) + } + + seen := map[string]bool{} + for _, w := range result.Definition.Widgets { + if w.Id == "" || seen[w.Id] { + t.Errorf("widget %q has missing or duplicate id %q", w.Title, w.Id) + } + seen[w.Id] = true + } + + if !hasWarningContaining(result.Warnings, "rate") { + t.Errorf("expected a rate() warning, got %v", result.Warnings) + } + if !hasWarningContaining(result.Warnings, "Prometheus") { + t.Errorf("expected a non-Prometheus warning, got %v", result.Warnings) + } + if !hasWarningContaining(result.Warnings, "unsupported panel type") { + t.Errorf("expected an unsupported panel warning, got %v", result.Warnings) + } + if !hasWarningContaining(result.Warnings, "only exact matches") { + t.Errorf("expected a dropped matcher warning, got %v", result.Warnings) + } +} + +func TestConvertApiWrappedDashboard(t *testing.T) { + wrapped := `{"dashboard": {"title": "Wrapped", "panels": [{"type": "timeseries", "title": "CPU", "targets": [{"expr": "node_cpu_seconds_total"}]}]}}` + result, err := Convert([]byte(wrapped)) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if result.Name != "Wrapped" || len(result.Definition.Widgets) != 1 { + t.Errorf("unexpected result: name %q, %d widgets", result.Name, len(result.Definition.Widgets)) + } +} + +func TestConvertRejectsInvalidJSON(t *testing.T) { + if _, err := Convert([]byte("not json")); err == nil { + t.Fatal("expected an error for invalid JSON") + } +} diff --git a/backend/cmd/run.go b/backend/cmd/run.go index 49ac7feb6..40eb96954 100644 --- a/backend/cmd/run.go +++ b/backend/cmd/run.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/tracewayapp/traceway/backend/app/backfill" "github.com/tracewayapp/traceway/backend/app/cache" "github.com/tracewayapp/traceway/backend/app/chdb" "github.com/tracewayapp/traceway/backend/app/config" @@ -102,6 +103,10 @@ func Run(opts ...Option) { panic(fmt.Errorf("migrations run failed: %w", err)) } + if err := backfill.RunDashboards(); err != nil { + panic(fmt.Errorf("dashboards backfill failed: %w", err)) + } + if o != nil { if err := seed(o); err != nil { panic(fmt.Errorf("seeding failed: %w", err)) diff --git a/docs/pages/client/sdk/metrics.mdx b/docs/pages/client/sdk/metrics.mdx index 02cb71b27..a63506340 100644 --- a/docs/pages/client/sdk/metrics.mdx +++ b/docs/pages/client/sdk/metrics.mdx @@ -2,7 +2,7 @@ Metrics are time-series measurements of your application's health. System metrics are collected automatically; you can also capture custom metrics. -Metrics Dashboard +Metrics on a dashboard ## CaptureMetric diff --git a/docs/pages/learn/_meta.json b/docs/pages/learn/_meta.json index a6067ce07..2f28ea925 100644 --- a/docs/pages/learn/_meta.json +++ b/docs/pages/learn/_meta.json @@ -12,6 +12,7 @@ "spans": "Spans", "healthchecks": "Healthchecks", "metrics": "Metrics", + "dashboards": "Dashboards", "logs": "Logs", "alerts": "Alerts", "sso": "SSO", diff --git a/docs/pages/learn/contributing.mdx b/docs/pages/learn/contributing.mdx index dfc796e8a..4e2fb237a 100644 --- a/docs/pages/learn/contributing.mdx +++ b/docs/pages/learn/contributing.mdx @@ -196,7 +196,7 @@ Two separate auth systems: ### Database Split -- **PostgreSQL** (or SQLite in embedded mode): Relational data that gets updated — users, organizations, projects, invitations, notification rules, widget config +- **PostgreSQL** (or SQLite in embedded mode): Relational data that gets updated — users, organizations, projects, invitations, notification rules, dashboards and templates - **ClickHouse** (or SQLite in embedded mode): High-volume append-only telemetry — traces, exceptions, metrics, spans, tasks, session recordings ### Backend Patterns diff --git a/docs/pages/learn/dashboards.mdx b/docs/pages/learn/dashboards.mdx new file mode 100644 index 000000000..12e2f3f26 --- /dev/null +++ b/docs/pages/learn/dashboards.mdx @@ -0,0 +1,117 @@ +# Dashboards + +Dashboards visualize your metrics as tabs of chart widgets. A dashboard belongs to your **organization**, not to a single project: you build it once and apply it to any number of projects, and edits show up everywhere it is applied. Every dashboard is a plain JSON document underneath, so you can edit, export, import, and version-control them as code. + +The Dashboards page + +## The Dashboards page + +Each tab on the Dashboards page is one dashboard applied to the current project. A project with no dashboards yet shows a single **Browse Dashboards & Templates** button that opens the command palette described below. The active tab's menu holds everything you can do with a dashboard: + +Dashboard menu + +- **Rename Dashboard**: names are unique within the organization +- **Edit as JSON**: edit the whole dashboard as one document +- **Export JSON**: download the dashboard for versioning or sharing +- **Apply to Projects**: choose which projects show this dashboard +- **Remove from this Project**: unassign it here; it stays available everywhere else +- **Delete Dashboard**: remove it from every project, permanently + +## Adding dashboards: the command palette + +Press ⌘K (Ctrl K on Windows/Linux), or click **Add or find dashboards...** in the header. One search box covers your organization's dashboards, the built-in template marketplace, and every metric name seen across your organization's projects: + +Command palette + +- Selecting one of **your dashboards** applies it to the current project (dashboards from another organization are copied) +- Selecting a **template** installs it as a new dashboard in your organization +- Selecting a **metric** opens the widget editor prefilled with that metric on the active dashboard + +## Built-in templates + +Templates are ready-made dashboards you install and then customize freely. The marketplace ships with: + +| Template | Category | Source of the metrics | +| --- | --- | --- | +| OTelemetry Server Agent | server | [Traceway OTel Agent](/learn/otel-agent) host CPU, memory, disk and network metrics | +| Go Application | go | Go SDK runtime and process metrics (goroutines, heap, GC, CPU, memory) | +| Traceway ClickHouse | traceway | A monitored Traceway instance's `traceway.ch.*` metrics | +| Traceway DuckDB | traceway | A monitored Traceway instance's `traceway.duckdb.*` metrics | + +Templates in the command palette + +Widgets can group series by resource tags such as `container.name`, `k8s.pod.name`, `k8s.node.name`, or `postgresql.database.name`, so one widget shows one line per container, pod, node, or database. Point the matching OpenTelemetry Collector receiver at your Traceway project's OTLP endpoint and build widgets on the metrics it reports. + +## Dashboards as code + +Every dashboard is a single JSON document. Widget order in the array is the display order, and each widget carries its own config: + +```json +{ + "schemaVersion": 1, + "name": "Server", + "widgets": [ + { + "id": "w_6f8d9c6d", + "title": "CPU Utilization", + "widgetType": "line_chart", + "config": { + "sources": [ + { "type": "metric", "name": "system.cpu.utilization", "aggregation": "avg" } + ] + } + } + ] +} +``` + +**Edit as JSON** gives you the document in place, validated on save: + +Edit as JSON + +**Export/Import** round-trips the same document. Import accepts a single dashboard or a whole-organization bundle, and the API supports an upsert mode that matches dashboards by name, so a CI job can keep dashboards in sync from files in your repository: + +```bash +curl -X POST "$TRACEWAY_URL/api/dashboards/import?projectId=$PROJECT_ID" \ + -H "Authorization: Bearer $TRACEWAY_PAT" \ + -H "Content-Type: application/json" \ + -d "{\"mode\": \"upsert\", \"data\": $(cat system.dashboard.json)}" +``` + +Widget `id`s are server-generated stable strings; keep them when editing so homepage stars survive, or omit them and they are regenerated. + +## Applying across projects + +**Apply to Projects** controls exactly which projects show a dashboard. Because the definition is shared, a fix made in one project is instantly live in all of them. Unchecking a project only removes it from that project's tabs. + +Apply to Projects + +## Importing from Grafana + +Paste any Grafana dashboard JSON export and Traceway converts it on a best-effort basis: `timeseries`/`graph` panels become line charts, `stat` becomes a single value, `gauge` stays a gauge, and Prometheus queries are unwrapped into metric sources (metric name, exact label matchers, `by (...)` grouping, aggregation). + +Grafana import + +Anything that cannot be converted faithfully is reported instead of silently dropped: `rate()` approximations, unsupported panel types, regex matchers, non-Prometheus queries. + +Grafana conversion warnings + +## Widgets + +| Type | Best For | +| --- | --- | +| Line Chart | Trends over time (CPU usage, request rate) | +| Area Chart | Volume over time with filled area | +| Stacked Area | Composition of a total across series | +| Bar Chart | Comparing values across time intervals | +| Single Value | Current or aggregated value at a glance | +| Gauge | A value against thresholds | +| Table | Tabular view of metric values | + +Each widget has one or more **sources**: a metric name (auto-discovered from your project), an aggregation (`avg`, `min`, `max`, `sum`, `count`, `last`), optional tag filters, and an optional group-by that splits the chart into one series per tag value. Multiple sources overlay on the same chart. + +Widget editor + +## Starring widgets + +The star on any widget pins it to that project's homepage, where you can resize and reorder your starred set. Stars are per project, so each project curates its own homepage even when dashboards are shared. diff --git a/docs/pages/learn/data-flow.mdx b/docs/pages/learn/data-flow.mdx index d11017694..3f2c1a6b0 100644 --- a/docs/pages/learn/data-flow.mdx +++ b/docs/pages/learn/data-flow.mdx @@ -24,7 +24,7 @@ Here's how the concepts connect in a typical scheduled/async task: 1. **System metrics collected** → CPU, memory, goroutines, and GC stats are captured automatically every 30 seconds (Automatically by the client SDK) 2. **Custom metrics captured** → Your code calls `CaptureMetric` or `CaptureMetricWithTags` to record application-specific measurements with optional tags 3. **Batched and uploaded** → All metrics are batched, compressed, and sent to Traceway with the next collection frame -4. **Displayed on dashboard** → Metrics appear in configurable widget groups with line charts, area charts, bar charts, single values, and tables — filterable and groupable by tags +4. **Displayed on dashboards** → Metrics appear on configurable, org-shareable dashboards with line charts, area charts, bar charts, single values, gauges, and tables — filterable and groupable by tags ## Ingestion pipelines diff --git a/docs/pages/learn/metrics.mdx b/docs/pages/learn/metrics.mdx index 6c070693e..a0d5c0391 100644 --- a/docs/pages/learn/metrics.mdx +++ b/docs/pages/learn/metrics.mdx @@ -36,46 +36,11 @@ Common tags include: Tags are captured via `CaptureMetricWithTags` in the Go SDK, or as resource attributes in OpenTelemetry. -## The Metrics Dashboard +## Visualizing Metrics -The metrics dashboard organizes your data into **widget groups** and **widgets**. +Metrics are visualized on [Dashboards](/learn/dashboards): organization-owned, JSON-defined tabs of chart widgets that you can apply to any of your projects, install from the built-in template marketplace (host metrics, Go runtime, Traceway self-monitoring), or import from Grafana. -Metrics Dashboard - -### Widget Groups - -Widget groups are named tabs that organize related widgets together. When you create a Go or Node.js project, default groups are created automatically with system metric widgets. OpenTelemetry projects start with a blank dashboard — you create groups as needed. - -You can create, rename, and delete widget groups from the dashboard. - -### Widgets - -Widgets are individual chart cards within a group. Each widget displays one or more metric sources as a visualization. - -## Widget Types - -| Type | Best For | -| ------------ | -------------------------------------------------- | -| Line Chart | Trends over time (CPU usage, request rate) | -| Area Chart | Volume over time with filled area | -| Bar Chart | Comparing values across time intervals | -| Single Value | Current or aggregated value at a glance | -| Table | Tabular view of metric values | - -## Widget Configuration - -Each widget contains one or more **sources**. Each source specifies: - -| Setting | Description | -| ------------ | --------------------------------------------------------------------------- | -| Metric Name | Selected from auto-discovered metrics in your project | -| Aggregation | How values are combined: `avg`, `min`, `max`, `sum`, or `count` | -| Tag Filters | Narrow to specific tag values (e.g. `server_name = web-1`) | -| Group By | Split into separate series per tag value (e.g. one line per `server_name`) | - -Multiple sources on the same widget overlay their series on the same chart, useful for comparing related metrics side by side. - -Widget configuration dialog +Dashboards page ## Time Range @@ -94,11 +59,13 @@ This keeps queries fast regardless of the time window. ## Creating Your First Dashboard -1. **Create a widget group** — Click the **+** button next to the existing tabs and give it a name -2. **Add a widget** — Click **Add Widget**, pick a metric from the dropdown, choose an aggregation, and optionally add tag filters or a group-by +1. **Open the Dashboards page** and press ⌘K (or click **Add or find dashboards...**) to install a template, pick up a dashboard from another project, or create a blank one +2. **Add a widget** — Click **Add Metric Widget**, pick a metric from the dropdown, choose an aggregation, and optionally add tag filters or a group-by 3. **Adjust the time range** — Use the preset buttons or select a custom range to see the data window you need 4. **Interact** — Hover over data points for exact values, drag-select a region to zoom in, and use the widget menu to move, edit, or delete widgets +See [Dashboards](/learn/dashboards) for applying dashboards across projects, editing them as JSON, and importing from Grafana. + ## Host-level metrics To collect **host-level** metrics (CPU, memory, disk, network, filesystem, processes) from a server and ship them to a Traceway project with a one-line install, see the [OTel Agent](/learn/otel-agent). diff --git a/docs/public/dashboards/dashboards-apply.png b/docs/public/dashboards/dashboards-apply.png new file mode 100644 index 000000000..d0c5a79e9 Binary files /dev/null and b/docs/public/dashboards/dashboards-apply.png differ diff --git a/docs/public/dashboards/dashboards-edit-json.png b/docs/public/dashboards/dashboards-edit-json.png new file mode 100644 index 000000000..01e60a3da Binary files /dev/null and b/docs/public/dashboards/dashboards-edit-json.png differ diff --git a/docs/public/dashboards/dashboards-grafana-import.png b/docs/public/dashboards/dashboards-grafana-import.png new file mode 100644 index 000000000..5994f2690 Binary files /dev/null and b/docs/public/dashboards/dashboards-grafana-import.png differ diff --git a/docs/public/dashboards/dashboards-grafana-warnings.png b/docs/public/dashboards/dashboards-grafana-warnings.png new file mode 100644 index 000000000..cf08f3407 Binary files /dev/null and b/docs/public/dashboards/dashboards-grafana-warnings.png differ diff --git a/docs/public/dashboards/dashboards-menu.png b/docs/public/dashboards/dashboards-menu.png new file mode 100644 index 000000000..aba90a38d Binary files /dev/null and b/docs/public/dashboards/dashboards-menu.png differ diff --git a/docs/public/dashboards/dashboards-new-widget.png b/docs/public/dashboards/dashboards-new-widget.png new file mode 100644 index 000000000..9b189568d Binary files /dev/null and b/docs/public/dashboards/dashboards-new-widget.png differ diff --git a/docs/public/dashboards/dashboards-page.png b/docs/public/dashboards/dashboards-page.png new file mode 100644 index 000000000..e7b0388a5 Binary files /dev/null and b/docs/public/dashboards/dashboards-page.png differ diff --git a/docs/public/dashboards/dashboards-palette-templates.png b/docs/public/dashboards/dashboards-palette-templates.png new file mode 100644 index 000000000..99dfa6c03 Binary files /dev/null and b/docs/public/dashboards/dashboards-palette-templates.png differ diff --git a/docs/public/dashboards/dashboards-palette.png b/docs/public/dashboards/dashboards-palette.png new file mode 100644 index 000000000..122d15f93 Binary files /dev/null and b/docs/public/dashboards/dashboards-palette.png differ diff --git a/docs/public/metrics.png b/docs/public/metrics.png deleted file mode 100644 index 12b9ef588..000000000 Binary files a/docs/public/metrics.png and /dev/null differ diff --git a/docs/public/new-widget-dialog.png b/docs/public/new-widget-dialog.png deleted file mode 100644 index ed67142b2..000000000 Binary files a/docs/public/new-widget-dialog.png and /dev/null differ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e62b1237a..45b907761 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -44,9 +44,12 @@ async function request(method: string, endpoint: string, data?: unknown, options if (response.status === 403) { if (method !== 'GET') { - toast.warning("You don't have permission to perform this action", { position: 'top-center' }); - const error = new Error('Forbidden') as Error & { status: number }; + const body = await response.json().catch(() => ({})); + const message = body.error || "You don't have permission to perform this action"; + toast.error(message, { position: 'top-center' }); + const error = new Error(message) as Error & { status: number; body?: unknown }; error.status = 403; + error.body = body; throw error; } const currentPath = window.location.pathname; @@ -62,8 +65,9 @@ async function request(method: string, endpoint: string, data?: unknown, options if (response.status === 422) { const body = await response.json().catch(() => ({})); - const error = new Error(body.error || 'Validation failed') as Error & { status: number }; + const error = new Error(body.error || 'Validation failed') as Error & { status: number; body?: unknown }; error.status = 422; + error.body = body; throw error; } diff --git a/frontend/src/lib/components/app-sidebar.svelte b/frontend/src/lib/components/app-sidebar.svelte index 2188b1469..ba5a69f07 100644 --- a/frontend/src/lib/components/app-sidebar.svelte +++ b/frontend/src/lib/components/app-sidebar.svelte @@ -43,10 +43,10 @@ 'Endpoints', 'Tasks', 'Profiles', - 'Metrics', + 'Dashboards', 'AI Traces' ]); - const hiddenForCloudflare = new Set(['Metrics']); + const hiddenForCloudflare = new Set(['Dashboards']); // Items that only make sense for frontend (browser) projects - dropped from // the sidebar for any other framework, so a Go backend project doesn't // surface a Sessions tab that can never be populated. @@ -68,8 +68,8 @@ }, { Icon: ChartNoAxesCombined, - href: '/metrics', - title: 'Metrics', + href: '/dashboards', + title: 'Dashboards', stickyParams: ['preset', 'from', 'to'] }, { Icon: Bell, href: '/notifications', title: 'Alerts', stickyParams: ['preset', 'from', 'to'] }, diff --git a/frontend/src/lib/components/dashboard/dashboard-command.svelte b/frontend/src/lib/components/dashboard/dashboard-command.svelte new file mode 100644 index 000000000..46e86f0c5 --- /dev/null +++ b/frontend/src/lib/components/dashboard/dashboard-command.svelte @@ -0,0 +1,318 @@ + + + + + + No results found. + + runAction('create')}> + + New Dashboard + + runAction('import')}> + + Import JSON + + runAction('grafana')}> + + Import from Grafana + + + {#each organizations as org (org.id)} + {#if org.dashboards.length > 0} + + {#each org.dashboards as dashboard (dashboard.id)} + {@const applied = currentProjectId + ? dashboard.appliedProjectIds.includes(currentProjectId) + : false} + selectDashboard(org, dashboard)} + > + +
+ {dashboard.name} + + {dashboard.widgetCount} widget{dashboard.widgetCount === 1 ? '' : 's'} + {#if dashboard.description}· {dashboard.description}{/if} + +
+ {#if currentOrgId !== null && org.id !== currentOrgId} + + will copy + + {:else if applied} + + applied + + {/if} +
+ {/each} +
+ {/if} + {/each} + {#if templates.length > 0} + + {#each templates as template (template.key)} + installTemplate(template)} + > + +
+ {template.name} + + {template.category} · {template.widgetCount} widget{template.widgetCount === 1 + ? '' + : 's'} + {#if template.description}· {template.description}{/if} + +
+
+ {/each} +
+ {/if} + {#if metrics.length > 0} + + {#each metrics as metric (metric.name)} + selectMetric(metric)} + > + +
+ {metric.name} + + Add as a widget + {#if metric.projectIds.length > 1} + · seen in {metric.projectIds.length} projects + {/if} + +
+
+ {/each} +
+ {/if} +
+ {#if metricsTruncated && metrics.length > 0} +
+ Metrics from your 25 most recent projects +
+ {/if} +
diff --git a/frontend/src/lib/components/dashboard/widget-config-panel.svelte b/frontend/src/lib/components/dashboard/widget-config-panel.svelte index d5651db93..455f05c62 100644 --- a/frontend/src/lib/components/dashboard/widget-config-panel.svelte +++ b/frontend/src/lib/components/dashboard/widget-config-panel.svelte @@ -52,7 +52,7 @@ onCancel } = $props<{ open: boolean; - widget: { id?: number; title: string; widgetType: string; config: any } | null; + widget: { id?: number | string; title: string; widgetType: string; config: any } | null; availableMetrics: DiscoveredMetric[]; error?: string; onSave: (data: { title: string; widgetType: string; config: any }) => void; diff --git a/frontend/src/lib/components/dashboard/widget-grid.svelte b/frontend/src/lib/components/dashboard/widget-grid.svelte index c9f960579..8d5221409 100644 --- a/frontend/src/lib/components/dashboard/widget-grid.svelte +++ b/frontend/src/lib/components/dashboard/widget-grid.svelte @@ -18,7 +18,7 @@ import WidgetRenderer from './widget-renderer.svelte'; type Widget = { - id: number; + id: number | string; title: string; widgetType: string; config: any; @@ -46,7 +46,7 @@ timeDomain: [Date, Date] | null; onEditWidget?: (widget: Widget) => void; onDeleteWidget?: (widget: Widget) => void; - onReorderWidgets?: (widgetIds: number[]) => void; + onReorderWidgets?: (widgetIds: (number | string)[]) => void; onDuplicateWidget?: (widget: Widget) => void; onResizeWidget?: (widget: Widget, layout: { colSpan: number; size: string }) => void; onAddWidget?: () => void; diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte index fef83dae4..449e5d274 100644 --- a/frontend/src/lib/components/ui/alert/alert.svelte +++ b/frontend/src/lib/components/ui/alert/alert.svelte @@ -7,7 +7,7 @@ variant: { default: 'bg-card text-card-foreground', destructive: - 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current' + 'text-destructive border-destructive/50 dark:border-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current' } }, defaultVariants: { diff --git a/frontend/src/lib/components/ui/command/command-dialog.svelte b/frontend/src/lib/components/ui/command/command-dialog.svelte new file mode 100644 index 000000000..a29a02cbe --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-dialog.svelte @@ -0,0 +1,50 @@ + + + + + + + {title} + {description} + + {@render children?.()} + + + + diff --git a/frontend/src/lib/components/ui/command/command-empty.svelte b/frontend/src/lib/components/ui/command/command-empty.svelte new file mode 100644 index 000000000..c2ce16529 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-empty.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/command/command-group.svelte b/frontend/src/lib/components/ui/command/command-group.svelte new file mode 100644 index 000000000..b425bb510 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-group.svelte @@ -0,0 +1,35 @@ + + + + {#if heading} + + {heading} + + {/if} + + diff --git a/frontend/src/lib/components/ui/command/command-input.svelte b/frontend/src/lib/components/ui/command/command-input.svelte new file mode 100644 index 000000000..601b979e4 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-input.svelte @@ -0,0 +1,27 @@ + + +
+ + +
diff --git a/frontend/src/lib/components/ui/command/command-item.svelte b/frontend/src/lib/components/ui/command/command-item.svelte new file mode 100644 index 000000000..3e4e74ef8 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-item.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/ui/command/command-list.svelte b/frontend/src/lib/components/ui/command/command-list.svelte new file mode 100644 index 000000000..6e99d2318 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-list.svelte @@ -0,0 +1,22 @@ + + + + + {@render children?.()} + + diff --git a/frontend/src/lib/components/ui/command/command-separator.svelte b/frontend/src/lib/components/ui/command/command-separator.svelte new file mode 100644 index 000000000..c21856b61 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/command/command-shortcut.svelte b/frontend/src/lib/components/ui/command/command-shortcut.svelte new file mode 100644 index 000000000..e8eaab5a2 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/command/command.svelte b/frontend/src/lib/components/ui/command/command.svelte new file mode 100644 index 000000000..e0897f816 --- /dev/null +++ b/frontend/src/lib/components/ui/command/command.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/ui/command/index.ts b/frontend/src/lib/components/ui/command/index.ts new file mode 100644 index 000000000..23b37a55a --- /dev/null +++ b/frontend/src/lib/components/ui/command/index.ts @@ -0,0 +1,31 @@ +import Root from './command.svelte'; +import Dialog from './command-dialog.svelte'; +import Empty from './command-empty.svelte'; +import Group from './command-group.svelte'; +import Input from './command-input.svelte'; +import Item from './command-item.svelte'; +import List from './command-list.svelte'; +import Separator from './command-separator.svelte'; +import Shortcut from './command-shortcut.svelte'; + +export { + Root, + Dialog, + Empty, + Group, + Input, + Item, + List, + Separator, + Shortcut, + // + Root as Command, + Dialog as CommandDialog, + Empty as CommandEmpty, + Group as CommandGroup, + Input as CommandInput, + Item as CommandItem, + List as CommandList, + Separator as CommandSeparator, + Shortcut as CommandShortcut +}; diff --git a/frontend/src/lib/state/timezone.svelte.ts b/frontend/src/lib/state/timezone.svelte.ts index 266a4f73e..47075fb9f 100644 --- a/frontend/src/lib/state/timezone.svelte.ts +++ b/frontend/src/lib/state/timezone.svelte.ts @@ -8,5 +8,12 @@ export function getTimezone(): string { const timezone = authState.getTimezoneForOrganization(organizationId); if (timezone) return timezone; } + // Before the project list resolves there is no current project to pick the + // organization from. Fall back to the sole organization's timezone so the + // resolved zone doesn't flip mid-session once projects load — wall-clock + // state materialized under one zone must not be reinterpreted under another. + if (authState.organizations.length === 1 && authState.organizations[0].timezone) { + return authState.organizations[0].timezone; + } return DateTime.local().zoneName || 'UTC'; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0e1b467b7..35b167597 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,14 +4,17 @@ import { authState } from '$lib/state/auth.svelte'; import { projectsState, type Project } from '$lib/state/projects.svelte'; import { themeState, initTheme, toggleTheme } from '$lib/state/theme.svelte'; + import { getTimezone } from '$lib/state/timezone.svelte'; + import { DateTime } from 'luxon'; import { incrementNavDepth, decrementNavDepth } from '$lib/utils/back-navigation'; import AppSidebar from '$lib/components/app-sidebar.svelte'; import AddProjectModal from '$lib/components/add-project-modal.svelte'; import EditProjectModal from '$lib/components/edit-project-modal.svelte'; + import DashboardCommand from '$lib/components/dashboard/dashboard-command.svelte'; import FrameworkIcon from '$lib/components/framework-icon.svelte'; import * as Sidebar from '$lib/components/ui/sidebar'; import { Button } from '$lib/components/ui/button'; - import { Sun, Moon, LogOut, Plus, Check, Pencil } from '@lucide/svelte'; + import { Sun, Moon, LogOut, Plus, Check, Pencil, TriangleAlert } from '@lucide/svelte'; import { onMount } from 'svelte'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import { ChevronDown } from 'lucide-svelte'; @@ -31,6 +34,19 @@ let { children } = $props(); let showAddProjectModal = $state(false); let showEditProjectModal = $state(false); + let showCommandPalette = $state(false); + + const isMacPlatform = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform); + + function handleGlobalKeydown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + if (!authState.isAuthenticated || isPublicPath(page.url.pathname)) return; + if (isMacPlatform && !e.metaKey) return; + if (page.url.pathname === '/dashboards') return; + e.preventDefault(); + showCommandPalette = !showCommandPalette; + } + } const SIDEBAR_OPEN_KEY = 'traceway_sidebar_open'; let sidebarOpen = $state(localStorage.getItem(SIDEBAR_OPEN_KEY) !== 'false'); @@ -38,6 +54,26 @@ const bannerOrganizationId = $derived(projectsState.currentProject?.organizationId ?? null); + // Warn when the browser's timezone renders times differently from the + // organization's timezone (all timestamps are shown in the org timezone). + // Compare offsets, not zone names — Europe/Berlin vs Europe/Belgrade is + // not a mismatch worth warning about. + const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const orgZone = $derived(getTimezone()); + const timezoneMismatch = $derived.by(() => { + if (!orgZone || orgZone === browserZone) return false; + const now = Date.now(); + return ( + DateTime.fromMillis(now, { zone: orgZone }).offset !== + DateTime.fromMillis(now, { zone: browserZone }).offset + ); + }); + + function zoneLabel(zone: string): string { + if (zone === 'UTC') return 'UTC'; + return `${zone}, UTC${DateTime.now().setZone(zone).toFormat('Z')}`; + } + const PUBLIC_PATHS = new Set([ '/login', '/register', @@ -166,6 +202,8 @@ Traceway + + {#snippet projectItem(project: Project, indent: boolean)} handleProjectSelect(project.id)} @@ -260,6 +298,17 @@
+ {#if timezoneMismatch} +
+ + + Your browser's timezone ({zoneLabel(browserZone)}) differs from the organization's + ({zoneLabel(orgZone)}). All times are shown in the organization's timezone. + +
+ {/if} {#if CrossSiteNotificationBanner && bannerOrganizationId !== null}
@@ -282,6 +331,8 @@ project={projectsState.currentProject} /> + + {:else} {#if isPublicPath(page.url.pathname)} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 2f245a035..71de5ed56 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -100,11 +100,11 @@ type StarredWidgetResponse = { id: number; - widgetGroupId: number; + dashboardId: number; + widgetId: string; title: string; widgetType: string; config: any; - position: number; homePosition: number; homeColSpan: number; homeSize: string; @@ -112,7 +112,8 @@ type StarredWidget = { id: number; - widgetGroupId: number; + dashboardId: number; + widgetId: string; title: string; widgetType: string; config: any; @@ -315,12 +316,13 @@ service: starredToUTC = new Date().toISOString(); const [overview, starred] = await Promise.all([ api.get('/dashboard/overview', { projectId }), - api.get('/widget-groups/starred', { projectId }).catch(() => []) + api.get('/dashboards/starred', { projectId }).catch(() => []) ]); data = overview; starredWidgets = ((starred as StarredWidgetResponse[]) ?? []).map((w) => ({ id: w.id, - widgetGroupId: w.widgetGroupId, + dashboardId: w.dashboardId, + widgetId: w.widgetId, title: w.title, widgetType: w.widgetType, config: { ...w.config, colSpan: w.homeColSpan, size: w.homeSize }, @@ -338,15 +340,16 @@ service: } } - async function handleReorderStarred(widgetIds: number[]) { + async function handleReorderStarred(reorderedIds: (number | string)[]) { + const ids = reorderedIds.map(Number); const previousPositions = new Map(starredWidgets.map((w) => [w.id, w.position])); for (const w of starredWidgets) { - w.position = widgetIds.indexOf(w.id); + w.position = ids.indexOf(w.id); } try { await api.put( '/starred-widgets/reorder', - { widgetIds }, + { ids }, { projectId: projectsState.currentProjectId ?? undefined } ); } catch (e: any) { @@ -361,16 +364,16 @@ service: } async function handleResizeStarred( - widget: { id: number }, + widget: { id: number | string }, layout: { colSpan: number; size: string } ) { - const target = starredWidgets.find((w) => w.id === widget.id); + const target = starredWidgets.find((w) => w.id === Number(widget.id)); if (!target) return; const previous = { colSpan: target.config.colSpan, size: target.config.size }; target.config.colSpan = layout.colSpan; target.config.size = layout.size; try { - await api.put(`/starred-widgets/${widget.id}`, layout, { + await api.put(`/starred-widgets/${target.id}`, layout, { projectId: projectsState.currentProjectId ?? undefined }); } catch (e: any) { @@ -382,14 +385,14 @@ service: } } - async function handleUnstar(widget: { id: number }) { - const target = starredWidgets.find((w) => w.id === widget.id); + async function handleUnstar(widget: { id: number | string }) { + const target = starredWidgets.find((w) => w.id === Number(widget.id)); if (!target) return; const previous = starredWidgets; - starredWidgets = starredWidgets.filter((w) => w.id !== widget.id); + starredWidgets = starredWidgets.filter((w) => w.id !== target.id); try { await api.put( - `/widget-groups/${target.widgetGroupId}/widgets/${target.id}/star`, + `/dashboards/${target.dashboardId}/widgets/${target.widgetId}/star`, {}, { projectId: projectsState.currentProjectId ?? undefined } ); @@ -867,7 +870,7 @@ service: -

Widgets you've starred from the Metrics page (last 24h)

+

Widgets you've starred from the Dashboards page (last 24h)

@@ -876,11 +879,9 @@ service: fromDateUTC={starredFromUTC} toDateUTC={starredToUTC} timeDomain={null} - onReorderWidgets={projectsState.canWriteCurrentProject - ? handleReorderStarred - : undefined} - onResizeWidget={projectsState.canWriteCurrentProject ? handleResizeStarred : undefined} - onToggleStar={projectsState.canWriteCurrentProject ? handleUnstar : undefined} + onReorderWidgets={handleReorderStarred} + onResizeWidget={handleResizeStarred} + onToggleStar={handleUnstar} /> {/if} diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte new file mode 100644 index 000000000..7affc48fa --- /dev/null +++ b/frontend/src/routes/dashboards/+page.svelte @@ -0,0 +1,1540 @@ + + + + +
+
+

Dashboards

+ {#if dashboards.length > 0} +
+ + +
+ {/if} +
+ + {#if loading} +
+ +
+ {:else if listError} + loadDashboards()} + /> + {:else if dashboards.length === 0} +
+

No dashboards yet.

+ +
+ {:else} + +
+ {#if hasOverflow} + { + if (v) handleTabChange(v); + }} + > + {activeTabName} + + {#each dashboards as dashboard (dashboard.id)} + {dashboard.name} + {/each} + + + {/if} +
+ + {#each dashboards as dashboard, i (dashboard.id)} + 1} + ondragstart={(e) => handleTabDragStart(e, i)} + ondragover={(e) => handleTabDragOver(e, i)} + ondrop={(e) => handleTabDrop(e, i)} + ondragend={handleTabDragEnd} + class="transition-opacity {tabDragIndex === i ? 'opacity-40' : ''} {tabDropIndex === + i && + tabDragIndex !== null && + tabDragIndex !== i + ? 'ring-2 ring-primary' + : ''}" + > + {dashboard.name} + {#if String(dashboard.id) === activeTabId} + + + {#snippet child({ props })} + e.stopPropagation()} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); + }} + > + + + {/snippet} + + + + + Rename Dashboard + + + + Edit as JSON + + + + Export JSON + + + + Apply to Projects + + + (showRemoveDialog = true)}> + + Remove from this Project + + (showDeleteDialog = true)} + > + + Delete Dashboard + + + + {/if} + + {/each} + +
+ + {#if hasOverflow && activeTabId} + + + + + + + + Rename Dashboard + + + + Edit as JSON + + + + Export JSON + + + + Apply to Projects + + + (showRemoveDialog = true)}> + + Remove from this Project + + (showDeleteDialog = true)}> + + Delete Dashboard + + + + {/if} + {#if lastUpdated} +
+ + Updated: {lastUpdatedFormatted} + + +
+ {/if} +
+ + {#each dashboards as dashboard (dashboard.id)} + + {#if loadingDashboard && activeTabId === String(dashboard.id)} +
+ +
+ {:else if loadError && activeTabId === String(dashboard.id)} +
+

{loadError}

+ +
+ {:else if activeDashboard && activeDashboard.id === dashboard.id && activeTabId === String(dashboard.id)} + openEditWidget(w as Widget)} + onDeleteWidget={(w) => openDeleteWidgetDialog(w as Widget)} + onReorderWidgets={handleReorderWidgets} + onDuplicateWidget={(w) => handleDuplicateWidget(w as Widget)} + onResizeWidget={(w, layout) => handleResizeWidget(w as Widget, layout)} + onAddWidget={openAddWidget} + onToggleStar={handleToggleStar} + onRangeSelect={handleChartRangeSelect} + /> + {/if} +
+ {/each} +
+ {/if} +
+ + { + showWidgetConfig = false; + editingWidget = null; + widgetPrefill = null; + widgetConfigError = ''; + }} +/> + + (showCreateDialog = true)} + onImportJson={() => (showImportDialog = true)} + onImportGrafana={() => (showGrafanaDialog = true)} + onApplied={handlePaletteApplied} + onAddMetric={openAddWidgetWithMetric} +/> + + { + if (!o) createError = ''; + }} +> + + + New Dashboard + + +
+
+ + +
+
+ + + + +
+
+ + { + if (!o) renameError = ''; + }} +> + + + Rename Dashboard + + +
+
+ + +
+
+ + + + +
+
+ + { + if (!o) jsonError = ''; + }} +> + + + Edit as JSON + + The full dashboard definition. Widget order in the array is the display order. + + + +
+ +
+ + + + +
+
+ + { + if (!o) applyError = ''; + }} +> + + + Apply to Projects + + Choose which projects show "{activeDashboard?.name}" in their dashboard tabs. + + + + + Shared across projects + + This is a single shared dashboard. Checking a project shows it in that project's tabs. + Nothing is copied or overwritten, but edits made from any project apply to all of them. + Unchecking a project removes the dashboard from its tabs, including any of its widgets + starred on that project's homepage. + + + +
+ {#each orgProjects as project (project.id)} + + {/each} +
+ + + + +
+
+ + { + if (!o) importError = ''; + }} +> + + + Import Dashboards + + Paste an exported dashboard JSON document or bundle. Imported dashboards are applied to the + current project. + + + +
+ + +
+ + + + +
+
+ + { + if (!o) { + grafanaError = ''; + grafanaWarnings = []; + } + }} +> + + + Import from Grafana + + Paste a Grafana dashboard JSON export. Panels and Prometheus queries are converted on a + best-effort basis. + + + + {#if grafanaWarnings.length > 0} +
+ {#each grafanaWarnings as warning} +
+ + {warning} +
+ {/each} +
+ {:else} +
+ + +
+ {/if} + + {#if grafanaWarnings.length > 0} + + {:else} + + + {/if} + +
+
+ + { + if (!o) removeError = ''; + }} +> + + + Remove Dashboard + + Remove "{activeDashboard?.name}" from this project's tabs? The dashboard itself is kept and + stays available in other projects and the library. + + + + + + + + + + + { + if (!o) deleteError = ''; + }} +> + + + Delete Dashboard + + Are you sure you want to delete "{activeDashboard?.name}"? It will be removed from every + project it is applied to{activeDashboard && activeDashboard.appliedProjectIds.length > 1 + ? ` (${activeDashboard.appliedProjectIds.length} projects)` + : ''}, along with all of its widgets. This action cannot be undone. + + + + + + + + + + + { + if (!o) deleteWidgetError = ''; + }} +> + + + Delete Widget + + Are you sure you want to delete "{deletingWidget?.title}"? This action cannot be undone. + + + + + + + + + diff --git a/frontend/src/routes/dashboards/+page.ts b/frontend/src/routes/dashboards/+page.ts new file mode 100644 index 000000000..9f2ce0c44 --- /dev/null +++ b/frontend/src/routes/dashboards/+page.ts @@ -0,0 +1,9 @@ +import { redirect } from '@sveltejs/kit'; +import { authState } from '$lib/state/auth.svelte'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + if (!authState.isAuthenticated) { + throw redirect(302, '/login'); + } +}; diff --git a/frontend/src/routes/metrics/+page.svelte b/frontend/src/routes/metrics/+page.svelte index d507305b8..0fbba9978 100644 --- a/frontend/src/routes/metrics/+page.svelte +++ b/frontend/src/routes/metrics/+page.svelte @@ -1,825 +1,2 @@ - -
-
-

Metrics

-
- -
-
- - {#if loading} -
- -
- {:else if widgetGroups.length === 0} -
-

No widget groups yet.

-
- {#if canPopulateDefaults} - - {/if} - -
-
- {:else} - -
- {#if hasOverflow} - { - if (v) handleTabChange(v); - }} - > - {activeTabName} - - {#each widgetGroups as group (group.id)} - {group.name} - {/each} - - - {/if} -
- - {#each widgetGroups as group (group.id)} - - {group.name} - {#if String(group.id) === activeTabId} - - - {#snippet child({ props })} - e.stopPropagation()} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') e.stopPropagation(); - }} - > - - - {/snippet} - - - - - Rename Group - - - (showDeleteDialog = true)} - > - - Delete Group - - - - {/if} - - {/each} - -
- - {#if hasOverflow && activeTabId} - - - - - - - - Rename Group - - - (showDeleteDialog = true)}> - - Delete Group - - - - {/if} - {#if lastUpdated} -
- - Updated: {lastUpdatedFormatted} - - -
- {/if} -
- - {#each widgetGroups as group (group.id)} - - {#if loadingGroup && activeTabId === String(group.id)} -
- -
- {:else if activeGroup && activeTabId === String(group.id)} - - {/if} -
- {/each} -
- {/if} -
- - { - showWidgetConfig = false; - editingWidget = null; - widgetConfigError = ''; - }} -/> - - { - if (!o) createError = ''; - }} -> - - - New Widget Group - - -
-
- - -
-
- - - - -
-
- - { - if (!o) renameError = ''; - }} -> - - - Rename Widget Group - - -
-
- - -
-
- - - - -
-
- - { - if (!o) deleteError = ''; - }} -> - - - Delete Widget Group - - Are you sure you want to delete "{activeGroup?.name}"? This will remove all widgets in this - group. This action cannot be undone. - - - - - - - - - - - { - if (!o) deleteWidgetError = ''; - }} -> - - - Delete Widget - - Are you sure you want to delete "{deletingWidget?.title}"? This action cannot be undone. - - - - - - - - - diff --git a/frontend/src/routes/metrics/+page.ts b/frontend/src/routes/metrics/+page.ts index 9f2ce0c44..3af7d34f2 100644 --- a/frontend/src/routes/metrics/+page.ts +++ b/frontend/src/routes/metrics/+page.ts @@ -1,9 +1,6 @@ import { redirect } from '@sveltejs/kit'; -import { authState } from '$lib/state/auth.svelte'; import type { PageLoad } from './$types'; -export const load: PageLoad = () => { - if (!authState.isAuthenticated) { - throw redirect(302, '/login'); - } +export const load: PageLoad = ({ url }) => { + throw redirect(301, '/dashboards' + url.search); }; diff --git a/testing/test_metric_widgets.sql b/testing/test_metric_widgets.sql deleted file mode 100644 index d1a412222..000000000 --- a/testing/test_metric_widgets.sql +++ /dev/null @@ -1,66 +0,0 @@ --- PostgreSQL widget groups + widgets for the test metric data --- Run: psql -d traceway -f backend/test_metric_widgets.sql --- Requires: test_metric_data.sql loaded into ClickHouse first --- --- Project ID: 5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e - --- Step 1: Register test metrics -INSERT INTO metric_registry (project_id, name, metric_type, unit, description) -VALUES - ('5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e', 'test.sine', 'gauge', '', 'Sine wave, 3 cycles, range 10-90'), - ('5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e', 'test.ramp', 'gauge', '', 'Linear ramp 0-100'), - ('5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e', 'test.stairs', 'gauge', '', '6-step staircase'), - ('5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e', 'test.spikes', 'gauge', '', 'Baseline 10, spikes to 90'), - ('5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e', 'test.multi_server', 'gauge', '', '3 sine waves with server tag'), - ('5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e', 'test.constant', 'gauge', '', 'Flat line at 42') -ON CONFLICT (project_id, name) DO NOTHING; - --- Step 2: Create widget groups and widgets -DO $$ -DECLARE - pid UUID := '5f6ca55f-a6b6-417e-b941-ee78d5fd5b6e'; - g1_id INT; - g2_id INT; - g3_id INT; -BEGIN - -- Group 1: Type Tests - INSERT INTO widget_groups (project_id, name, description, is_default, created_by) - VALUES (pid, 'Type Tests', 'One widget per chart type', false, NULL) - RETURNING id INTO g1_id; - - INSERT INTO widget_group_widgets (widget_group_id, title, widget_type, config, position) VALUES - (g1_id, 'Sine Wave', 'line_chart', - '{"sources": [{"type": "metric", "name": "test.sine", "aggregation": "avg"}]}', 0), - (g1_id, 'Ramp', 'area_chart', - '{"sources": [{"type": "metric", "name": "test.ramp", "aggregation": "avg"}]}', 1), - (g1_id, 'All Metrics', 'bar_chart', - '{"sources": [{"type": "metric", "name": "test.sine", "aggregation": "avg"}, {"type": "metric", "name": "test.ramp", "aggregation": "avg"}, {"type": "metric", "name": "test.stairs", "aggregation": "avg"}, {"type": "metric", "name": "test.constant", "aggregation": "avg"}]}', 2), - (g1_id, 'Constant', 'single_value', - '{"sources": [{"type": "metric", "name": "test.constant", "aggregation": "avg"}]}', 3), - (g1_id, 'Spikes', 'table', - '{"sources": [{"type": "metric", "name": "test.spikes", "aggregation": "avg"}]}', 4); - - -- Group 2: Aggregations - INSERT INTO widget_groups (project_id, name, description, is_default, created_by) - VALUES (pid, 'Aggregations', 'Same metric with different aggregation methods', false, NULL) - RETURNING id INTO g2_id; - - INSERT INTO widget_group_widgets (widget_group_id, title, widget_type, config, position) VALUES - (g2_id, 'Spikes (avg)', 'line_chart', - '{"sources": [{"type": "metric", "name": "test.spikes", "aggregation": "avg"}]}', 0), - (g2_id, 'Spikes (max)', 'line_chart', - '{"sources": [{"type": "metric", "name": "test.spikes", "aggregation": "max"}]}', 1), - (g2_id, 'Spikes (min)', 'line_chart', - '{"sources": [{"type": "metric", "name": "test.spikes", "aggregation": "min"}]}', 2), - (g2_id, 'Spikes (count)', 'line_chart', - '{"sources": [{"type": "metric", "name": "test.spikes", "aggregation": "count"}]}', 3); - - -- Group 3: Multi-Series - INSERT INTO widget_groups (project_id, name, description, is_default, created_by) - VALUES (pid, 'Multi-Series', 'GroupBy tag to split into multiple series', false, NULL) - RETURNING id INTO g3_id; - - INSERT INTO widget_group_widgets (widget_group_id, title, widget_type, config, position) VALUES - (g3_id, 'By Server', 'line_chart', - '{"sources": [{"type": "metric", "name": "test.multi_server", "aggregation": "avg", "groupBy": "server"}]}', 0); -END $$; diff --git a/website/app/product/dashboards/page.tsx b/website/app/product/dashboards/page.tsx new file mode 100644 index 000000000..0bf4ecdc4 --- /dev/null +++ b/website/app/product/dashboards/page.tsx @@ -0,0 +1,246 @@ +import Link from "next/link"; +import { ArrowRight, LayoutDashboard } from "lucide-react"; + +import { Chip } from "@/components/chip"; +import { SectionHead } from "@/components/section-head"; +import { FeatureRow } from "@/components/feature-row"; +import { FaqList } from "@/components/faq-list"; +import { FinalCTA } from "@/components/final-cta"; +import { AuroraBackground } from "@/components/aurora-background"; + +export default function DashboardsPage() { + return ( +
+
+ +
+ + + Dashboards as Code + +

+ Dashboards you can diff, review, and ship. +

+

+ Every Traceway dashboard is a plain JSON document underneath. Build + it in the UI, export it to your repo, sync it from CI, and apply it + across every project in your organization. No click-ops, no drift. +

+
+ + Read the Docs + + + Try Traceway Cloud + +
+
+
+ + {/* WHITE BAND: feature sections render on white */} +
+
+ + Every dashboard is one JSON document + + } + description="Widgets, sources, aggregations, and layout live in a single versioned document with a schemaVersion. Edit it in place from the dashboard menu, validated on save, or round-trip it through export and import. Widget ids are stable, so homepage stars survive edits." + bullets={[ + "Edit as JSON directly in the UI, validated on save", + "Export a dashboard, or your whole organization, as JSON", + "Widget order in the array is the display order", + "Stable widget ids keep starred widgets intact", + ]} + image={{ + src: "/images/dashboards-edit-json.png", + alt: "Editing a Traceway dashboard as JSON", + }} + /> +
+ +
+ + Keep dashboards in your repository + + } + description={ + <> + The import API has an upsert mode that matches dashboards by + name, so a CI job is one curl:{" "} + + POST /api/dashboards/import{" "} + {`{"mode": "upsert", "data": ...}`} + {" "} + with a personal access token. Review dashboard changes in pull + requests like any other code, and let the pipeline keep every + environment in sync. + + } + bullets={[ + "Upsert mode matches dashboards by name, so re-runs are safe", + "Import a single dashboard or a whole-organization bundle", + "Authenticate with a personal access token from CI", + "The UI and the API edit the same document, so nothing drifts", + ]} + image={{ + src: "/images/dashboards-page.png", + alt: "The Traceway dashboards page", + }} + /> +
+ +
+ + Build once, apply everywhere + + } + description="Dashboards belong to your organization, not to a single project. Apply the same dashboard to any set of projects; a fix made in one is instantly live in all of them. Each project still curates its own homepage by starring the widgets it cares about." + bullets={[ + "One shared definition, per-project tabs", + "Nothing is copied, and edits propagate everywhere", + "Per-project starred widgets for the homepage", + "Unapplying only removes it from that project's tabs", + ]} + image={{ + src: "/images/dashboards-apply.png", + alt: "Applying a dashboard to projects", + }} + /> +
+ +
+ + Bring the Grafana dashboards you already have + + } + description="Paste any Grafana dashboard JSON export. Timeseries and graph panels become line charts, stat becomes a single value, gauges stay gauges, and Prometheus queries are unwrapped into metric sources with their label matchers and group-bys. Anything that cannot be converted faithfully is reported, not silently dropped." + bullets={[ + "Panels and Prometheus queries converted automatically", + "rate() approximations and skipped panels are called out", + "Imported dashboards are normal dashboards you can edit freely", + ]} + image={{ + src: "/images/dashboards-grafana-warnings.png", + alt: "Grafana import with conversion warnings", + }} + /> +
+ +
+ + Start from a template, find anything fast + + } + description="One command palette covers your organization's dashboards, the built-in template marketplace, and every metric name seen across your projects. Install the OTelemetry Server Agent template for host metrics, Go Application for runtime metrics, or ClickHouse and DuckDB health dashboards for a monitored Traceway instance." + bullets={[ + "⌘K searches dashboards, templates, and metrics together", + "Templates install as normal dashboards you customize freely", + "Selecting a metric opens the widget editor prefilled", + ]} + image={{ + src: "/images/dashboards-palette-templates.png", + alt: "Dashboard templates in the command palette", + }} + /> +
+
+ + + Observability dashboards without the click-ops + + } + description="Plain JSON, org-wide sharing, CI sync, Grafana import. Included on every plan." + primary={{ + label: "Read the Dashboards docs", + href: "https://docs.tracewayapp.com/learn/dashboards", + }} + secondary={{ + label: "Start Free", + href: "https://cloud.tracewayapp.com/register", + }} + /> + +
+
+ +
+ +

+ A dashboard is one document with a{" "} + schemaVersion, a name, and a{" "} + widgets array. Each widget carries its + type (line chart, area, stacked area, bar, single + value, gauge, table) and one or more sources: a metric + name, an aggregation, optional tag filters, and an + optional group-by that splits the chart into one + series per tag value. Array order is display order. +

+ + ), + }, + { + q: "How do I sync dashboards from CI?", + a: ( + <> +

+ Export the dashboard once, commit the JSON to your + repo, and have CI post it to{" "} + /api/dashboards/import with{" "} + {`"mode": "upsert"`} and a personal access + token. Upsert matches dashboards by name, so the job + idempotent. Re-running it converges every + environment to the file in your repository. +

+ + ), + }, + { + q: "What happens when someone edits a dashboard in the UI?", + a: "The UI edits the exact same JSON document the API serves. There is no separate representation. If you treat your repo as the source of truth, the next CI upsert brings the dashboard back to the committed state; if you prefer UI-first, just export after editing and commit the result.", + }, + { + q: "How faithful is the Grafana import?", + a: "Panels and Prometheus queries are converted on a best-effort basis, and every compromise is reported in the import dialog: rate() approximations, dropped regex matchers, unsupported panel types. You end up with a normal Traceway dashboard you can keep editing, never a black box.", + }, + ]} + /> +
+
+
+
+ ); +} diff --git a/website/app/product/metrics/page.tsx b/website/app/product/metrics/page.tsx index ae8e57e13..887954e8f 100644 --- a/website/app/product/metrics/page.tsx +++ b/website/app/product/metrics/page.tsx @@ -81,23 +81,23 @@ export default function MetricsPage() { /> - {/* Widget groups */} + {/* Dashboards */}
Dashboards that match your team's mental model } - description="Pick metrics, pick charts, group them into widget pages. No query language required; filters, tag breakdowns, and rollups are all declarative." + description="Build a dashboard once and apply it across every project in your organization. Each dashboard is a plain JSON document, so you can edit it as code, keep it in your repo, or import the Grafana dashboards you already have." bullets={[ - "Drag-to-add charts", - "Group widgets by feature, service, or team", - "Per-metric filters and rollups", - "Set default dashboards per organization", + "One dashboard, applied across projects", + "Template marketplace: OTel host agent, Go runtime, ClickHouse, DuckDB", + "Import existing Grafana dashboards", + "Search and add anything with ⌘K", ]} - image={{ src: "/images/metrics-widget-groups.png", alt: "Widget groups dashboard" }} + image={{ src: "/images/metrics-dashboards.png", alt: "Traceway dashboards" }} />
@@ -174,7 +174,7 @@ export default function MetricsPage() { }, { q: "Can I query metrics by tag or dimension?", - a: "Yes. Every tag becomes a facet you can filter on; widget groups let you build per-dimension chart panels. For example, a `plan` tag on a signups metric lets you chart signups broken down by plan, region, or tenant.", + a: "Yes. Every tag becomes a facet you can filter on; dashboards let you build per-dimension chart panels. For example, a `plan` tag on a signups metric lets you chart signups broken down by plan, region, or tenant.", }, ]} /> diff --git a/website/components/site-footer.tsx b/website/components/site-footer.tsx index 67018c047..4391720ac 100644 --- a/website/components/site-footer.tsx +++ b/website/components/site-footer.tsx @@ -26,6 +26,7 @@ const COLUMNS: Column[] = [ { label: "Agent Skills", href: "/product/agent-skills" }, { label: "MCP Server", href: "/product/mcp" }, { label: "AI Tracing", href: "/product/ai-tracing" }, + { label: "Dashboards as Code", href: "/product/dashboards" }, { label: "Performance", href: "/product/performance" }, { label: "Flutter Session Replay", href: "/product/flutter-session-replay" }, { label: "Symbolicator", href: "/product/symbolication" }, diff --git a/website/components/site-header.tsx b/website/components/site-header.tsx index 149823191..277115aad 100644 --- a/website/components/site-header.tsx +++ b/website/components/site-header.tsx @@ -17,6 +17,7 @@ import { Bot, Braces, Plug, + LayoutDashboard, } from "lucide-react"; import { MobileNav } from "@/components/mobile-nav"; import { DiscordIcon } from "@/components/discord-icon"; @@ -83,6 +84,12 @@ const SPECIALIZED: NavItem[] = [ href: "/product/ai-tracing", icon: Workflow, }, + { + title: "Dashboards as Code", + description: "JSON dashboards you version, sync, and share.", + href: "/product/dashboards", + icon: LayoutDashboard, + }, { title: "Performance", description: "P50/P95/P99 percentiles, waterfall traces.", diff --git a/website/public/images/dashboards-apply.png b/website/public/images/dashboards-apply.png new file mode 100644 index 000000000..d0c5a79e9 Binary files /dev/null and b/website/public/images/dashboards-apply.png differ diff --git a/website/public/images/dashboards-edit-json.png b/website/public/images/dashboards-edit-json.png new file mode 100644 index 000000000..01e60a3da Binary files /dev/null and b/website/public/images/dashboards-edit-json.png differ diff --git a/website/public/images/dashboards-grafana-warnings.png b/website/public/images/dashboards-grafana-warnings.png new file mode 100644 index 000000000..cf08f3407 Binary files /dev/null and b/website/public/images/dashboards-grafana-warnings.png differ diff --git a/website/public/images/dashboards-page.png b/website/public/images/dashboards-page.png new file mode 100644 index 000000000..e7b0388a5 Binary files /dev/null and b/website/public/images/dashboards-page.png differ diff --git a/website/public/images/dashboards-palette-templates.png b/website/public/images/dashboards-palette-templates.png new file mode 100644 index 000000000..99dfa6c03 Binary files /dev/null and b/website/public/images/dashboards-palette-templates.png differ diff --git a/website/public/images/metrics-dashboards.png b/website/public/images/metrics-dashboards.png new file mode 100644 index 000000000..978d65968 Binary files /dev/null and b/website/public/images/metrics-dashboards.png differ diff --git a/website/public/images/metrics-widget-groups.png b/website/public/images/metrics-widget-groups.png deleted file mode 100644 index dac97b3a6..000000000 Binary files a/website/public/images/metrics-widget-groups.png and /dev/null differ