Guardrails
+ +No guardrails configured for this plan.
+diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index 28158fa0..158a16c2 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -50,6 +50,9 @@ func TestIndex_ReturnsHTML(t *testing.T) { if !strings.Contains(body, "audit logs") { t.Errorf("expected audit logs navigation item in page HTML") } + if !strings.Contains(body, "workflows") { + t.Errorf("expected workflows navigation item in page HTML") + } if !strings.Contains(body, `x-data="dashboard()"`) { t.Errorf("expected alpine dashboard root in page HTML") } @@ -150,6 +153,29 @@ func TestStatic_ServesAliasesModuleJS(t *testing.T) { } } +func TestStatic_ServesExecutionPlansModuleJS(t *testing.T) { + h, err := New() + if err != nil { + t.Fatalf("New() returned error: %v", err) + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/admin/static/js/modules/execution-plans.js", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.Static(c); err != nil { + t.Fatalf("Static() returned error: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + if rec.Body.Len() == 0 { + t.Error("expected non-empty body for execution plans module JS file") + } +} + func TestStatic_ServesFavicon(t *testing.T) { h, err := New() if err != nil { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index a921fcc4..7a08dc0d 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -1187,6 +1187,177 @@ td.col-price { opacity: 0.72; } +/* Execution Plans */ +.execution-plan-page-note { + margin-top: 6px; + color: var(--text-muted); + font-size: 14px; +} + +.execution-plans-layout { + display: flex; + flex-direction: column; + gap: 16px; +} + +.execution-plans-layout.is-editor-open { + align-items: stretch; +} + +.execution-plans-list { + min-width: 0; +} + +.execution-plan-card-grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.execution-plan-card { + display: flex; + flex-direction: column; + gap: 16px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.execution-plan-card-head, +.execution-plan-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.execution-plan-card-footer { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; +} + +.execution-plan-card-head h3, +.execution-plan-section-head h4 { + font-size: 18px; + font-weight: 700; +} + +.execution-plan-section-head h4 { + font-size: 14px; +} + +.execution-plan-card-badges, +.execution-plan-card-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.execution-plan-card-meta-footer { + justify-content: flex-start; +} + +.execution-plan-card-footer .alias-actions-cell { + align-self: flex-end; +} + +.execution-plan-card-description { + color: var(--text-muted); + font-size: 14px; +} + +.execution-plan-heading { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.execution-plan-help { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + cursor: help; +} + +.execution-plan-guardrails, +.execution-plan-guardrail-editor { + display: flex; + flex-direction: column; + gap: 12px; +} + +.execution-plan-guardrail-list, +.execution-plan-guardrail-list-editor { + display: flex; + flex-direction: column; + gap: 10px; +} + +.execution-plan-guardrail-item, +.execution-plan-guardrail-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); +} + +.execution-plan-guardrail-row { + justify-content: stretch; +} + +.execution-plan-editor { + margin-bottom: 0; + width: 100%; +} + +.execution-plan-input { + max-width: none; + width: 100%; +} + +.execution-plan-step-input { + max-width: 120px; +} + +.execution-plan-feature-toggles { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.execution-plan-feature-toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + font-size: 14px; + font-weight: 500; +} + +.execution-plan-feature-toggle input { + width: 16px; + height: 16px; +} + /* Usage Mode Toggle */ .usage-mode-toggle { display: inline-flex; @@ -2082,6 +2253,24 @@ body.conversation-drawer-open { .alias-form-grid { grid-template-columns: 1fr; } + .execution-plan-card-grid, + .execution-plan-feature-toggles { + grid-template-columns: 1fr; + } + .execution-plan-card-head, + .execution-plan-card-footer, + .execution-plan-section-head, + .execution-plan-card-badges, + .execution-plan-card-meta, + .execution-plan-guardrail-item, + .execution-plan-guardrail-row { + flex-direction: column; + align-items: flex-start; + } + .execution-plan-step-input { + max-width: none; + width: 100%; + } .table-toolbar { flex-direction: column; align-items: stretch; @@ -2173,3 +2362,461 @@ body.conversation-drawer-open { display: flex; } } + +/* ═══════════════════════════════════════════════════════════════ + Execution Pipeline Visualization + ═══════════════════════════════════════════════════════════════ */ + +.exec-pipeline { + display: flex; + flex-direction: column; + gap: 0; + padding: 18px 20px 20px; + margin-bottom: 12px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg); +} + +/* ─── Main pipeline row ─── */ + +.exec-pipeline-row { + display: flex; + align-items: center; + width: 100%; +} + +/* Left/right halves — equal width keeps AI centered */ +.ep-left, +.ep-right { + display: flex; + align-items: center; + flex: 1; + min-width: 0; +} + +.ep-right { + justify-content: flex-end; +} + +/* Optional step wrapper (connector + node shown/hidden together) */ +.ep-step { + display: flex; + align-items: center; + flex-shrink: 0; +} + +/* ─── Connectors ─── */ + +.ep-conn { + flex-shrink: 0; + position: relative; + width: 32px; + height: 2px; + background: color-mix(in srgb, var(--accent) 44%, var(--border)); +} + +/* Growing connector — fills remaining half to keep AI centered */ +.ep-conn-grow { + flex: 1; + min-width: 16px; + width: auto; +} + +.ep-conn::after { + content: ""; + position: absolute; + right: -1px; + top: 50%; + transform: translateY(-50%); + width: 7px; + height: 9px; + background: color-mix(in srgb, var(--accent) 44%, var(--border)); + clip-path: polygon(0 0, 100% 50%, 0 100%); +} + +/* Green connector — cache hit path */ +.ep-conn-hit { + background: color-mix(in srgb, var(--success) 58%, var(--border)); +} + +.ep-conn-hit::after { + background: color-mix(in srgb, var(--success) 58%, var(--border)); +} + +/* Dimmed connector — path not taken */ +.ep-conn-dim { + background: color-mix(in srgb, var(--border) 75%, transparent); + opacity: 0.4; +} + +.ep-conn-dim::after { + background: color-mix(in srgb, var(--border) 75%, transparent); +} + +/* ─── Base node ─── */ + +.ep-node { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + padding: 10px 14px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-surface); + min-width: 72px; + text-align: center; + flex-shrink: 0; +} + +.ep-node-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius); + background: var(--bg); + color: var(--text-muted); +} + +.ep-node-icon svg { + width: 15px; + height: 15px; + stroke: currentcolor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ep-node-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--text); + white-space: nowrap; + line-height: 1.2; +} + +.ep-node-sub { + font-size: 10px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.2; + font-family: var(--font-mono, ui-monospace, monospace); +} + +.ep-node-badge { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: var(--radius); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text-muted); + line-height: 1.5; +} + +/* ─── Endpoint nodes (Client / Response) ─── */ + +.ep-node-endpoint { + flex-direction: row; + padding: 10px 14px; + border-radius: var(--radius); + min-width: auto; + gap: 7px; + border-color: var(--border); + background: var(--bg-surface); +} + +.ep-node-icon-endpoint { + width: auto; + height: auto; + justify-content: flex-start; + padding: 0; + background: transparent; + border-radius: var(--radius); + color: var(--text-muted); +} + +.ep-node-icon-endpoint svg { + width: 14px; + height: 14px; +} + +.ep-node-endpoint .ep-node-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); +} + +/* ─── Guardrails node ─── */ + +.ep-node-guardrails { + border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); +} + +.ep-node-guardrails .ep-node-icon { + background: color-mix(in srgb, var(--accent) 16%, var(--bg)); + color: var(--accent); +} + +.ep-node-guardrails .ep-node-label { + color: var(--accent); +} + +.ep-node-guardrails .ep-node-sub { + color: color-mix(in srgb, var(--accent) 70%, var(--text-muted)); +} + +/* ─── Cache node ─── */ + +.ep-node-cache { + border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); +} + +.ep-node-cache .ep-node-icon { + background: color-mix(in srgb, var(--accent) 16%, var(--bg)); + color: var(--accent); +} + +.ep-node-cache .ep-node-label { + color: var(--accent); +} + +/* Cache — runtime: exact hit */ +.ep-node-cache-hit { + border-color: color-mix(in srgb, var(--success) 52%, var(--border)); + background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); +} + +.ep-node-cache-hit .ep-node-icon { + background: color-mix(in srgb, var(--success) 18%, var(--bg)); + color: var(--success); +} + +.ep-node-cache-hit .ep-node-label { + color: color-mix(in srgb, var(--success) 85%, var(--text)); +} + +.ep-node-cache-hit .ep-node-badge { + background: color-mix(in srgb, var(--success) 14%, var(--bg)); + border-color: color-mix(in srgb, var(--success) 38%, var(--border)); + color: var(--success); +} + +/* Cache — runtime: semantic hit */ +.ep-node-cache-semantic { + border-color: color-mix(in srgb, #a78bfa 46%, var(--border)); + background: color-mix(in srgb, #a78bfa 8%, var(--bg-surface)); +} + +.ep-node-cache-semantic .ep-node-icon { + background: color-mix(in srgb, #a78bfa 16%, var(--bg)); + color: #a78bfa; +} + +.ep-node-cache-semantic .ep-node-label { + color: #a78bfa; +} + +.ep-node-cache-semantic .ep-node-badge { + background: color-mix(in srgb, #a78bfa 14%, var(--bg)); + border-color: color-mix(in srgb, #a78bfa 38%, var(--border)); + color: #a78bfa; +} + +/* Cache — runtime: miss badge only */ +.ep-node-cache-miss .ep-node-badge { + color: var(--text-muted); + border-color: var(--border); +} + +/* ─── AI node ─── */ + +.ep-node-ai { + min-width: 96px; + padding: 12px 16px; + border-radius: var(--radius); + gap: 6px; +} + +/* AI skipped — cache hit absorbed the request */ +.ep-node-ai-skipped { + opacity: 0.28; +} + +/* ─── Async section ─── */ + +/* + * Async nodes fire after the response is returned to the client. + * They drop below the main row via an L-turn from Response, then + * flow right-to-left: Audit Log on the right, Usage on the left. + * + * [Client] ──→ [Cache?] ──── [AI] ──────── [Response] + * │ + * │ (dashed drop) + * │ + * [Usage] ← ─ ─ [Audit Log] + */ + +/* Container — full-width branch lane below the main row */ +.ep-async-section { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0; + min-width: 0; + padding-right: 20px; +} + +/* L-turn connector: centered horizontal leg plus vertical rise back to Response */ +.ep-async-turn { + flex: 0 0 28px; + width: 28px; + position: relative; /* for arrowhead + vertical rise */ + height: 2px; + background: repeating-linear-gradient( + to left, + color-mix(in srgb, var(--text-muted) 45%, var(--border)) 0, + color-mix(in srgb, var(--text-muted) 45%, var(--border)) 5px, + transparent 5px, + transparent 9px + ); +} + +/* Left-pointing arrowhead at the end of the horizontal L-turn line */ +.ep-async-turn::before { + content: ""; + position: absolute; + left: -7px; + top: 50%; + transform: translateY(-50%); + width: 7px; + height: 9px; + background: color-mix(in srgb, var(--text-muted) 40%, var(--border)); + clip-path: polygon(100% 0, 0 50%, 100% 100%); +} + +/* Vertical dashed rise that connects the inline turn back up to Response */ +.ep-async-turn::after { + content: ""; + position: absolute; + right: 0; + bottom: 1px; + height: 16px; + border-right: 2px dashed color-mix(in srgb, var(--text-muted) 40%, var(--border)); +} + +/* RTL async row — Audit Log right, Usage left */ +.ep-async-row { + display: flex; + align-items: center; + min-width: 0; + margin-right: 7px; +} + +/* Dashed left-pointing connector between async nodes */ +.ep-conn-async { + background: repeating-linear-gradient( + to left, + color-mix(in srgb, var(--text-muted) 45%, var(--border)) 0, + color-mix(in srgb, var(--text-muted) 45%, var(--border)) 5px, + transparent 5px, + transparent 9px + ); + width: 24px; + flex-shrink: 0; +} + +/* Left-pointing arrowhead (overrides ep-conn::after right-pointing default) */ +.ep-conn-async::after { + background: color-mix(in srgb, var(--text-muted) 45%, var(--border)); + left: -1px; + right: auto; + clip-path: polygon(100% 0, 0 50%, 100% 100%); +} + +/* Async nodes — horizontal inline pills */ +.ep-node-async { + flex-direction: row; + padding: 7px 12px; + border-radius: var(--radius); + border-style: dashed; + min-width: auto; + gap: 7px; +} + +.ep-node-async .ep-node-icon { + width: 20px; + height: 20px; + border-radius: var(--radius); +} + +.ep-node-async .ep-node-icon svg { + width: 12px; + height: 12px; +} + +.ep-node-async .ep-node-label { + font-size: 10px; + font-weight: 700; +} + +.ep-node-async-audit { + border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); +} + +.ep-node-async-audit .ep-node-icon { + background: color-mix(in srgb, var(--accent) 16%, var(--bg)); + color: var(--accent); +} + +.ep-node-async-audit .ep-node-label { + color: var(--accent); +} + +.ep-node-async-usage { + border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); +} + +.ep-node-async-usage .ep-node-icon { + background: color-mix(in srgb, var(--accent) 16%, var(--bg)); + color: var(--accent); +} + +.ep-node-async-usage .ep-node-label { + color: var(--accent); +} + +/* "ASYNC" label inline on the right of the branch */ +.ep-async-label { + display: inline-flex; + align-items: center; + margin-left: 8px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + opacity: 0.55; + white-space: nowrap; + flex-shrink: 0; +} diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index d21bcbf9..99dc30bf 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -76,7 +76,14 @@ function dashboard() { const path = pathname.replace(/\/$/, ''); const rest = path.replace('/admin/dashboard', '').replace(/^\//, ''); const parts = rest.split('/'); - const page = (['overview', 'usage', 'models', 'audit'].includes(parts[0])) ? parts[0] : 'overview'; + let page = parts[0]; + if (page === 'execution-plans') { + page = 'workflows'; + } + if (page === 'audit') { + page = 'audit-logs'; + } + page = (['overview', 'usage', 'models', 'workflows', 'audit-logs'].includes(page)) ? page : 'overview'; const sub = parts[1] || null; return { page, sub }; }, @@ -90,7 +97,7 @@ function dashboard() { const { page, sub } = this._parseRoute(window.location.pathname); this.page = page; if (page === 'usage' && sub === 'costs') this.usageMode = 'costs'; - if (page === 'audit') this.fetchAuditLog(true); + if (page === 'audit-logs') this.fetchAuditLog(true); window.addEventListener('popstate', () => { const { page: p, sub: s } = this._parseRoute(window.location.pathname); @@ -100,7 +107,10 @@ function dashboard() { this.fetchUsagePage(); } if (p === 'overview') this.renderChart(); - if (p === 'audit') this.fetchAuditLog(true); + if (p === 'audit-logs') this.fetchAuditLog(true); + if (p === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') { + this.fetchExecutionPlansPage(); + } }); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { @@ -124,7 +134,8 @@ function dashboard() { history.pushState(null, '', '/admin/dashboard/' + page); if (page === 'overview') this.renderChart(); if (page === 'usage') this.fetchUsagePage(); - if (page === 'audit') this.fetchAuditLog(true); + if (page === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') this.fetchExecutionPlansPage(); + if (page === 'audit-logs') this.fetchAuditLog(true); }, setTheme(t) { @@ -187,6 +198,9 @@ function dashboard() { if (typeof this.fetchAliases === 'function') { requests.push(this.fetchAliases()); } + if (typeof this.fetchExecutionPlansPage === 'function') { + requests.push(this.fetchExecutionPlansPage()); + } if (this.hasCalendarModule && typeof this.fetchCalendarData === 'function') { requests.push(this.fetchCalendarData()); } @@ -329,6 +343,7 @@ function dashboard() { typeof dashboardUsageModule === 'function' ? dashboardUsageModule : null, typeof dashboardAuditListModule === 'function' ? dashboardAuditListModule : null, typeof dashboardAliasesModule === 'function' ? dashboardAliasesModule : null, + typeof dashboardExecutionPlansModule === 'function' ? dashboardExecutionPlansModule : null, typeof dashboardConversationDrawerModule === 'function' ? dashboardConversationDrawerModule : null, calendarModuleFactory, typeof dashboardChartsModule === 'function' ? dashboardChartsModule : null diff --git a/internal/admin/dashboard/static/js/modules/execution-plans-layout.test.js b/internal/admin/dashboard/static/js/modules/execution-plans-layout.test.js new file mode 100644 index 00000000..c241ac6a --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/execution-plans-layout.test.js @@ -0,0 +1,196 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function readFixture(relativePath) { + return fs.readFileSync(path.join(__dirname, relativePath), 'utf8'); +} + +function readCSSRule(source, selector) { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = source.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\n\\}`, 'm')); + assert.ok(match, `Expected CSS rule for ${selector}`); + return match[1]; +} + +test('async pipeline branch spans full width and keeps the turn inline', () => { + const template = readFixture('../../../templates/index.html'); + const css = readFixture('../../css/dashboard.css'); + + assert.match( + template, + /
Active workflows are matched with most-specific-wins precedence: provider + model, then provider, then global.
+Loading workflows...
+ +No guardrails configured for this plan.
+No active workflows found.
+No workflows match your filter.
+