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, + /
[\s\S]*?<\/div>\s*
<\/div>/ + ); + + const asyncSectionRule = readCSSRule(css, '.ep-async-section'); + assert.match(asyncSectionRule, /width:\s*100%/); + assert.doesNotMatch(asyncSectionRule, /flex-direction:\s*column/); + assert.match(asyncSectionRule, /align-items:\s*center/); + assert.doesNotMatch(asyncSectionRule, /margin-top:\s*[1-9]/); + + const asyncTurnRule = readCSSRule(css, '.ep-async-turn'); + assert.match(asyncTurnRule, /width:\s*\d/); + assert.match(asyncTurnRule, /height:\s*2px/); + assert.doesNotMatch(asyncTurnRule, /border-bottom:/); + assert.doesNotMatch(asyncTurnRule, /border-right:/); + + const asyncRowRule = readCSSRule(css, '.ep-async-row'); + assert.match(asyncRowRule, /display:\s*flex/); + assert.match(asyncRowRule, /margin-right:\s*7px/); + + const asyncTurnVerticalRule = readCSSRule(css, '.ep-async-turn::after'); + assert.match(asyncTurnVerticalRule, /border-right:/); + assert.match(asyncTurnVerticalRule, /bottom:\s*1px/); + assert.doesNotMatch(asyncTurnVerticalRule, /transform:/); +}); + +test('async label stays inline on the right side of the branch', () => { + const template = readFixture('../../../templates/index.html'); + const css = readFixture('../../css/dashboard.css'); + + assert.match( + template, + /
[\s\S]*ep-node-async-usage[\s\S]*ep-conn-async[\s\S]*ep-node-async-audit[\s\S]*<\/div>\s*
<\/div>\s*Async<\/span>/ + ); + + const asyncLabelRule = readCSSRule(css, '.ep-async-label'); + assert.doesNotMatch(asyncLabelRule, /position:\s*absolute/); +}); + +test('workflow nodes use endpoint and feature color groups consistently', () => { + const css = readFixture('../../css/dashboard.css'); + + const endpointRule = readCSSRule(css, '.ep-node-endpoint'); + assert.match(endpointRule, /background:\s*var\(--bg-surface\)/); + + const featureSelectors = [ + '.ep-node-guardrails', + '.ep-node-async-audit', + '.ep-node-async-usage' + ]; + for (const selector of featureSelectors) { + const rule = readCSSRule(css, selector); + assert.match(rule, /border-color:\s*color-mix\(in srgb, var\(--accent\) 46%, var\(--border\)\)/); + assert.match(rule, /background:\s*color-mix\(in srgb, var\(--accent\) 8%, var\(--bg-surface\)\)/); + } + + const featureIconSelectors = [ + '.ep-node-guardrails .ep-node-icon', + '.ep-node-async-audit .ep-node-icon', + '.ep-node-async-usage .ep-node-icon' + ]; + for (const selector of featureIconSelectors) { + const rule = readCSSRule(css, selector); + assert.match(rule, /background:\s*color-mix\(in srgb, var\(--accent\) 16%, var\(--bg\)\)/); + assert.match(rule, /color:\s*var\(--accent\)/); + } + + const featureLabelSelectors = [ + '.ep-node-guardrails .ep-node-label', + '.ep-node-async-audit .ep-node-label', + '.ep-node-async-usage .ep-node-label' + ]; + for (const selector of featureLabelSelectors) { + const rule = readCSSRule(css, selector); + assert.match(rule, /color:\s*var\(--accent\)/); + } +}); + +test('execution plan authoring inputs expose stable accessible names', () => { + const template = readFixture('../../../templates/index.html'); + + assert.match( + template, + /x-model="executionPlanFilter"[^>]*aria-label="Filter workflows by scope, name, hash, or guardrail"/ + ); + assert.match( + template, + /x-model="step\.ref"[^>]*:aria-label="'Guardrail reference ' \+ \(index \+ 1\)"/ + ); + assert.match( + template, + /x-model\.number="step\.step"[^>]*:aria-label="'Guardrail step ' \+ \(index \+ 1\)"/ + ); +}); + +test('execution plan card actions expose plan-specific accessible names', () => { + const template = readFixture('../../../templates/index.html'); + + assert.match( + template, + /class="table-action-btn table-action-btn-danger"[\s\S]*?:aria-label="'Deactivate workflow ' \+ workflowDisplayName\(plan\)"/ + ); + assert.match( + template, + /class="table-action-btn"[^>]*:aria-label="'Edit workflow ' \+ workflowDisplayName\(plan\)"/ + ); +}); + +test('guardrails node only renders a sublabel when step detail exists', () => { + const template = readFixture('../../../templates/index.html'); + + assert.match( + template, + /Guardrails<\/span>\s*<\/span>/ + ); +}); + +test('execution pipeline icons use lowercase currentcolor keyword', () => { + const css = readFixture('../../css/dashboard.css'); + const iconRule = readCSSRule(css, '.ep-node-icon svg'); + + assert.match(iconRule, /stroke:\s*currentcolor;/); +}); + +test('exec pipeline has bottom spacing so adjacent cards do not touch it', () => { + const css = readFixture('../../css/dashboard.css'); + const pipelineRule = readCSSRule(css, '.exec-pipeline'); + + assert.match(pipelineRule, /margin-bottom:\s*\d+px/); +}); + +test('execution pipeline uses var(--radius) for chart-local corners', () => { + const css = readFixture('../../css/dashboard.css'); + + const radiusSelectors = [ + '.exec-pipeline', + '.ep-node', + '.ep-node-icon', + '.ep-node-badge', + '.ep-node-endpoint', + '.ep-node-icon-endpoint', + '.ep-node-ai', + '.ep-node-async', + '.ep-node-async .ep-node-icon' + ]; + + for (const selector of radiusSelectors) { + const rule = readCSSRule(css, selector); + assert.match(rule, /border-radius:\s*var\(--radius\)/); + } +}); + +test('AI node renders as a text-only card without an icon', () => { + const template = readFixture('../../../templates/index.html'); + const css = readFixture('../../css/dashboard.css'); + + assert.doesNotMatch(template, /class="ep-node ep-node-ai[^"]*"[^>]*>\s*
/); + assert.doesNotMatch(css, /\.ep-node-ai \.ep-node-icon\s*\{/); +}); + +test('endpoint pills use dedicated flush-left icons and tighter right padding', () => { + const template = readFixture('../../../templates/index.html'); + const css = readFixture('../../css/dashboard.css'); + + assert.match(template, /class="ep-node-icon ep-node-icon-endpoint"/); + + const endpointRule = readCSSRule(css, '.ep-node-endpoint'); + assert.match(endpointRule, /padding:\s*10px 14px/); + + const endpointIconRule = readCSSRule(css, '.ep-node-icon-endpoint'); + assert.match(endpointIconRule, /width:\s*auto/); + assert.match(endpointIconRule, /height:\s*auto/); + assert.match(endpointIconRule, /justify-content:\s*flex-start/); + assert.match(endpointIconRule, /padding:\s*0/); +}); diff --git a/internal/admin/dashboard/static/js/modules/execution-plans.js b/internal/admin/dashboard/static/js/modules/execution-plans.js new file mode 100644 index 00000000..21f41f9a --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/execution-plans.js @@ -0,0 +1,612 @@ +(function(global) { + function dashboardExecutionPlansModule() { + return { + executionPlans: [], + executionPlansAvailable: true, + executionPlansLoading: false, + executionPlanError: '', + executionPlanNotice: '', + executionPlanFilter: '', + executionPlanFormOpen: false, + executionPlanSubmitting: false, + executionPlanDeactivatingID: '', + executionPlanFormError: '', + executionPlanHydratedScope: { + scope_provider: '', + scope_model: '' + }, + guardrailRefs: [], + executionPlanForm: { + scope_provider: '', + scope_model: '', + name: '', + description: '', + features: { + cache: true, + audit: true, + usage: true, + guardrails: false + }, + guardrails: [] + }, + + defaultExecutionPlanForm() { + return { + scope_provider: '', + scope_model: '', + name: '', + description: '', + features: { + cache: true, + audit: true, + usage: true, + guardrails: false + }, + guardrails: [] + }; + }, + + defaultExecutionPlanGuardrailStep(step) { + return { + ref: '', + step: Number.isFinite(step) ? step : 10 + }; + }, + + get filteredExecutionPlans() { + if (!this.executionPlanFilter) { + return this.executionPlans; + } + const filter = this.executionPlanFilter.toLowerCase(); + return this.executionPlans.filter((plan) => { + const fields = [ + plan.name, + plan.description, + plan.scope_display, + plan.scope_type, + plan.scope && plan.scope.scope_provider, + plan.scope && plan.scope.scope_model, + plan.plan_hash, + ...(Array.isArray(plan.plan_payload && plan.plan_payload.guardrails) + ? plan.plan_payload.guardrails.map((step) => step.ref) + : []) + ]; + return fields.some((value) => String(value || '').toLowerCase().includes(filter)); + }); + }, + + executionPlanProviderOptions() { + const options = new Set(); + const preservedProvider = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_provider || '').trim(); + if (preservedProvider) { + options.add(preservedProvider); + } + const models = Array.isArray(this.models) ? this.models : []; + models.forEach((model) => { + const providerType = String(model && model.provider_type || '').trim(); + if (providerType) { + options.add(providerType); + } + }); + return [...options].sort(); + }, + + executionPlanModelOptions(providerType) { + const wantedProvider = String(providerType || '').trim(); + const options = new Set(); + const preservedProvider = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_provider || '').trim(); + const preservedModel = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_model || '').trim(); + if (wantedProvider && wantedProvider === preservedProvider && preservedModel) { + options.add(preservedModel); + } + const models = Array.isArray(this.models) ? this.models : []; + models.forEach((model) => { + if (wantedProvider && String(model && model.provider_type || '').trim() !== wantedProvider) { + return; + } + const modelID = String(model && model.model && model.model.id || '').trim(); + if (modelID) { + options.add(modelID); + } + }); + return [...options].sort(); + }, + + planScopeTypeLabel(plan) { + const scopeType = String(plan && plan.scope_type || '').trim(); + if (scopeType === 'provider_model') return 'Provider + Model'; + if (scopeType === 'provider') return 'Provider'; + return 'Global'; + }, + + planScopeLabel(plan) { + return String(plan && plan.scope_display || 'global').trim() || 'global'; + }, + + workflowDisplayName(plan) { + const explicitName = String(plan && plan.name || '').trim(); + if (explicitName) { + return explicitName; + } + const scopeLabel = this.planScopeLabel(plan); + if (scopeLabel === 'global') { + return 'All models'; + } + return scopeLabel; + }, + + executionPlanSourceFeatures(source) { + const raw = source && source.plan_payload && source.plan_payload.features + ? source.plan_payload.features + : source && source.features + ? source.features + : {}; + return { + cache: !!raw.cache, + audit: !!raw.audit, + usage: !!raw.usage, + guardrails: !!raw.guardrails + }; + }, + + executionPlanSourceGuardrails(source) { + const raw = Array.isArray(source && source.plan_payload && source.plan_payload.guardrails) + ? source.plan_payload.guardrails + : Array.isArray(source && source.guardrails) + ? source.guardrails + : []; + return raw + .map((step) => ({ + ref: String(step && step.ref || '').trim(), + step: Number(step && step.step) + })) + .filter((step) => Number.isFinite(step.step)); + }, + + canDeactivateExecutionPlan(plan) { + return String(plan && plan.scope_type || '').trim() !== 'global'; + }, + + executionPlanEditorScrollTarget() { + if (!global.document || typeof global.document.querySelector !== 'function') { + return null; + } + return global.document.querySelector('.execution-plan-editor'); + }, + + scrollExecutionPlanFormIntoView() { + const scroll = () => { + const editor = this.executionPlanEditorScrollTarget(); + if (editor && typeof editor.scrollIntoView === 'function') { + editor.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + if (typeof global.requestAnimationFrame === 'function') { + global.requestAnimationFrame(scroll); + return; + } + scroll(); + }, + + planGuardrails(plan) { + return Array.isArray(plan && plan.plan_payload && plan.plan_payload.guardrails) + ? plan.plan_payload.guardrails + : []; + }, + + shortHash(value) { + const hash = String(value || '').trim(); + if (!hash) return '\u2014'; + if (hash.length <= 14) return hash; + return hash.slice(0, 12) + '\u2026'; + }, + + openExecutionPlanCreate(plan) { + this.executionPlanFormOpen = true; + this.executionPlanSubmitting = false; + this.executionPlanFormError = ''; + this.executionPlanNotice = ''; + + if (!plan) { + this.executionPlanHydratedScope = { + scope_provider: '', + scope_model: '' + }; + this.executionPlanForm = this.defaultExecutionPlanForm(); + this.scrollExecutionPlanFormIntoView(); + return; + } + + this.executionPlanHydratedScope = { + scope_provider: String(plan.scope && plan.scope.scope_provider || '').trim(), + scope_model: String(plan.scope && plan.scope.scope_model || '').trim() + }; + const features = this.executionPlanSourceFeatures(plan); + const guardrails = this.executionPlanSourceGuardrails(plan); + this.executionPlanForm = { + scope_provider: String(plan.scope && plan.scope.scope_provider || ''), + scope_model: String(plan.scope && plan.scope.scope_model || ''), + name: String(plan.name || ''), + description: String(plan.description || ''), + features: { + cache: !!features.cache, + audit: !!features.audit, + usage: !!features.usage, + guardrails: !!features.guardrails + }, + guardrails: guardrails.map((step) => ({ + ref: String(step && step.ref || ''), + step: Number.isFinite(step && step.step) ? step.step : 10 + })) + }; + this.scrollExecutionPlanFormIntoView(); + }, + + closeExecutionPlanForm() { + this.executionPlanFormOpen = false; + this.executionPlanSubmitting = false; + this.executionPlanFormError = ''; + this.executionPlanHydratedScope = { + scope_provider: '', + scope_model: '' + }; + this.executionPlanForm = this.defaultExecutionPlanForm(); + }, + + setExecutionPlanProvider(provider) { + this.executionPlanForm.scope_provider = String(provider || '').trim(); + if (!this.executionPlanForm.scope_provider) { + this.executionPlanForm.scope_provider = ''; + this.executionPlanForm.scope_model = ''; + return; + } + const modelOptions = this.executionPlanModelOptions(this.executionPlanForm.scope_provider); + if (!modelOptions.includes(String(this.executionPlanForm.scope_model || '').trim())) { + this.executionPlanForm.scope_model = ''; + } + }, + + addExecutionPlanGuardrailStep() { + const steps = Array.isArray(this.executionPlanForm.guardrails) ? this.executionPlanForm.guardrails : []; + const nextStep = steps.reduce((maxStep, step) => { + const parsed = Number(step && step.step); + return Number.isFinite(parsed) ? Math.max(maxStep, parsed) : maxStep; + }, 0) + 10; + this.executionPlanForm.guardrails.push(this.defaultExecutionPlanGuardrailStep(nextStep)); + }, + + removeExecutionPlanGuardrailStep(index) { + if (!Array.isArray(this.executionPlanForm.guardrails)) return; + this.executionPlanForm.guardrails.splice(index, 1); + }, + + buildExecutionPlanRequest() { + const form = this.executionPlanForm || this.defaultExecutionPlanForm(); + const provider = String(form.scope_provider || '').trim(); + const model = provider ? String(form.scope_model || '').trim() : ''; + const features = form.features || {}; + + const guardrails = !!features.guardrails + ? (Array.isArray(form.guardrails) ? form.guardrails : []).map((step) => { + const rawStep = step && step.step; + const trimmedStep = rawStep === null || rawStep === undefined ? '' : String(rawStep).trim(); + const parsedStep = trimmedStep !== '' && Number.isFinite(Number(trimmedStep)) + ? Number(trimmedStep) + : Number.NaN; + return { + ref: String(step && step.ref || '').trim(), + step: parsedStep + }; + }) + : []; + + return { + scope_provider: provider, + scope_model: model, + name: String(form.name || '').trim(), + description: String(form.description || '').trim(), + plan_payload: { + schema_version: 1, + features: { + cache: !!features.cache, + audit: !!features.audit, + usage: !!features.usage, + guardrails: !!features.guardrails + }, + guardrails + } + }; + }, + + validateExecutionPlanRequest(payload) { + const preservedProvider = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_provider || '').trim(); + const preservedModel = String(this.executionPlanHydratedScope && this.executionPlanHydratedScope.scope_model || '').trim(); + + if (payload.scope_provider) { + const providers = this.executionPlanProviderOptions(); + if (!providers.includes(payload.scope_provider) && payload.scope_provider !== preservedProvider) { + return 'Choose a registered provider.'; + } + } + if (payload.scope_model && !payload.scope_provider) { + return 'Model selection requires a provider.'; + } + if (payload.scope_model) { + const models = this.executionPlanModelOptions(payload.scope_provider); + const isPreservedModel = payload.scope_provider === preservedProvider && payload.scope_model === preservedModel; + if (!models.includes(payload.scope_model) && !isPreservedModel) { + return 'Choose a registered model for the selected provider.'; + } + } + + const features = payload.plan_payload && payload.plan_payload.features ? payload.plan_payload.features : {}; + const guardrails = Array.isArray(payload.plan_payload && payload.plan_payload.guardrails) + ? payload.plan_payload.guardrails + : []; + if (!features.guardrails) { + return ''; + } + + const seen = new Set(); + for (const step of guardrails) { + if (!step.ref) { + return 'Each guardrail step needs a guardrail ref.'; + } + if (!Number.isInteger(step.step) || step.step < 0) { + return 'Each guardrail step must use a non-negative integer step number.'; + } + if (seen.has(step.ref)) { + return 'Each guardrail ref may appear only once in a plan.'; + } + seen.add(step.ref); + } + + return ''; + }, + + async executionPlanResponseMessage(res, fallback) { + try { + const payload = await res.json(); + if (payload && payload.error && payload.error.message) { + return payload.error.message; + } + } catch (_) { + // Ignore invalid or empty responses and return the fallback message. + } + return fallback; + }, + + async fetchExecutionPlans() { + this.executionPlansLoading = true; + this.executionPlanError = ''; + const controller = typeof AbortController === 'function' ? new AbortController() : null; + const timeoutID = controller && typeof setTimeout === 'function' + ? setTimeout(() => controller.abort(), 10000) + : null; + try { + const request = { headers: this.headers() }; + if (controller) { + request.signal = controller.signal; + } + const res = await fetch('/admin/api/v1/execution-plans', request); + if (res.status === 503) { + this.executionPlansAvailable = false; + this.executionPlans = []; + return; + } + this.executionPlansAvailable = true; + if (!this.handleFetchResponse(res, 'workflows')) { + this.executionPlans = []; + return; + } + const payload = await res.json(); + this.executionPlans = Array.isArray(payload) ? payload : []; + } catch (e) { + console.error('Failed to fetch workflows:', e); + this.executionPlans = []; + this.executionPlanError = e && e.name === 'AbortError' + ? 'Loading workflows timed out.' + : 'Unable to load workflows.'; + } finally { + if (timeoutID !== null && typeof clearTimeout === 'function') { + clearTimeout(timeoutID); + } + this.executionPlansLoading = false; + } + }, + + async fetchExecutionPlanGuardrails() { + try { + const res = await fetch('/admin/api/v1/execution-plans/guardrails', { headers: this.headers() }); + if (!this.handleFetchResponse(res, 'workflow guardrails')) { + this.guardrailRefs = []; + return; + } + const payload = await res.json(); + this.guardrailRefs = Array.isArray(payload) ? payload : []; + } catch (e) { + console.error('Failed to fetch workflow guardrails:', e); + this.guardrailRefs = []; + } + }, + + async fetchExecutionPlansPage() { + await Promise.all([this.fetchExecutionPlans(), this.fetchExecutionPlanGuardrails()]); + }, + + async submitExecutionPlanForm() { + if (this.executionPlanSubmitting) { + return; + } + this.executionPlanFormError = ''; + this.executionPlanNotice = ''; + + const payload = this.buildExecutionPlanRequest(); + const validationError = this.validateExecutionPlanRequest(payload); + if (validationError) { + this.executionPlanFormError = validationError; + return; + } + + this.executionPlanSubmitting = true; + try { + const res = await fetch('/admin/api/v1/execution-plans', { + method: 'POST', + headers: this.headers(), + body: JSON.stringify(payload) + }); + + if (res.status === 401) { + this.handleFetchResponse(res, 'create workflow'); + return; + } + if (!res.ok) { + this.executionPlanFormError = await this.executionPlanResponseMessage(res, 'Unable to create workflow.'); + return; + } + + this.executionPlanNotice = 'Workflow created and activated.'; + this.closeExecutionPlanForm(); + await this.fetchExecutionPlansPage(); + } catch (e) { + console.error('Failed to create workflow:', e); + this.executionPlanFormError = 'Unable to create workflow.'; + } finally { + this.executionPlanSubmitting = false; + } + }, + + // ─── Execution Pipeline helpers ─── + + epHasGuardrails(source) { + return !!this.executionPlanSourceFeatures(source).guardrails; + }, + + epHasCache(source) { + return !!this.executionPlanSourceFeatures(source).cache; + }, + + epHasAudit(source) { + return !!this.executionPlanSourceFeatures(source).audit; + }, + + epHasUsage(source) { + return !!this.executionPlanSourceFeatures(source).usage; + }, + + epHasAsync(source) { + const f = this.executionPlanSourceFeatures(source); + return !!(f.audit || f.usage); + }, + + epGuardrailLabel(source) { + const count = this.executionPlanSourceGuardrails(source).length; + if (count === 0) return ''; + return count === 1 ? '1 step' : count + ' steps'; + }, + + epAiLabel(source, runtime) { + if (runtime && runtime.provider) return runtime.provider; + const provider = source && source.scope && source.scope.scope_provider; + return provider || 'AI'; + }, + + epAiSublabel(source, runtime) { + if (runtime && runtime.model) return runtime.model; + return source && source.scope && source.scope.scope_model || null; + }, + + // runtime shape: { cacheHit: bool|null, cacheType: 'exact'|'semantic'|null, provider, model } + epRuntimeHasCache(runtime) { + return !!(runtime && runtime.cacheHit !== null && runtime.cacheHit !== undefined); + }, + + epShowCacheStep(source, runtime) { + return this.epHasCache(source) || this.epRuntimeHasCache(runtime); + }, + + epCacheNodeClass(runtime) { + if (!this.epRuntimeHasCache(runtime)) return ''; + if (runtime.cacheHit && runtime.cacheType === 'semantic') return 'ep-node-cache-semantic'; + if (runtime.cacheHit) return 'ep-node-cache-hit'; + return 'ep-node-cache-miss'; + }, + + epCacheConnClass(runtime) { + if (!this.epRuntimeHasCache(runtime)) return ''; + return runtime.cacheHit ? 'ep-conn-hit' : ''; + }, + + epCacheStatusLabel(runtime) { + if (!this.epRuntimeHasCache(runtime)) return null; + if (runtime.cacheHit && runtime.cacheType === 'semantic') return 'Semantic'; + return runtime.cacheHit ? 'Hit' : 'Miss'; + }, + + epAiConnClass(runtime) { + if (!this.epRuntimeHasCache(runtime)) return ''; + return runtime.cacheHit ? 'ep-conn-dim' : ''; + }, + + epAiNodeClass(runtime) { + if (!this.epRuntimeHasCache(runtime)) return ''; + return runtime.cacheHit ? 'ep-node-ai-skipped' : ''; + }, + + epRuntimeFromEntry(entry) { + if (!entry) return null; + const cacheHit = (entry.cache_hit !== undefined && entry.cache_hit !== null) + ? !!entry.cache_hit + : null; + return { + cacheHit, + cacheType: entry.cache_type || null, + provider: entry.provider || null, + model: entry.model || null + }; + }, + + async deactivateExecutionPlan(plan) { + const planID = String(plan && plan.id || '').trim(); + if (!planID || this.executionPlanDeactivatingID || !this.canDeactivateExecutionPlan(plan)) { + return; + } + const workflowName = this.workflowDisplayName(plan); + if (typeof global.confirm === 'function' && !global.confirm( + 'Deactivate workflow "' + workflowName + '"? Requests will fall back to the next active workflow for this scope.' + )) { + return; + } + + this.executionPlanError = ''; + this.executionPlanNotice = ''; + this.executionPlanDeactivatingID = planID; + try { + const res = await fetch('/admin/api/v1/execution-plans/' + encodeURIComponent(planID) + '/deactivate', { + method: 'POST', + headers: this.headers() + }); + + if (res.status === 401) { + this.handleFetchResponse(res, 'deactivate workflow'); + return; + } + if (!res.ok) { + this.executionPlanError = await this.executionPlanResponseMessage(res, 'Unable to deactivate workflow.'); + return; + } + + this.executionPlanNotice = 'Workflow deactivated.'; + await this.fetchExecutionPlansPage(); + } catch (e) { + console.error('Failed to deactivate workflow:', e); + this.executionPlanError = 'Unable to deactivate workflow.'; + } finally { + this.executionPlanDeactivatingID = ''; + } + } + }; + } + + global.dashboardExecutionPlansModule = dashboardExecutionPlansModule; +})(window); diff --git a/internal/admin/dashboard/static/js/modules/execution-plans.test.js b/internal/admin/dashboard/static/js/modules/execution-plans.test.js new file mode 100644 index 00000000..927bc568 --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/execution-plans.test.js @@ -0,0 +1,483 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function loadExecutionPlansModuleFactory(overrides = {}) { + const source = fs.readFileSync(path.join(__dirname, 'execution-plans.js'), 'utf8'); + const window = { + ...(overrides.window || {}) + }; + const context = { + console, + ...overrides, + window + }; + vm.createContext(context); + vm.runInContext(source, context); + return context.window.dashboardExecutionPlansModule; +} + +function createExecutionPlansModule(overrides) { + const factory = loadExecutionPlansModuleFactory(overrides); + return factory(); +} + +test('executionPlanProviderOptions returns unique sorted provider types', () => { + const module = createExecutionPlansModule(); + module.models = [ + { provider_type: 'anthropic', model: { id: 'claude-3-7' } }, + { provider_type: 'openai', model: { id: 'gpt-5' } }, + { provider_type: 'openai', model: { id: 'gpt-4o-mini' } } + ]; + + assert.equal( + JSON.stringify(module.executionPlanProviderOptions()), + JSON.stringify(['anthropic', 'openai']) + ); +}); + +test('buildExecutionPlanRequest emits provider-model payload and strips guardrails when disabled', () => { + const module = createExecutionPlansModule(); + module.executionPlanForm = { + scope_provider: 'openai', + scope_model: 'gpt-5', + name: 'OpenAI GPT-5', + description: 'Primary translated requests', + features: { + cache: true, + audit: true, + usage: true, + guardrails: false + }, + guardrails: [ + { ref: 'policy-system', step: 10 } + ] + }; + + assert.equal( + JSON.stringify(module.buildExecutionPlanRequest()), + JSON.stringify({ + scope_provider: 'openai', + scope_model: 'gpt-5', + name: 'OpenAI GPT-5', + description: 'Primary translated requests', + plan_payload: { + schema_version: 1, + features: { + cache: true, + audit: true, + usage: true, + guardrails: false + }, + guardrails: [] + } + }) + ); +}); + +test('openExecutionPlanCreate hydrates features and guardrails via shared normalizers', () => { + const module = createExecutionPlansModule(); + module.executionPlanSourceFeatures = () => ({ + cache: false, + audit: false, + usage: true, + guardrails: true + }); + module.executionPlanSourceGuardrails = () => ([ + { ref: 'policy-system', step: 30 } + ]); + module.scrollExecutionPlanFormIntoView = () => {}; + + module.openExecutionPlanCreate({ + scope: { + scope_provider: 'openai', + scope_model: 'gpt-5' + }, + name: 'Hydrated workflow', + description: 'Uses helper normalization', + plan_payload: { + features: { + cache: true, + audit: true, + usage: false, + guardrails: false + }, + guardrails: [ + { ref: 'wrong-source', step: 10 } + ] + } + }); + + assert.equal( + JSON.stringify(module.executionPlanForm.features), + JSON.stringify({ + cache: false, + audit: false, + usage: true, + guardrails: true + }) + ); + assert.equal( + JSON.stringify(module.executionPlanForm.guardrails), + JSON.stringify([{ ref: 'policy-system', step: 30 }]) + ); +}); + +test('editing a cloned workflow preserves retired provider and model options', () => { + const module = createExecutionPlansModule(); + module.models = [ + { provider_type: 'openai', model: { id: 'gpt-5' } } + ]; + module.scrollExecutionPlanFormIntoView = () => {}; + + module.openExecutionPlanCreate({ + scope: { + scope_provider: 'anthropic', + scope_model: 'claude-retired' + }, + name: 'Retired workflow', + description: 'Cloned from an older deployment', + plan_payload: { + features: { + cache: true, + audit: true, + usage: true, + guardrails: false + }, + guardrails: [] + } + }); + + assert.equal( + JSON.stringify(module.executionPlanProviderOptions()), + JSON.stringify(['anthropic', 'openai']) + ); + assert.equal( + JSON.stringify(module.executionPlanModelOptions('anthropic')), + JSON.stringify(['claude-retired']) + ); + assert.equal(module.validateExecutionPlanRequest(module.buildExecutionPlanRequest()), ''); + + const invalidPayload = module.buildExecutionPlanRequest(); + invalidPayload.scope_model = 'different-retired-model'; + assert.equal( + module.validateExecutionPlanRequest(invalidPayload), + 'Choose a registered model for the selected provider.' + ); +}); + +test('buildExecutionPlanRequest preserves blank guardrail steps as invalid so validation rejects them', () => { + const module = createExecutionPlansModule(); + module.models = [ + { provider_type: 'openai', model: { id: 'gpt-5' } } + ]; + module.executionPlanForm = { + scope_provider: 'openai', + scope_model: 'gpt-5', + name: 'OpenAI GPT-5', + description: 'Primary translated requests', + features: { + cache: true, + audit: true, + usage: true, + guardrails: true + }, + guardrails: [ + { ref: 'policy-system', step: ' ' } + ] + }; + + const payload = module.buildExecutionPlanRequest(); + + assert.ok(Number.isNaN(payload.plan_payload.guardrails[0].step)); + assert.equal( + module.validateExecutionPlanRequest(payload), + 'Each guardrail step must use a non-negative integer step number.' + ); +}); + +test('validateExecutionPlanRequest rejects negative guardrail step numbers', () => { + const module = createExecutionPlansModule(); + const payload = { + scope_provider: '', + scope_model: '', + name: 'Global', + plan_payload: { + schema_version: 1, + features: { + cache: true, + audit: true, + usage: true, + guardrails: true + }, + guardrails: [ + { ref: 'policy-system', step: -1 } + ] + } + }; + + assert.equal( + module.validateExecutionPlanRequest(payload), + 'Each guardrail step must use a non-negative integer step number.' + ); +}); + +test('validateExecutionPlanRequest rejects duplicate guardrail refs', () => { + const module = createExecutionPlansModule(); + const payload = { + scope_provider: '', + scope_model: '', + name: 'Global', + plan_payload: { + schema_version: 1, + features: { + cache: true, + audit: true, + usage: true, + guardrails: true + }, + guardrails: [ + { ref: 'policy-system', step: 10 }, + { ref: 'policy-system', step: 20 } + ] + } + }; + + assert.equal( + module.validateExecutionPlanRequest(payload), + 'Each guardrail ref may appear only once in a plan.' + ); +}); + +test('setExecutionPlanProvider clears model when provider changes', () => { + const module = createExecutionPlansModule(); + module.models = [ + { provider_type: 'openai', model: { id: 'gpt-5' } }, + { provider_type: 'anthropic', model: { id: 'claude-3-7' } } + ]; + module.executionPlanForm = module.defaultExecutionPlanForm(); + module.executionPlanForm.scope_provider = 'openai'; + module.executionPlanForm.scope_model = 'gpt-5'; + + module.setExecutionPlanProvider('anthropic'); + + assert.equal(module.executionPlanForm.scope_provider, 'anthropic'); + assert.equal(module.executionPlanForm.scope_model, ''); +}); + +test('validateExecutionPlanRequest rejects unregistered provider-model selections', () => { + const module = createExecutionPlansModule(); + module.models = [ + { provider_type: 'openai', model: { id: 'gpt-5' } } + ]; + + assert.equal( + module.validateExecutionPlanRequest({ + scope_provider: 'anthropic', + scope_model: '', + plan_payload: { + schema_version: 1, + features: { cache: true, audit: true, usage: true, guardrails: false }, + guardrails: [] + } + }), + 'Choose a registered provider.' + ); + + assert.equal( + module.validateExecutionPlanRequest({ + scope_provider: 'openai', + scope_model: 'gpt-4o-mini', + plan_payload: { + schema_version: 1, + features: { cache: true, audit: true, usage: true, guardrails: false }, + guardrails: [] + } + }), + 'Choose a registered model for the selected provider.' + ); +}); + +test('workflowDisplayName falls back to scope label or All models', () => { + const module = createExecutionPlansModule(); + + assert.equal( + module.workflowDisplayName({ name: '', scope_display: 'global' }), + 'All models' + ); + assert.equal( + module.workflowDisplayName({ name: '', scope_display: 'openai/gpt-5' }), + 'openai/gpt-5' + ); + assert.equal( + module.workflowDisplayName({ name: 'Primary workflow', scope_display: 'openai/gpt-5' }), + 'Primary workflow' + ); +}); + +test('epGuardrailLabel only shows a sublabel when guardrail steps exist', () => { + const module = createExecutionPlansModule(); + + assert.equal( + module.epGuardrailLabel({ + plan_payload: { + guardrails: [] + } + }), + '' + ); + + assert.equal( + module.epGuardrailLabel({ + plan_payload: { + guardrails: [{ ref: 'policy-system', step: 10 }] + } + }), + '1 step' + ); + + assert.equal( + module.epGuardrailLabel({ + plan_payload: { + guardrails: [ + { ref: 'policy-system', step: 10 }, + { ref: 'pii', step: 20 } + ] + } + }), + '2 steps' + ); +}); + +test('deactivateExecutionPlan requires confirmation before posting', async () => { + let fetchCalled = false; + const module = createExecutionPlansModule({ + window: { + confirm(message) { + assert.match(message, /Deactivate workflow "Primary workflow"\?/); + return false; + } + }, + fetch() { + fetchCalled = true; + throw new Error('fetch should not be called when deactivation is cancelled'); + } + }); + module.headers = () => ({}); + + await module.deactivateExecutionPlan({ + id: 'workflow-1', + name: 'Primary workflow', + scope_type: 'provider' + }); + + assert.equal(fetchCalled, false); + assert.equal(module.executionPlanDeactivatingID, ''); +}); + +test('deactivateExecutionPlan ignores duplicate clicks while another deactivation is in flight', async () => { + let confirmCalled = false; + let fetchCalled = false; + const module = createExecutionPlansModule({ + window: { + confirm() { + confirmCalled = true; + return true; + } + }, + fetch() { + fetchCalled = true; + throw new Error('fetch should not be called while another deactivation is already in flight'); + } + }); + module.executionPlanDeactivatingID = 'workflow-1'; + module.headers = () => ({}); + + await module.deactivateExecutionPlan({ + id: 'workflow-1', + name: 'Primary workflow', + scope_type: 'provider' + }); + + assert.equal(confirmCalled, false); + assert.equal(fetchCalled, false); + assert.equal(module.executionPlanDeactivatingID, 'workflow-1'); +}); + +test('fetchExecutionPlans aborts hung requests and clears loading state', async () => { + let timeoutCleared = false; + class AbortControllerStub { + constructor() { + this.signal = { aborted: false }; + } + + abort() { + this.signal.aborted = true; + } + } + + const module = createExecutionPlansModule({ + AbortController: AbortControllerStub, + setTimeout(fn) { + fn(); + return 42; + }, + clearTimeout(id) { + assert.equal(id, 42); + timeoutCleared = true; + }, + fetch(_url, options) { + assert.equal(options.headers.authorization, 'Bearer token'); + assert.equal(options.signal.aborted, true); + return Promise.reject(Object.assign(new Error('timed out'), { name: 'AbortError' })); + } + }); + module.headers = () => ({ authorization: 'Bearer token' }); + + await module.fetchExecutionPlans(); + + assert.equal(JSON.stringify(module.executionPlans), JSON.stringify([])); + assert.equal(module.executionPlanError, 'Loading workflows timed out.'); + assert.equal(module.executionPlansLoading, false); + assert.equal(timeoutCleared, true); +}); + +test('submitExecutionPlanForm ignores duplicate submissions while a request is already in flight', async () => { + let fetchCalled = false; + const module = createExecutionPlansModule({ + fetch() { + fetchCalled = true; + return Promise.resolve({ + ok: true, + status: 201 + }); + } + }); + module.models = [ + { provider_type: 'openai', model: { id: 'gpt-5' } } + ]; + module.executionPlanSubmitting = true; + module.executionPlanForm = { + scope_provider: 'openai', + scope_model: 'gpt-5', + name: 'OpenAI GPT-5', + description: 'Primary translated requests', + features: { + cache: true, + audit: true, + usage: true, + guardrails: false + }, + guardrails: [] + }; + module.headers = () => ({}); + module.closeExecutionPlanForm = () => {}; + module.fetchExecutionPlansPage = async () => {}; + + await module.submitExecutionPlanForm(); + + assert.equal(fetchCalled, false); + assert.equal(module.executionPlanSubmitting, true); +}); diff --git a/internal/admin/dashboard/static/js/modules/usage.js b/internal/admin/dashboard/static/js/modules/usage.js index 13834092..305cca9a 100644 --- a/internal/admin/dashboard/static/js/modules/usage.js +++ b/internal/admin/dashboard/static/js/modules/usage.js @@ -34,7 +34,7 @@ this.daily = await dailyRes.json(); this.renderChart(); if (this.page === 'usage') this.fetchUsagePage(); - if (this.page === 'audit') this.fetchAuditLog(true); + if (this.page === 'audit-logs') this.fetchAuditLog(true); } catch (e) { console.error('Failed to fetch usage:', e); } diff --git a/internal/admin/dashboard/templates/index.html b/internal/admin/dashboard/templates/index.html index 6828ac76..b43d304c 100644 --- a/internal/admin/dashboard/templates/index.html +++ b/internal/admin/dashboard/templates/index.html @@ -612,8 +612,298 @@

No models match your filter.

+ +
+ + +
+ Authentication required. Enter your API key in the sidebar to view data. +
+
+ Workflows feature is unavailable. +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+

Workflow

+ ? +
+
+ +
+ +
+ +
+ + + + + +
+ +

Leave provider empty to target all providers and models. Select a provider without a model to target all models for that provider.

+

If you leave the name empty, the workflow will display as the matched provider/model scope or “All models”.

+ + + +
+ + + + +
+ +
+
+
+

Guardrail Steps

+

Guardrails in the same numeric step run together. Later steps wait for earlier ones to finish.

+
+ +
+ +
+ No named guardrails are currently registered on this deployment. You can still draft a plan, but guardrail-backed creation may be rejected. +
+ +
+ +
+ +

No guardrail steps configured yet.

+
+ +
+ + +
+
+
+ +
+

Loading workflows...

+ +
+ +
+ +

No active workflows found.

+

No workflows match your filter.

+
+ +
+ + + + +
+ -
+