Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ run:

# Seed the local SQLite database and start GoModel with a populated dashboard.
demo: seed-demo-data
$(MAKE) run
$(MAKE) run GOMODEL_DEMO_MODE=true

# Clean build artifacts
clean:
Expand Down
3 changes: 3 additions & 0 deletions cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,16 @@ The most common way to configure GoModel. Set any of the variables below to over
| `PORT` | HTTP server port | `8080` |
| `BASE_PATH` | Mount path prefix, for example `/g` | `/` |
| `GOMODEL_MASTER_KEY` | Authentication key for securing the gateway | _(empty, unsafe mode)_ |
| `GOMODEL_DEMO_MODE` | Enable public demo warnings in logs and the dashboard | `false` |
| `BODY_SIZE_LIMIT` | Max request body size (e.g., `10M`, `1024K`, `500KB`) | _(no limit)_ |
| `USER_PATH_HEADER` | Header used to read/write request `user_path` values | `X-GoModel-User-Path` |

Set `GOMODEL_DEMO_MODE=true` for a public or shared demonstration instance.
GoModel logs a warning at startup and every five minutes, renders a persistent
warning at the top of the dashboard, and exposes `DEMO_MODE=on` through the
allowlisted `/admin/runtime/config` response. Demo mode does not reset storage;
schedule that separately in the deployment.

#### MCP Gateway

See [MCP Gateway](/features/mcp-gateway) for the full feature guide.
Expand Down
3 changes: 3 additions & 0 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion internal/admin/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,18 @@ type Handler struct {
indexTmpl *template.Template
staticFS http.Handler
basePath string
demoMode bool
}

// NewWithBasePath creates a dashboard handler for an app mounted under basePath.
// It parses templates and sets up the static file server.
func NewWithBasePath(basePath string) (*Handler, error) {
return NewWithDemoMode(basePath, false)
}

