From df433a9179ce2daac0e6c24a2d68c6b45d30cc7b Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 2 Jun 2026 14:36:41 +0800 Subject: [PATCH] feat: add embedded web dashboard for remote server Co-authored-by: Cursor --- internal/server/http.go | 1 + internal/server/http_test.go | 22 ++++ internal/server/web.go | 11 ++ web/app.js | 239 +++++++++++++++++++++++++++++++++++ web/embed.go | 13 ++ web/index.html | 55 ++++++++ web/style.css | 190 ++++++++++++++++++++++++++++ 7 files changed, 531 insertions(+) create mode 100644 internal/server/web.go create mode 100644 web/app.js create mode 100644 web/embed.go create mode 100644 web/index.html create mode 100644 web/style.css 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 + + + +
+

agent-notify

+ +
+
+ +
+ + + + + + + + + + + + + +
MachineDirectoryAgentStatusUpdated
+
+
+ + + 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; +}