mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-02 11:36:25 +00:00
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
This commit is contained in:
@@ -12,6 +12,7 @@ import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
|
||||
import { GitServersSection } from "@/components/sections/GitServersSection";
|
||||
import { ActivitySection } from "@/components/sections/ActivitySection";
|
||||
import { SharedSection } from "@/components/sections/SharedSection";
|
||||
import { LoginSection } from "@/components/sections/LoginSection";
|
||||
|
||||
interface NavDef {
|
||||
key: Section;
|
||||
@@ -34,6 +35,13 @@ const NAV: NavDef[] = [
|
||||
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
|
||||
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
|
||||
{ key: "shared", label: "Shared", count: (s) => s.shared.context.length },
|
||||
{
|
||||
key: "login",
|
||||
label: "Claude Login",
|
||||
count: () => 0,
|
||||
// Draw the eye to it until Claude is logged in on the host this session.
|
||||
accent: (s) => s.claudeLogin.status !== "done",
|
||||
},
|
||||
];
|
||||
|
||||
export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
|
||||
@@ -97,6 +105,7 @@ export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
|
||||
{s.section === "servers" && <GitServersSection />}
|
||||
{s.section === "activity" && <ActivitySection />}
|
||||
{s.section === "shared" && <SharedSection />}
|
||||
{s.section === "login" && <LoginSection />}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/* Claude Login — drive the bundled `claude /login` OAuth flow on the host from the web UI.
|
||||
*
|
||||
* Click "Log in to Claude" → the worker opens `claude /login` in the control container,
|
||||
* selects the subscription account, and returns the claude.com authorization URL. That URL
|
||||
* is shown in an embedded frame (and as a new-tab link, since claude.com may refuse to be
|
||||
* framed); after authorizing, paste the code back to finish. All state lives in the store's
|
||||
* `claudeLogin` machine (login_start / login_submit commands). */
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useDashboard } from "@/components/store";
|
||||
import { Button, Callout, Input } from "@/components/ui";
|
||||
|
||||
export function LoginSection() {
|
||||
const s = useDashboard();
|
||||
const { status, url, message } = s.claudeLogin;
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
const busy = status === "starting" || status === "submitting";
|
||||
const awaiting = status === "awaiting" || status === "submitting";
|
||||
|
||||
const submit = async () => {
|
||||
const ok = await s.submitClaudeCode(code);
|
||||
if (ok) setCode("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="section-title">Claude Login</div>
|
||||
<div className="section-desc">
|
||||
Log Claude Code in on the host so agents can run. This drives{" "}
|
||||
<span className="mono">claude /login</span> in the control container and picks the
|
||||
Claude account with a subscription.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-body" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{message && (
|
||||
<Callout tone={status === "error" ? "danger" : status === "done" ? "success" : "info"}>
|
||||
{message}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{status === "done" ? (
|
||||
<div>
|
||||
<Button variant="secondary" onClick={s.resetClaudeLogin}>
|
||||
Log in again
|
||||
</Button>
|
||||
</div>
|
||||
) : !awaiting ? (
|
||||
<div className="hstack" style={{ gap: 10 }}>
|
||||
<Button variant="primary" disabled={busy} onClick={s.startClaudeLogin}>
|
||||
{status === "starting" ? "Starting…" : "Log in to Claude"}
|
||||
</Button>
|
||||
{status === "error" && (
|
||||
<Button variant="ghost" disabled={busy} onClick={s.startClaudeLogin}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hstack" style={{ gap: 10, flexWrap: "wrap" }}>
|
||||
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
|
||||
Open login page in a new tab ↗
|
||||
</a>
|
||||
<Button variant="ghost" disabled={busy} onClick={s.startClaudeLogin}>
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border-default)",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
height: 460,
|
||||
background: "var(--surface-1, #111)",
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
title="Claude login"
|
||||
src={url}
|
||||
style={{ width: "100%", height: "100%", border: "none" }}
|
||||
sandbox="allow-forms allow-scripts allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>
|
||||
<div className="faint" style={{ fontSize: "var(--text-xs)" }}>
|
||||
If the frame stays blank, claude.com is refusing to be embedded — use the
|
||||
new-tab link above instead. The login session stays open until you submit the
|
||||
code or restart.
|
||||
</div>
|
||||
|
||||
<div className="hstack" style={{ gap: 10, alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<div style={{ flex: "1 1 320px" }}>
|
||||
<Input
|
||||
label="Authorization code"
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
placeholder="Paste the code from claude.com"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={status === "submitting" || !code.trim()}
|
||||
onClick={submit}
|
||||
>
|
||||
{status === "submitting" ? "Submitting…" : "Finish login"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,24 @@ export type Section =
|
||||
| "approvals"
|
||||
| "servers"
|
||||
| "activity"
|
||||
| "shared";
|
||||
| "shared"
|
||||
| "login";
|
||||
|
||||
/* The claude web-login flow, driven through the login_start / login_submit commands.
|
||||
* idle → starting → awaiting (have URL) → submitting → done | error */
|
||||
export type ClaudeLoginStatus =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "awaiting"
|
||||
| "submitting"
|
||||
| "done"
|
||||
| "error";
|
||||
|
||||
export interface ClaudeLoginState {
|
||||
status: ClaudeLoginStatus;
|
||||
url: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RunAgent extends Agent {}
|
||||
|
||||
@@ -97,6 +114,12 @@ interface StoreValue {
|
||||
deleteSchedule: (id: number) => Promise<void>;
|
||||
pollCi: () => Promise<void>;
|
||||
setSharedKey: (key: string, value: string) => Promise<boolean>;
|
||||
|
||||
// Claude web-login
|
||||
claudeLogin: ClaudeLoginState;
|
||||
startClaudeLogin: () => Promise<void>;
|
||||
submitClaudeCode: (code: string) => Promise<boolean>;
|
||||
resetClaudeLogin: () => void;
|
||||
}
|
||||
|
||||
export interface SpawnBody {
|
||||
@@ -189,6 +212,11 @@ export function DashboardProvider({
|
||||
const [cmd, setCmd] = useState<CmdState>({ text: "", error: false, busy: false });
|
||||
const [lastError, setLastError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [claudeLogin, setClaudeLogin] = useState<ClaudeLoginState>({
|
||||
status: "idle",
|
||||
url: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
// Keep polling loop reading fresh values without re-subscribing every render.
|
||||
const sectionRef = useRef(section);
|
||||
@@ -747,6 +775,89 @@ export function DashboardProvider({
|
||||
await loadCommands();
|
||||
}, [enqueueAndTrack, loadCommands]);
|
||||
|
||||
// ---- claude web-login (login_start / login_submit through the command queue) ----
|
||||
const startClaudeLogin = useCallback(async () => {
|
||||
setClaudeLogin({
|
||||
status: "starting",
|
||||
url: "",
|
||||
message: "Opening `claude /login` in the control container and selecting the subscription account…",
|
||||
});
|
||||
try {
|
||||
const command = await clientRef.current.api<Command>("/login/start", { method: "POST" });
|
||||
// login_start boots claude, drives the menu, and scrapes the URL — allow ~90s
|
||||
// (worker claim latency + boot waits + URL timeout).
|
||||
const final = await clientRef.current.trackCommand(command.id, { attempts: 180 });
|
||||
if (!final) {
|
||||
setClaudeLogin({
|
||||
status: "error",
|
||||
url: "",
|
||||
message: "Still starting (see Activity). Is the control worker running?",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (final.status !== "done") {
|
||||
setClaudeLogin({ status: "error", url: "", message: final.error || "Failed to start login." });
|
||||
return;
|
||||
}
|
||||
const url = final.result && typeof final.result.url === "string" ? final.result.url : "";
|
||||
if (!url) {
|
||||
setClaudeLogin({ status: "error", url: "", message: "No login URL was returned by claude." });
|
||||
return;
|
||||
}
|
||||
setClaudeLogin({
|
||||
status: "awaiting",
|
||||
url,
|
||||
message: "Authorize in the window below (or open it in a new tab), then paste the code claude gives you.",
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return;
|
||||
setClaudeLogin({ status: "error", url: "", message: (e as Error).message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitClaudeCode = useCallback(async (code: string) => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return false;
|
||||
setClaudeLogin((c) => ({ ...c, status: "submitting", message: "Submitting the authorization code…" }));
|
||||
try {
|
||||
const command = await clientRef.current.api<Command>("/login/submit", {
|
||||
method: "POST",
|
||||
body: { code: trimmed },
|
||||
});
|
||||
const final = await clientRef.current.trackCommand(command.id, { attempts: 60 });
|
||||
if (!final) {
|
||||
setClaudeLogin((c) => ({
|
||||
...c,
|
||||
status: "awaiting",
|
||||
message: "Submit still running (see Activity). Is the control worker running?",
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
if (final.status === "done") {
|
||||
setClaudeLogin({
|
||||
status: "done",
|
||||
url: "",
|
||||
message: "Claude Code is now logged in on the host — new agents will use this account.",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
setClaudeLogin((c) => ({
|
||||
...c,
|
||||
status: "awaiting",
|
||||
message: final.error || "Login was not confirmed. Re-check the code, or restart the flow.",
|
||||
}));
|
||||
return false;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setClaudeLogin((c) => ({ ...c, status: "awaiting", message: (e as Error).message }));
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetClaudeLogin = useCallback(() => {
|
||||
setClaudeLogin({ status: "idle", url: "", message: "" });
|
||||
}, []);
|
||||
|
||||
const setSharedKey = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
try {
|
||||
@@ -806,6 +917,10 @@ export function DashboardProvider({
|
||||
deleteSchedule,
|
||||
pollCi,
|
||||
setSharedKey,
|
||||
claudeLogin,
|
||||
startClaudeLogin,
|
||||
submitClaudeCode,
|
||||
resetClaudeLogin,
|
||||
};
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/* Typed client for the Handler API + the row shapes it returns (mirrors the FastAPI
|
||||
* pydantic schemas in src/handler/api/schemas.py). The browser calls the API same-origin
|
||||
* with relative paths; set NEXT_PUBLIC_API_BASE to point `npm run dev` at another origin.
|
||||
*
|
||||
* NOTE: this file lives under frontend/lib/, now un-ignored in .gitignore so the source
|
||||
* ships and the build works from a fresh clone (the built export under
|
||||
* src/handler/api/static/ is what the package serves). */
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
|
||||
|
||||
export type CommandStatus = "queued" | "running" | "done" | "failed";
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
root_dir: string;
|
||||
git_remote?: string | null;
|
||||
credential_ref?: string | null;
|
||||
created_at: string;
|
||||
/* Present on the registration response in git-server mode: the enqueued clone. */
|
||||
sync_command_id?: number | null;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
project_id: string;
|
||||
name: string;
|
||||
working_dir: string;
|
||||
status: string;
|
||||
role?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Checkmark {
|
||||
agent_id: number;
|
||||
checkpoint_at: string;
|
||||
status: string;
|
||||
where_it_stopped?: string | null;
|
||||
next_steps?: string[] | null;
|
||||
open_question?: string | null;
|
||||
log_entry_id?: number | null;
|
||||
tests_status: string;
|
||||
tested_at?: string | null;
|
||||
build_status: string;
|
||||
built_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
agent_id: number;
|
||||
created_at: string;
|
||||
session_id?: string | null;
|
||||
status: string;
|
||||
summary?: string | null;
|
||||
decisions?: string | null;
|
||||
question?: string | null;
|
||||
answer?: string | null;
|
||||
visibility: string;
|
||||
push_sha?: string | null;
|
||||
ci_status: string;
|
||||
ci_checked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Approval {
|
||||
id: number;
|
||||
project_id: string;
|
||||
branch: string;
|
||||
approved_sha?: string | null;
|
||||
pr_ref?: string | null;
|
||||
status: string;
|
||||
approved_by_agent_id?: number | null;
|
||||
actor?: string | null;
|
||||
note?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
hostname: string;
|
||||
forge_type: string;
|
||||
token_env_var?: string | null;
|
||||
base_url?: string | null;
|
||||
ssh_public_key?: string | null;
|
||||
has_token: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: number;
|
||||
project_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
type: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
status: CommandStatus;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: string | null;
|
||||
requested_by?: string | null;
|
||||
claimed_by?: string | null;
|
||||
created_at: string;
|
||||
claimed_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
id: number;
|
||||
project_id: string;
|
||||
name_prefix: string;
|
||||
task: string;
|
||||
role?: string | null;
|
||||
worktree?: string | null;
|
||||
subdir?: string | null;
|
||||
interval_seconds: number;
|
||||
enabled: boolean;
|
||||
next_run_at: string;
|
||||
last_run_at?: string | null;
|
||||
last_command_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SharedContext {
|
||||
key: string;
|
||||
value: string;
|
||||
set_by_agent_id?: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/* Thrown on a 401 so callers can distinguish "token rejected" from real errors and stay
|
||||
* quiet while the app re-prompts for a token. */
|
||||
export class AuthError extends Error {
|
||||
constructor(message = "unauthorized") {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
/* Any non-2xx (other than 401); carries the HTTP status so callers can branch on 404 etc. */
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
}
|
||||
|
||||
interface ApiOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
interface TrackOptions {
|
||||
attempts?: number;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
api: <T>(path: string, opts?: ApiOptions) => Promise<T>;
|
||||
/* Poll GET /commands/{id} until it reaches done/failed; null if still running after the
|
||||
* budget (worker down or a very slow command). */
|
||||
trackCommand: (id: number, opts?: TrackOptions) => Promise<Command | null>;
|
||||
}
|
||||
|
||||
export function createClient(token: string, onUnauthorized: () => void): ApiClient {
|
||||
async function api<T>(path: string, opts?: ApiOptions): Promise<T> {
|
||||
const hasBody = opts?.body !== undefined && opts?.body !== null;
|
||||
const res = await fetch(BASE + path, {
|
||||
method: opts?.method ?? (hasBody ? "POST" : "GET"),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: hasBody ? JSON.stringify(opts!.body) : undefined,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized();
|
||||
throw new AuthError();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail: string = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && typeof j.detail !== "undefined") {
|
||||
detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON error body; keep statusText */
|
||||
}
|
||||
const err = new Error(detail) as ApiError;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
async function trackCommand(id: number, opts?: TrackOptions): Promise<Command | null> {
|
||||
const attempts = opts?.attempts ?? 60;
|
||||
const intervalMs = opts?.intervalMs ?? 500;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const cmd = await api<Command>(`/commands/${id}`);
|
||||
if (cmd.status === "done" || cmd.status === "failed") return cmd;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { api, trackCommand };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user