feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
This commit is contained in:
135
public/git-log.ts
Normal file
135
public/git-log.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* public/git-log.ts (W3 quick-wins d) — render-only recent-commit list.
|
||||
*
|
||||
* Fetches GET /projects/log for a repo and renders each commit as an inert row.
|
||||
* Zero parsing lives here (parsing is in src/http/git-log.ts). It NEVER throws
|
||||
* and degrades to a short inert message on any failure.
|
||||
*
|
||||
* Security: SEC-H5 — ALL text is set via textContent / el(). Zero innerHTML. A
|
||||
* commit subject is attacker-influenced (anyone who can push to a repo the host
|
||||
* can read), so it appears strictly as literal text.
|
||||
*/
|
||||
|
||||
import type { CommitLogEntry, GitLogResult } from '../src/types.js'
|
||||
|
||||
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Create an element with an optional CSS class and text content. */
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
cls?: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const node = document.createElement(tag)
|
||||
if (cls) node.className = cls
|
||||
if (text !== undefined) node.textContent = text
|
||||
return node
|
||||
}
|
||||
|
||||
/* ── normalize (never trust the API shape) ───────────────────────────────────── */
|
||||
|
||||
/** Coerce one untrusted /projects/log element into a safe CommitLogEntry, or null. */
|
||||
function normalizeCommit(raw: unknown): CommitLogEntry | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
if (typeof o['hash'] !== 'string' || typeof o['subject'] !== 'string') return null
|
||||
if (typeof o['at'] !== 'number' || !Number.isFinite(o['at'])) return null
|
||||
return { hash: o['hash'], at: o['at'], subject: o['subject'] }
|
||||
}
|
||||
|
||||
/** Coerce an untrusted GET /projects/log response into a GitLogResult, or null. */
|
||||
export function normalizeGitLog(raw: unknown): GitLogResult | null {
|
||||
if (raw === null || typeof raw !== 'object') return null
|
||||
const o = raw as Record<string, unknown>
|
||||
if (!Array.isArray(o['commits'])) return null
|
||||
const commits = o['commits']
|
||||
.map(normalizeCommit)
|
||||
.filter((c): c is CommitLogEntry => c !== null)
|
||||
return { commits, truncated: o['truncated'] === true }
|
||||
}
|
||||
|
||||
/* ── fetch ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Fetch the recent-commit log for a repo path. null on any error (best-effort). */
|
||||
export async function fetchGitLog(repoPath: string): Promise<GitLogResult | null> {
|
||||
try {
|
||||
if (typeof fetch === 'undefined') return null
|
||||
const res = await fetch(`/projects/log?path=${encodeURIComponent(repoPath)}`)
|
||||
if (!res.ok) return null
|
||||
return normalizeGitLog(await res.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ── render ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Coarse "Ns / Nm / Nh / Nd ago" formatter (local copy — avoids importing xterm). */
|
||||
function relTime(ms: number): string {
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||
if (s < 60) return `${Math.floor(s)}s`
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
||||
return `${Math.floor(s / 86400)}d`
|
||||
}
|
||||
|
||||
/** One commit row: short hash · relative time · subject (all inert text). */
|
||||
function renderCommitRow(c: CommitLogEntry): HTMLElement {
|
||||
const row = el('div', 'proj-commit-row')
|
||||
row.append(el('span', 'proj-commit-hash', c.hash))
|
||||
row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`))
|
||||
row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent
|
||||
return row
|
||||
}
|
||||
|
||||
/** Render a GitLogResult into a container (clears first). Empty → an inert note. */
|
||||
export function renderGitLog(container: HTMLElement, log: GitLogResult): void {
|
||||
container.textContent = ''
|
||||
if (log.commits.length === 0) {
|
||||
container.append(el('div', 'proj-empty', 'No commits yet.'))
|
||||
return
|
||||
}
|
||||
const list = el('div', 'proj-commitlog-list')
|
||||
for (const c of log.commits) list.append(renderCommitRow(c))
|
||||
container.append(list)
|
||||
if (log.truncated) {
|
||||
container.append(el('div', 'proj-commit-more', `Showing the latest ${log.commits.length} commits.`))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── mount ───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Handle returned by mountGitLog for cleanup. */
|
||||
export interface GitLogHandle {
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a recent-commit list into `container`: show a loading placeholder, fetch
|
||||
* the log, then swap in the rows. A fetch failure degrades to a short inert
|
||||
* message (never throws). destroy() removes the node and cancels the swap.
|
||||
*/
|
||||
export function mountGitLog(container: HTMLElement, repoPath: string): GitLogHandle {
|
||||
let destroyed = false
|
||||
|
||||
container.textContent = ''
|
||||
container.append(el('div', 'proj-commitlog-loading', 'Loading commits…'))
|
||||
|
||||
void (async () => {
|
||||
const log = await fetchGitLog(repoPath)
|
||||
if (destroyed) return
|
||||
if (log === null) {
|
||||
container.textContent = ''
|
||||
container.append(el('div', 'proj-empty', 'Could not read recent commits.'))
|
||||
return
|
||||
}
|
||||
renderGitLog(container, log)
|
||||
})()
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
destroyed = true
|
||||
container.textContent = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user