diff --git a/.env.template b/.env.template index 509fc97b..16e470f0 100644 --- a/.env.template +++ b/.env.template @@ -130,6 +130,10 @@ # to streaming requests for OpenAI-compatible providers # ENFORCE_RETURNING_USAGE_DATA=true +# Enable/disable guardrails globally (default: false) +# When enabled, configured guardrails can run for workflows that reference them +# GUARDRAILS_ENABLED=false + # Guardrails for inline batch processing (default: false) # When true, guardrails are applied to inline /v1/batches request items # (e.g. /v1/chat/completions and /v1/responses items). diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index 158a16c2..c8ccfa0c 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -176,6 +176,29 @@ func TestStatic_ServesExecutionPlansModuleJS(t *testing.T) { } } +func TestStatic_ServesGuardrailsModuleJS(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/guardrails.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 guardrails 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 8a5c0eb9..8185c9d3 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -792,6 +792,14 @@ body { color: var(--success); } +.alert-inline-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + /* Table */ .table-toolbar { display: flex; @@ -2343,6 +2351,166 @@ body.conversation-drawer-open { width: 100%; } +.page-subtitle { + margin-top: 6px; + color: var(--text-muted); + font-size: 14px; +} + +.settings-subnav { + display: inline-flex; + gap: 8px; + padding: 6px; + margin-bottom: 20px; + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-surface) 80%, transparent); +} + +.settings-subnav-btn { + border: 0; + border-radius: 999px; + padding: 10px 14px; + background: transparent; + color: var(--text-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease; +} + +.settings-subnav-btn:hover { + color: var(--text); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.settings-subnav-btn.active { + color: var(--text); + background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent-hover) 16%, transparent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, var(--border)); +} + +.settings-guardrails-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 24px; + margin-bottom: 20px; + border: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border)); + border-radius: var(--radius); + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--accent-hover) 18%, transparent), transparent 42%), + radial-gradient(circle at bottom left, color-mix(in srgb, var(--accent) 16%, transparent), transparent 40%), + var(--bg-surface); +} + +.settings-kicker { + margin: 0 0 10px; + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-guardrails-hero h3 { + margin-bottom: 8px; +} + +.settings-guardrails-hero p:last-child { + margin-bottom: 0; + color: var(--text-muted); +} + +.settings-guardrails-meta { + display: flex; + gap: 12px; +} + +.settings-guardrails-stat { + min-width: 110px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border)); + border-radius: 16px; + background: color-mix(in srgb, var(--bg-surface-hover) 76%, transparent); +} + +.settings-guardrails-stat-label { + display: block; + margin-bottom: 8px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-guardrails-stat strong { + font-size: 24px; + font-weight: 700; +} + +.settings-guardrails-layout { + display: grid; + grid-template-columns: 1fr; + gap: 20px; + align-items: start; +} + +.settings-guardrails-layout.is-editor-open { + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr); +} + +.settings-guardrails-list, +.settings-guardrails-editor { + min-width: 0; +} + +.settings-guardrail-type-pill { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--text); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.settings-guardrail-summary { + color: var(--text); + font-size: 14px; + line-height: 1.45; +} + +.settings-guardrail-description { + margin-top: 6px; + color: var(--text-muted); + font-size: 12px; +} + +.settings-guardrail-textarea { + min-height: 150px; + width: 100%; + resize: vertical; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + line-height: 1.55; +} + +.settings-guardrail-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + /* Responsive */ @media (max-width: 768px) { .sidebar { width: 60px; flex-basis: 60px; } @@ -2376,6 +2544,31 @@ body.conversation-drawer-open { gap: 8px; } + .settings-subnav { + display: flex; + width: 100%; + } + + .settings-subnav-btn { + flex: 1 1 0; + } + + .settings-guardrails-hero { + flex-direction: column; + } + + .settings-guardrails-meta { + width: 100%; + } + + .settings-guardrails-stat { + flex: 1 1 0; + } + + .settings-guardrails-layout { + grid-template-columns: 1fr; + } + /* Category tabs mobile */ .category-tabs { gap: 4px; @@ -2581,45 +2774,20 @@ body.conversation-drawer-open { 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; + flex: 1 1 0; + min-width: 13px; position: relative; - width: 32px; + width: auto; 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; @@ -2658,8 +2826,8 @@ body.conversation-drawer-open { flex-direction: column; align-items: center; justify-content: center; - gap: 5px; - padding: 10px 14px; + gap: 4px; + padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-surface); @@ -2759,154 +2927,90 @@ body.conversation-drawer-open { color: var(--text-muted); } -.ep-node-endpoint-success { - border-color: color-mix(in srgb, var(--success) 52%, var(--border)); - background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); -} +/* ─── Shared node variants ─── */ -.ep-node-endpoint-success .ep-node-icon-endpoint { - color: var(--success); -} - -.ep-node-endpoint-success .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); -} - -/* ─── Guardrails node ─── */ - -.ep-node-guardrails { +.ep-node-feature { 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 { +.ep-node-feature .ep-node-icon { background: color-mix(in srgb, var(--accent) 16%, var(--bg)); color: var(--accent); } -.ep-node-guardrails .ep-node-label { +.ep-node-feature .ep-node-label { color: var(--accent); } -.ep-node-guardrails .ep-node-sub { +.ep-node-feature .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 { +.ep-node-success { 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 { +.ep-node-success .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)); +.ep-node-success .ep-node-icon-endpoint { color: var(--success); } -/* Cache — runtime: semantic hit */ -.ep-node-cache-semantic { - 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-semantic .ep-node-icon { - background: color-mix(in srgb, var(--success) 18%, var(--bg)); - color: var(--success); +.ep-node-success .ep-node-label { + color: color-mix(in srgb, var(--success) 85%, var(--text)); } -.ep-node-cache-semantic .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); +.ep-node-success .ep-node-sub { + color: color-mix(in srgb, var(--success) 74%, var(--text-muted)); } -.ep-node-cache-semantic .ep-node-badge { +.ep-node-success .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: miss badge only */ -.ep-node-cache-miss .ep-node-badge { - color: var(--text-muted); - border-color: var(--border); -} - -/* ─── Auth node ─── */ - -.ep-node-auth { - border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); - background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); -} - -.ep-node-auth .ep-node-icon { - background: color-mix(in srgb, var(--accent) 16%, var(--bg)); - color: var(--accent); -} - -.ep-node-auth .ep-node-label { - color: var(--accent); +.ep-node-error { + border-color: color-mix(in srgb, var(--danger) 52%, var(--border)); + background: color-mix(in srgb, var(--danger) 9%, var(--bg-surface)); } -.ep-node-auth .ep-node-sub { - color: color-mix(in srgb, var(--accent) 70%, var(--text-muted)); +.ep-node-error .ep-node-icon { + background: color-mix(in srgb, var(--danger) 14%, var(--bg)); + color: var(--danger); } -.ep-node-auth-success { - border-color: color-mix(in srgb, var(--success) 52%, var(--border)); - background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); +.ep-node-error .ep-node-icon-endpoint { + color: var(--danger); } -.ep-node-auth-success .ep-node-icon { - background: color-mix(in srgb, var(--success) 18%, var(--bg)); - color: var(--success); +.ep-node-error .ep-node-label { + color: color-mix(in srgb, var(--danger) 85%, var(--text)); } -.ep-node-auth-success .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); +.ep-node-error .ep-node-sub { + color: color-mix(in srgb, var(--danger) 72%, var(--text-muted)); } -.ep-node-auth-success .ep-node-sub { - color: color-mix(in srgb, var(--success) 70%, var(--text-muted)); +.ep-node-skipped { + position: relative; + opacity: 0.28; } -.ep-node-auth-error { - border-color: color-mix(in srgb, var(--danger) 52%, var(--border)); - background: color-mix(in srgb, var(--danger) 9%, var(--bg-surface)); -} +/* ─── Cache node ─── */ -.ep-node-auth-error .ep-node-icon { - background: color-mix(in srgb, var(--danger) 14%, var(--bg)); - color: var(--danger); +/* Cache — runtime: miss badge only */ +.ep-node-cache-miss .ep-node-badge { + color: var(--text-muted); + border-color: var(--border); } -.ep-node-auth-error .ep-node-label { - color: color-mix(in srgb, var(--danger) 85%, var(--text)); -} +/* ─── Auth node ─── */ /* ─── AI node ─── */ @@ -2917,25 +3021,6 @@ body.conversation-drawer-open { gap: 6px; } -.ep-node-ai-success { - border-color: color-mix(in srgb, var(--success) 52%, var(--border)); - background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); -} - -.ep-node-ai-success .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); -} - -.ep-node-ai-success .ep-node-sub { - color: color-mix(in srgb, var(--success) 78%, var(--text-muted)); -} - -/* AI skipped — cache hit absorbed the request */ -.ep-node-ai-skipped { - position: relative; - opacity: 0.28; -} - /* ─── Async section ─── */ /* @@ -3008,6 +3093,7 @@ body.conversation-drawer-open { /* Dashed left-pointing connector between async nodes */ .ep-conn-async { + flex: 0 0 24px; background: repeating-linear-gradient( to left, color-mix(in srgb, var(--text-muted) 45%, var(--border)) 0, @@ -3016,7 +3102,6 @@ body.conversation-drawer-open { transparent 9px ); width: 24px; - flex-shrink: 0; } /* Left-pointing arrowhead (overrides ep-conn::after right-pointing default) */ @@ -3053,32 +3138,12 @@ body.conversation-drawer-open { 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); + --accent: var(--success); } -.ep-node-async-usage .ep-node-label { - color: var(--accent); +.ep-node-async-audit { + --accent: var(--warning); } /* "ASYNC" label inline on the right of the branch */ diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 43fa8714..35da1bd8 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -30,6 +30,7 @@ function dashboard() { apiKey: '', theme: 'system', sidebarCollapsed: false, + settingsSubpage: 'general', // Date picker datePickerOpen: false, @@ -113,11 +114,47 @@ function dashboard() { if (page === 'audit') { page = 'audit-logs'; } - page = (['overview', 'usage', 'models', 'workflows', 'audit-logs', 'auth-keys', 'settings'].includes(page)) ? page : 'overview'; const sub = parts[1] || null; + if (page === 'settings' && sub === 'guardrails') { + return { page: 'guardrails', sub: null }; + } + page = (['overview', 'usage', 'models', 'workflows', 'audit-logs', 'guardrails', 'auth-keys', 'settings'].includes(page)) ? page : 'overview'; return { page, sub }; }, + _normalizeSettingsSubpage(subpage) { + return 'general'; + }, + + _settingsPath(subpage) { + return '/admin/dashboard/settings'; + }, + + _applyRoute(page, sub) { + this.page = page; + this.settingsSubpage = page === 'settings' + ? this._normalizeSettingsSubpage(sub) + : 'general'; + + if (page === 'usage' && sub === 'costs') this.usageMode = 'costs'; + if (page === 'usage' && sub !== 'costs') this.usageMode = 'tokens'; + if (page === 'audit-logs') this.fetchAuditLog(true); + if (page === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); + if (page === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') { + this.fetchExecutionPlansPage(); + } + if (page === 'guardrails' && typeof this.fetchGuardrailsPage === 'function') { + this.fetchGuardrailsPage(); + } + if (page === 'settings') { + if (typeof this.ensureTimezoneOptions === 'function') { + this.ensureTimezoneOptions(); + } + } + if (page === 'overview') this.renderChart(); + if (page === 'usage') this.fetchUsagePage(); + }, + init() { if (typeof this.initTimeZoneState === 'function') { this.initTimeZoneState(); @@ -128,28 +165,11 @@ function dashboard() { this.applyTheme(); const { page, sub } = this._parseRoute(window.location.pathname); - this.page = page; - if (page === 'usage' && sub === 'costs') this.usageMode = 'costs'; - if (page === 'audit-logs') this.fetchAuditLog(true); - if (page === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); - if (page === 'settings' && typeof this.ensureTimezoneOptions === 'function') this.ensureTimezoneOptions(); + this._applyRoute(page, sub); window.addEventListener('popstate', () => { const { page: p, sub: s } = this._parseRoute(window.location.pathname); - this.page = p; - if (p === 'usage') { - this.usageMode = s === 'costs' ? 'costs' : 'tokens'; - this.fetchUsagePage(); - } - if (p === 'overview') this.renderChart(); - if (p === 'audit-logs') this.fetchAuditLog(true); - if (p === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); - if (p === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') { - this.fetchExecutionPlansPage(); - } - if (p === 'settings' && typeof this.ensureTimezoneOptions === 'function') { - this.ensureTimezoneOptions(); - } + this._applyRoute(p, s); }); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { @@ -168,15 +188,25 @@ function dashboard() { }, navigate(page) { - this.page = page; - if (page === 'usage') this.usageMode = 'tokens'; + if (page === 'settings') { + this.navigateSettings('general'); + return; + } + history.pushState(null, '', '/admin/dashboard/' + page); - if (page === 'overview') this.renderChart(); - if (page === 'usage') this.fetchUsagePage(); - if (page === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') this.fetchExecutionPlansPage(); - if (page === 'audit-logs') this.fetchAuditLog(true); - if (page === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); - if (page === 'settings' && typeof this.ensureTimezoneOptions === 'function') this.ensureTimezoneOptions(); + this._applyRoute(page, null); + }, + + navigateSettings(subpage) { + const normalized = this._normalizeSettingsSubpage(subpage); + history.pushState(null, '', this._settingsPath(normalized)); + this._applyRoute('settings', normalized); + }, + + guardrailsPageVisible() { + return typeof this.executionPlanRuntimeBooleanFlag === 'function' + ? this.executionPlanRuntimeBooleanFlag('GUARDRAILS_ENABLED', true) + : true; }, setTheme(t) { @@ -489,6 +519,10 @@ function dashboard() { typeof dashboardAuthKeysModule === 'function' ? dashboardAuthKeysModule : null, 'dashboardAuthKeysModule' ), + resolveModuleFactory( + typeof dashboardGuardrailsModule === 'function' ? dashboardGuardrailsModule : null, + 'dashboardGuardrailsModule' + ), resolveModuleFactory( typeof dashboardExecutionPlansModule === 'function' ? dashboardExecutionPlansModule : null, 'dashboardExecutionPlansModule' diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index 7b3cfd67..1e5df291 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -29,6 +29,7 @@ test('sidebar and main content share the flex layout without manual content offs assert.match(template, /