// NewWithDemoMode creates a dashboard handler and controls whether the demo
// warning is rendered in the main content area.
func NewWithDemoMode(basePath string, demoMode bool) (*Handler, error) {
basePath = config.NormalizeBasePath(basePath)
assetVersions, err := buildFrontendAssetVersions()
if err != nil {
Expand Down Expand Up @@ -58,18 +65,20 @@ func NewWithBasePath(basePath string) (*Handler, error) {
indexTmpl: tmpl,
staticFS: http.StripPrefix("/admin/static/", http.FileServer(http.FS(staticSub))),
basePath: basePath,
demoMode: demoMode,
}, nil
}

type templateData struct {
BasePath string
Version string
DemoMode bool
}

// Index serves GET /admin/dashboard — the main dashboard page.
func (h *Handler) Index(c *echo.Context) error {
var buf bytes.Buffer
if err := h.indexTmpl.ExecuteTemplate(&buf, "layout", templateData{BasePath: h.basePath, Version: version.Info()}); err != nil {
if err := h.indexTmpl.ExecuteTemplate(&buf, "layout", templateData{BasePath: h.basePath, Version: version.Info(), DemoMode: h.demoMode}); err != nil {
slog.Error("failed to render admin dashboard", "path", c.Request().URL.Path, "error", err)
return err
}
Expand Down
43 changes: 43 additions & 0 deletions internal/admin/dashboard/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,49 @@ func TestIndex_ReturnsHTML(t *testing.T) {
}
}

func TestIndex_DemoModeShowsWarning(t *testing.T) {
h, err := NewWithDemoMode("/", true)
if err != nil {
t.Fatalf("NewWithDemoMode() returned error: %v", err)
}

e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

if err := h.Index(c); err != nil {
t.Fatalf("Index() returned error: %v", err)
}

body := rec.Body.String()
if !strings.Contains(body, `class="demo-mode-banner"`) {
t.Error("expected demo mode banner in page HTML")
}
if !strings.Contains(body, "Do not enter sensitive or personal data. Demo data is reset regularly.") {
t.Error("expected demo mode data warning in page HTML")
}
}

func TestIndex_StandardModeHidesDemoWarning(t *testing.T) {
h, err := NewWithBasePath("/")
if err != nil {
t.Fatalf("NewWithBasePath() returned error: %v", err)
}

e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

if err := h.Index(c); err != nil {
t.Fatalf("Index() returned error: %v", err)
}
if strings.Contains(rec.Body.String(), `class="demo-mode-banner"`) {
t.Error("did not expect demo mode banner in standard mode")
}
}

func TestIndex_UsesBasePathForGeneratedURLs(t *testing.T) {
h, err := NewWithBasePath("g/")
if err != nil {
Expand Down
47 changes: 47 additions & 0 deletions internal/admin/dashboard/static/css/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,45 @@ body.dashboard-modal-open {
transition: width 0.2s;
}

.demo-mode-banner {
position: sticky;
top: 16px;
z-index: 8;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding: 12px 16px;
border: 1px solid color-mix(in srgb, var(--warning) 55%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--warning) 14%, var(--bg-surface));
color: var(--text);
box-shadow: 0 8px 24px color-mix(in srgb, var(--bg) 70%, transparent);
}

.demo-mode-banner-icon {
width: 20px;
height: 20px;
flex: 0 0 20px;
color: var(--warning);
}

.demo-mode-banner div {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
font-size: 13px;
}

.demo-mode-banner strong {
flex-shrink: 0;
color: var(--warning);
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}

.page-header {
display: flex;
align-items: center;
Expand Down Expand Up @@ -5353,6 +5392,14 @@ body.conversation-drawer-open {
margin: 0 auto;
padding: 20px;
}
.demo-mode-banner {
top: 10px;
align-items: flex-start;
}
.demo-mode-banner div {
display: grid;
gap: 2px;
}
.auth-dialog-shell {
align-items: end;
padding: 12px;
Expand Down
1 change: 1 addition & 0 deletions internal/admin/dashboard/static/js/modules/workflows.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@

workflowRuntimeConfigKeys() {
return [
'DEMO_MODE',
'FAILOVER_ENABLED',
'LOGGING_ENABLED',
'LOGGING_RETENTION_DAYS',
Expand Down
2 changes: 2 additions & 0 deletions internal/admin/dashboard/static/js/modules/workflows.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,7 @@ test('fetchWorkflowRuntimeConfig loads FAILOVER_ENABLED from the admin config en
return Promise.resolve({
ok: true,
json: async () => ({
DEMO_MODE: 'on',
FAILOVER_ENABLED: 'on',
LOGGING_ENABLED: 'on',
LOGGING_RETENTION_DAYS: 30,
Expand All @@ -1307,6 +1308,7 @@ test('fetchWorkflowRuntimeConfig loads FAILOVER_ENABLED from the admin config en
assert.equal(
JSON.stringify(module.workflowRuntimeConfig),
JSON.stringify({
DEMO_MODE: 'on',
FAILOVER_ENABLED: 'on',
LOGGING_ENABLED: 'on',
LOGGING_RETENTION_DAYS: '30',
Expand Down
10 changes: 10 additions & 0 deletions internal/admin/dashboard/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow, nosnippet, noimageindex">
<title>GoModel Dashboard</title>
<link rel="icon" type="image/svg+xml" href="{{assetURL "favicon.svg"}}">
<link rel="stylesheet" href="{{assetURL "fonts/inter.css"}}">
Expand Down Expand Up @@ -76,6 +77,15 @@
<div class="app">
{{template "sidebar" .}}
<main class="content">
{{if .DemoMode}}
<aside class="demo-mode-banner" role="alert" aria-label="Demo mode notice">
<i data-lucide="triangle-alert" class="demo-mode-banner-icon" aria-hidden="true"></i>
<div>
<strong>Public demo</strong>
<span>Do not enter sensitive or personal data. Demo data is reset regularly.</span>
</div>
</aside>
{{end}}
{{template "index" .}}
</main>
<div class="auth-dialog-backdrop"
Expand Down
3 changes: 3 additions & 0 deletions internal/admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type Handler struct {
type Option func(*Handler)

const (
DashboardConfigDemoMode = "DEMO_MODE"
DashboardConfigFailoverEnabled = "FAILOVER_ENABLED"
DashboardConfigLoggingEnabled = "LOGGING_ENABLED"
DashboardConfigLoggingRetentionDays = "LOGGING_RETENTION_DAYS"
Expand All @@ -81,6 +82,7 @@ const statusClientClosedRequest = 499

// DashboardConfigResponse is the allowlisted runtime config contract exposed to the dashboard UI.
type DashboardConfigResponse struct {
DemoMode string `json:"DEMO_MODE,omitempty"`
FailoverEnabled string `json:"FAILOVER_ENABLED,omitempty"`
LoggingEnabled string `json:"LOGGING_ENABLED,omitempty"`
LoggingRetentionDays string `json:"LOGGING_RETENTION_DAYS,omitempty"`
Expand Down Expand Up @@ -326,6 +328,7 @@ func NewHandler(reader usage.UsageReader, registry *providers.ModelRegistry, opt

func normalizeDashboardRuntimeConfig(values DashboardConfigResponse) DashboardConfigResponse {
return DashboardConfigResponse{
DemoMode: strings.TrimSpace(values.DemoMode),
FailoverEnabled: strings.TrimSpace(values.FailoverEnabled),
LoggingEnabled: strings.TrimSpace(values.LoggingEnabled),
LoggingRetentionDays: strings.TrimSpace(values.LoggingRetentionDays),
Expand Down
4 changes: 4 additions & 0 deletions internal/admin/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2250,6 +2250,7 @@ func TestBuildProviderStatusItem_ClassifyAndDisplayFallbacks(t *testing.T) {

func TestDashboardConfig_ReturnsAllowlistedRuntimeFlags(t *testing.T) {
h := NewHandler(nil, nil, WithDashboardRuntimeConfig(DashboardConfigResponse{
DemoMode: "on",
FailoverEnabled: "on",
LoggingEnabled: "on",
LoggingRetentionDays: "14",
Expand All @@ -2276,6 +2277,9 @@ func TestDashboardConfig_ReturnsAllowlistedRuntimeFlags(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if got := body.DemoMode; got != "on" {
t.Fatalf("DEMO_MODE = %q, want on", got)
}
if got := body.FailoverEnabled; got != "on" {
t.Fatalf("FAILOVER_ENABLED = %q, want on", got)
}
Expand Down
11 changes: 8 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ type Config struct {
// rewriters, middleware, routes). The registry is snapshotted here; later
// registrations have no effect.
Extensions *ext.Registry

// DemoMode exposes a prominent dashboard warning for public demo instances.
// It does not change persistence or security behavior.
DemoMode bool
}

// applyExtensions snapshots a registered extension set into the server
Expand Down Expand Up @@ -637,7 +641,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
taggingResult.Service,
mcpResult,
app,
dashboardRuntimeConfig(appCfg, usageEnabledForDashboard),
dashboardRuntimeConfig(appCfg, usageEnabledForDashboard, cfg.DemoMode),
app.live,
requestHealth,
usagePricingRecalculationConfigured(appCfg),
Expand Down Expand Up @@ -1162,7 +1166,7 @@ func initAdmin(
var dashHandler *dashboard.Handler
if uiEnabled {
var err error
dashHandler, err = dashboard.NewWithBasePath(basePath)
dashHandler, err = dashboard.NewWithDemoMode(basePath, runtimeConfig.DemoMode == "on")
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize dashboard: %w", err)
}
Expand Down Expand Up @@ -1277,8 +1281,9 @@ func defaultWorkflowInput(cfg *config.Config, availableGuardrails []string, conf
}
}

func dashboardRuntimeConfig(cfg *config.Config, usageEnabled bool) admin.DashboardConfigResponse {
func dashboardRuntimeConfig(cfg *config.Config, usageEnabled, demoMode bool) admin.DashboardConfigResponse {
return admin.DashboardConfigResponse{
DemoMode: dashboardEnabledValue(demoMode),
FailoverEnabled: dashboardEnabledValue(failoverFeatureEnabledGlobally(cfg)),
LoggingEnabled: dashboardEnabledValue(cfg != nil && cfg.Logging.Enabled),
LoggingRetentionDays: dashboardLoggingRetentionDays(cfg),
Expand Down
19 changes: 13 additions & 6 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -442,20 +442,27 @@ func TestDashboardRuntimeConfig_ExposesFailoverEnabled(t *testing.T) {
},
}

values := dashboardRuntimeConfig(cfg, false)
values := dashboardRuntimeConfig(cfg, false, false)
if got := values.FailoverEnabled; got != "on" {
t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigFailoverEnabled, got)
}
}

func TestDashboardRuntimeConfig_ExposesDemoMode(t *testing.T) {
values := dashboardRuntimeConfig(&config.Config{}, false, true)
if got := values.DemoMode; got != "on" {
t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigDemoMode, got)
}
}

func TestDashboardRuntimeConfig_FailoverDisabled(t *testing.T) {
cfg := &config.Config{
Failover: config.FailoverConfig{
Enabled: false,
},
}

values := dashboardRuntimeConfig(cfg, false)
values := dashboardRuntimeConfig(cfg, false, false)
if got := values.FailoverEnabled; got != "off" {
t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want off", admin.DashboardConfigFailoverEnabled, got)
}
Expand All @@ -469,7 +476,7 @@ func TestDashboardRuntimeConfig_DefaultModeDoesNotEnableFailover(t *testing.T) {
},
}

values := dashboardRuntimeConfig(cfg, false)
values := dashboardRuntimeConfig(cfg, false, false)
if got := values.FailoverEnabled; got != "off" {
t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want off", admin.DashboardConfigFailoverEnabled, got)
}
Expand Down Expand Up @@ -506,7 +513,7 @@ func TestDashboardRuntimeConfig_ExposesFeatureAvailabilityFlags(t *testing.T) {
},
}

values := dashboardRuntimeConfig(cfg, true)
values := dashboardRuntimeConfig(cfg, true, false)
if got := values.LoggingEnabled; got != "on" {
t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want on", admin.DashboardConfigLoggingEnabled, got)
}
Expand Down Expand Up @@ -537,7 +544,7 @@ func TestDashboardRuntimeConfig_ExposesFeatureAvailabilityFlags(t *testing.T) {
}

func TestDashboardRuntimeConfig_ExposesIndefiniteLoggingRetention(t *testing.T) {
values := dashboardRuntimeConfig(&config.Config{}, false)
values := dashboardRuntimeConfig(&config.Config{}, false, false)
if got := values.LoggingRetentionDays; got != "0" {
t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want 0", admin.DashboardConfigLoggingRetentionDays, got)
}
Expand All @@ -559,7 +566,7 @@ func TestDashboardRuntimeConfig_HidesCacheAnalyticsWhenUsageDisabled(t *testing.
},
}

values := dashboardRuntimeConfig(cfg, false)
values := dashboardRuntimeConfig(cfg, false, false)
if got := values.UsageEnabled; got != "off" {
t.Fatalf("dashboardRuntimeConfig()[%q] = %q, want off", admin.DashboardConfigUsageEnabled, got)
}
Expand Down
Loading