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]>
108 lines
3.6 KiB
Go
108 lines
3.6 KiB
Go
package server
|
|
|
|
import (
|
|
"strconv"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
|
|
gen "github.com/hanzoai/observability/gen"
|
|
)
|
|
|
|
// encode.go is the single place a Base record is projected onto its generated
|
|
// wire struct. One encoder per model; handlers call these so the record→view
|
|
// mapping lives in exactly one location (no per-handler field plucking).
|
|
//
|
|
// JSON columns (definition, filters, dimensions, …) are emitted verbatim as the
|
|
// text they were stored as — the UI parses them, the transport never does. The
|
|
// created/updated columns are emitted as their stored autodate strings.
|
|
|
|
func encodeDashboard(r *core.Record) []byte {
|
|
return gen.NewDashboard(gen.DashboardInput{
|
|
Id: r.Id,
|
|
ProjectId: r.GetString(fProjectId),
|
|
Name: r.GetString(fName),
|
|
Description: r.GetString(fDescription),
|
|
Owner: r.GetString(fOwner),
|
|
Definition: r.GetString(fDefinition),
|
|
Filters: r.GetString(fFilters),
|
|
CreatedBy: r.GetString(fCreatedBy),
|
|
CreatedAt: r.GetString("created"),
|
|
UpdatedAt: r.GetString("updated"),
|
|
})
|
|
}
|
|
|
|
func encodeWidget(r *core.Record) []byte {
|
|
return gen.NewWidget(gen.WidgetInput{
|
|
Id: r.Id,
|
|
ProjectId: r.GetString(fProjectId),
|
|
Name: r.GetString(fName),
|
|
Description: r.GetString(fDescription),
|
|
View: r.GetString(fView),
|
|
Owner: r.GetString(fOwner),
|
|
Dimensions: r.GetString(fDimensions),
|
|
Metrics: r.GetString(fMetrics),
|
|
Filters: r.GetString(fFilters),
|
|
ChartType: r.GetString(fChartType),
|
|
ChartConfig: r.GetString(fChartConfig),
|
|
CreatedAt: r.GetString("created"),
|
|
UpdatedAt: r.GetString("updated"),
|
|
})
|
|
}
|
|
|
|
func encodePreset(r *core.Record) []byte {
|
|
return gen.NewPreset(gen.PresetInput{
|
|
Id: r.Id,
|
|
ProjectId: r.GetString(fProjectId),
|
|
Name: r.GetString(fName),
|
|
TableName: r.GetString(fTableName),
|
|
Filters: r.GetString(fFilters),
|
|
ColumnOrder: r.GetString(fColumnOrder),
|
|
ColumnVisibility: r.GetString(fColumnVisibility),
|
|
SearchQuery: r.GetString(fSearchQuery),
|
|
OrderByColumn: r.GetString(fOrderByColumn),
|
|
OrderByOrder: r.GetString(fOrderByOrder),
|
|
CreatedBy: r.GetString(fCreatedBy),
|
|
CreatedAt: r.GetString("created"),
|
|
UpdatedAt: r.GetString("updated"),
|
|
})
|
|
}
|
|
|
|
func encodeMonitor(r *core.Record) []byte {
|
|
return gen.NewMonitor(gen.MonitorInput{
|
|
Id: r.Id,
|
|
ProjectId: r.GetString(fProjectId),
|
|
Name: r.GetString(fName),
|
|
View: r.GetString(fView),
|
|
Filters: r.GetString(fFilters),
|
|
Metric: r.GetString(fMetric),
|
|
Window: r.GetString(fWindow),
|
|
ThresholdOperator: r.GetString(fThresholdOperator),
|
|
AlertThreshold: r.GetFloat(fAlertThreshold),
|
|
WarningThreshold: nullableTextToFloat(r.GetString(fWarningThreshold)),
|
|
NoData: r.GetString(fNoData),
|
|
Renotify: r.GetString(fRenotify),
|
|
Tags: r.GetString(fTags),
|
|
Status: r.GetString(fStatus),
|
|
Severity: r.GetString(fSeverity),
|
|
SeverityChangedAt: r.GetString(fSeverityChangedAt),
|
|
AlertedAt: r.GetString(fAlertedAt),
|
|
NextRunAt: r.GetString(fNextRunAt),
|
|
CreatedBy: r.GetString(fCreatedBy),
|
|
CreatedAt: r.GetString("created"),
|
|
UpdatedAt: r.GetString("updated"),
|
|
})
|
|
}
|
|
|
|
// nullableTextToFloat decodes the warningThreshold storage column: "" → the NaN
|
|
// null sentinel, else the parsed decimal. Inverse of floatToNullableText.
|
|
func nullableTextToFloat(s string) float64 {
|
|
if s == "" {
|
|
return nullThreshold()
|
|
}
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return nullThreshold()
|
|
}
|
|
return f
|
|
}
|