Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
a374505
docs(plan): agent dashboards milestone 2 (analysis)
vishr Oct 6, 2026
7825d14
feat(annotations): derive deploys and anomaly history for time panels
vishr Oct 6, 2026
db1212c
feat(panel): exemplar traces behind a panel selection; dashboards rea…
vishr Oct 6, 2026
13bd157
feat(panel): histogram and heatmap distributions
vishr Oct 6, 2026
c63ac93
feat(panel): scatter and state timeline panels
vishr Oct 6, 2026
78fcdc4
feat(panel): log stream, log pattern and trace list panels
vishr Oct 6, 2026
c535213
feat(panel): service map and health panels on the existing rollups
vishr Oct 6, 2026
790006e
feat(ui): heatmap, histogram, scatter, state timeline, service map an…
vishr Oct 6, 2026
9ed6678
feat(ui): logs, log pattern and trace list panels
vishr Oct 6, 2026
179c7ea
feat(dashboard): click to filter, linked crosshair, brush zoom and pa…
vishr Oct 6, 2026
f88b90b
feat(dashboard): drill-down drawer with exemplar traces, the trace wa…
vishr Oct 6, 2026
f93e61c
feat(panel): deploy split bars and memoized annotation scope
vishr Oct 6, 2026
cd44e36
feat(ui): deploy and anomaly markers on time panels
vishr Oct 6, 2026
7e336da
feat(dashboard): table column formats and sparklines
vishr Oct 6, 2026
5035fe9
feat(agent): guide the dashboard agent through the analysis panels
vishr Oct 6, 2026
93cd755
fix(dashboards): repair what the real-browser check found
vishr Oct 7, 2026
ef61ab1
fix(dashboards): keep heatmap and timeline time labels apart
vishr Oct 7, 2026
346ba12
docs(benchmarks): record milestone 2 browser verification
vishr Oct 7, 2026
2c5a686
fix(dashboards): address the milestone 2 final review
vishr Oct 7, 2026
8dafb3e
docs(benchmarks): refresh milestone 2 verification after the final re…
vishr Oct 7, 2026
a7a660f
docs(plans): drop local paths from the milestone 2 plan
vishr Oct 7, 2026
13aa694
fix(dashboards): repair what hands-on testing found
vishr Oct 7, 2026
2c37797
fix(dashboards): give each series a distinguishable colour
vishr Oct 7, 2026
669bec2
fix(dashboards): keep a refresh pressed during a lazy batch
vishr Oct 7, 2026
f1f7d18
docs(benchmarks): add hands-on testing to the milestone 2 results
vishr Oct 7, 2026
27d1470
test(query): keep nanoseconds in the anomaly merge fixture
vishr Oct 7, 2026
d9814ae
fix(dashboards): bring every panel type up to production quality
vishr Oct 7, 2026
4e571cc
fix(dashboards): keep failing series visible and harden layout editing
vishr Oct 7, 2026
7d5bdf3
feat(dashboards): preview-grade panels and a layered service map
vishr Oct 7, 2026
7804c40
feat(dashboards): preview-grade heatmaps, bars, tables and log patterns
vishr Oct 7, 2026
4e2c6ea
fix(dashboards): readable service map and quieter chart labels
vishr Oct 7, 2026
5978427
fix(dashboards): rank series by confidence and polish heatmaps and pa…
vishr Oct 7, 2026
688e426
fix(dashboards): fit the service map to real panel sizes
vishr Oct 7, 2026
bcb3a23
fix(dashboards): size heatmap cells to the panel
vishr Oct 7, 2026
d797c2b
fix(dashboards): keep service names whole and the map inside its panel
vishr Oct 7, 2026
60793ba
fix(dashboards): give charts room and fix the remaining audit findings
vishr Oct 8, 2026
cd88e33
fix(dashboards): stop the service map crashing the page on scroll
vishr Oct 8, 2026
2acdac3
fix(dashboards): final widget polish
vishr Oct 8, 2026
87a80d8
fix(dashboards): raise state timeline legend contrast
vishr Oct 8, 2026
f446dc0
fix(dashboards): reach Data and Spec from small panels
vishr Oct 8, 2026
5c81ba7
fix(dashboards): fast, non-trapping service map and no test hooks in …
vishr Oct 8, 2026
ab8dfb0
fix(dashboards): views always return and map layout ignores live metrics
vishr Oct 8, 2026
cec4335
fix(dashboards): show service-map metrics on most cards again
vishr Oct 8, 2026
ef412b4
chore: list the service map's layout library in third-party notices
vishr Oct 8, 2026
d4f2f07
fix(dashboards): register the hidden brush toolbox and merge anomaly …
vishr Oct 8, 2026
38175a1
docs(benchmarks): record the production-grade pass for milestone 2
vishr Oct 8, 2026
added1f
fix(ui): keep host and apps workspaces independent for container builds
vishr Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ WORKDIR /app
COPY ui/apps/package.json ui/apps/bun.lock ./ui/apps/
RUN cd ui/apps && bun install --frozen-lockfile
COPY ui/*.ts ./ui/
COPY ui/panels/ ./ui/panels/
COPY ui/apps/ ./ui/apps/
COPY internal/mcp/apps/ ./internal/mcp/apps/
RUN cd ui/apps && bun run build
Expand Down
27 changes: 27 additions & 0 deletions THIRD_PARTY_NOTICES
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ COMPONENT INVENTORY
- npm: @ag-ui/encoder 1.0.1 (declared license: MIT)
- npm: @ag-ui/proto 1.0.1 (declared license: MIT)
- npm: @bufbuild/protobuf 2.16.0 (declared license: (Apache-2.0 AND BSD-3-Clause)); no license file was present in the installed package
- npm: @dagrejs/dagre 3.1.1 (declared license: MIT)
- npm: @dagrejs/graphlib 4.0.5 (declared license: MIT)
- npm: @floating-ui/core 1.8.0 (declared license: MIT)
- npm: @floating-ui/dom 1.8.0 (declared license: MIT)
- npm: @floating-ui/react 0.27.20 (declared license: MIT)
Expand Down Expand Up @@ -7380,6 +7382,31 @@ not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.

--- Applies to -------------------------------------------------------------
- npm: @dagrejs/dagre 3.1.1 / LICENSE
- npm: @dagrejs/graphlib 4.0.5 / LICENSE
----------------------------------------------------------------------------

Copyright (c) 2012-2014 Chris Pettitt

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

--- Applies to -------------------------------------------------------------
- npm: @floating-ui/core 1.8.0 / LICENSE
- npm: @floating-ui/dom 1.8.0 / LICENSE
Expand Down
5 changes: 4 additions & 1 deletion cmd/fanout/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (

"github.com/labstack/fanout/internal/agent"
"github.com/labstack/fanout/internal/alert"
"github.com/labstack/fanout/internal/annotations"
"github.com/labstack/fanout/internal/api"
"github.com/labstack/fanout/internal/auth"
"github.com/labstack/fanout/internal/config"
Expand Down Expand Up @@ -266,8 +267,10 @@ func main() {
// raw *sql.DB here bypassed the Telemetry maintenance-race protection.
queries := observability.New(q, q, cfg.RetentionDays)
panels := panel.NewExecutor(q, cfg.RetentionDays)
panels.SetRollupReader(queries)
api.RegisterPanelRoutes(e, panels)
api.NewObservabilityHandler(queries).Register(e.Group("/api/observability", api.RequireCapability(api.ReadTelemetry)))
api.RegisterAnnotationRoutes(e, annotations.New(q))
api.NewObservabilityHandler(queries, cfg.RetentionDays).Register(e.Group("/api/observability", api.RequireCapability(api.ReadTelemetry)))
api.RegisterIntelligenceRoutes(e, detector)
dashboards := dashboard.New(sqlite.DB, panels)
api.RegisterDashboardRoutes(e, dashboards)
Expand Down
288 changes: 288 additions & 0 deletions docs/benchmarks/2026-10-agent-dashboards-m2.md

Large diffs are not rendered by default.

6,341 changes: 6,341 additions & 0 deletions docs/superpowers/plans/2026-10-05-agent-dashboards-m2.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/superpowers/specs/2026-10-04-agent-dashboards-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ sees from them.
| `reduce` | For `stat` and `gauge`: `last`, `mean`, `min`, `max`, `sum`, `window`. Default `window`, which computes the measure over the whole time range rather than reading the last bucket. |
| `thresholds` | Up to 4 of `{ value, status, label? }` with `status` in `ok`, `warn`, `bad`. Direction comes from `better`. |
| `better` | `lower` or `higher`. Inferred for known measures (latency and errors are `lower`). |
| `options` | Viz-specific, closed per viz: `style` (`line`, `area`, `bars`, `stacked`), `scale` (`linear`, `log`), `top` (series limit, default 8, the rest become Other), `legend` (`auto`, `hidden`), `sort`. |
| `options` | Viz-specific, closed per viz: `style` (`line`, `area`, `bars`, `stacked`), `scale` (`linear`, `log`), `top` (categorical series limit, default/max 6; structured series are chosen worst-first by the panel's measure; the rest fold into Other (N); SQL series past six are left out with a note; noncategorical rows default 8, maximum 20), `legend` (`auto`, `hidden`), `sort`. |
| `click` | `{ "set_variable": "<name>" }`: clicking a bar, row or series sets that variable to the clicked value. |
| `drill` | `traces` (exemplar traces for the clicked bucket or row) or `logs`. |
| `time` | `{ "range": "30d" }` or `{ "shift": "1d" }`, overriding the dashboard range for this panel. |
Expand Down Expand Up @@ -342,7 +342,11 @@ research describes.
pruning applies, then run with named parameters on one connection under the
read gate. Each panel has a 10-second timeout. Tables, bars and SQL panels
cap at 1000 rows; time series cap at 2000 points per series, and a series
beyond `top` folds into Other. One request holds at most 200,000 cells;
beyond `top` folds into server-computed Other (N) for structured queries.
Series are chosen worst-first by the panel's first measure over the full
window: additive volumes largest first, other measures use explicit/inferred
`better`, ties by row count then name. SQL series past six are left out with
a note. One request holds at most 200,000 cells;
past that, time series drop their oldest buckets and say so. When the
whole request runs out of time, finished panels still return and the rest
report that they did not run.
Expand Down
37 changes: 37 additions & 0 deletions internal/agent/dashboard_guidance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package agent

import (
"strings"
"testing"
)

func TestM2DashboardGuidancePreservesIntentAndRequiresPreview(t *testing.T) {
for _, term := range []string{"answer a single factual question with a view instead", "preview_panels", "fix every invalid panel", "get_dashboard first", "edit_dashboard", "heatmap", "histogram", "scatter", "state_timeline", "log_patterns", "service_map", "health", "drill", "annotations", "split", "distinguish missing data from healthy behavior", "single fact"} {
if !strings.Contains(systemPrompt, term) {
t.Errorf("prompt omits %q", term)
}
}
intent := "Build one whenever the user asks for an overview, asks why something is slow, failing or changing, asks to compare, break down or track telemetry, or asks for anything they would want to look at again; answer a single factual question with a view instead."
if !strings.Contains(systemPrompt, intent) {
t.Error("prompt omits M1 intent-preservation rule")
}
}

func TestM2DashboardGuidancePinsEvidenceAndIntent(t *testing.T) {
if !strings.HasSuffix(systemPrompt, dashboardAnalysisGuidance) {
t.Fatal("analysis guidance is not appended to the system prompt")
}
for _, sentence := range []string{
"Use drill for span or log evidence; keep checked filters.",
"Include annotations for change investigations; deploys and detector findings do not prove causes.",
"Use a deploy split for scoped before/since comparisons; retain missing-deploy explanations.",
"A definition, explanation, or single fact is an answer intent; do not create or replace a dashboard for it.",
"Preserve every requested facet and explain absent telemetry without inventing it.",
"use only the types the question needs; do not fill a dashboard with all of them",
"never name schema fields to the user",
} {
if !strings.Contains(dashboardAnalysisGuidance, sentence) {
t.Errorf("guidance omits %q", sentence)
}
}
}
4 changes: 3 additions & 1 deletion internal/agent/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import (
appid "github.com/labstack/fanout/internal/id"
)

const systemPrompt = `You are Fanout's observability assistant. When a view is attached to your reply it is the picture: never draw diagrams, trees, or charts in text, never use code fences to draw boxes, arrows, or trees, and never add a table or list that restates what an attached view already shows; your prose adds only what the view omits. Use get_observability_overview first for broad health questions, get_intelligence_snapshot for the latest precomputed anomalies and recurring log patterns, get_service_topology for direct dependency edges, get_service_dependencies for bounded upstream or downstream reachability from a service, get_service_performance for activity/latency/endpoints/comparisons, inspect_trace for trace or root-cause inspection, and search_logs for log questions. Treat structured outputs as authoritative. You build and change the user's dashboards. Build one whenever the user asks for an overview, asks why something is slow, failing or changing, asks to compare, break down or track telemetry, or asks for anything they would want to look at again; answer a single factual question with a view instead. To build one, read get_telemetry_schema, draft a complete spec of panels that answer the request, run preview_panels, fix every invalid panel, replace or explain every empty one, and only then call create_dashboard. Cover every part of the request: when a part has no data, keep its panel and say why in the panel description instead of dropping it. Title each panel with exactly what it measures. Prefer a few precise panels over many vague ones: headline stats first, then the time series that explain them, then a table of the worst offenders. After saving, reply in two or three sentences with what the dashboard shows and what stands out. Use filter values exactly as the schema lists them. To change a dashboard, get_dashboard first and use edit_dashboard so unrelated panels stay as they are; replace only when the user asks for a redesign. State the time window you used, distinguish missing data from healthy behavior, and never invent services, metrics, or causal claims. Keep answers concise because attached views provide interactive details. Never expose implementation details to the user: do not mention protocol names, tool names, schemas, query IDs, data-source names, storage engines, providers, or internal execution steps. Refer to attached interactive content simply as a view.`
const systemPrompt = `You are Fanout's observability assistant. When a view is attached to your reply it is the picture: never draw diagrams, trees, or charts in text, never use code fences to draw boxes, arrows, or trees, and never add a table or list that restates what an attached view already shows; your prose adds only what the view omits. Use get_observability_overview first for broad health questions, get_intelligence_snapshot for the latest precomputed anomalies and recurring log patterns, get_service_topology for direct dependency edges, get_service_dependencies for bounded upstream or downstream reachability from a service, get_service_performance for activity/latency/endpoints/comparisons, inspect_trace for trace or root-cause inspection, and search_logs for log questions. Treat structured outputs as authoritative. You build and change the user's dashboards. Build one whenever the user asks for an overview, asks why something is slow, failing or changing, asks to compare, break down or track telemetry, or asks for anything they would want to look at again; answer a single factual question with a view instead. To build one, read get_telemetry_schema, draft a complete spec of panels that answer the request, run preview_panels, fix every invalid panel, replace or explain every empty one, and only then call create_dashboard. Cover every part of the request: when a part has no data, keep its panel and say why in the panel description instead of dropping it. Title each panel with exactly what it measures. Prefer a few precise panels over many vague ones: headline stats first, then the time series that explain them, then a table of the worst offenders. After saving, reply in two or three sentences with what the dashboard shows and what stands out. Use filter values exactly as the schema lists them. To change a dashboard, get_dashboard first and use edit_dashboard so unrelated panels stay as they are; replace only when the user asks for a redesign. State the time window you used, distinguish missing data from healthy behavior, and never invent services, metrics, or causal claims. Keep answers concise because attached views provide interactive details. Never expose implementation details to the user: do not mention protocol names, tool names, schemas, query IDs, data-source names, storage engines, providers, or internal execution steps. Refer to attached interactive content simply as a view.` + dashboardAnalysisGuidance

const dashboardAnalysisGuidance = ` For analysis dashboards, use only the types the question needs; do not fill a dashboard with all of them. Use heatmap for latency changes, histogram for distributions, scatter for relationships, state_timeline for threshold states, logs for events, log_patterns for repeated messages, traces for slow or erroring traces, service_map for dependencies, and health for service health. Use drill for span or log evidence; keep checked filters. Include annotations for change investigations; deploys and detector findings do not prove causes. Use a deploy split for scoped before/since comparisons; retain missing-deploy explanations. A definition, explanation, or single fact is an answer intent; do not create or replace a dashboard for it. Preserve every requested facet and explain absent telemetry without inventing it. In replies, never name schema fields to the user.`

// Error categories used to pick a client-safe RUN_ERROR message; the raw
// error (which can include provider response bodies) stays server-side.
Expand Down
146 changes: 146 additions & 0 deletions internal/annotations/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package annotations

import (
"context"
"errors"
"strings"
"time"

"github.com/labstack/fanout/internal/queryrows"
)

var ErrRequest = errors.New("annotations need a positive range of at most 430 days and at most 100 services")

type Request struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Services []string `json:"services,omitempty"`
// An empty Namespace uses the query engine's DefaultNamespace.
Namespace string `json:"namespace,omitempty"`
}
type Deploy struct {
Namespace string `json:"namespace"`
Service string `json:"service"`
Version string `json:"version"`
At time.Time `json:"at"`
}
type Anomaly struct {
Namespace string `json:"namespace"`
Service string `json:"service"`
Kind string `json:"kind"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Title string `json:"title"`
Severity string `json:"severity"`
}
type Response struct {
Deploys []Deploy `json:"deploys"`
Anomalies []Anomaly `json:"anomalies"`
Truncated bool `json:"truncated,omitempty"`
}
type Service struct{ db queryrows.Queryer }

func New(db queryrows.Queryer) *Service { return &Service{db: db} }
func (s *Service) Read(ctx context.Context, req Request) (Response, error) {
out := Response{Deploys: []Deploy{}, Anomalies: []Anomaly{}}
if req.From.IsZero() || req.To.IsZero() || !req.From.Before(req.To) || req.To.Sub(req.From) > 430*24*time.Hour || len(req.Services) > 100 || len(req.Namespace) > 200 {
return out, ErrRequest
}
if req.Namespace == "" {
if db, ok := s.db.(interface{ DefaultNamespace() string }); ok {
req.Namespace = db.DefaultNamespace()
}
}
suffix := ""
args := []any{req.From.UTC(), req.To.UTC(), req.Namespace, req.Namespace}
if len(req.Services) > 0 {
slots := make([]string, len(req.Services))
for i, v := range req.Services {
if v == "" || len(v) > 200 {
return out, ErrRequest
}
slots[i] = "?"
args = append(args, v)
}
suffix = " AND service IN (" + strings.Join(slots, ",") + ")"
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
reader, ok := s.db.(queryrows.ReadTransactioner)
if !ok {
return out, errors.New("annotation reads require a read transaction")
}
err := reader.WithReadTransaction(ctx, func(db queryrows.Queryer) error {
var readErr error
out, readErr = readSnapshot(ctx, db, args, suffix)
return readErr
})
return out, err
}

func readSnapshot(ctx context.Context, db queryrows.Queryer, args []any, suffix string) (Response, error) {
out := Response{Deploys: []Deploy{}, Anomalies: []Anomaly{}}
rows, err := db.QueryContext(ctx, `WITH versions AS (SELECT *,row_number() OVER(PARTITION BY namespace,service ORDER BY first_seen,service_version) AS n FROM version_rollup)
SELECT namespace,service,service_version,first_seen::TIMESTAMP_NS FROM versions
WHERE first_seen>=?::TIMESTAMP_NS::TIMESTAMPTZ_NS AND first_seen<?::TIMESTAMP_NS::TIMESTAMPTZ_NS AND (?='' OR namespace=?) AND n>1`+suffix+` ORDER BY first_seen DESC,namespace,service,service_version LIMIT 1001`, args...)
if err != nil {
return out, err
}
for rows.Next() {
var a Deploy
if err := rows.Scan(&a.Namespace, &a.Service, &a.Version, &a.At); err != nil {
rows.Close()
return out, err
}
a.At = a.At.UTC()
out.Deploys = append(out.Deploys, a)
}
err = rows.Err()
rows.Close()
if err != nil {
return out, err
}
if len(out.Deploys) > 1000 {
out.Deploys = out.Deploys[:1000]
out.Truncated = true
}
rows, err = db.QueryContext(ctx, `SELECT namespace,service,kind,start_time::TIMESTAMP_NS,end_time::TIMESTAMP_NS,title,severity FROM anomaly_log
WHERE end_time>?::TIMESTAMP_NS::TIMESTAMPTZ_NS AND start_time<?::TIMESTAMP_NS::TIMESTAMPTZ_NS AND (?='' OR namespace=?)`+suffix+` ORDER BY end_time DESC,namespace,service,kind LIMIT 1001`, args...)
if err != nil {
return out, err
}
for rows.Next() {
var a Anomaly
if err := rows.Scan(&a.Namespace, &a.Service, &a.Kind, &a.From, &a.To, &a.Title, &a.Severity); err != nil {
rows.Close()
return out, err
}
a.From = a.From.UTC()
a.To = a.To.UTC()
out.Anomalies = append(out.Anomalies, a)
}
err = rows.Err()
rows.Close()
if err != nil {
return out, err
}
if len(out.Anomalies) > 1000 {
out.Anomalies = out.Anomalies[:1000]
out.Truncated = true
}
rows, err = db.QueryContext(ctx, `SELECT coalesce(max(last_ingested_unix_nano),0) FROM rollup_state WHERE cache_key IN ('version_rollup_v1_limited','version_rollup_v1_history_limited')`)
if err != nil {
return out, err
}
if rows.Next() {
var limited int64
if err := rows.Scan(&limited); err != nil {
rows.Close()
return out, err
}
out.Truncated = out.Truncated || limited > 0
}
err = rows.Err()
rows.Close()
return out, err
}
Loading
Loading