Skip to content

Commit 5adaf7a

Browse files
authored
feat: add branded AiGate dashboard (#9)
## Summary - add a local AiGate web dashboard served from the Go binary - wire audit logging into sandbox runs and deny_exec preflight blocks - rebrand the dashboard around AxeForge's dark/orange operator style with live counters, filters, and event timeline - include demo recording assets and update structure lint rules for embedded web assets - bump Go declaration to 1.25.10 so govulncheck uses the fixed standard library version ## Verification - go test ./... - /home/youngestaxe/go/bin/go1.25.10 test ./... - govulncheck ./... - structlint validate --config .structlint.yaml - served dashboard with yoink: yoink run --alias aigate-dashboard go run . serve --addr 127.0.0.1:8080 - exercised forwarded Claude command through AiGate via yoink; /api/overview recorded the run_started events
1 parent 02dc267 commit 5adaf7a

14 files changed

Lines changed: 1904 additions & 2 deletions

File tree

.structlint.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ dir_structure:
66
- "services/**"
77
- "helpers/**"
88
- "integration/**"
9+
- "internal/**"
910
- "docs/**"
1011
- "dist/**"
1112
- ".github/**"
1213
- ".claude/**"
14+
- ".agents/**"
15+
- ".codex/**"
1316
disallowedPaths:
1417
- "vendor/**"
1518
- "node_modules/**"
@@ -36,9 +39,14 @@ file_naming_pattern:
3639
- "*.md"
3740
- "*.txt"
3841
- "*.png"
42+
- "*.gif"
3943
- "*.jpg"
4044
- "*.svg"
4145
- "*.puml"
46+
- "*.cast"
47+
- "*.css"
48+
- "*.js"
49+
- "*.tmpl"
4250
- "README*"
4351
- "LICENSE*"
4452
- "CHANGELOG*"
@@ -83,6 +91,8 @@ ignore:
8391
- ".idea"
8492
- ".vscode"
8593
- ".DS_Store"
94+
- ".agents"
95+
- ".codex"
8696
- "*.log"
8797
- "*.tmp"
8898
- "aigate"

docs/aigate-demo.cast

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
{"version": 2, "width": 130, "height": 30, "timestamp": 1775685449, "idle_time_limit": 3.0, "env": {"SHELL": "/usr/bin/zsh", "TERM": "tmux-256color"}, "title": "aigate — sandbox AI agents in seconds"}
2+
[0.011109, "o", "\u001b[H\u001b[J\u001b[3J"]
3+
[0.515434, "o", "\u001b[2;36m# aigate — wrap any command in an OS-level sandbox for AI agents\u001b[0m\r\n"]
4+
[2.316579, "o", "\u001b[2;36m# every example below uses the default rules from ~/.aigate/config.yaml\u001b[0m\r\n"]
5+
[4.318677, "o", "\u001b[H\u001b[J\u001b[3J"]
6+
[4.719883, "o", "\u001b[2;36m# 1) deny_exec — shell tools blocked even when installed on the host\u001b[0m\r\n"]
7+
[6.121717, "o", "\u001b[1;32m$\u001b[0m a"]
8+
[6.186953, "o", "i"]
9+
[6.252542, "o", "g"]
10+
[6.31701, "o", "a"]
11+
[6.382896, "o", "t"]
12+
[6.449218, "o", "e"]
13+
[6.515163, "o", " "]
14+
[6.581809, "o", "r"]
15+
[6.635178, "o", "u"]
16+
[6.687339, "o", "n"]
17+
[6.74003, "o", " "]
18+
[6.79346, "o", "-"]
19+
[6.846505, "o", "-"]
20+
[6.899489, "o", " "]
21+
[6.952924, "o", "c"]
22+
[7.006153, "o", "u"]
23+
[7.059022, "o", "r"]
24+
[7.112076, "o", "l"]
25+
[7.164782, "o", " "]
26+
[7.217827, "o", "h"]
27+
[7.270647, "o", "t"]
28+
[7.322659, "o", "t"]
29+
[7.375458, "o", "p"]
30+
[7.429369, "o", "s"]
31+
[7.481472, "o", ":"]
32+
[7.533275, "o", "/"]
33+
[7.585457, "o", "/"]
34+
[7.64712, "o", "a"]
35+
[7.709539, "o", "p"]
36+
[7.770963, "o", "i"]
37+
[7.833362, "o", "."]
38+
[7.894528, "o", "g"]
39+
[7.95609, "o", "i"]
40+
[8.018667, "o", "t"]
41+
[8.079994, "o", "h"]
42+
[8.142215, "o", "u"]
43+
[8.203565, "o", "b"]
44+
[8.267329, "o", "."]
45+
[8.331411, "o", "c"]
46+
[8.393408, "o", "o"]
47+
[8.455442, "o", "m"]
48+
[8.768926, "o", "\r\n"]
49+
[8.772711, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\ncommand is blocked by deny rules: \"curl\" is in the deny_exec list\r\n"]
50+
[12.277041, "o", "\u001b[H\u001b[J\u001b[3J"]
51+
[12.678972, "o", "\u001b[2;36m# 2) deny_read — secrets are hidden from the sandboxed process\u001b[0m\r\n"]
52+
[14.080608, "o", "\u001b[2;36m# first, what the host sees:\u001b[0m\r\n"]
53+
[14.882601, "o", "\u001b[1;32m$\u001b[0m c"]
54+
[14.92618, "o", "a"]
55+
[14.969463, "o", "t"]
56+
[15.012669, "o", " "]
57+
[15.055577, "o", "."]
58+
[15.098407, "o", "e"]
59+
[15.141096, "o", "n"]
60+
[15.183574, "o", "v"]
61+
[15.477939, "o", "\r\n"]
62+
[15.479518, "o", "OPENAI_API_KEY=sk-proj-fake-DEMO-key-1234567890\r\nDB_PASSWORD=hunter2\r\n"]
63+
[17.681149, "o", "\u001b[2;36m# now from inside the sandbox:\u001b[0m\r\n"]
64+
[18.482612, "o", "\u001b[1;32m$\u001b[0m "]
65+
[18.482642, "o", "a"]
66+
[18.542319, "o", "i"]
67+
[18.602688, "o", "g"]
68+
[18.658458, "o", "a"]
69+
[18.713295, "o", "t"]
70+
[18.768658, "o", "e"]
71+
[18.823415, "o", " "]
72+
[18.879534, "o", "r"]
73+
[18.934989, "o", "u"]
74+
[18.991067, "o", "n"]
75+
[19.046959, "o", " "]
76+
[19.103129, "o", "-"]
77+
[19.159111, "o", "-"]
78+
[19.215067, "o", " "]
79+
[19.271536, "o", "c"]
80+
[19.327361, "o", "a"]
81+
[19.383821, "o", "t"]
82+
[19.43905, "o", " "]
83+
[19.494332, "o", "."]
84+
[19.549987, "o", "e"]
85+
[19.605914, "o", "n"]
86+
[19.666133, "o", "v"]
87+
[19.977953, "o", "\r\n"]
88+
[19.980355, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\n"]
89+
[19.980778, "o", "\u001b[90m\u001b[90m23:57:49\u001b[0m\u001b[0m \u001b[32mINFO \u001b[0m starting bwrap network-filtered sandbox \u001b[36mallow_net=\u001b[0m[\"api.anthropic.com\",\"api.openai.com\",\"api.github.com\",\"registry.npmjs.org\",\"proxy.golang.org\"] \u001b[36mdns_servers=\u001b[0m[\"1.1.1.1\",\"1.0.0.1\",\"1.1.1.1\",\"1.0.0.1\",\"8.8.8.8\"]\r\n"]
90+
[20.475756, "o", "[aigate] access denied: this file is protected by sandbox policy. See /tmp/.aigate-policy for all active restrictions.\r\n"]
91+
[24.496781, "o", "\u001b[H\u001b[J\u001b[3J"]
92+
[24.898714, "o", "\u001b[2;36m# 3) mask_stdout — secrets that slip out are redacted on the way back\u001b[0m\r\n"]
93+
[26.300264, "o", "\u001b[1;32m$\u001b[0m a"]
94+
[26.346693, "o", "i"]
95+
[26.393402, "o", "g"]
96+
[26.439559, "o", "a"]
97+
[26.48566, "o", "t"]
98+
[26.531279, "o", "e"]
99+
[26.577147, "o", " "]
100+
[26.620166, "o", "r"]
101+
[26.66312, "o", "u"]
102+
[26.706201, "o", "n"]
103+
[26.749177, "o", " "]
104+
[26.792617, "o", "-"]
105+
[26.836017, "o", "-"]
106+
[26.878714, "o", " "]
107+
[26.921408, "o", "p"]
108+
[26.965167, "o", "r"]
109+
[27.008495, "o", "i"]
110+
[27.051596, "o", "n"]
111+
[27.095046, "o", "t"]
112+
[27.138411, "o", "f"]
113+
[27.181549, "o", " "]
114+
[27.224143, "o", "'"]
115+
[27.267298, "o", "o"]
116+
[27.310091, "o", "p"]
117+
[27.353028, "o", "e"]
118+
[27.395705, "o", "n"]
119+
[27.438599, "o", "a"]
120+
[27.480523, "o", "i"]
121+
[27.522922, "o", " "]
122+
[27.566044, "o", "k"]
123+
[27.614399, "o", "e"]
124+
[27.661275, "o", "y"]
125+
[27.709589, "o", ":"]
126+
[27.757745, "o", " "]
127+
[27.806121, "o", "s"]
128+
[27.853978, "o", "k"]
129+
[27.902123, "o", "-"]
130+
[27.951075, "o", "p"]
131+
[27.999664, "o", "r"]
132+
[28.047941, "o", "o"]
133+
[28.096834, "o", "j"]
134+
[28.1455, "o", "-"]
135+
[28.193679, "o", "a"]
136+
[28.242345, "o", "b"]
137+
[28.290521, "o", "c"]
138+
[28.338888, "o", "1"]
139+
[28.386976, "o", "2"]
140+
[28.434915, "o", "3"]
141+
[28.482619, "o", "d"]
142+
[28.530065, "o", "e"]
143+
[28.579807, "o", "f"]
144+
[28.646161, "o", "4"]
145+
[28.713249, "o", "5"]
146+
[28.780359, "o", "6"]
147+
[28.84742, "o", "g"]
148+
[28.915209, "o", "h"]
149+
[28.982761, "o", "i"]
150+
[29.049949, "o", "7"]
151+
[29.117158, "o", "8"]
152+
[29.184676, "o", "9"]
153+
[29.250339, "o", "\\"]
154+
[29.316657, "o", "n"]
155+
[29.383747, "o", "'"]
156+
[29.702308, "o", "\r\n"]
157+
[29.705, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\n"]
158+
[29.705258, "o", "\u001b[90m\u001b[90m23:57:59\u001b[0m\u001b[0m \u001b[32mINFO \u001b[0m starting bwrap network-filtered sandbox \u001b[36mallow_net=\u001b[0m[\"api.anthropic.com\",\"api.openai.com\",\"api.github.com\",\"registry.npmjs.org\",\"proxy.golang.org\"] \u001b[36mdns_servers=\u001b[0m[\"1.1.1.1\",\"1.0.0.1\",\"1.1.1.1\",\"1.0.0.1\",\"8.8.8.8\"]\r\n"]
159+
[29.770698, "o", "openai key: sk-***\r\n"]
160+
[33.795432, "o", "\u001b[H\u001b[J\u001b[3J"]
161+
[34.197103, "o", "\u001b[2;36m# 4) wrap claude itself — the full interactive TUI works the same way\u001b[0m\r\n"]
162+
[137.01605, "o", "\u001b[1;32m$\u001b[0m aigate run -- claude\r\n"]
163+
[137.020615, "o", "[aigate] sandbox active\r\n[aigate] deny_read: .env, .env.*, secrets/, credentials/, ~/.ssh/, *.pem, *.key, *.p12, ~/.aws/, ~/.gcloud/, ~/.kube/config, ~/.npmrc, ~/.pypirc, terraform.tfstate, *.tfvars\r\n[aigate] deny_exec: curl, wget, nc, ncat, netcat, ssh, scp, rsync, ftp, kubectl delete, kubectl exec\r\n[aigate] allow_net: api.anthropic.com, api.openai.com, api.github.com, registry.npmjs.org, proxy.golang.org (all other outbound connections will be blocked)\r\n[aigate] mask_stdout: openai, anthropic, aws_key, github, bearer; +1 custom pattern(s)\r\n23:58:05 INFO starting bwrap network-filtered sandbox allow_net=[\"api.anthropic.com\",\"api.openai.com\",\"api.github.com\",\"registry.npmjs.org\",\"proxy.golang.org\"] dns_servers=[\"1.1.1.1\",\"1.0.0.1\",\"1.1.1.1\",\"1.0.0.1\",\"8.8.8.8\"]\r\n ▐▛███▜▌ Claude Code v2.1.97\r\n▝▜█████▛▘ Opus 4.6 (1M context) · Claude Max\r\n ▘▘ ▝▝ ~/Documents/workspace/axeforge/git/aigate\r\n ⎿  SessionStart:startup says: {\"content\":[{\"type\":\"text\",\"text\":\"\"}]}\r\n\r\n View Observations Live @ http://localhost:37777\r\n\r\n❯ what is 2+2? answer in one short sentence.\r\n\r\n● 4.\r\n\r\n\r\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\n❯ \r\n─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\r\n"]
164+
[137.020879, "o", "\r\n\u001b[1;32m✓ claude (inside aigate sandbox) answered:\u001b[0m \u001b[1;33m4.\u001b[0m\r\n"]

docs/aigate-demo.gif

186 KB
Loading

docs/cast.yaml

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Demo recording spec for the README — see docs/aigate-demo.gif
2+
#
3+
# Render with: python3 cast.py docs/cast.yaml (run from repo root)
4+
# (cast.py lives in AxeForging/scripts → cast/cast.py)
5+
#
6+
# The demo deliberately operates in /tmp/aigate-demo using a synthetic .env
7+
# so no real host secrets / SSH filenames are ever rendered.
8+
#
9+
# Why no shell-alias wrapper? Earlier drafts piped aigate through a tiny
10+
# `arun` helper to suppress the verbose [aigate] startup header. That made
11+
# every demo line read `arun run -- …`, which falsely implied `arun` was
12+
# part of aigate. The recording now uses plain `aigate run -- …` so what
13+
# you see is exactly what you would type yourself.
14+
15+
output: docs/aigate-demo.gif
16+
17+
recording:
18+
cols: 130
19+
rows: 30
20+
idle_time_limit: 3
21+
title: "aigate — sandbox AI agents in seconds"
22+
23+
render:
24+
theme: dracula
25+
font: "JetBrains Mono"
26+
font_size: 15
27+
line_height: 1.4
28+
speed: 1.3
29+
idle_time_limit: 2
30+
31+
defaults:
32+
type_delay: 50
33+
pause: 1.6
34+
35+
scenes:
36+
# ── silent setup ─────────────────────────────────────────────────────────
37+
# Re-route bare `aigate` calls to the locally-built binary so the GIF
38+
# always reflects this repo's current build, regardless of which version
39+
# happens to be in $PATH on the host. The shell function is invisible to
40+
# the recording — viewers only ever see `aigate ...` typed at the prompt.
41+
- bash: "_AIGATE_BIN=$PWD/aigate && _AIGATE_REPO=$PWD"
42+
# Trailing `|| true` keeps the cast script alive past commands that exit
43+
# non-zero (deny rules, claude --bare, etc). At an interactive prompt the
44+
# exit code is just background info, so this matches what a viewer would
45+
# experience typing the same commands themselves — it's a path-redirect
46+
# plus error-tolerance, not a behavioural change to aigate.
47+
- bash: |
48+
aigate() { "$_AIGATE_BIN" "$@" || true; }
49+
# Stage a synthetic workdir with a fake .env so we never render any real
50+
# host secrets or filenames in the GIF.
51+
- bash: |
52+
rm -rf /tmp/aigate-demo
53+
mkdir -p /tmp/aigate-demo
54+
cd /tmp/aigate-demo
55+
printf 'OPENAI_API_KEY=sk-proj-fake-DEMO-key-1234567890\nDB_PASSWORD=hunter2\n' > .env
56+
57+
# ── intro ────────────────────────────────────────────────────────────────
58+
- comment: "aigate — wrap any command in an OS-level sandbox for AI agents"
59+
pause: 1.8
60+
- comment: "every example below uses the default rules from ~/.aigate/config.yaml"
61+
pause: 2.0
62+
63+
# ── 1) deny_exec ─────────────────────────────────────────────────────────
64+
- clear: true
65+
pause: 0.4
66+
- comment: "1) deny_exec — shell tools blocked even when installed on the host"
67+
pause: 1.4
68+
- type: "aigate run -- curl https://api.github.com"
69+
pause: 3.5
70+
71+
# ── 2) deny_read (synthetic .env, no real secrets) ───────────────────────
72+
- clear: true
73+
pause: 0.4
74+
- comment: "2) deny_read — secrets are hidden from the sandboxed process"
75+
pause: 1.4
76+
- comment: "first, what the host sees:"
77+
pause: 0.8
78+
- type: "cat .env"
79+
pause: 2.2
80+
- comment: "now from inside the sandbox:"
81+
pause: 0.8
82+
- type: "aigate run -- cat .env"
83+
pause: 4.0
84+
85+
# ── 3) mask_stdout ───────────────────────────────────────────────────────
86+
- clear: true
87+
pause: 0.4
88+
- comment: "3) mask_stdout — secrets that slip out are redacted on the way back"
89+
pause: 1.4
90+
- type: "aigate run -- printf 'openai key: sk-proj-abc123def456ghi789\\n'"
91+
pause: 4.0
92+
93+
# ── 4) wrap interactive claude ───────────────────────────────────────────
94+
# Interactive TUIs can't be typed into from a plain recorded shell, so
95+
# we drive claude in a detached tmux session: launch it inside aigate,
96+
# send the prompt via `tmux send-keys`, wait for the answer, then
97+
# `tmux capture-pane` the rendered TUI back into the visible recording.
98+
# No yoink/tmux machinery is shown to the viewer — they only see what
99+
# they would themselves type at the prompt.
100+
- clear: true
101+
pause: 0.4
102+
- comment: "4) wrap claude itself — the full interactive TUI works the same way"
103+
pause: 1.8
104+
- bash: |
105+
tmux kill-session -t aigate-demo-cl 2>/dev/null || true
106+
# Launch claude with the repo as cwd — it's an already-trusted folder,
107+
# so claude skips its first-run "trust this folder" dialog. (Launching
108+
# from /tmp/aigate-demo would block on that prompt forever.)
109+
tmux new-session -d -s aigate-demo-cl -x 130 -y 22 -c "$_AIGATE_REPO" "$_AIGATE_BIN run -- claude"
110+
# Let claude finish booting (sandbox setup + TUI splash + plugin load).
111+
sleep 6
112+
tmux send-keys -t aigate-demo-cl "what is 2+2? answer in one short sentence." Enter
113+
# Claude latency inside the sandbox is ~60–90s on first call (cold
114+
# start + post-answer "stop hooks"). We wait long enough for the
115+
# status spinner to clear so the captured pane shows a clean answer
116+
# frame, not a half-rendered cogitating one. The cast's idle_time_limit
117+
# collapses this dead air into ~2s in the rendered GIF.
118+
sleep 95
119+
# Render a green prompt line so the captured pane reads as a real
120+
# session, then dump the pane. Trim trailing blank rows AND drop
121+
# claude's transient status spinner lines ("Cogitating…",
122+
# "Fiddle-faddling…", "running stop hooks", etc) — they're noise
123+
# by the time the answer is in.
124+
printf '\e[1;32m$\e[0m aigate run -- claude\n'
125+
tmux capture-pane -p -t aigate-demo-cl \
126+
| sed -E '/(Cogitating|Osmosing|Fiddle-faddling|Wrangling|running (stop|pre)? ?hooks|esc to interrupt)/d' \
127+
| sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}'
128+
# Highlight the answer one more time, BELOW the pane, in a colour
129+
# that pops — the answer line inside claude's TUI is small and easy
130+
# to miss in a fast-playing GIF.
131+
printf '\n\e[1;32m✓ claude (inside aigate sandbox) answered:\e[0m \e[1;33m4.\e[0m\n'
132+
tmux kill-session -t aigate-demo-cl 2>/dev/null || true
133+
# Hold the final answer frame long enough that it's unmissable on
134+
# loop. The render's `idle_time_limit: 2` caps silent gaps at 2s, so
135+
# we emit a no-op write (space+backspace) once a second for 8s to
136+
# generate "activity" the renderer won't compress away.
137+
for _ in 1 2 3 4 5 6 7 8; do printf ' \b'; sleep 1; done
138+
- pause: 0.4

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/AxeForging/aigate
22

3-
go 1.25.8
3+
go 1.25.10
44

55
require (
66
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2

internal/web/handlers.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package web
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
)
7+
8+
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
9+
if r.URL.Path != "/" {
10+
http.NotFound(w, r)
11+
return
12+
}
13+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
14+
if err := s.templates.ExecuteTemplate(w, "layout", s.buildOverview()); err != nil {
15+
http.Error(w, err.Error(), http.StatusInternalServerError)
16+
}
17+
}
18+
19+
func (s *Server) handleOverview(w http.ResponseWriter, _ *http.Request) {
20+
w.Header().Set("Content-Type", "application/json")
21+
_ = json.NewEncoder(w).Encode(s.buildOverview())
22+
}

0 commit comments

Comments
 (0)