feat(dashboard): entity list UX — REF column, name cells, detail pages (CUST-WP-0030)
- ref-cell.js: REF column component — click=copy deeplink, dblclick=open
- field-help.js: field registry + fieldRow helper with help-tip decoration;
FK fields (task_id, workstream_id, repo_id) render as async-linked cells
with entity-title bubble-help on hover
- GET /token-events/{id} endpoint + get-by-id tests
- GET /repos/by-id/{repo_id} UUID lookup endpoint
- Landing pages: /token-events/[id], /workstreams/[id], /repos/[slug], /tasks/[id]
- token-cost.md: REF + Name columns on all three tables; parallel fetch of
workstreams/tasks for title resolution
- reference.md: entity detail page URL scheme documented
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -326,6 +326,17 @@ async def get_repo_doi(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-id/{repo_id}", response_model=RepoRead)
|
||||
async def get_repo_by_id(
|
||||
repo_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
repo = await session.get(ManagedRepo, repo_id)
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/{slug}/", response_model=RepoRead)
|
||||
async def get_repo(
|
||||
slug: str,
|
||||
|
||||
@@ -166,6 +166,17 @@ async def get_tokens_by_repo(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{event_id}", response_model=TokenEventRead)
|
||||
async def get_token_event(
|
||||
event_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenEvent:
|
||||
event = await session.get(TokenEvent, event_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Token event not found")
|
||||
return event
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TokenEventRead])
|
||||
async def list_token_events(
|
||||
task_id: uuid.UUID | None = None,
|
||||
|
||||
263
state-hub/dashboard/src/components/field-help.js
Normal file
263
state-hub/dashboard/src/components/field-help.js
Normal file
@@ -0,0 +1,263 @@
|
||||
// Field-level help registry for dashboard entity detail pages.
|
||||
//
|
||||
// FIELD_HELP maps a field key to { label, description, doc? }.
|
||||
// label — human-readable field name (used as bold heading in help-tip)
|
||||
// description — one sentence explaining the field
|
||||
// doc — optional anchor into /docs/ pages for "Learn more"
|
||||
//
|
||||
// fieldRow(key, value) → <tr> element with a help-tip-decorated key cell and
|
||||
// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP.
|
||||
//
|
||||
// Usage:
|
||||
// import {fieldRow} from "./components/field-help.js";
|
||||
// const tbody = html`<tbody>${fields.map(([k,v]) => fieldRow(k,v))}</tbody>`;
|
||||
|
||||
import {HelpTip} from "./help-tip.js"; // ensures custom element is registered
|
||||
import {API} from "./config.js";
|
||||
void HelpTip; // silence unused-import linters
|
||||
|
||||
// ── Entity link registry ────────────────────────────────────────────────────
|
||||
// Maps FK field names to fetch/URL/title resolution rules.
|
||||
// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug.
|
||||
const FIELD_LINKS = {
|
||||
task_id: {
|
||||
apiUrl: id => `${API}/tasks/${id}`,
|
||||
getUrl: (id, _d) => `/tasks/${id}`,
|
||||
getTitle: d => d.title,
|
||||
},
|
||||
workstream_id: {
|
||||
apiUrl: id => `${API}/workstreams/${id}`,
|
||||
getUrl: (id, _d) => `/workstreams/${id}`,
|
||||
getTitle: d => d.title || d.slug,
|
||||
},
|
||||
repo_id: {
|
||||
apiUrl: id => `${API}/repos/by-id/${id}`,
|
||||
getUrl: (_id, d) => `/repos/${d.slug}`,
|
||||
getTitle: d => d.name || d.slug,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an entity-reference value as a link with an async help-tip showing
|
||||
* the entity title. Falls back gracefully if the fetch fails.
|
||||
*/
|
||||
function _linkCell(key, id) {
|
||||
const rule = FIELD_LINKS[key];
|
||||
const shortId = String(id).slice(0, 8) + "…";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.textContent = shortId;
|
||||
a.href = "#";
|
||||
a.style.fontFamily = "var(--monospace, monospace)";
|
||||
a.title = String(id); // full UUID as native tooltip while async loads
|
||||
|
||||
fetch(rule.apiUrl(id))
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
const title = rule.getTitle(data);
|
||||
const url = rule.getUrl(id, data);
|
||||
if (url) a.href = url;
|
||||
if (title) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", title);
|
||||
tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`);
|
||||
a.replaceWith(tip);
|
||||
tip.appendChild(a);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* leave plain link */ });
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
export const FIELD_HELP = {
|
||||
// ── TokenEvent ─────────────────────────────────────────────────────────────
|
||||
id: {
|
||||
label: "ID",
|
||||
description: "Unique identifier for this token event (UUID v4).",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_in: {
|
||||
label: "Tokens In",
|
||||
description: "Number of input (prompt) tokens consumed in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_out: {
|
||||
label: "Tokens Out",
|
||||
description: "Number of output (completion) tokens generated in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_total: {
|
||||
label: "Tokens Total",
|
||||
description: "Sum of input and output tokens — total cost proxy for this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
task_id: {
|
||||
label: "Task ID",
|
||||
description: "The task this token event was recorded against (if any).",
|
||||
doc: "/docs/tasks",
|
||||
},
|
||||
workstream_id: {
|
||||
label: "Workstream ID",
|
||||
description: "The workstream this event belongs to; auto-resolved from task if not set directly.",
|
||||
doc: "/docs/workstreams",
|
||||
},
|
||||
repo_id: {
|
||||
label: "Repo ID",
|
||||
description: "The managed repo this event is attributed to; auto-resolved from workstream.",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
session_id: {
|
||||
label: "Session ID",
|
||||
description: "Opaque identifier for the Claude Code session that produced this event.",
|
||||
},
|
||||
model: {
|
||||
label: "Model",
|
||||
description: "The Claude model used (e.g. claude-sonnet-4-6).",
|
||||
doc: "/docs/reference#models",
|
||||
},
|
||||
agent: {
|
||||
label: "Agent",
|
||||
description: "The agent persona that produced this event (e.g. custodian, ralph).",
|
||||
doc: "/docs/reference#agents",
|
||||
},
|
||||
ref_type: {
|
||||
label: "Ref Type",
|
||||
description: "What kind of entity the ref_id points to: task, commit, release, or session.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
ref_id: {
|
||||
label: "Ref ID",
|
||||
description: "Free-form reference ID; interpretation depends on ref_type.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
note: {
|
||||
label: "Note",
|
||||
description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
created_at: {
|
||||
label: "Created At",
|
||||
description: "Timestamp when this token event was recorded (UTC).",
|
||||
},
|
||||
|
||||
// ── Workstream ──────────────────────────────────────────────────────────────
|
||||
slug: {
|
||||
label: "Slug",
|
||||
description: "URL-safe short identifier for this entity.",
|
||||
},
|
||||
title: {
|
||||
label: "Title",
|
||||
description: "Human-readable name for this workstream or task.",
|
||||
},
|
||||
status: {
|
||||
label: "Status",
|
||||
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
|
||||
doc: "/docs/workstream-lifecycle",
|
||||
},
|
||||
topic_id: {
|
||||
label: "Topic ID",
|
||||
description: "The topic this workstream is grouped under.",
|
||||
doc: "/docs/reference#topics",
|
||||
},
|
||||
repo_goal_id: {
|
||||
label: "Repo Goal ID",
|
||||
description: "Optional link to a repo-level strategic goal this workstream advances.",
|
||||
doc: "/docs/goals",
|
||||
},
|
||||
|
||||
// ── Task ───────────────────────────────────────────────────────────────────
|
||||
assignee: {
|
||||
label: "Assignee",
|
||||
description: "Who is responsible for completing this task (agent name or human).",
|
||||
},
|
||||
priority: {
|
||||
label: "Priority",
|
||||
description: "Relative urgency: high, medium, or low.",
|
||||
},
|
||||
due_date: {
|
||||
label: "Due Date",
|
||||
description: "Target completion date (ISO 8601).",
|
||||
},
|
||||
needs_human: {
|
||||
label: "Needs Human",
|
||||
description: "True if the task is blocked waiting for human input or approval.",
|
||||
doc: "/interventions",
|
||||
},
|
||||
intervention_note: {
|
||||
label: "Intervention Note",
|
||||
description: "Why human intervention is required for this task.",
|
||||
},
|
||||
|
||||
// ── Repo ───────────────────────────────────────────────────────────────────
|
||||
repo_slug: {
|
||||
label: "Repo Slug",
|
||||
description: "Short identifier for the repository (matches the git remote slug).",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
event_count: {
|
||||
label: "Event Count",
|
||||
description: "Total number of token events attributed to this entity.",
|
||||
},
|
||||
by_model: {
|
||||
label: "By Model",
|
||||
description: "Token totals broken down by Claude model.",
|
||||
},
|
||||
by_note: {
|
||||
label: "By Note",
|
||||
description: "Token totals broken down by quality tier (measured / workplan / heuristic).",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single key-value row for an entity detail table.
|
||||
* @param {string} key — field name
|
||||
* @param {*} value — field value (stringified automatically)
|
||||
* @returns {HTMLTableRowElement}
|
||||
*/
|
||||
export function fieldRow(key, value) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
// Key cell
|
||||
const tdKey = document.createElement("td");
|
||||
tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;";
|
||||
|
||||
const help = FIELD_HELP[key];
|
||||
if (help) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", help.label);
|
||||
tip.setAttribute("description", help.description);
|
||||
if (help.doc) tip.setAttribute("doc", help.doc);
|
||||
tip.textContent = help.label;
|
||||
tdKey.appendChild(tip);
|
||||
} else {
|
||||
tdKey.textContent = key;
|
||||
}
|
||||
tr.appendChild(tdKey);
|
||||
|
||||
// Value cell
|
||||
const tdVal = document.createElement("td");
|
||||
tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;";
|
||||
|
||||
let display;
|
||||
if (value === null || value === undefined) {
|
||||
display = document.createElement("span");
|
||||
display.style.color = "var(--theme-foreground-faint,#aaa)";
|
||||
display.textContent = "—";
|
||||
} else if (key in FIELD_LINKS) {
|
||||
display = _linkCell(key, value);
|
||||
} else if (typeof value === "object") {
|
||||
display = document.createElement("pre");
|
||||
display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;";
|
||||
display.textContent = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
display = document.createElement("span");
|
||||
display.textContent = String(value);
|
||||
}
|
||||
tdVal.appendChild(display);
|
||||
tr.appendChild(tdVal);
|
||||
|
||||
return tr;
|
||||
}
|
||||
101
state-hub/dashboard/src/components/ref-cell.js
Normal file
101
state-hub/dashboard/src/components/ref-cell.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// refCell(index, recordType, id) → HTMLElement
|
||||
//
|
||||
// Renders a 1-based row number in a table cell.
|
||||
// Single click — copies deep-link to clipboard and flashes "Copied!".
|
||||
// Double click — opens deep-link in a new tab.
|
||||
//
|
||||
// Deep-link format: <origin>/data/<recordType>/<id>
|
||||
//
|
||||
// Usage:
|
||||
// import {refCell} from "./components/ref-cell.js";
|
||||
// // in an Inputs.table format callback:
|
||||
// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) }
|
||||
|
||||
const _STYLE_ID = "refcell-global-style";
|
||||
if (!document.getElementById(_STYLE_ID)) {
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
.ref-cell {
|
||||
display: inline-block;
|
||||
font-family: var(--monospace, monospace);
|
||||
font-size: 0.78rem;
|
||||
color: var(--theme-foreground-focus, #3b82f6);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ref-cell:hover {
|
||||
background: var(--theme-foreground-faint, #e8f0fe);
|
||||
}
|
||||
.ref-cell-toast {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--theme-background, #fff);
|
||||
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-foreground, #333);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ref-cell-toast.ref-cell-toast-visible { opacity: 1; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _showToast(anchorEl, text) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "ref-cell-toast";
|
||||
toast.textContent = text;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const gap = 6;
|
||||
toast.style.left = `${rect.left}px`;
|
||||
toast.style.top = `${rect.top - toast.offsetHeight - gap}px`;
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible"));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("ref-cell-toast-visible");
|
||||
toast.addEventListener("transitionend", () => toast.remove(), {once: true});
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
export function refCell(index, recordType, id) {
|
||||
const deepLink = `${location.origin}/${recordType}/${id}`;
|
||||
|
||||
const el = document.createElement("span");
|
||||
el.className = "ref-cell";
|
||||
el.title = `Click to copy link · Double-click to open\n${deepLink}`;
|
||||
el.textContent = String(index);
|
||||
|
||||
let clickTimer = null;
|
||||
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// Use a short delay so a double-click cancels the single-click handler.
|
||||
clickTimer = setTimeout(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(deepLink);
|
||||
_showToast(el, "Copied!");
|
||||
} catch {
|
||||
// Fallback for environments where clipboard API is blocked.
|
||||
_showToast(el, deepLink);
|
||||
}
|
||||
}, 180);
|
||||
});
|
||||
|
||||
el.addEventListener("dblclick", (e) => {
|
||||
e.stopPropagation();
|
||||
clearTimeout(clickTimer);
|
||||
window.open(deepLink, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
29
state-hub/dashboard/src/data/repos/[slug].json.py
Normal file
29
state-hub/dashboard/src/data/repos/[slug].json.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches a single repo by slug."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
slug = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
|
||||
if not slug:
|
||||
print(json.dumps({"error": "No repo slug provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/repos/{slug}/", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(json.dumps({"error": f"Repo {slug!r} not found"}))
|
||||
sys.exit(1)
|
||||
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
29
state-hub/dashboard/src/data/token-events/[id].json.py
Normal file
29
state-hub/dashboard/src/data/token-events/[id].json.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches a single token event by ID."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
event_id = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
|
||||
if not event_id:
|
||||
print(json.dumps({"error": "No event ID provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/token-events/{event_id}", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(json.dumps({"error": f"Token event {event_id!r} not found"}))
|
||||
sys.exit(1)
|
||||
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
29
state-hub/dashboard/src/data/workstreams/[id].json.py
Normal file
29
state-hub/dashboard/src/data/workstreams/[id].json.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: fetches a single workstream by ID."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
|
||||
|
||||
ws_id = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
|
||||
if not ws_id:
|
||||
print(json.dumps({"error": "No workstream ID provided"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(f"{API_BASE}/workstreams/{ws_id}", timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
print(json.dumps(data))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print(json.dumps({"error": f"Workstream {ws_id!r} not found"}))
|
||||
sys.exit(1)
|
||||
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
@@ -54,6 +54,28 @@ convention used in the Custodian State Hub.
|
||||
|
||||
---
|
||||
|
||||
## Entity Detail Pages & REF Column
|
||||
|
||||
Every entity table in the dashboard includes a **REF** column (leftmost) that shows
|
||||
the 1-based row number for the current view. Clicking the REF number copies a
|
||||
deep-link to the clipboard in the format `/data/<recordtype>/<id>`. Double-clicking
|
||||
opens that link in a new tab.
|
||||
|
||||
The deep-link target is an entity detail page that renders all fields of the record
|
||||
in a key-value layout. Each field key is decorated with a `<help-tip>` that shows a
|
||||
one-sentence description of the field and a "Learn more" link to the relevant
|
||||
documentation section.
|
||||
|
||||
Currently implemented record types:
|
||||
|
||||
| Record type | URL pattern | Source endpoint |
|
||||
|-------------|-------------|-----------------|
|
||||
| `token-events` | `/token-events/<id>` | `GET /token-events/{id}` |
|
||||
|
||||
Further record types (repos, workstreams, tasks) will be added in subsequent workplans.
|
||||
|
||||
---
|
||||
|
||||
## Meta
|
||||
|
||||
| Topic | What it covers |
|
||||
|
||||
42
state-hub/dashboard/src/repos/[slug].md
Normal file
42
state-hub/dashboard/src/repos/[slug].md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Repo
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const repoSlug = observable.params.slug;
|
||||
const raw = await fetch(`${API}/repos/${repoSlug}/`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const name = raw.name || raw.slug || repoSlug;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Repo · <em>${name}</em></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/repos">← Repos</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","slug","name","domain_slug","status","description",
|
||||
"local_path","remote_url","git_fingerprint",
|
||||
"sbom_source","last_sbom_at","last_state_synced_at",
|
||||
"created_at","updated_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
42
state-hub/dashboard/src/tasks/[id].md
Normal file
42
state-hub/dashboard/src/tasks/[id].md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Task
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const taskId = observable.params.id;
|
||||
const raw = await fetch(`${API}/tasks/${taskId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const name = raw.title || taskId;
|
||||
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Task · <em>${shortName}</em></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/tasks">← Tasks</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","title","status","priority","assignee",
|
||||
"workstream_id","due_date","needs_human","intervention_note",
|
||||
"created_at","updated_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
@@ -4,26 +4,37 @@ title: Token Cost
|
||||
|
||||
```js
|
||||
import {API} from "./components/config.js";
|
||||
import {refCell} from "./components/ref-cell.js";
|
||||
const POLL = 60_000;
|
||||
```
|
||||
|
||||
```js
|
||||
// Fetch both /by-repo/ and raw events in parallel
|
||||
// Fetch token events, by-repo summary, workstreams, and tasks in parallel
|
||||
const tokenState = (async function*() {
|
||||
while (true) {
|
||||
let byRepo = [], events = [], ok = false;
|
||||
let byRepo = [], events = [], wsMap = {}, taskMap = {}, ok = false;
|
||||
try {
|
||||
const [r1, r2] = await Promise.all([
|
||||
const [r1, r2, r3, r4] = await Promise.all([
|
||||
fetch(`${API}/token-events/by-repo/`),
|
||||
fetch(`${API}/token-events/?limit=1000`),
|
||||
fetch(`${API}/workstreams/`),
|
||||
fetch(`${API}/tasks/`),
|
||||
]);
|
||||
ok = r1.ok && r2.ok;
|
||||
if (ok) {
|
||||
byRepo = await r1.json();
|
||||
events = await r2.json();
|
||||
}
|
||||
if (r3.ok) {
|
||||
const wsList = await r3.json();
|
||||
for (const w of wsList) wsMap[w.id] = w;
|
||||
}
|
||||
if (r4.ok) {
|
||||
const taskList = await r4.json();
|
||||
for (const t of taskList) taskMap[t.id] = t;
|
||||
}
|
||||
} catch {}
|
||||
yield {byRepo, events, ok, ts: new Date()};
|
||||
yield {byRepo, events, wsMap, taskMap, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
@@ -58,11 +69,22 @@ function buildSummary(events) {
|
||||
total_events: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
function nameCell(name, fullName) {
|
||||
const s = String(name ?? fullName ?? "—");
|
||||
const full = String(fullName ?? name ?? "—");
|
||||
const el = document.createElement("span");
|
||||
el.title = full;
|
||||
el.textContent = s.length > 80 ? s.slice(0, 80) + "…" : s;
|
||||
return el;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const byRepo = tokenState.byRepo ?? [];
|
||||
const summary = buildSummary(tokenState.events ?? []);
|
||||
const byRepo = tokenState.byRepo ?? [];
|
||||
const summary = buildSummary(tokenState.events ?? []);
|
||||
const wsMap = tokenState.wsMap ?? {};
|
||||
const taskMap = tokenState.taskMap ?? {};
|
||||
const _ok = tokenState.ok ?? false;
|
||||
const _ts = tokenState.ts;
|
||||
```
|
||||
@@ -99,6 +121,26 @@ if (byRepo.length === 0) {
|
||||
),
|
||||
],
|
||||
}));
|
||||
|
||||
display(Inputs.table(byRepo.map((r, i) => ({...r, _ref: i})), {
|
||||
columns: ["_ref", "repo_slug", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
||||
header: {
|
||||
_ref: "REF",
|
||||
repo_slug: "Repo",
|
||||
tokens_in: "Tokens In",
|
||||
tokens_out: "Tokens Out",
|
||||
tokens_total: "Total",
|
||||
event_count: "Events",
|
||||
},
|
||||
format: {
|
||||
_ref: (_, i) => refCell(i + 1, "repos", byRepo[i].repo_slug),
|
||||
repo_slug: d => nameCell(d, d),
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
width: {_ref: 50, repo_slug: 160, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
@@ -109,22 +151,27 @@ const wsRows = summary.by_workstream.slice(0, 20);
|
||||
if (wsRows.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(wsRows, {
|
||||
columns: ["scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
||||
display(Inputs.table(wsRows.map((r, i) => ({...r, _ref: i})), {
|
||||
columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
|
||||
header: {
|
||||
scope_id: "Workstream ID",
|
||||
_ref: "REF",
|
||||
scope_id: "Workstream",
|
||||
tokens_in: "Tokens In",
|
||||
tokens_out: "Tokens Out",
|
||||
tokens_total: "Total",
|
||||
event_count: "Events",
|
||||
},
|
||||
format: {
|
||||
scope_id: d => d.slice(0,8) + "…",
|
||||
_ref: (_, i) => refCell(i + 1, "workstreams", wsRows[i].scope_id),
|
||||
scope_id: d => {
|
||||
const ws = wsMap[d];
|
||||
return nameCell(ws?.title ?? ws?.slug, d);
|
||||
},
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
width: {scope_id: 120, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
||||
width: {_ref: 50, scope_id: 200, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
|
||||
}));
|
||||
}
|
||||
```
|
||||
@@ -153,15 +200,20 @@ if (summary.by_model.length === 0) {
|
||||
if (summary.top_tasks.length === 0) {
|
||||
display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
|
||||
} else {
|
||||
display(Inputs.table(summary.top_tasks, {
|
||||
columns: ["task_id", "tokens_in", "tokens_out", "tokens_total"],
|
||||
header: {task_id: "Task ID", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"},
|
||||
display(Inputs.table(summary.top_tasks.map((r, i) => ({...r, _ref: i})), {
|
||||
columns: ["_ref", "task_id", "tokens_in", "tokens_out", "tokens_total"],
|
||||
header: {_ref: "REF", task_id: "Task", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"},
|
||||
format: {
|
||||
task_id: d => d.slice(0,8) + "…",
|
||||
_ref: (_, i) => refCell(i + 1, "tasks", summary.top_tasks[i].task_id),
|
||||
task_id: d => {
|
||||
const task = taskMap[d];
|
||||
return nameCell(task?.title, d);
|
||||
},
|
||||
tokens_in: d => d.toLocaleString(),
|
||||
tokens_out: d => d.toLocaleString(),
|
||||
tokens_total: d => d.toLocaleString(),
|
||||
},
|
||||
width: {_ref: 50, task_id: 240},
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
42
state-hub/dashboard/src/token-events/[id].md
Normal file
42
state-hub/dashboard/src/token-events/[id].md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: Token Event
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const eventId = observable.params.id;
|
||||
const raw = await fetch(`${API}/token-events/${eventId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const shortId = raw.id ? raw.id.slice(0, 8) + "…" : eventId;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Token Event · <code>${shortId}</code></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","tokens_in","tokens_out","tokens_total",
|
||||
"note","model","agent","session_id",
|
||||
"task_id","workstream_id","repo_id",
|
||||
"ref_type","ref_id","created_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
41
state-hub/dashboard/src/workstreams/[id].md
Normal file
41
state-hub/dashboard/src/workstreams/[id].md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Workstream
|
||||
---
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
import {fieldRow} from "../components/field-help.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const wsId = observable.params.id;
|
||||
const raw = await fetch(`${API}/workstreams/${wsId}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
||||
.catch(e => ({error: String(e)}));
|
||||
```
|
||||
|
||||
```js
|
||||
if (raw.error) {
|
||||
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
||||
} else {
|
||||
const name = raw.title || raw.slug || wsId;
|
||||
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
|
||||
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`);
|
||||
display(html`<p style="margin-top:0"><a href="/workstreams">← Workstreams</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
||||
|
||||
const FIELD_ORDER = [
|
||||
"id","slug","title","status","topic_id","repo_id","repo_goal_id",
|
||||
"created_at","updated_at",
|
||||
];
|
||||
|
||||
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
||||
}
|
||||
|
||||
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
||||
<colgroup><col style="width:160px"><col></colgroup>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`);
|
||||
}
|
||||
```
|
||||
@@ -196,3 +196,22 @@ class TestTokenSummary:
|
||||
s = r.json()
|
||||
assert s["tokens_total"] == 0
|
||||
assert s["event_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTokenEventGetById:
|
||||
async def test_get_by_id(self, client):
|
||||
ev = await _post_event(client, tokens_in=111, tokens_out=222, note="get-by-id-test")
|
||||
r = await client.get(f"/token-events/{ev['id']}")
|
||||
assert r.status_code == 200
|
||||
result = r.json()
|
||||
assert result["id"] == ev["id"]
|
||||
assert result["tokens_in"] == 111
|
||||
assert result["tokens_out"] == 222
|
||||
assert result["tokens_total"] == 333
|
||||
assert result["note"] == "get-by-id-test"
|
||||
|
||||
async def test_get_by_id_not_found(self, client):
|
||||
import uuid
|
||||
r = await client.get(f"/token-events/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user