From 9b8ffa3c9001df8180322dfabad1e92d3edc50e1 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Sat, 28 Mar 2026 09:38:11 +0100 Subject: [PATCH 1/8] feat: implemented api and ui for the admin/dashboard --- internal/admin/dashboard/dashboard_test.go | 26 + .../admin/dashboard/static/css/dashboard.css | 574 +++++++++++++++++ .../admin/dashboard/static/js/dashboard.js | 23 +- .../static/js/modules/execution-plans.js | 557 ++++++++++++++++ .../static/js/modules/execution-plans.test.js | 197 ++++++ .../dashboard/static/js/modules/usage.js | 2 +- internal/admin/dashboard/templates/index.html | 338 +++++++++- .../admin/dashboard/templates/layout.html | 7 +- internal/admin/handler.go | 151 ++++- internal/admin/handler_executionplans_test.go | 603 ++++++++++++++++++ internal/app/app.go | 28 +- internal/executionplans/service.go | 188 ++++++ internal/executionplans/service_test.go | 224 ++++++- internal/executionplans/store.go | 1 + internal/executionplans/store_mongodb.go | 14 + internal/executionplans/store_postgresql.go | 15 + internal/executionplans/store_sqlite.go | 19 + internal/executionplans/types.go | 3 - internal/executionplans/types_test.go | 26 + internal/executionplans/view.go | 14 + internal/guardrails/registry.go | 15 + internal/server/http.go | 4 + internal/server/http_test.go | 27 + 23 files changed, 3039 insertions(+), 17 deletions(-) create mode 100644 internal/admin/dashboard/static/js/modules/execution-plans.js create mode 100644 internal/admin/dashboard/static/js/modules/execution-plans.test.js create mode 100644 internal/admin/handler_executionplans_test.go create mode 100644 internal/executionplans/view.go 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..de46b4d0 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,388 @@ body.conversation-drawer-open { display: flex; } } + +/* ═══════════════════════════════════════════════════════════════ + Execution Pipeline Visualization + ═══════════════════════════════════════════════════════════════ */ + +.exec-pipeline { + display: flex; + flex-direction: column; + gap: 0; + padding: 18px 20px 20px; + border-radius: 16px; + 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: 12px; + 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: 8px; + 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: 999px; + 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: 8px 16px; + border-radius: 999px; + min-width: auto; + gap: 7px; + border-color: color-mix(in srgb, var(--border) 70%, transparent); + background: color-mix(in srgb, var(--bg-surface) 60%, var(--bg)); +} + +.ep-node-endpoint .ep-node-icon { + width: 20px; + height: 20px; + background: transparent; + border-radius: 4px; + color: var(--text-muted); +} + +.ep-node-endpoint .ep-node-icon 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(--warning) 46%, var(--border)); + background: color-mix(in srgb, var(--warning) 8%, var(--bg-surface)); +} + +.ep-node-guardrails .ep-node-icon { + background: color-mix(in srgb, var(--warning) 16%, var(--bg)); + color: color-mix(in srgb, var(--warning) 90%, var(--text)); +} + +.ep-node-guardrails .ep-node-label { + color: color-mix(in srgb, var(--warning) 80%, var(--text)); +} + +.ep-node-guardrails .ep-node-sub { + color: color-mix(in srgb, var(--warning) 60%, 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: 14px; + gap: 6px; +} + +.ep-node-ai .ep-node-icon { + width: 32px; + height: 32px; + border-radius: 10px; +} + +.ep-node-ai .ep-node-icon svg { + width: 17px; + height: 17px; +} + +/* AI skipped — cache hit absorbed the request */ +.ep-node-ai-skipped { + opacity: 0.28; +} + +/* ─── Async connector ─── */ + +/* + * Async nodes live after Response in the same row, connected by a dashed + * line — they fire after the response is returned to the client. + * + * [AI] ──────→ [Response] - - → [Audit Log] - - → [Usage] + */ + +/* Dashed connector for async nodes */ +.ep-conn-async { + background: repeating-linear-gradient( + to right, + 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: 20px; + flex-shrink: 0; +} + +.ep-conn-async::after { + background: color-mix(in srgb, var(--text-muted) 45%, var(--border)); +} + +/* Async nodes — horizontal inline pills */ +.ep-node-async { + flex-direction: row; + padding: 7px 12px; + border-radius: 10px; + border-style: dashed; + min-width: auto; + gap: 7px; +} + +.ep-node-async .ep-node-icon { + width: 20px; + height: 20px; + border-radius: 5px; +} + +.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(--success) 40%, var(--border)); + background: color-mix(in srgb, var(--success) 7%, var(--bg-surface)); +} + +.ep-node-async-audit .ep-node-icon { + background: color-mix(in srgb, var(--success) 14%, var(--bg)); + color: var(--success); +} + +.ep-node-async-audit .ep-node-label { + color: color-mix(in srgb, var(--success) 75%, var(--text-muted)); +} + +.ep-node-async-usage { + border-color: color-mix(in srgb, #60a5fa 40%, var(--border)); + background: color-mix(in srgb, #60a5fa 7%, var(--bg-surface)); +} + +.ep-node-async-usage .ep-node-icon { + background: color-mix(in srgb, #60a5fa 14%, var(--bg)); + color: #60a5fa; +} + +.ep-node-async-usage .ep-node-label { + color: color-mix(in srgb, #60a5fa 75%, var(--text-muted)); +} 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.js b/internal/admin/dashboard/static/js/modules/execution-plans.js new file mode 100644 index 00000000..48d0d32e --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/execution-plans.js @@ -0,0 +1,557 @@ +(function(global) { + function dashboardExecutionPlansModule() { + return { + executionPlans: [], + executionPlansAvailable: true, + executionPlansLoading: false, + executionPlanError: '', + executionPlanNotice: '', + executionPlanFilter: '', + executionPlanFormOpen: false, + executionPlanSubmitting: false, + executionPlanDeactivatingID: '', + executionPlanFormError: '', + 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(); + this.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(); + this.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.executionPlanForm = this.defaultExecutionPlanForm(); + this.scrollExecutionPlanFormIntoView(); + return; + } + + const features = plan && plan.plan_payload && plan.plan_payload.features ? plan.plan_payload.features : {}; + 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: this.planGuardrails(plan).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.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) => ({ + ref: String(step && step.ref || '').trim(), + step: Number(step && step.step) + })) + : []; + + 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) { + if (payload.scope_provider) { + const providers = this.executionPlanProviderOptions(); + if (!providers.includes(payload.scope_provider)) { + 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); + if (!models.includes(payload.scope_model)) { + 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)) { + return 'Each guardrail step must use an 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 = ''; + try { + const res = await fetch('/admin/api/v1/execution-plans', { headers: this.headers() }); + 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 = 'Unable to load workflows.'; + } finally { + 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() { + 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 'Guardrails'; + 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.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..bf34f8fd --- /dev/null +++ b/internal/admin/dashboard/static/js/modules/execution-plans.test.js @@ -0,0 +1,197 @@ +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('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('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, ''); +}); 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..e562ec22 100644 --- a/internal/admin/dashboard/templates/index.html +++ b/internal/admin/dashboard/templates/index.html @@ -612,8 +612,292 @@

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.

+
+ +
+ + + + +
+ -
+