Add minimal Workspace UI consuming /v1/space/workspace
This commit is contained in:
parent
3ada8d6a71
commit
5a140178a1
10
README.md
10
README.md
|
|
@ -339,6 +339,13 @@ curl --unix-socket amduatd.sock \
|
|||
-H 'X-Amduat-Space: demo'
|
||||
```
|
||||
|
||||
## Workspace UI
|
||||
|
||||
`/workspace` serves a minimal, human-facing page that consumes
|
||||
`/v1/space/workspace` and `/v1/space/mounts/sync/until`. It is a convenience
|
||||
view for inspection and manual sync control, not a stable API. For
|
||||
programmatic use, call the `/v1/*` endpoints directly.
|
||||
|
||||
## Space mounts sync (track mounts)
|
||||
|
||||
`/v1/space/mounts/sync/until` runs the federation pull/until loop for every
|
||||
|
|
@ -554,6 +561,9 @@ When the daemon uses the `fs` store backend, index-only checks are reported as
|
|||
- response: `{program_ref}`
|
||||
- `POST /v1/context_frames`
|
||||
|
||||
UI (human-facing, not an API contract):
|
||||
- `GET /workspace` → minimal workspace snapshot + sync controls (uses `/v1/space/workspace`)
|
||||
|
||||
Receipt example (with v1.1 fields):
|
||||
|
||||
```json
|
||||
|
|
|
|||
312
src/amduatd.c
312
src/amduatd.c
|
|
@ -133,6 +133,304 @@ static const uint64_t AMDUATD_FED_TICK_MS = 1000u;
|
|||
static const size_t AMDUATD_FED_INGEST_MAX_BYTES = 8u * 1024u * 1024u;
|
||||
static const size_t AMDUATD_REF_TEXT_MAX = 256u;
|
||||
static const size_t AMDUATD_SPACE_MANIFEST_MAX_BYTES = 64u * 1024u;
|
||||
static const char k_amduatd_workspace_html[] =
|
||||
"<!doctype html>\n"
|
||||
"<html lang=\"en\">\n"
|
||||
"<head>\n"
|
||||
" <meta charset=\"utf-8\" />\n"
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n"
|
||||
" <title>amduatd workspace</title>\n"
|
||||
" <style>\n"
|
||||
" :root{color-scheme:light dark;}\n"
|
||||
" body{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,Arial,sans-serif;"
|
||||
" margin:0;line-height:1.4;background:#0f1216;color:#e7edf4;}\n"
|
||||
" .wrap{max-width:980px;margin:0 auto;padding:28px 20px 60px;}\n"
|
||||
" h1{margin:0 0 12px;font-size:22px;letter-spacing:.02em;}\n"
|
||||
" h2{margin:18px 0 10px;font-size:16px;}\n"
|
||||
" p{margin:8px 0;color:#c9d2dc;}\n"
|
||||
" .bar{display:flex;gap:12px;flex-wrap:wrap;align-items:center;"
|
||||
" padding:12px 14px;border:1px solid rgba(120,130,140,.4);"
|
||||
" border-radius:12px;background:rgba(120,130,140,.08);}\n"
|
||||
" label{font-size:13px;color:#c9d2dc;}\n"
|
||||
" input{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,monospace;"
|
||||
" font-size:13px;padding:6px 8px;border-radius:8px;border:1px solid rgba(120,130,140,.4);"
|
||||
" background:#141922;color:#e7edf4;min-width:200px;}\n"
|
||||
" button{font-size:13px;padding:7px 12px;border-radius:8px;border:1px solid #5b6b7c;"
|
||||
" background:#1b232f;color:#e7edf4;cursor:pointer;}\n"
|
||||
" button:disabled{opacity:.6;cursor:default;}\n"
|
||||
" .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}\n"
|
||||
" .card{padding:14px;border-radius:12px;border:1px solid rgba(120,130,140,.4);"
|
||||
" background:rgba(120,130,140,.06);}\n"
|
||||
" .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,monospace;font-size:13px;}\n"
|
||||
" table{width:100%;border-collapse:collapse;font-size:13px;}\n"
|
||||
" th,td{border-bottom:1px solid rgba(120,130,140,.3);padding:8px 6px;text-align:left;}\n"
|
||||
" th{color:#c9d2dc;font-weight:600;}\n"
|
||||
" .muted{color:#9aa6b2;font-size:12px;}\n"
|
||||
" pre{margin:8px 0 0;padding:10px;border-radius:8px;background:#12161e;color:#e7edf4;overflow:auto;}\n"
|
||||
" details{margin-top:8px;}\n"
|
||||
" summary{cursor:pointer;color:#c9d2dc;font-size:13px;}\n"
|
||||
" .status-ok{color:#7dd56f;}\n"
|
||||
" .status-bad{color:#f09a8d;}\n"
|
||||
" @media (max-width:760px){.grid{grid-template-columns:1fr;}}\n"
|
||||
" </style>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
" <div class=\"wrap\">\n"
|
||||
" <h1>Workspace snapshot</h1>\n"
|
||||
" <p class=\"muted\">Local, read-only view of the effective space.</p>\n"
|
||||
" <div class=\"bar\">\n"
|
||||
" <label for=\"space\">Space (optional)</label>\n"
|
||||
" <input id=\"space\" placeholder=\"space-id\" />\n"
|
||||
" <button id=\"refresh\">Refresh</button>\n"
|
||||
" <button id=\"sync\">Sync mounts</button>\n"
|
||||
" <span id=\"syncing\" class=\"muted\" hidden>syncing...</span>\n"
|
||||
" <span id=\"space_error\" class=\"muted\" hidden>invalid space id (no \"/\" or \"..\")</span>\n"
|
||||
" </div>\n"
|
||||
"\n"
|
||||
" <div id=\"message\" class=\"card\" style=\"margin-top:16px;\" hidden></div>\n"
|
||||
"\n"
|
||||
" <div class=\"grid\" style=\"margin-top:16px;\">\n"
|
||||
" <div class=\"card\">\n"
|
||||
" <h2>Effective space</h2>\n"
|
||||
" <div id=\"effective\" class=\"mono\">-</div>\n"
|
||||
" <h2>Store backend</h2>\n"
|
||||
" <div id=\"backend\" class=\"mono\">-</div>\n"
|
||||
" <h2>Federation</h2>\n"
|
||||
" <div id=\"federation\" class=\"mono\">-</div>\n"
|
||||
" <h2>Manifest ref</h2>\n"
|
||||
" <div id=\"manifest_ref\" class=\"mono\">-</div>\n"
|
||||
" </div>\n"
|
||||
" <div class=\"card\">\n"
|
||||
" <h2>Store capabilities</h2>\n"
|
||||
" <div id=\"capabilities\" class=\"mono\">-</div>\n"
|
||||
" <h2>Sync result</h2>\n"
|
||||
" <div id=\"sync_summary\" class=\"mono\">-</div>\n"
|
||||
" <details id=\"sync_details\" hidden>\n"
|
||||
" <summary>Full sync response</summary>\n"
|
||||
" <pre id=\"sync_raw\"></pre>\n"
|
||||
" </details>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
"\n"
|
||||
" <div class=\"card\" style=\"margin-top:16px;\">\n"
|
||||
" <h2>Mounts</h2>\n"
|
||||
" <table id=\"mounts_table\">\n"
|
||||
" <thead>\n"
|
||||
" <tr>\n"
|
||||
" <th>Name</th>\n"
|
||||
" <th>Peer</th>\n"
|
||||
" <th>Remote space</th>\n"
|
||||
" <th>Mode</th>\n"
|
||||
" <th>Cursor</th>\n"
|
||||
" <th>Status</th>\n"
|
||||
" </tr>\n"
|
||||
" </thead>\n"
|
||||
" <tbody id=\"mounts_body\"></tbody>\n"
|
||||
" </table>\n"
|
||||
" </div>\n"
|
||||
"\n"
|
||||
" <div class=\"card\" style=\"margin-top:16px;\">\n"
|
||||
" <h2>Raw workspace JSON</h2>\n"
|
||||
" <pre id=\"workspace_raw\"></pre>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
"\n"
|
||||
" <script>\n"
|
||||
" const spaceInput = document.getElementById('space');\n"
|
||||
" const spaceError = document.getElementById('space_error');\n"
|
||||
" const message = document.getElementById('message');\n"
|
||||
" const refreshBtn = document.getElementById('refresh');\n"
|
||||
" const syncBtn = document.getElementById('sync');\n"
|
||||
" const syncing = document.getElementById('syncing');\n"
|
||||
" const effectiveEl = document.getElementById('effective');\n"
|
||||
" const backendEl = document.getElementById('backend');\n"
|
||||
" const federationEl = document.getElementById('federation');\n"
|
||||
" const manifestRefEl = document.getElementById('manifest_ref');\n"
|
||||
" const capabilitiesEl = document.getElementById('capabilities');\n"
|
||||
" const mountsBody = document.getElementById('mounts_body');\n"
|
||||
" const workspaceRaw = document.getElementById('workspace_raw');\n"
|
||||
" const syncSummary = document.getElementById('sync_summary');\n"
|
||||
" const syncDetails = document.getElementById('sync_details');\n"
|
||||
" const syncRaw = document.getElementById('sync_raw');\n"
|
||||
"\n"
|
||||
" function getSpaceHeader() {\n"
|
||||
" const raw = spaceInput.value.trim();\n"
|
||||
" if (!raw) {\n"
|
||||
" spaceError.hidden = true;\n"
|
||||
" return null;\n"
|
||||
" }\n"
|
||||
" if (raw.includes('/') || raw.includes('..')) {\n"
|
||||
" spaceError.hidden = false;\n"
|
||||
" return null;\n"
|
||||
" }\n"
|
||||
" spaceError.hidden = true;\n"
|
||||
" return raw;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function clearMessage() {\n"
|
||||
" message.hidden = true;\n"
|
||||
" message.textContent = '';\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function showMessage(text) {\n"
|
||||
" message.hidden = false;\n"
|
||||
" message.textContent = text;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function setMonospaceText(el, text) {\n"
|
||||
" el.textContent = text || '-';\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function renderCapabilities(caps) {\n"
|
||||
" if (!caps || !caps.store_ops) {\n"
|
||||
" setMonospaceText(capabilitiesEl, '-');\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" const keys = ['put','get','put_indexed','get_indexed','tombstone','tombstone_lift','log_scan','current_state','validate_config'];\n"
|
||||
" const lines = keys.map((key) => `${key}: ${caps.store_ops[key] === true ? 'true' : 'false'}`);\n"
|
||||
" capabilitiesEl.textContent = lines.join('\\n');\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function renderMounts(mounts) {\n"
|
||||
" mountsBody.innerHTML = '';\n"
|
||||
" if (!Array.isArray(mounts)) {\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" mounts.forEach((mount) => {\n"
|
||||
" const row = document.createElement('tr');\n"
|
||||
" const cursor = mount.mode === 'track' ? mount.tracking : null;\n"
|
||||
" const cursorText = cursor && cursor.pull_cursor\n"
|
||||
" ? `present=${cursor.pull_cursor.present}` +\n"
|
||||
" (cursor.pull_cursor.last_logseq !== undefined ? ` last_logseq=${cursor.pull_cursor.last_logseq}` : '')\n"
|
||||
" : 'present=false';\n"
|
||||
" const notes = mount.status && Array.isArray(mount.status.notes)\n"
|
||||
" ? mount.status.notes.join(', ')\n"
|
||||
" : '';\n"
|
||||
" const statusText = mount.status && mount.status.ok === true ? 'ok' : 'not-ok';\n"
|
||||
" row.innerHTML = `\n"
|
||||
" <td class=\"mono\">${mount.name || ''}</td>\n"
|
||||
" <td class=\"mono\">${mount.peer_key || ''}</td>\n"
|
||||
" <td class=\"mono\">${mount.remote_space_id || ''}</td>\n"
|
||||
" <td>${mount.mode || ''}</td>\n"
|
||||
" <td class=\"mono\">${cursorText}</td>\n"
|
||||
" <td class=\"mono\">${statusText}${notes ? ` (${notes})` : ''}</td>\n"
|
||||
" `;\n"
|
||||
" mountsBody.appendChild(row);\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function loadWorkspace() {\n"
|
||||
" clearMessage();\n"
|
||||
" const space = getSpaceHeader();\n"
|
||||
" if (spaceError.hidden === false) {\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" const headers = {};\n"
|
||||
" if (space) {\n"
|
||||
" headers['X-Amduat-Space'] = space;\n"
|
||||
" }\n"
|
||||
" let res;\n"
|
||||
" try {\n"
|
||||
" res = await fetch('/v1/space/workspace', { headers });\n"
|
||||
" } catch (err) {\n"
|
||||
" showMessage('Failed to reach /v1/space/workspace.');\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (res.status === 404) {\n"
|
||||
" showMessage('No manifest configured for this space. Use PUT /v1/space/manifest to create one.');\n"
|
||||
" workspaceRaw.textContent = '';\n"
|
||||
" renderMounts([]);\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" const raw = await res.text();\n"
|
||||
" if (!res.ok) {\n"
|
||||
" showMessage(`Request failed (${res.status}).`);\n"
|
||||
" workspaceRaw.textContent = raw;\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" workspaceRaw.textContent = raw;\n"
|
||||
" let data;\n"
|
||||
" try {\n"
|
||||
" data = JSON.parse(raw);\n"
|
||||
" } catch (err) {\n"
|
||||
" showMessage('Invalid JSON response.');\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (data.effective_space) {\n"
|
||||
" const mode = data.effective_space.mode || 'unknown';\n"
|
||||
" const sid = data.effective_space.space_id || 'null';\n"
|
||||
" setMonospaceText(effectiveEl, `${mode} (${sid})`);\n"
|
||||
" } else {\n"
|
||||
" setMonospaceText(effectiveEl, '-');\n"
|
||||
" }\n"
|
||||
" setMonospaceText(backendEl, data.store_backend || '-');\n"
|
||||
" if (data.federation) {\n"
|
||||
" const enabled = data.federation.enabled ? 'enabled' : 'disabled';\n"
|
||||
" const transport = data.federation.transport || 'unknown';\n"
|
||||
" setMonospaceText(federationEl, `${enabled} (${transport})`);\n"
|
||||
" } else {\n"
|
||||
" setMonospaceText(federationEl, '-');\n"
|
||||
" }\n"
|
||||
" setMonospaceText(manifestRefEl, data.manifest_ref || '-');\n"
|
||||
" renderCapabilities(data.capabilities);\n"
|
||||
" renderMounts(data.mounts);\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" async function syncMounts() {\n"
|
||||
" clearMessage();\n"
|
||||
" const space = getSpaceHeader();\n"
|
||||
" if (spaceError.hidden === false) {\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" syncBtn.disabled = true;\n"
|
||||
" syncing.hidden = false;\n"
|
||||
" syncSummary.textContent = 'syncing...';\n"
|
||||
" const headers = {};\n"
|
||||
" if (space) {\n"
|
||||
" headers['X-Amduat-Space'] = space;\n"
|
||||
" }\n"
|
||||
" let res;\n"
|
||||
" try {\n"
|
||||
" res = await fetch('/v1/space/mounts/sync/until?limit=128&max_rounds=10&max_mounts=32', {\n"
|
||||
" method: 'POST',\n"
|
||||
" headers\n"
|
||||
" });\n"
|
||||
" } catch (err) {\n"
|
||||
" showMessage('Failed to reach /v1/space/mounts/sync/until.');\n"
|
||||
" syncBtn.disabled = false;\n"
|
||||
" syncing.hidden = true;\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" const raw = await res.text();\n"
|
||||
" syncRaw.textContent = raw;\n"
|
||||
" syncDetails.hidden = false;\n"
|
||||
" let data;\n"
|
||||
" try {\n"
|
||||
" data = JSON.parse(raw);\n"
|
||||
" } catch (err) {\n"
|
||||
" syncSummary.textContent = `Request failed (${res.status}).`;\n"
|
||||
" syncBtn.disabled = false;\n"
|
||||
" syncing.hidden = true;\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" const ok = data.ok === true;\n"
|
||||
" const synced = data.mounts_synced !== undefined ? data.mounts_synced : '-';\n"
|
||||
" let failures = 0;\n"
|
||||
" if (Array.isArray(data.results)) {\n"
|
||||
" failures = data.results.filter((r) => r && r.ok === false).length;\n"
|
||||
" }\n"
|
||||
" const statusClass = ok ? 'status-ok' : 'status-bad';\n"
|
||||
" syncSummary.innerHTML = `<span class=\"${statusClass}\">ok=${ok}</span> mounts_synced=${synced} failures=${failures}`;\n"
|
||||
" syncBtn.disabled = false;\n"
|
||||
" syncing.hidden = true;\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" refreshBtn.addEventListener('click', loadWorkspace);\n"
|
||||
" syncBtn.addEventListener('click', syncMounts);\n"
|
||||
" window.addEventListener('load', loadWorkspace);\n"
|
||||
" </script>\n"
|
||||
"</body>\n"
|
||||
"</html>\n";
|
||||
static const char k_amduatd_contract_v1_json[] =
|
||||
"{"
|
||||
"\"contract\":\"AMDUATD/API/1\","
|
||||
|
|
@ -1848,6 +2146,16 @@ mounts_sync_cleanup:
|
|||
return ok;
|
||||
}
|
||||
|
||||
static bool amduatd_handle_get_workspace_ui(int fd) {
|
||||
return amduatd_http_send_status(fd,
|
||||
200,
|
||||
"OK",
|
||||
"text/html; charset=utf-8",
|
||||
(const uint8_t *)k_amduatd_workspace_html,
|
||||
strlen(k_amduatd_workspace_html),
|
||||
false);
|
||||
}
|
||||
|
||||
static bool amduatd_handle_get_space_workspace(
|
||||
int fd,
|
||||
amduat_asl_store_t *store,
|
||||
|
|
@ -9101,6 +9409,10 @@ static bool amduatd_handle_conn(int fd,
|
|||
ok = amduatd_handle_meta(fd, cfg, api_contract_ref, false);
|
||||
goto conn_cleanup;
|
||||
}
|
||||
if (strcmp(req.method, "GET") == 0 && strcmp(no_query, "/workspace") == 0) {
|
||||
ok = amduatd_handle_get_workspace_ui(fd);
|
||||
goto conn_cleanup;
|
||||
}
|
||||
if (strcmp(req.method, "HEAD") == 0 && strcmp(no_query, "/v1/meta") == 0) {
|
||||
ok = amduatd_handle_meta(fd, cfg, api_contract_ref, true);
|
||||
goto conn_cleanup;
|
||||
|
|
|
|||
|
|
@ -8,24 +8,7 @@
|
|||
#include <string.h>
|
||||
|
||||
static bool amduatd_space_name_valid(const char *name) {
|
||||
size_t i;
|
||||
size_t len;
|
||||
if (name == NULL) {
|
||||
return false;
|
||||
}
|
||||
len = strlen(name);
|
||||
if (len == 0 || len > 64) {
|
||||
return false;
|
||||
}
|
||||
for (i = 0; i < len; ++i) {
|
||||
unsigned char c = (unsigned char)name[i];
|
||||
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' ||
|
||||
c == '-' || c == '.' || c == '/') {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return amduat_asl_pointer_name_is_valid(name);
|
||||
}
|
||||
|
||||
static bool amduatd_space_strdup_cstr(const char *s, char **out) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue