Files
handler/frontend/lib/format.ts
T
Claude fa2e97130d feat: bundle agent executables + web-driven claude login
Two changes so an operator can stand up and authenticate Handler entirely
from the browser, with a self-contained control image.

Bundle executables in the control image (Dockerfile.control)
- Node.js (NodeSource) + the Claude Code CLI, mise (official apt repo), and
  forge (git-pkgs/forge, built in a Go stage) join the existing git/tmux/ssh.
  No more bring-your-own binaries: live agent spawning, the verification gate,
  CI resolution, and the login flow all work out of the box. Installed under
  /usr so the /var/lib/handler VOLUME never masks them; mise apt source pinned
  to $TARGETARCH for the multi-arch (amd64/arm64) build.

Claude login from the web UI
- New login_start / login_submit command types (migration 0005) drive the
  interactive `claude /login` through the same enqueue→worker handoff every
  other control action uses — the API container has no claude binary.
- control/login.py opens `claude` in a dedicated tmux session, sends /login,
  selects the subscription account, and scrapes the claude.com authorization
  URL (tmux.capture_pane, -pJ so a wrapped URL rejoins); a second command feeds
  back the pasted code. Fully mockable via the tmux seam.
- API: POST /login/start, POST /login/submit (admin-gated).
- Dashboard: a "Claude Login" pane — a button that starts the flow, embeds the
  URL in an iframe (with a new-tab fallback, since claude.com may refuse
  framing), and takes the code to finish.

Also un-ignores frontend/lib/ (a broad Python `lib/` rule was swallowing the
UI's own api client + formatters, breaking rebuilds from a fresh clone) and
reconstructs those two source files; rebuilt static export committed.

Tests: control/login unit tests (tmux faked), worker dispatch, and API route
tests. Full suite green (195 tests), ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKVyBmKvWDVgrFC9WER2f2
2026-07-13 17:45:58 +00:00

93 lines
2.9 KiB
TypeScript

/* Formatting + status helpers shared by the UI. Pure functions, no API access.
*
* NOTE: this file lives under frontend/lib/, which the repo's top-level .gitignore used to
* swallow (a broad `lib/` rule) — it is now un-ignored (see .gitignore) so the source ships
* and `npm run build` works from a fresh clone. The *built* export under
* src/handler/api/static/ is still what the Python package serves. */
export type Tone = "neutral" | "info" | "success" | "warning" | "danger";
/* Map a raw handler status string (agent status, gate status, CI status) to a badge tone. */
export function statusTone(status: string | null | undefined): Tone {
switch ((status ?? "").toLowerCase()) {
case "pass":
case "done":
case "completed":
case "approved":
case "success":
return "success";
case "fail":
case "failed":
case "blocked":
case "rejected":
case "error":
return "danger";
case "pending":
case "queued":
case "running":
case "working":
case "paused_for_input":
return "warning";
case "not_applicable":
case "unknown":
case "":
return "neutral";
default:
return "info";
}
}
const LABELS: Record<string, string> = {
paused_for_input: "Needs input",
not_applicable: "N/A",
};
/* A tidy, human-readable label for a status string. */
export function statusLabel(status: string | null | undefined): string {
const raw = (status ?? "").trim();
if (!raw) return "—";
const key = raw.toLowerCase();
if (LABELS[key]) return LABELS[key];
return key
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/* Full local date + time, e.g. "Jul 13, 2026, 2:04 PM". "—" for empty. */
export function fmtFull(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/* First 7 chars of a commit sha. "—" for empty. */
export function shortSha(sha: string | null | undefined): string {
if (!sha) return "—";
return sha.slice(0, 7);
}
/* Compact relative time, e.g. "3m", "2h", "5d". "—" for empty. */
export function timeAgo(iso: string | null | undefined): string {
if (!iso) return "—";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "—";
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo`;
return `${Math.floor(months / 12)}y`;
}