feat: add embedded web dashboard for remote server

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:36:41 +08:00
parent 8c43a96817
commit df433a9179
7 changed files with 531 additions and 0 deletions
+1
View File
@@ -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
}
+22
View File
@@ -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"))
+11
View File
@@ -0,0 +1,11 @@
package server
import (
"net/http"
"github.com/longbin/agent-notify/web"
)
func webHandler() http.Handler {
return web.Handler()
}
+239
View File
@@ -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 =
'<tr><td colspan="6" class="empty">No sessions</td></tr>';
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(
'<tr' +
cls +
' data-key="' +
escapeHtml(key) +
'">' +
'<td>' +
escapeHtml(formatMachine(row)) +
'</td>' +
'<td title="' +
escapeHtml(row.cwd || '') +
'">' +
escapeHtml(basename(row.cwd)) +
'</td>' +
'<td>' +
escapeHtml(row.agent || '') +
'</td>' +
'<td><span class="status">' +
escapeHtml(row.status || '') +
'</span></td>' +
'<td>' +
escapeHtml(formatTime(row.updated_at)) +
'</td>' +
'<td><button type="button" class="btn-expand" data-key="' +
escapeHtml(key) +
'">' +
(open ? 'Hide' : 'Details') +
'</button></td>' +
'</tr>'
);
if (open) {
parts.push(
'<tr class="details-row"><td colspan="6">' +
'<div class="label">last_user</div>' +
'<pre>' +
escapeHtml(row.last_user || '(empty)') +
'</pre>' +
'<div class="label">last_agent</div>' +
'<pre>' +
escapeHtml(row.last_agent || '(empty)') +
'</pre>' +
'</td></tr>'
);
}
}
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();
})();
+13
View File
@@ -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))
}
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agent-notify</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>agent-notify</h1>
<button type="button" id="btn-token" title="Change API token">Token</button>
</header>
<div class="layout">
<aside class="sidebar">
<label>Host
<select id="filter-host"><option value="">All</option></select>
</label>
<label>Agent
<select id="filter-agent"><option value="">All</option></select>
</label>
<label>Status
<select id="filter-status">
<option value="">All</option>
<option value="waiting">waiting</option>
<option value="running">running</option>
<option value="tool">tool</option>
<option value="idle">idle</option>
<option value="offline">offline</option>
</select>
</label>
<label>CWD
<select id="filter-cwd"><option value="">All</option></select>
</label>
</aside>
<main>
<p id="error" class="error hidden"></p>
<table>
<thead>
<tr>
<th>Machine</th>
<th>Directory</th>
<th>Agent</th>
<th>Status</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="sessions"></tbody>
</table>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>
+190
View File
@@ -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;
}