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.
+
+
+
+
+
+
+
+
+
+
+ Loading workflows...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Guardrails
+
+
+
+
No guardrails configured for this plan.
+
+
+
+
+
+
+
+ No active workflows found.
+ No workflows match your filter.
+
+
+
+
+
+
+
-