mirror of
https://github.com/hanzoai/observability.git
synced 2026-10-30 20:22:53 +00:00
hanzoai/dashboards shipped as its own binary (msgType 206) serving five console
tRPC routers: dashboards, widgets, table batch-actions, view presets, monitors.
It is now this service's 200+ ordinal range and its dash_*.go files.
They were never two concerns. A dashboard is a saved QUERY over exactly the
traces, observations and scores this service owns, and both halves were blocked
on the SAME ClickHouse shim — kept apart, that shim had to be written twice
against two copies of one OLTP model, and chart/scoreHistogram could not reach
the scores they aggregate without a network hop. The duplication was already
visible in the capability bits: both services had independently minted a
PermAnalyticsRead. Merged, there is one.
Ordinals are the standalone binary's 0–29 offset by 200, not renumbered into the
gaps: the offset is mechanical and reversible, renumbering would silently
repoint a client built against the old ids.
Three conventions were doubled and are now single:
- TENANCY. authorize() answered both "may this caller invoke this method" and
"whose rows may it touch", returning a defaultOrg the handlers ignored while
each re-read projectId from its own params. Split: authorize() is the cap gate,
scope() is the ONE tenant resolver, and its project is passed to every handler.
A pipelined call now INHERITS its target's project instead of re-reading the
payload, so pipelining cannot be used to hop tenants. Server.defaultOrg was
dead once scope() existed and is deleted rather than left as unread state on a
security-relevant struct.
- NOT-IMPLEMENTED. Observability answered stubs 200 + Empty{Stubbed:true};
dashboards answered 501. Unified on 501 through one Server.stub helper. A
stubbed 200 carrying an empty result is indistinguishable at the call site from
a real query that matched nothing — the UI renders "no data" and the missing
backend never surfaces. The status is the one field a caller cannot skip
reading; a flag in the body is one it can, and eventually does.
- COLUMN NAMES. fDashDescription, fWidgetDescription and fDescription were three
constants for the column "description". One constant per column name now.
scope() reads the payload's field at text @0 without knowing its type, which is
silently wrong for any request struct that puts another text field first — and
wrong by reading ANOTHER TENANT'S rows, not by failing. That assumption is now
pinned by TestEveryRequestStructCarriesProjectIdAtZero over the schema itself
(43 declarations, 9 result structs exempted). Verified it fails when the
invariant is broken, not just that it passes.
Verified: 22 tests green (14 obs + 8 dashboards, bodies ported byte-identical so
they diff against the originals) plus the invariant; go vet clean; make zap-gen
byte-identical on regen; CGO_ENABLED=0 build ok.
go.sum carried the same moved-tag corruption fixed in hanzoai/dashboards
(age/pq/zap hashes restored to the checksum DB; threshold v1.9.4→v1.9.5 and
orm v0.6.14→v0.6.16, both unbuildable from a clean cache).
Co-authored-by: Hanzo Dev <[email protected]>
179 lines
6.3 KiB
Go
179 lines
6.3 KiB
Go
package server
|
|
|
|
import (
|
|
"github.com/hanzoai/base/core"
|
|
)
|
|
|
|
// dash_collections.go — the five Base collections behind the dashboards surface,
|
|
// one per model the migrated tRPC routers persisted. Every row is scoped by
|
|
// projectId; the service reads and writes ONLY within the project scope() has
|
|
// resolved, which is the multi-tenant boundary (mirrors the console's
|
|
// throwIfNoProjectAccess(projectId)).
|
|
//
|
|
// These live alongside the obs_* OLTP tables rather than in their own store: a
|
|
// dashboard is a saved query over those tables, so keeping both in one Base app
|
|
// is what lets the analytics shim join them without a network hop.
|
|
const (
|
|
CollectionDashboard = "dashboards" // dashboardRouter
|
|
CollectionWidget = "dashboard_widgets" // dashboardWidgetRouter
|
|
CollectionTable = "table_batch_jobs" // tableRouter (batch-action job tracking)
|
|
CollectionPreset = "table_view_presets" // TableViewPresetsRouter
|
|
CollectionMonitor = "monitors" // monitorsRouter
|
|
)
|
|
|
|
// Column names for the dashboards surface. ONE constant per COLUMN NAME, shared
|
|
// by every collection that uses it — `description` is the same column name
|
|
// whether it sits on a dashboard or a widget, so it is one constant, not three.
|
|
// (fProjectId, fName, fDescription and fTags are declared in collections.go with
|
|
// the obs_* tables and reused here.)
|
|
const (
|
|
fCreatedBy = "createdBy"
|
|
fOwner = "owner"
|
|
fView = "view"
|
|
fFilters = "filters"
|
|
|
|
// dashboard
|
|
fDefinition = "definition"
|
|
|
|
// widget
|
|
fDimensions = "dimensions"
|
|
fMetrics = "metrics"
|
|
fChartType = "chartType"
|
|
fChartConfig = "chartConfig"
|
|
|
|
// table batch job
|
|
fTableName = "tableName"
|
|
fActionId = "actionId"
|
|
fState = "state"
|
|
|
|
// table view preset
|
|
fColumnOrder = "columnOrder"
|
|
fColumnVisibility = "columnVisibility"
|
|
fSearchQuery = "searchQuery"
|
|
fOrderByColumn = "orderByColumn"
|
|
fOrderByOrder = "orderByOrder"
|
|
|
|
// monitor
|
|
fMetric = "metric"
|
|
fWindow = "window"
|
|
fThresholdOperator = "thresholdOperator"
|
|
fAlertThreshold = "alertThreshold"
|
|
fWarningThreshold = "warningThreshold"
|
|
fNoData = "noData"
|
|
fRenotify = "renotify"
|
|
fStatus = "status"
|
|
fSeverity = "severity"
|
|
fSeverityChangedAt = "severityChangedAt"
|
|
fAlertedAt = "alertedAt"
|
|
fNextRunAt = "nextRunAt"
|
|
)
|
|
|
|
// dashText adds TextFields sized for short scalars. The obs_* tables use text()
|
|
// (1 MiB) because they hold IO blobs; these columns are names, enums and
|
|
// timestamps, so they take a tighter cap.
|
|
func dashText(col *core.Collection, names ...string) {
|
|
for _, n := range names {
|
|
col.Fields.Add(&core.TextField{Name: n, Max: 4096})
|
|
}
|
|
}
|
|
|
|
// jsonField adds JSONFields sized for structured blobs (definitions, filters).
|
|
func jsonField(col *core.Collection, names ...string) {
|
|
for _, n := range names {
|
|
col.Fields.Add(&core.JSONField{Name: n, MaxSize: 262144})
|
|
}
|
|
}
|
|
|
|
// timestamps adds the created/updated autodate pair every collection carries.
|
|
func timestamps(col *core.Collection) {
|
|
col.Fields.Add(&core.AutodateField{Name: "created", OnCreate: true})
|
|
col.Fields.Add(&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true})
|
|
}
|
|
|
|
// ensureDashboardCollections creates any missing dashboards-surface collection.
|
|
// Called from EnsureCollections alongside the obs_* tables; idempotent.
|
|
func ensureDashboardCollections(app core.App) error {
|
|
for _, build := range []func(core.App) error{
|
|
ensureDashboard, ensureWidget, ensureTable, ensurePreset, ensureMonitor,
|
|
} {
|
|
if err := build(app); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureDashboard(app core.App) error {
|
|
if has(app, CollectionDashboard) {
|
|
return nil
|
|
}
|
|
col := core.NewBaseCollection(CollectionDashboard)
|
|
col.Fields.Add(&core.TextField{Name: fProjectId, Required: true, Max: 255})
|
|
dashText(col, fName, fDescription, fOwner, fCreatedBy)
|
|
jsonField(col, fDefinition, fFilters)
|
|
timestamps(col)
|
|
col.AddIndex("idx_dashboards_project", false, fProjectId, "")
|
|
return app.Save(col)
|
|
}
|
|
|
|
func ensureWidget(app core.App) error {
|
|
if has(app, CollectionWidget) {
|
|
return nil
|
|
}
|
|
col := core.NewBaseCollection(CollectionWidget)
|
|
col.Fields.Add(&core.TextField{Name: fProjectId, Required: true, Max: 255})
|
|
dashText(col, fName, fDescription, fView, fOwner, fChartType, fCreatedBy)
|
|
jsonField(col, fDimensions, fMetrics, fFilters, fChartConfig)
|
|
timestamps(col)
|
|
col.AddIndex("idx_widgets_project", false, fProjectId, "")
|
|
return app.Save(col)
|
|
}
|
|
|
|
func ensureTable(app core.App) error {
|
|
if has(app, CollectionTable) {
|
|
return nil
|
|
}
|
|
col := core.NewBaseCollection(CollectionTable)
|
|
col.Fields.Add(&core.TextField{Name: fProjectId, Required: true, Max: 255})
|
|
dashText(col, fTableName, fActionId, fState, fCreatedBy)
|
|
timestamps(col)
|
|
col.AddIndex("idx_table_jobs_project", false, fProjectId, "")
|
|
return app.Save(col)
|
|
}
|
|
|
|
func ensurePreset(app core.App) error {
|
|
if has(app, CollectionPreset) {
|
|
return nil
|
|
}
|
|
col := core.NewBaseCollection(CollectionPreset)
|
|
col.Fields.Add(&core.TextField{Name: fProjectId, Required: true, Max: 255})
|
|
dashText(col, fName, fTableName, fSearchQuery, fOrderByColumn, fOrderByOrder, fCreatedBy)
|
|
jsonField(col, fFilters, fColumnOrder, fColumnVisibility)
|
|
timestamps(col)
|
|
// Unique (projectId, tableName, name): mirrors the console P2002 conflict the
|
|
// TableViewPresetsRouter maps to HanzoConflictError.
|
|
col.AddIndex("idx_presets_unique", true, fProjectId+", "+fTableName+", "+fName, "")
|
|
return app.Save(col)
|
|
}
|
|
|
|
func ensureMonitor(app core.App) error {
|
|
if has(app, CollectionMonitor) {
|
|
return nil
|
|
}
|
|
col := core.NewBaseCollection(CollectionMonitor)
|
|
col.Fields.Add(&core.TextField{Name: fProjectId, Required: true, Max: 255})
|
|
dashText(col, fName, fView, fThresholdOperator, fStatus,
|
|
fSeverity, fSeverityChangedAt, fAlertedAt, fNextRunAt, fCreatedBy)
|
|
col.Fields.Add(&core.NumberField{Name: fAlertThreshold})
|
|
// warningThreshold is z.number().nullable() upstream. Base's NumberField is
|
|
// NUMERIC DEFAULT 0 NOT NULL — it cannot hold SQL NULL — so the nullable
|
|
// number is stored as text: a decimal string when set, "" when null. This
|
|
// preserves the source's three-state (set / null) faithfully; the wire still
|
|
// carries an f64 with NaN as the null sentinel (see dash_encode.go).
|
|
dashText(col, fWarningThreshold)
|
|
jsonField(col, fFilters, fMetric, fWindow, fNoData, fRenotify, fTags)
|
|
timestamps(col)
|
|
col.AddIndex("idx_monitors_project", false, fProjectId, "")
|
|
return app.Save(col)
|
|
}
|