diff --git a/internal/server/http.go b/internal/server/http.go
index 45a50c2..c2a3d85 100644
--- a/internal/server/http.go
+++ b/internal/server/http.go
@@ -23,6 +23,7 @@ func NewHandler(store *Store, token string) http.Handler {
}
})))
mux.Handle("/api/v1/meta", bearerAuth(token, http.HandlerFunc(handleMeta(store))))
+ mux.Handle("/", webHandler())
return mux
}
diff --git a/internal/server/http_test.go b/internal/server/http_test.go
index d1bfc3e..9b16bec 100644
--- a/internal/server/http_test.go
+++ b/internal/server/http_test.go
@@ -131,6 +131,28 @@ func TestHTTPGetAgentFilter(t *testing.T) {
}
}
+func TestHTTPGetRootNoAuth200(t *testing.T) {
+ store := openTestStore(t)
+ srv := httptest.NewServer(NewHandler(store, "secret"))
+ defer srv.Close()
+
+ resp, err := http.Get(srv.URL + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(body), "agent-notify") {
+ t.Fatalf("expected dashboard HTML, got: %q", string(body[:min(80, len(body))]))
+ }
+}
+
func TestHTTPHealthz200(t *testing.T) {
store := openTestStore(t)
srv := httptest.NewServer(NewHandler(store, "secret"))
diff --git a/internal/server/web.go b/internal/server/web.go
new file mode 100644
index 0000000..1ac7a25
--- /dev/null
+++ b/internal/server/web.go
@@ -0,0 +1,11 @@
+package server
+
+import (
+ "net/http"
+
+ "github.com/longbin/agent-notify/web"
+)
+
+func webHandler() http.Handler {
+ return web.Handler()
+}
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..72878df
--- /dev/null
+++ b/web/app.js
@@ -0,0 +1,239 @@
+(function () {
+ const STORAGE_KEY = 'agent_notify_token';
+ const POLL_MS = 3000;
+
+ const elHost = document.getElementById('filter-host');
+ const elAgent = document.getElementById('filter-agent');
+ const elStatus = document.getElementById('filter-status');
+ const elCwd = document.getElementById('filter-cwd');
+ const elSessions = document.getElementById('sessions');
+ const elError = document.getElementById('error');
+ const btnToken = document.getElementById('btn-token');
+
+ let expanded = new Set();
+ let pollTimer = null;
+
+ function getToken() {
+ return sessionStorage.getItem(STORAGE_KEY) || '';
+ }
+
+ function promptToken() {
+ const current = getToken();
+ const t = window.prompt('API token (Bearer):', current);
+ if (t === null) return false;
+ const trimmed = t.trim();
+ if (!trimmed) {
+ sessionStorage.removeItem(STORAGE_KEY);
+ return false;
+ }
+ sessionStorage.setItem(STORAGE_KEY, trimmed);
+ return true;
+ }
+
+ function ensureToken() {
+ if (getToken()) return true;
+ return promptToken();
+ }
+
+ function showError(msg) {
+ if (!msg) {
+ elError.textContent = '';
+ elError.classList.add('hidden');
+ return;
+ }
+ elError.textContent = msg;
+ elError.classList.remove('hidden');
+ }
+
+ function basename(path) {
+ if (!path) return '';
+ const i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
+ return i >= 0 ? path.slice(i + 1) : path;
+ }
+
+ function formatMachine(row) {
+ const ips = (row.ips || []).join(', ');
+ return row.hostname + (ips ? ' (' + ips + ')' : '');
+ }
+
+ function formatTime(iso) {
+ if (!iso) return '';
+ try {
+ const d = new Date(iso);
+ return isNaN(d.getTime()) ? iso : d.toLocaleString();
+ } catch {
+ return iso;
+ }
+ }
+
+ function escapeHtml(s) {
+ const div = document.createElement('div');
+ div.textContent = s;
+ return div.innerHTML;
+ }
+
+ async function apiGet(path) {
+ const token = getToken();
+ if (!token) throw new Error('No token');
+ const res = await fetch(path, {
+ headers: { Authorization: 'Bearer ' + token },
+ });
+ if (res.status === 401) {
+ sessionStorage.removeItem(STORAGE_KEY);
+ throw new Error('Unauthorized — check token');
+ }
+ if (!res.ok) {
+ throw new Error(res.status + ' ' + res.statusText);
+ }
+ return res.json();
+ }
+
+ function queryParams() {
+ const q = new URLSearchParams();
+ if (elHost.value) q.set('host', elHost.value);
+ if (elAgent.value) q.set('agent', elAgent.value);
+ if (elStatus.value) q.set('status', elStatus.value);
+ if (elCwd.value) q.set('cwd', elCwd.value);
+ const s = q.toString();
+ return s ? '?' + s : '';
+ }
+
+ function fillSelect(select, values, keepValue) {
+ const current = keepValue ? select.value : '';
+ while (select.options.length > 1) select.remove(1);
+ for (const v of values || []) {
+ const opt = document.createElement('option');
+ opt.value = v;
+ opt.textContent = v;
+ select.appendChild(opt);
+ }
+ if (current && [...select.options].some((o) => o.value === current)) {
+ select.value = current;
+ }
+ }
+
+ async function loadMeta() {
+ const meta = await apiGet('/api/v1/meta');
+ fillSelect(elHost, meta.hosts, true);
+ fillSelect(elAgent, meta.agents, true);
+ fillSelect(elCwd, meta.cwds, true);
+ }
+
+ async function loadStatus() {
+ const rows = await apiGet('/api/v1/status' + queryParams());
+ renderTable(Array.isArray(rows) ? rows : []);
+ }
+
+ function renderTable(rows) {
+ if (rows.length === 0) {
+ elSessions.innerHTML =
+ '
| No sessions |
';
+ return;
+ }
+
+ const parts = [];
+ for (const row of rows) {
+ const key = row.session_key || row.hostname + row.cwd + row.agent;
+ const offline = row.status === 'offline';
+ const cls = offline ? ' class="offline"' : '';
+ const open = expanded.has(key);
+
+ parts.push(
+ '' +
+ '| ' +
+ escapeHtml(formatMachine(row)) +
+ ' | ' +
+ '' +
+ escapeHtml(basename(row.cwd)) +
+ ' | ' +
+ '' +
+ escapeHtml(row.agent || '') +
+ ' | ' +
+ '' +
+ escapeHtml(row.status || '') +
+ ' | ' +
+ '' +
+ escapeHtml(formatTime(row.updated_at)) +
+ ' | ' +
+ ' | ' +
+ '
'
+ );
+
+ if (open) {
+ parts.push(
+ '| ' +
+ ' last_user ' +
+ '' +
+ escapeHtml(row.last_user || '(empty)') +
+ ' ' +
+ 'last_agent ' +
+ '' +
+ escapeHtml(row.last_agent || '(empty)') +
+ ' ' +
+ ' |
'
+ );
+ }
+ }
+ elSessions.innerHTML = parts.join('');
+ }
+
+ async function refresh() {
+ if (!ensureToken()) {
+ showError('Token required');
+ return;
+ }
+ try {
+ showError('');
+ await Promise.all([loadMeta(), loadStatus()]);
+ } catch (e) {
+ showError(e.message || String(e));
+ if (String(e.message || '').includes('Unauthorized')) {
+ if (promptToken()) refresh();
+ }
+ }
+ }
+
+ function startPoll() {
+ stopPoll();
+ pollTimer = setInterval(refresh, POLL_MS);
+ }
+
+ function stopPoll() {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ }
+
+ [elHost, elAgent, elStatus, elCwd].forEach((el) => {
+ el.addEventListener('change', refresh);
+ });
+
+ btnToken.addEventListener('click', () => {
+ promptToken();
+ refresh();
+ });
+
+ elSessions.addEventListener('click', (e) => {
+ const btn = e.target.closest('.btn-expand');
+ if (!btn) return;
+ const key = btn.getAttribute('data-key');
+ if (expanded.has(key)) expanded.delete(key);
+ else expanded.add(key);
+ refresh();
+ });
+
+ if (!getToken()) promptToken();
+ refresh();
+ startPoll();
+})();
diff --git a/web/embed.go b/web/embed.go
new file mode 100644
index 0000000..752578d
--- /dev/null
+++ b/web/embed.go
@@ -0,0 +1,13 @@
+package web
+
+import (
+ "embed"
+ "net/http"
+)
+
+//go:embed index.html app.js style.css
+var content embed.FS
+
+func Handler() http.Handler {
+ return http.FileServer(http.FS(content))
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..7539cad
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ agent-notify
+
+
+
+
+
+
+
+
+
+
+
+ | Machine |
+ Directory |
+ Agent |
+ Status |
+ Updated |
+ |
+
+
+
+
+
+
+
+
+
diff --git a/web/style.css b/web/style.css
new file mode 100644
index 0000000..5593718
--- /dev/null
+++ b/web/style.css
@@ -0,0 +1,190 @@
+:root {
+ --bg: #0f1114;
+ --surface: #161a20;
+ --border: #2a3038;
+ --text: #e4e6eb;
+ --muted: #8b929a;
+ --accent: #5b9fd4;
+ --error: #e06c75;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.4;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+
+header h1 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+#btn-token {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+#btn-token:hover {
+ color: var(--text);
+ border-color: var(--muted);
+}
+
+.layout {
+ display: flex;
+ min-height: calc(100vh - 49px);
+}
+
+.sidebar {
+ width: 200px;
+ flex-shrink: 0;
+ padding: 16px;
+ border-right: 1px solid var(--border);
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.sidebar label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.sidebar select {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: 8px;
+ border-radius: 4px;
+ font-size: 13px;
+ text-transform: none;
+ letter-spacing: normal;
+}
+
+main {
+ flex: 1;
+ padding: 16px 20px;
+ overflow-x: auto;
+}
+
+.error {
+ color: var(--error);
+ margin: 0 0 12px;
+}
+
+.error.hidden {
+ display: none;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ text-align: left;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border);
+}
+
+th {
+ color: var(--muted);
+ font-weight: 500;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+tr.offline td {
+ color: var(--muted);
+}
+
+.status {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 12px;
+ background: var(--border);
+}
+
+tr.offline .status {
+ background: transparent;
+ border: 1px solid var(--border);
+}
+
+.btn-expand {
+ background: transparent;
+ border: none;
+ color: var(--accent);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 0;
+}
+
+.btn-expand:hover {
+ text-decoration: underline;
+}
+
+.details-row td {
+ padding: 0 12px 12px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.details-row pre {
+ margin: 8px 0 0;
+ padding: 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ overflow-x: auto;
+ font-size: 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 200px;
+}
+
+.details-row .label {
+ font-size: 11px;
+ color: var(--muted);
+ text-transform: uppercase;
+ margin-top: 10px;
+}
+
+.details-row .label:first-child {
+ margin-top: 0;
+}
+
+.empty {
+ color: var(--muted);
+ text-align: center;
+ padding: 24px;
+}