Add 'python/robomp/' from commit '553fd1cfcf59e4c501c54fc81bc083ffd2ca007b'
git-subtree-dir: python/robomp git-subtree-mainline:4f6e70f779git-subtree-split:553fd1cfcf
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#07090c" />
|
||||
<title>robomp</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><circle cx='32' cy='32' r='28' fill='%230a84ff'/><circle cx='32' cy='32' r='12' fill='%23ffffff'/></svg>"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script id="robomp-config" type="application/json">__ROBOMP_CONFIG__</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "robomp-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Glassmorphic SolidJS dashboard bundled by Vite and served by robomp's FastAPI app.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "^1.9.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"@types/node": "^22.10.5",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-solid": "^2.11.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type JSX, onCleanup, onMount } from "solid-js";
|
||||
|
||||
import { Browse } from "./components/Browse";
|
||||
import { Events } from "./components/Events";
|
||||
import { Header } from "./components/Header";
|
||||
import { Issues } from "./components/Issues";
|
||||
import { Logs } from "./components/Logs";
|
||||
import { Stats } from "./components/Stats";
|
||||
import { Trigger } from "./components/Trigger";
|
||||
import { Working } from "./components/Working";
|
||||
import { runTrigger, startPolling, stopPolling } from "./state";
|
||||
|
||||
export function App(): JSX.Element {
|
||||
onMount(() => {
|
||||
startPolling();
|
||||
});
|
||||
onCleanup(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
const handleRetry = (deliveryId: string): void => {
|
||||
void runTrigger({ mode: "retry", delivery_id: deliveryId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="mx-auto w-full max-w-[1400px]">
|
||||
<Header />
|
||||
|
||||
<main class="px-6 lg:px-10 pb-16 flex flex-col gap-5">
|
||||
<div class="grid gap-5" style={{ "grid-template-columns": "minmax(0, 1fr)" }}>
|
||||
<Trigger />
|
||||
<Browse />
|
||||
</div>
|
||||
|
||||
<Stats />
|
||||
|
||||
<div class="grid gap-5 grid-cols-1 xl:grid-cols-2">
|
||||
<Working />
|
||||
<Issues onRetry={handleRetry} />
|
||||
</div>
|
||||
|
||||
<Events onRetry={handleRetry} />
|
||||
|
||||
<Logs />
|
||||
|
||||
<footer class="text-center text-[11px] text-ink-500 pt-2 pb-1">
|
||||
robomp · self-hosted triage & fix · polling every 3s
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { AUTH_HEADERS } from "./config";
|
||||
import type {
|
||||
BrowseResponse,
|
||||
CancelResponse,
|
||||
LogsResponse,
|
||||
StatusResponse,
|
||||
TriggerResponse,
|
||||
} from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(body: unknown): string | null {
|
||||
if (body == null || typeof body !== "object") return null;
|
||||
const detail = (body as Record<string, unknown>).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
const message = (body as Record<string, unknown>).message;
|
||||
if (typeof message === "string") return message;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function unwrap<T>(resp: Response): Promise<T> {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
// Endpoint returned non-JSON. For 2xx that's still valid for callers that
|
||||
// expect an empty body; we only surface the parse failure on errors.
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const detail = extractDetail(body) ?? resp.statusText ?? `HTTP ${resp.status}`;
|
||||
throw new ApiError(resp.status, detail);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return { ...AUTH_HEADERS };
|
||||
}
|
||||
|
||||
function jsonHeaders(): Record<string, string> {
|
||||
return { "Content-Type": "application/json", ...AUTH_HEADERS };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
status(signal?: AbortSignal): Promise<StatusResponse> {
|
||||
return fetch("/api/status", { signal }).then(unwrap<StatusResponse>);
|
||||
},
|
||||
logs(limit = 400, signal?: AbortSignal): Promise<LogsResponse> {
|
||||
return fetch(`/api/logs?limit=${limit}`, { signal }).then(unwrap<LogsResponse>);
|
||||
},
|
||||
browse(state: string, refresh = false, signal?: AbortSignal): Promise<BrowseResponse> {
|
||||
const qs = new URLSearchParams({ state, limit: "50" });
|
||||
if (refresh) qs.set("refresh", "1");
|
||||
return fetch(`/api/github/issues?${qs.toString()}`, {
|
||||
headers: authHeaders(),
|
||||
signal,
|
||||
}).then(unwrap<BrowseResponse>);
|
||||
},
|
||||
trigger(body: {
|
||||
mode: "triage" | "retry";
|
||||
issue?: string;
|
||||
delivery_id?: string;
|
||||
}): Promise<TriggerResponse> {
|
||||
return fetch("/api/trigger", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
}).then(unwrap<TriggerResponse>);
|
||||
},
|
||||
cancel(deliveryId: string): Promise<CancelResponse> {
|
||||
return fetch("/api/cancel", {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ delivery_id: deliveryId }),
|
||||
}).then(unwrap<CancelResponse>);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
type JSX,
|
||||
type ResourceReturn,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
|
||||
import { ApiError, api } from "../api";
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge } from "../format";
|
||||
import { runTrigger } from "../state";
|
||||
import type { BrowseResponse } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
interface BrowseQuery {
|
||||
state: string;
|
||||
refreshCount: number;
|
||||
}
|
||||
|
||||
const EMPTY_RESPONSE: BrowseResponse = {
|
||||
issues: [],
|
||||
errors: [],
|
||||
repos: [],
|
||||
cache: { hit: false, fetched_at: 0 },
|
||||
};
|
||||
|
||||
export function Browse(): JSX.Element {
|
||||
const [state, setState] = createSignal<string>("open");
|
||||
const [refreshCount, setRefreshCount] = createSignal<number>(0);
|
||||
const [filter, setFilter] = createSignal<string>("");
|
||||
const [hideProcessed, setHideProcessed] = createSignal<boolean>(true);
|
||||
|
||||
const fetchBrowse = async (query: BrowseQuery): Promise<BrowseResponse> => {
|
||||
return api.browse(query.state, query.refreshCount > 0);
|
||||
};
|
||||
|
||||
const tuple: ResourceReturn<BrowseResponse> = createResource<BrowseResponse, BrowseQuery>(
|
||||
() => ({ state: state(), refreshCount: refreshCount() }),
|
||||
fetchBrowse,
|
||||
);
|
||||
const [browseResource] = tuple;
|
||||
|
||||
const data = createMemo<BrowseResponse>(() => browseResource.latest ?? EMPTY_RESPONSE);
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const all = data().issues;
|
||||
const needle = filter().trim().toLowerCase();
|
||||
const hidden = hideProcessed();
|
||||
const list = hidden ? all.filter((i) => !i.processed) : all;
|
||||
if (!needle) return list;
|
||||
return list.filter((i) => `${i.repo} ${i.title} #${i.number}`.toLowerCase().includes(needle));
|
||||
});
|
||||
|
||||
const processedCount = createMemo(() => data().issues.filter((i) => i.processed).length);
|
||||
|
||||
const errorMessage = (): string | null => {
|
||||
const err = browseResource.error;
|
||||
if (!err) return null;
|
||||
if (err instanceof ApiError) return `error ${err.status}: ${err.message}`;
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
};
|
||||
|
||||
const meta = (): string => {
|
||||
const d = data();
|
||||
const totalRepos = d.repos.length ? d.repos.join(", ") : "(allowlist empty)";
|
||||
const ageSeconds =
|
||||
d.cache.fetched_at > 0 ? Math.max(0, (Date.now() - d.cache.fetched_at * 1000) / 1000) : 0;
|
||||
const cacheInfo =
|
||||
d.cache.fetched_at > 0
|
||||
? ` · ${d.cache.hit ? "cached" : "loaded"} ${ageSeconds.toFixed(0)}s ago`
|
||||
: "";
|
||||
const hidden =
|
||||
hideProcessed() && processedCount() > 0 ? ` · ${processedCount()} processed hidden` : "";
|
||||
return `${filtered().length}/${d.issues.length} from ${totalRepos}${cacheInfo}${hidden}`;
|
||||
};
|
||||
|
||||
const triggerFor = (mode: "triage" | "retry", repo: string, number: number): void => {
|
||||
void runTrigger({ mode, issue: `${repo}#${number}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="browse" accessory={<span class="text-[11px] text-ink-400">{meta()}</span>}>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={
|
||||
<div class="px-5 py-7 text-ink-300 text-[13px] leading-relaxed">
|
||||
issue browser disabled — same gate as the trigger surface.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="px-4 pb-3 pt-1 flex flex-wrap items-center gap-3 border-b border-stroke-soft">
|
||||
<select value={state()} onChange={(ev) => setState(ev.currentTarget.value)}>
|
||||
<option value="open">open</option>
|
||||
<option value="closed">closed</option>
|
||||
<option value="all">all</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
class="flex-1 min-w-[180px]"
|
||||
placeholder="filter title or repo"
|
||||
value={filter()}
|
||||
onInput={(ev) => setFilter(ev.currentTarget.value)}
|
||||
/>
|
||||
<label class="flex items-center gap-2 text-[12px] text-ink-300 select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideProcessed()}
|
||||
onChange={(ev) => setHideProcessed(ev.currentTarget.checked)}
|
||||
/>
|
||||
hide processed
|
||||
</label>
|
||||
<button
|
||||
class="ghost"
|
||||
disabled={browseResource.loading}
|
||||
onClick={() => setRefreshCount((n) => n + 1)}
|
||||
>
|
||||
{browseResource.loading ? "loading…" : "refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.05)]">
|
||||
{errorMessage()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={data().errors.length}>
|
||||
<For each={data().errors}>
|
||||
{(err) => (
|
||||
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.04)]">
|
||||
<code>{err.repo}</code> <span class="text-ink-300">{err.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<div class="max-h-[44vh] overflow-y-auto scrollable">
|
||||
<Show
|
||||
when={filtered().length}
|
||||
fallback={
|
||||
<div class="empty">
|
||||
{hideProcessed() &&
|
||||
processedCount() > 0 &&
|
||||
processedCount() === data().issues.length
|
||||
? `all ${processedCount()} issues already processed — uncheck "hide processed" to see them`
|
||||
: "no issues"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filtered()}>
|
||||
{(issue) => (
|
||||
<div
|
||||
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-white/[0.025] transition-colors"
|
||||
style={{
|
||||
"grid-template-columns": "1fr auto",
|
||||
opacity: issue.processed ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
class="font-medium text-ink-100 hover:text-accent-2 truncate"
|
||||
href={issue.html_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span class="font-mono text-[12px] text-ink-300 mr-2">
|
||||
{issue.repo}#{issue.number}
|
||||
</span>
|
||||
{issue.title}
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-400">
|
||||
<Pill state={issue.state}>{issue.state}</Pill>
|
||||
<Show when={issue.processed}>
|
||||
<span
|
||||
class="pill"
|
||||
style={{
|
||||
color: "#9ec9ff",
|
||||
"border-color": "rgba(100,175,255,0.32)",
|
||||
"background-color": "rgba(10,132,255,0.10)",
|
||||
}}
|
||||
>
|
||||
processed
|
||||
</span>
|
||||
</Show>
|
||||
<span>by {issue.author || "—"}</span>
|
||||
<span>updated {fmtAge(issue.updated_at)}</span>
|
||||
<span>{issue.comments} comments</span>
|
||||
<For each={issue.labels.slice(0, 6)}>{(label) => <code>{label}</code>}</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
class="primary tiny"
|
||||
onClick={() => triggerFor("triage", issue.repo, issue.number)}
|
||||
>
|
||||
triage
|
||||
</button>
|
||||
<button
|
||||
class="tiny"
|
||||
onClick={() => triggerFor("retry", issue.repo, issue.number)}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, splitIssueKey } from "../format";
|
||||
import { statusResource } from "../state";
|
||||
import type { RecentEvent } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
export interface EventsProps {
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
export function Events(props: EventsProps): JSX.Element {
|
||||
const events = (): RecentEvent[] => statusResource()?.recent_events ?? [];
|
||||
|
||||
return (
|
||||
<GlassCard heading="recent events" accessory={<span class="tabular">{events().length}</span>}>
|
||||
<Show when={events().length} fallback={<div class="empty">no events recorded yet</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>received</th>
|
||||
<th>event</th>
|
||||
<th>where</th>
|
||||
<th>state</th>
|
||||
<th>tries</th>
|
||||
<th>error</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={events()}>
|
||||
{(event) => <EventRow event={event} onRetry={props.onRetry} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
event: RecentEvent;
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
function EventRow(props: RowProps): JSX.Element {
|
||||
const ref = (): { repo: string; number: string } => splitIssueKey(props.event.issue_key);
|
||||
const canRetry = (): boolean => props.event.state === "failed" || props.event.state === "done";
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td class="text-ink-300 tabular whitespace-nowrap">{fmtAge(props.event.received_at)}</td>
|
||||
<td class="text-ink-200">{props.event.event_type}</td>
|
||||
<td>
|
||||
<Show
|
||||
when={ref().number}
|
||||
fallback={<span class="text-ink-300">{props.event.repo ?? "—"}</span>}
|
||||
>
|
||||
<IssueLink repo={ref().repo} number={ref().number} />
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Pill state={props.event.state}>{props.event.state}</Pill>
|
||||
</td>
|
||||
<td class="text-ink-300 tabular">{props.event.attempts}</td>
|
||||
<td class="err-cell">{props.event.last_error ?? ""}</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && canRetry()}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button class="tiny" onClick={() => props.onRetry(props.event.delivery_id)}>
|
||||
retry
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
export interface GlassCardProps {
|
||||
heading?: string;
|
||||
accessory?: JSX.Element;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
bare?: boolean;
|
||||
children: JSX.Element;
|
||||
style?: JSX.CSSProperties;
|
||||
}
|
||||
|
||||
// Single glass surface used for every section card. The `bare` variant skips
|
||||
// the inset content padding so tables/log lists can reach the edge.
|
||||
export function GlassCard(props: GlassCardProps): JSX.Element {
|
||||
const cls = (): string => {
|
||||
const base = "glass glass-rise rounded-[22px] overflow-hidden";
|
||||
return props.class ? `${base} ${props.class}` : base;
|
||||
};
|
||||
return (
|
||||
<section class={cls()} style={props.style}>
|
||||
{props.heading != null && (
|
||||
<div class="section-heading">
|
||||
<h2>{props.heading}</h2>
|
||||
{props.accessory && <div class="accessory">{props.accessory}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div class={props.contentClass}>{props.children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtDuration } from "../format";
|
||||
import { isFetching, lastTickAt, lastTickError, statusResource } from "../state";
|
||||
import type { RuntimeInfo } from "../types";
|
||||
|
||||
function relativeAgo(ms: number): string {
|
||||
const seconds = Math.max(0, (Date.now() - ms) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
return `${fmtDuration(seconds)} ago`;
|
||||
}
|
||||
|
||||
export function Header(): JSX.Element {
|
||||
const runtime = (): RuntimeInfo | undefined => statusResource()?.runtime;
|
||||
|
||||
return (
|
||||
<header class="px-6 lg:px-10 pt-8 pb-6 flex flex-col gap-5">
|
||||
<div class="flex flex-wrap items-end gap-x-6 gap-y-3 justify-between">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h1 class="text-[26px] font-semibold tracking-tight leading-none">
|
||||
<span class="text-ink-50">robomp</span>
|
||||
<span class="inline-block ml-2 size-[7px] rounded-full align-middle bg-accent shadow-[0_0_10px_rgba(10,132,255,0.7)]" />
|
||||
</h1>
|
||||
<span class="eyebrow">triage · fix · ship</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-[11px] text-ink-300">
|
||||
<Show
|
||||
when={lastTickError()}
|
||||
fallback={
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class={`inline-block size-[7px] rounded-full ${
|
||||
isFetching() ? "bg-warn" : "bg-ok"
|
||||
}`}
|
||||
style={{
|
||||
"box-shadow": isFetching()
|
||||
? "0 0 8px rgba(255,214,10,0.6)"
|
||||
: "0 0 8px rgba(48,209,88,0.6)",
|
||||
}}
|
||||
/>
|
||||
{isFetching() ? "syncing…" : `synced ${relativeAgo(lastTickAt())}`}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="flex items-center gap-2 text-err">
|
||||
<span class="inline-block size-[7px] rounded-full bg-err" />
|
||||
{lastTickError()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-7 gap-y-2 text-[12px] text-ink-300 tabular">
|
||||
<Meta label="bot" value={runtime()?.bot_login} mono />
|
||||
<Meta
|
||||
label="model"
|
||||
value={runtime()?.model}
|
||||
mono
|
||||
title={runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined}
|
||||
/>
|
||||
<Meta
|
||||
label="concurrency"
|
||||
value={
|
||||
runtime()?.max_concurrency != null ? String(runtime()?.max_concurrency) : undefined
|
||||
}
|
||||
/>
|
||||
<Meta
|
||||
label="uptime"
|
||||
value={
|
||||
runtime()?.uptime_seconds != null ? fmtDuration(runtime()?.uptime_seconds) : undefined
|
||||
}
|
||||
/>
|
||||
<Meta
|
||||
label="allowlist"
|
||||
value={
|
||||
runtime()?.repo_allowlist?.length ? runtime()!.repo_allowlist.join(", ") : "(none)"
|
||||
}
|
||||
mono
|
||||
/>
|
||||
<Show when={!CONFIG.replayEnabled}>
|
||||
<span class="pill skipped">read-only · trigger disabled</span>
|
||||
</Show>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetaProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
mono?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function Meta(props: MetaProps): JSX.Element {
|
||||
return (
|
||||
<span class="inline-flex items-baseline gap-1.5" title={props.title}>
|
||||
<span class="text-ink-500 uppercase tracking-[0.14em] text-[10px]">{props.label}</span>
|
||||
<span class={props.mono ? "font-mono text-[12px] text-ink-100" : "text-ink-100"}>
|
||||
{props.value ?? "…"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
import { issueUrl, prUrl } from "../format";
|
||||
|
||||
export interface IssueLinkProps {
|
||||
repo: string;
|
||||
number: number | string;
|
||||
}
|
||||
|
||||
export function IssueLink(props: IssueLinkProps): JSX.Element {
|
||||
return (
|
||||
<a
|
||||
class="font-mono text-[12px] text-ink-100 hover:text-accent-2"
|
||||
href={issueUrl(props.repo, props.number)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{props.repo}
|
||||
<span class="text-ink-400">#</span>
|
||||
{props.number}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PrLinkProps {
|
||||
repo: string;
|
||||
number: number | string | null | undefined;
|
||||
}
|
||||
|
||||
export function PrLink(props: PrLinkProps): JSX.Element {
|
||||
if (props.number == null || props.number === "") {
|
||||
return <span class="text-ink-400">—</span>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
class="font-mono text-[12px] text-accent-2 hover:underline"
|
||||
href={prUrl(props.repo, props.number)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
#{props.number}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, shortText } from "../format";
|
||||
import { statusResource } from "../state";
|
||||
import { type IssueRow, type LatestEvent, TERMINAL_ISSUE_STATES } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink, PrLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
export interface IssuesProps {
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
export function Issues(props: IssuesProps): JSX.Element {
|
||||
const active = (): IssueRow[] => {
|
||||
const s = statusResource();
|
||||
if (!s) return [];
|
||||
return s.issues.filter((i) => !TERMINAL_ISSUE_STATES.has(i.state));
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="active issues" accessory={<span class="tabular">{active().length}</span>}>
|
||||
<Show when={active().length} fallback={<div class="empty">no active issues</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>issue</th>
|
||||
<th>state</th>
|
||||
<th>last event</th>
|
||||
<th>class</th>
|
||||
<th>branch</th>
|
||||
<th>pr</th>
|
||||
<th>error</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={active()}>
|
||||
{(issue) => <IssueRowView issue={issue} onRetry={props.onRetry} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
issue: IssueRow;
|
||||
onRetry: (deliveryId: string) => void;
|
||||
}
|
||||
|
||||
function IssueRowView(props: RowProps): JSX.Element {
|
||||
const ev = (): LatestEvent | null => props.issue.latest_event;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<IssueLink repo={props.issue.repo} number={props.issue.number} />
|
||||
</td>
|
||||
<td>
|
||||
<Pill>{props.issue.state}</Pill>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={ev()} fallback={<span class="text-ink-400">—</span>}>
|
||||
{(latest) => (
|
||||
<>
|
||||
<Pill state={latest().state}>{latest().state}</Pill>
|
||||
<span class="meta-line">
|
||||
{latest().event_type} · attempt #{latest().attempts} ·{" "}
|
||||
{fmtAge(latest().received_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-ink-300">{props.issue.classification ?? ""}</td>
|
||||
<td>
|
||||
{props.issue.branch ? (
|
||||
<code>{props.issue.branch}</code>
|
||||
) : (
|
||||
<span class="text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<PrLink repo={props.issue.repo} number={props.issue.pr_number} />
|
||||
</td>
|
||||
<td class="err-cell">
|
||||
<Show
|
||||
when={ev()?.state === "failed" && ev()?.last_error}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<span title={ev()?.last_error ?? ""}>{shortText(ev()?.last_error)}</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && ev()?.state === "failed"}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button
|
||||
class="tiny"
|
||||
onClick={() => {
|
||||
const latest = ev();
|
||||
if (latest) props.onRetry(latest.delivery_id);
|
||||
}}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createEffect, createSignal, For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { fmtTimestamp } from "../format";
|
||||
import { logsResource } from "../state";
|
||||
import { LEVEL_ORDER, type LogEntry } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
|
||||
const RESERVED_LOG_FIELDS = new Set(["ts", "level", "logger", "msg", "exc"]);
|
||||
|
||||
interface Extra {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface FormattedRow {
|
||||
index: number;
|
||||
ts: string;
|
||||
level: string;
|
||||
logger: string;
|
||||
message: string;
|
||||
extras: Extra[];
|
||||
exc: string | null;
|
||||
}
|
||||
|
||||
function formatExtraValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildExtras(entry: LogEntry): Extra[] {
|
||||
const out: Extra[] = [];
|
||||
for (const [key, value] of Object.entries(entry)) {
|
||||
if (RESERVED_LOG_FIELDS.has(key)) continue;
|
||||
out.push({ key, value: formatExtraValue(value) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function Logs(): JSX.Element {
|
||||
const [level, setLevel] = createSignal<string>("INFO");
|
||||
const [filter, setFilter] = createSignal<string>("");
|
||||
const [follow, setFollow] = createSignal<boolean>(true);
|
||||
|
||||
let scrollEl: HTMLDivElement | undefined;
|
||||
|
||||
const allEntries = (): LogEntry[] => logsResource()?.entries ?? [];
|
||||
|
||||
const rows = (): FormattedRow[] => {
|
||||
const wantLevel = level();
|
||||
const minOrd = wantLevel ? (LEVEL_ORDER[wantLevel] ?? 0) : 0;
|
||||
const needle = filter().trim().toLowerCase();
|
||||
const out: FormattedRow[] = [];
|
||||
let index = 0;
|
||||
for (const entry of allEntries()) {
|
||||
const lvl = entry.level ?? "INFO";
|
||||
if ((LEVEL_ORDER[lvl] ?? 20) < minOrd) continue;
|
||||
const msg = entry.msg ?? "";
|
||||
const extras = buildExtras(entry);
|
||||
if (needle) {
|
||||
const haystack = (
|
||||
msg +
|
||||
" " +
|
||||
extras.map((e) => `${e.key}=${e.value}`).join(" ")
|
||||
).toLowerCase();
|
||||
if (!haystack.includes(needle)) continue;
|
||||
}
|
||||
out.push({
|
||||
index: index++,
|
||||
ts: fmtTimestamp(entry.ts),
|
||||
level: lvl,
|
||||
logger: entry.logger ?? "",
|
||||
message: msg,
|
||||
extras,
|
||||
exc: entry.exc ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
// Touch dependencies so effect re-runs on new data / toggles.
|
||||
rows();
|
||||
if (follow() && scrollEl) {
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
heading="agent logs"
|
||||
accessory={
|
||||
<span class="tabular">
|
||||
{rows().length} / {allEntries().length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div class="px-4 pb-3 pt-1 flex flex-wrap items-center gap-3 border-b border-stroke-soft">
|
||||
<label class="flex items-center gap-2 text-[11px] uppercase tracking-[0.14em] text-ink-400">
|
||||
level
|
||||
<select value={level()} onChange={(ev) => setLevel(ev.currentTarget.value)}>
|
||||
<option value="">all</option>
|
||||
<option value="DEBUG">debug+</option>
|
||||
<option value="INFO">info+</option>
|
||||
<option value="WARNING">warn+</option>
|
||||
<option value="ERROR">error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-[11px] uppercase tracking-[0.14em] text-ink-400 flex-1 min-w-[200px]">
|
||||
filter
|
||||
<input
|
||||
type="search"
|
||||
value={filter()}
|
||||
placeholder="substring"
|
||||
onInput={(ev) => setFilter(ev.currentTarget.value)}
|
||||
class="flex-1"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-[12px] text-ink-300 select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={follow()}
|
||||
onChange={(ev) => setFollow(ev.currentTarget.checked)}
|
||||
/>
|
||||
follow tail
|
||||
</label>
|
||||
</div>
|
||||
<div class="logs scrollable" ref={(el) => (scrollEl = el)}>
|
||||
<Show when={rows().length} fallback={<div class="empty">no log entries match</div>}>
|
||||
<For each={rows()}>
|
||||
{(row) => (
|
||||
<div class="log-row">
|
||||
<span class="ts">{row.ts}</span>
|
||||
<span class={`lvl ${row.level}`}>{row.level}</span>
|
||||
<span class="logger">{row.logger}</span>
|
||||
<span>
|
||||
<span class="msg">{row.message}</span>
|
||||
<Show when={row.extras.length}>
|
||||
<span class="extras">
|
||||
<For each={row.extras}>
|
||||
{(extra) => (
|
||||
<span>
|
||||
{" "}
|
||||
<b>{extra.key}</b>={extra.value}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={row.exc}>
|
||||
<span class="exc">{row.exc}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { JSX } from "solid-js";
|
||||
|
||||
export interface PillProps {
|
||||
state?: string;
|
||||
dot?: boolean;
|
||||
title?: string;
|
||||
class?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export function Pill(props: PillProps): JSX.Element {
|
||||
const className = (): string => {
|
||||
const parts = ["pill"];
|
||||
if (props.state) parts.push(props.state);
|
||||
if (props.dot) parts.push("dot");
|
||||
if (props.class) parts.push(props.class);
|
||||
return parts.join(" ");
|
||||
};
|
||||
return (
|
||||
<span class={className()} title={props.title}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
|
||||
import { statusResource } from "../state";
|
||||
import { EVENT_STATE_ORDER, type EventState } from "../types";
|
||||
|
||||
const ACCENT: Record<EventState, string> = {
|
||||
queued: "text-[#9ec9ff]",
|
||||
running: "text-[#ffe26b]",
|
||||
done: "text-[#7fe5a3]",
|
||||
failed: "text-[#ff8e85]",
|
||||
skipped: "text-ink-300",
|
||||
};
|
||||
|
||||
export function Stats(): JSX.Element {
|
||||
const counts = (): Record<EventState, number> => {
|
||||
const status = statusResource();
|
||||
if (!status) return { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 };
|
||||
return status.issue_event_counts ?? status.event_counts;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
class="glass glass-rise rounded-[22px] grid gap-px overflow-hidden"
|
||||
style={{
|
||||
"grid-template-columns": "repeat(5, minmax(0, 1fr))",
|
||||
"background-color": "rgba(255, 255, 255, 0.05)",
|
||||
}}
|
||||
title="newest non-skipped event per issue"
|
||||
>
|
||||
<For each={EVENT_STATE_ORDER}>
|
||||
{(state) => (
|
||||
<div
|
||||
class="px-5 py-5 flex flex-col gap-1.5"
|
||||
style={{ "background-color": "rgba(8, 11, 16, 0.55)" }}
|
||||
>
|
||||
<span class="eyebrow">{state}</span>
|
||||
<span class={`text-[34px] leading-none font-semibold tabular ${ACCENT[state]}`}>
|
||||
{counts()[state] ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createSignal, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { runTrigger, triggerStatus } from "../state";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
|
||||
const STATUS_TONE = {
|
||||
idle: "text-ink-400",
|
||||
pending: "text-ink-200",
|
||||
ok: "text-[#7fe5a3]",
|
||||
err: "text-[#ff8e85]",
|
||||
} as const;
|
||||
|
||||
export function Trigger(): JSX.Element {
|
||||
const [issue, setIssue] = createSignal<string>("");
|
||||
|
||||
const validate = (): string | null => {
|
||||
const value = issue().trim();
|
||||
if (!value) return "enter owner/repo#NN";
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleTriage = (): void => {
|
||||
const value = issue().trim();
|
||||
if (!value) return;
|
||||
void runTrigger({ mode: "triage", issue: value });
|
||||
};
|
||||
|
||||
const handleRetry = (): void => {
|
||||
const value = issue().trim();
|
||||
if (!value) return;
|
||||
void runTrigger({ mode: "retry", issue: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="trigger" accessory={<span class="text-ink-400">owner/repo#NN</span>}>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled}
|
||||
fallback={
|
||||
<div class="px-5 py-7 text-ink-300 text-[13px] leading-relaxed">
|
||||
trigger disabled. set <code>ROBOMP_REPLAY_TOKEN</code> in the server env to enable
|
||||
manual triage and retry actions.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="px-5 pb-5 pt-1 flex flex-col gap-4">
|
||||
<div class="form-row">
|
||||
<input
|
||||
type="text"
|
||||
spellcheck={false}
|
||||
placeholder="octo/widget#42"
|
||||
autocomplete="off"
|
||||
value={issue()}
|
||||
onInput={(ev) => setIssue(ev.currentTarget.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Enter") handleTriage();
|
||||
}}
|
||||
class="flex-1 min-w-[220px] font-mono"
|
||||
/>
|
||||
<button class="primary" onClick={handleTriage}>
|
||||
fetch & triage
|
||||
</button>
|
||||
<button onClick={handleRetry}>retry latest run</button>
|
||||
</div>
|
||||
<Show
|
||||
when={triggerStatus().text}
|
||||
fallback={
|
||||
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{validate() ?? "ready"}</span>
|
||||
}
|
||||
>
|
||||
<span class={`text-[12px] ${STATUS_TONE[triggerStatus().kind]}`}>
|
||||
{triggerStatus().text}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
|
||||
import { CONFIG } from "../config";
|
||||
import { fmtAge, fmtDuration, shortDelivery, splitIssueKey } from "../format";
|
||||
import { runCancel, statusResource } from "../state";
|
||||
import type { RunningEvent } from "../types";
|
||||
import { GlassCard } from "./GlassCard";
|
||||
import { IssueLink } from "./IssueLink";
|
||||
import { Pill } from "./Pill";
|
||||
|
||||
interface Row {
|
||||
key: string;
|
||||
delivery_id: string;
|
||||
issue_key: string | null;
|
||||
event_type: string;
|
||||
attempts: number;
|
||||
model: string | null;
|
||||
last_tool: string | null;
|
||||
last_tool_ts: string | null;
|
||||
started_at: string | null;
|
||||
inflight_only: boolean;
|
||||
}
|
||||
|
||||
function rowsFor(running: RunningEvent[], inflight: string[]): Row[] {
|
||||
const out: Row[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of running) {
|
||||
const key = e.issue_key ?? e.delivery_id;
|
||||
seen.add(key);
|
||||
out.push({
|
||||
key,
|
||||
delivery_id: e.delivery_id,
|
||||
issue_key: e.issue_key,
|
||||
event_type: e.event_type,
|
||||
attempts: e.attempts,
|
||||
model: e.model,
|
||||
last_tool: e.last_tool,
|
||||
last_tool_ts: e.last_tool_ts,
|
||||
started_at: e.started_at ?? e.received_at,
|
||||
inflight_only: false,
|
||||
});
|
||||
}
|
||||
for (const key of inflight) {
|
||||
if (seen.has(key)) continue;
|
||||
out.push({
|
||||
key,
|
||||
delivery_id: "",
|
||||
issue_key: key,
|
||||
event_type: "",
|
||||
attempts: 0,
|
||||
model: null,
|
||||
last_tool: null,
|
||||
last_tool_ts: null,
|
||||
started_at: null,
|
||||
inflight_only: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function cancelDelivery(deliveryId: string): Promise<void> {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Kill this running task? The omp subprocess dies and the row lands in 'failed'.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCancel(deliveryId);
|
||||
}
|
||||
|
||||
function elapsed(startedAt: string | null): string {
|
||||
if (!startedAt) return "—";
|
||||
const t = Date.parse(startedAt);
|
||||
if (Number.isNaN(t)) return "—";
|
||||
return fmtDuration((Date.now() - t) / 1000);
|
||||
}
|
||||
|
||||
export function Working(): JSX.Element {
|
||||
const rows = (): Row[] => {
|
||||
const s = statusResource();
|
||||
return s ? rowsFor(s.running_events, s.inflight) : [];
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassCard heading="currently working" accessory={<span class="tabular">{rows().length}</span>}>
|
||||
<Show when={rows().length} fallback={<div class="empty">idle — waiting for events</div>}>
|
||||
<div class="overflow-x-auto scrollable">
|
||||
<table class="t">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>issue</th>
|
||||
<th>event</th>
|
||||
<th>state</th>
|
||||
<th>elapsed</th>
|
||||
<th>model</th>
|
||||
<th>last action</th>
|
||||
<th>attempt</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={rows()}>{(r) => <WorkingRow row={r} />}</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkingRow(props: { row: Row }): JSX.Element {
|
||||
const ref = (): { repo: string; number: string } => splitIssueKey(props.row.issue_key);
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<Show when={ref().number} fallback={<code>{shortDelivery(props.row.delivery_id)}</code>}>
|
||||
<IssueLink repo={ref().repo} number={ref().number} />
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-ink-300">{props.row.event_type || "—"}</td>
|
||||
<td>
|
||||
<Pill state="running" dot>
|
||||
{props.row.inflight_only ? "inflight" : "running"}
|
||||
</Pill>
|
||||
</td>
|
||||
<td class="tabular">{elapsed(props.row.started_at)}</td>
|
||||
<td>
|
||||
{props.row.model ? (
|
||||
<code title={props.row.model}>{props.row.model}</code>
|
||||
) : (
|
||||
<span class="text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{props.row.last_tool ? (
|
||||
<span>
|
||||
<code>{props.row.last_tool}</code>
|
||||
<span class="text-ink-400 ml-2">{fmtAge(props.row.last_tool_ts)}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span class="text-ink-400">{props.row.inflight_only ? "held by pool" : "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td class="text-ink-300 tabular">
|
||||
{props.row.inflight_only ? "—" : `#${props.row.attempts}`}
|
||||
</td>
|
||||
<td>
|
||||
<Show
|
||||
when={CONFIG.replayEnabled && !props.row.inflight_only}
|
||||
fallback={<span class="text-ink-400">—</span>}
|
||||
>
|
||||
<button class="tiny danger" onClick={() => cancelDelivery(props.row.delivery_id)}>
|
||||
cancel
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Configuration injected by FastAPI at request time. The server replaces the
|
||||
// `__ROBOMP_CONFIG__` sentinel in `static/index.html` with a JSON blob so the
|
||||
// SPA never needs to make an extra round-trip just to learn whether the
|
||||
// trigger surface is enabled.
|
||||
|
||||
export interface AppConfig {
|
||||
replayEnabled: boolean;
|
||||
replayToken: string;
|
||||
}
|
||||
|
||||
function readConfig(): AppConfig {
|
||||
const node = document.getElementById("robomp-config");
|
||||
const text = node?.textContent?.trim();
|
||||
if (!text || text === "__ROBOMP_CONFIG__") {
|
||||
return { replayEnabled: false, replayToken: "" };
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return { replayEnabled: false, replayToken: "" };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
return {
|
||||
replayEnabled: Boolean(record.replayEnabled),
|
||||
replayToken: typeof record.replayToken === "string" ? record.replayToken : "",
|
||||
};
|
||||
} catch {
|
||||
return { replayEnabled: false, replayToken: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export const CONFIG: AppConfig = readConfig();
|
||||
|
||||
export const AUTH_HEADERS: Readonly<Record<string, string>> = CONFIG.replayEnabled
|
||||
? Object.freeze({ "X-Robomp-Replay-Token": CONFIG.replayToken })
|
||||
: Object.freeze({});
|
||||
|
||||
export const POLL_INTERVAL_MS = 3000;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,57 @@
|
||||
// Compact, allocation-light formatters. All return `"—"` for empty / invalid
|
||||
// inputs so the templates can stay terse.
|
||||
|
||||
const DASH = "—";
|
||||
|
||||
export function fmtDuration(seconds?: number | null): string {
|
||||
if (seconds == null || !Number.isFinite(seconds)) return DASH;
|
||||
const s = Math.max(0, seconds);
|
||||
if (s < 60) return `${Math.round(s)}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
||||
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
||||
}
|
||||
|
||||
export function fmtAge(iso?: string | null): string {
|
||||
if (!iso) return DASH;
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return iso;
|
||||
return `${fmtDuration((Date.now() - t) / 1000)} ago`;
|
||||
}
|
||||
|
||||
export function shortText(value: unknown, limit = 180): string {
|
||||
const text = value == null ? "" : typeof value === "string" ? value : String(value);
|
||||
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
||||
}
|
||||
|
||||
export interface IssueRef {
|
||||
repo: string;
|
||||
number: string;
|
||||
}
|
||||
|
||||
export function splitIssueKey(key: string | null | undefined): IssueRef {
|
||||
const k = key ?? "";
|
||||
const idx = k.lastIndexOf("#");
|
||||
if (idx === -1) return { repo: k, number: "" };
|
||||
return { repo: k.slice(0, idx), number: k.slice(idx + 1) };
|
||||
}
|
||||
|
||||
export function issueUrl(repo: string, number: number | string): string {
|
||||
return `https://github.com/${repo}/issues/${number}`;
|
||||
}
|
||||
|
||||
export function prUrl(repo: string, prNumber: number | string): string {
|
||||
return `https://github.com/${repo}/pull/${prNumber}`;
|
||||
}
|
||||
|
||||
export function shortDelivery(id: string | null | undefined): string {
|
||||
if (!id) return DASH;
|
||||
return id.length > 8 ? id.slice(0, 8) : id;
|
||||
}
|
||||
|
||||
export function fmtTimestamp(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
// Drop the `T` separator and trailing `Z` so the log table looks like a
|
||||
// single calm timestamp instead of an RFC-3339 dump.
|
||||
return iso.replace("T", " ").replace("Z", "");
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render } from "solid-js/web";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles/index.css";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
if (!root) {
|
||||
throw new Error("robomp dashboard: #app mount node missing");
|
||||
}
|
||||
|
||||
render(() => <App />, root);
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createResource, createSignal, type ResourceReturn } from "solid-js";
|
||||
|
||||
import { ApiError, api } from "./api";
|
||||
import { POLL_INTERVAL_MS } from "./config";
|
||||
import type { LogsResponse, StatusResponse } from "./types";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// The dashboard polls two endpoints in lockstep every 3s. Each component
|
||||
// reads from these resources directly so re-renders stay narrow.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const statusFetcher = (): Promise<StatusResponse> => api.status();
|
||||
const logsFetcher = (): Promise<LogsResponse> => api.logs(400);
|
||||
|
||||
const statusTuple: ResourceReturn<StatusResponse> = createResource(statusFetcher);
|
||||
const logsTuple: ResourceReturn<LogsResponse> = createResource(logsFetcher);
|
||||
|
||||
export const statusResource = statusTuple[0];
|
||||
export const logsResource = logsTuple[0];
|
||||
|
||||
const refetchStatus = statusTuple[1].refetch;
|
||||
const refetchLogs = logsTuple[1].refetch;
|
||||
|
||||
const [lastTickAt, setLastTickAt] = createSignal<number>(Date.now());
|
||||
const [lastTickError, setLastTickError] = createSignal<string | null>(null);
|
||||
const [isFetching, setIsFetching] = createSignal<boolean>(false);
|
||||
|
||||
export { isFetching, lastTickAt, lastTickError };
|
||||
|
||||
let pollHandle: number | null = null;
|
||||
|
||||
export async function tick(): Promise<void> {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
await Promise.all([refetchStatus(), refetchLogs()]);
|
||||
setLastTickAt(Date.now());
|
||||
setLastTickError(null);
|
||||
} catch (err) {
|
||||
setLastTickError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function startPolling(): void {
|
||||
if (pollHandle != null) return;
|
||||
void tick();
|
||||
pollHandle = window.setInterval(() => {
|
||||
void tick();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopPolling(): void {
|
||||
if (pollHandle != null) {
|
||||
window.clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Trigger + cancel — shared status surface so every entry point (form,
|
||||
// retry buttons, browse list) feeds the same status line.
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TriggerStatusKind = "idle" | "pending" | "ok" | "err";
|
||||
|
||||
export interface TriggerStatus {
|
||||
kind: TriggerStatusKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const [triggerStatus, setTriggerStatus] = createSignal<TriggerStatus>({
|
||||
kind: "idle",
|
||||
text: "",
|
||||
});
|
||||
|
||||
export { triggerStatus };
|
||||
|
||||
export interface TriggerInput {
|
||||
mode: "triage" | "retry";
|
||||
issue?: string;
|
||||
delivery_id?: string;
|
||||
}
|
||||
|
||||
export async function runTrigger(input: TriggerInput): Promise<void> {
|
||||
setTriggerStatus({ kind: "pending", text: "queuing…" });
|
||||
try {
|
||||
const data = await api.trigger(input);
|
||||
setTriggerStatus({
|
||||
kind: "ok",
|
||||
text: `queued ${data.mode ?? input.mode}: ${data.delivery}`,
|
||||
});
|
||||
} catch (err) {
|
||||
const detail = err instanceof ApiError ? err.message : String(err);
|
||||
const status = err instanceof ApiError ? `error ${err.status}` : "error";
|
||||
setTriggerStatus({ kind: "err", text: `${status}: ${detail}` });
|
||||
}
|
||||
void tick();
|
||||
}
|
||||
|
||||
export async function runCancel(deliveryId: string): Promise<void> {
|
||||
setTriggerStatus({ kind: "pending", text: `cancelling ${deliveryId.slice(0, 8)}…` });
|
||||
try {
|
||||
const data = await api.cancel(deliveryId);
|
||||
setTriggerStatus({
|
||||
kind: "ok",
|
||||
text: `cancel signaled: ${deliveryId.slice(0, 8)} (fired=${data.fired})`,
|
||||
});
|
||||
} catch (err) {
|
||||
const detail = err instanceof ApiError ? err.message : String(err);
|
||||
const status = err instanceof ApiError ? `cancel ${err.status}` : "cancel";
|
||||
setTriggerStatus({ kind: "err", text: `${status}: ${detail}` });
|
||||
}
|
||||
void tick();
|
||||
}
|
||||
|
||||
export function clearTriggerStatus(): void {
|
||||
setTriggerStatus({ kind: "idle", text: "" });
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Design tokens. Exposed both as plain CSS variables (for raw `var()` use)
|
||||
and as Tailwind theme keys so utilities like `bg-surface-1`, `text-ink-100`,
|
||||
`border-stroke` work without a separate config file.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
@theme {
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
|
||||
"Inter", system-ui, sans-serif;
|
||||
--font-mono:
|
||||
ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
--color-ink-50: #f8fafc;
|
||||
--color-ink-100: #e8eaef;
|
||||
--color-ink-200: #c6cad3;
|
||||
--color-ink-300: #8b94a1;
|
||||
--color-ink-400: #5e6772;
|
||||
--color-ink-500: #3a4049;
|
||||
--color-ink-700: #1c2026;
|
||||
--color-ink-800: #14171d;
|
||||
--color-ink-900: #0a0c11;
|
||||
--color-ink-950: #07090c;
|
||||
|
||||
--color-surface-1: rgba(255, 255, 255, 0.045);
|
||||
--color-surface-2: rgba(255, 255, 255, 0.075);
|
||||
--color-surface-3: rgba(255, 255, 255, 0.11);
|
||||
--color-stroke: rgba(255, 255, 255, 0.10);
|
||||
--color-stroke-soft: rgba(255, 255, 255, 0.06);
|
||||
|
||||
--color-accent: #0a84ff;
|
||||
--color-accent-2: #5aa9ff;
|
||||
--color-ok: #30d158;
|
||||
--color-warn: #ffd60a;
|
||||
--color-err: #ff453a;
|
||||
--color-info: #64d2ff;
|
||||
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 22px;
|
||||
--radius-2xl: 28px;
|
||||
|
||||
--shadow-glass:
|
||||
0 24px 60px -20px rgba(0, 0, 0, 0.55),
|
||||
0 8px 24px -12px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
--shadow-soft: 0 8px 24px -12px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--blur-glass: blur(32px) saturate(180%);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Root + page chrome. The body gradient sits behind every glass card.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
background-color: var(--color-ink-950);
|
||||
color: var(--color-ink-100);
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "ss01", "ss03", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(
|
||||
120vw 70vh at 88% -12%,
|
||||
rgba(10, 132, 255, 0.18),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
90vw 60vh at -10% 110%,
|
||||
rgba(100, 210, 255, 0.10),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(60vw 40vh at 50% 50%, rgba(20, 24, 36, 0.45), transparent 70%),
|
||||
linear-gradient(180deg, #05070a 0%, #090c12 55%, #050608 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(10, 132, 255, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent-2);
|
||||
text-decoration: none;
|
||||
transition: color 150ms ease-out;
|
||||
}
|
||||
a:hover {
|
||||
color: #9ec9ff;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Reusable utility classes layered on top of Tailwind.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.glass {
|
||||
background: var(--color-surface-1);
|
||||
backdrop-filter: var(--blur-glass);
|
||||
-webkit-backdrop-filter: var(--blur-glass);
|
||||
border: 1px solid var(--color-stroke);
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
|
||||
.glass-flat {
|
||||
background: var(--color-surface-1);
|
||||
border: 1px solid var(--color-stroke);
|
||||
}
|
||||
|
||||
.hairline {
|
||||
border-color: var(--color-stroke-soft);
|
||||
}
|
||||
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-300);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Pills — capsule status tags, tabular, hairline outline. Tints are flat
|
||||
(no glow); the only colour is the text + a hairline border.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid var(--color-stroke);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-ink-200);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pill.queued {
|
||||
color: #9ec9ff;
|
||||
border-color: rgba(100, 175, 255, 0.32);
|
||||
background: rgba(10, 132, 255, 0.10);
|
||||
}
|
||||
.pill.running {
|
||||
color: #ffe26b;
|
||||
border-color: rgba(255, 214, 10, 0.32);
|
||||
background: rgba(255, 214, 10, 0.08);
|
||||
}
|
||||
.pill.done {
|
||||
color: #7fe5a3;
|
||||
border-color: rgba(48, 209, 88, 0.32);
|
||||
background: rgba(48, 209, 88, 0.08);
|
||||
}
|
||||
.pill.failed {
|
||||
color: #ff8e85;
|
||||
border-color: rgba(255, 69, 58, 0.36);
|
||||
background: rgba(255, 69, 58, 0.08);
|
||||
}
|
||||
.pill.skipped {
|
||||
color: var(--color-ink-300);
|
||||
border-color: rgba(255, 255, 255, 0.10);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.pill.open {
|
||||
color: #9ec9ff;
|
||||
border-color: rgba(100, 175, 255, 0.32);
|
||||
background: rgba(10, 132, 255, 0.08);
|
||||
}
|
||||
.pill.closed {
|
||||
color: #d8b4ff;
|
||||
border-color: rgba(186, 130, 255, 0.32);
|
||||
background: rgba(186, 130, 255, 0.08);
|
||||
}
|
||||
.pill.dot::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Pulsing dot for running pills. */
|
||||
.pill.running.dot::before {
|
||||
animation: pulse-dot 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.55; transform: scale(0.75); }
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Buttons.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
button,
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 7px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-stroke);
|
||||
background: var(--color-surface-1);
|
||||
color: var(--color-ink-100);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out,
|
||||
color 150ms ease-out,
|
||||
transform 80ms ease-out;
|
||||
user-select: none;
|
||||
}
|
||||
button:hover:not(:disabled),
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--color-surface-2);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
button:active:not(:disabled),
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
button:disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.primary,
|
||||
.btn.primary {
|
||||
background: linear-gradient(180deg, #1f95ff 0%, #0a84ff 100%);
|
||||
border-color: rgba(10, 132, 255, 0.7);
|
||||
color: #fff;
|
||||
box-shadow:
|
||||
0 6px 16px -6px rgba(10, 132, 255, 0.55),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
button.primary:hover:not(:disabled),
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
background: linear-gradient(180deg, #34a0ff 0%, #1d8eff 100%);
|
||||
}
|
||||
button.ghost,
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--color-ink-300);
|
||||
}
|
||||
button.ghost:hover:not(:disabled),
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
background: var(--color-surface-1);
|
||||
color: var(--color-ink-100);
|
||||
border-color: var(--color-stroke);
|
||||
}
|
||||
button.danger,
|
||||
.btn.danger {
|
||||
color: #ff8e85;
|
||||
border-color: rgba(255, 69, 58, 0.32);
|
||||
background: rgba(255, 69, 58, 0.08);
|
||||
}
|
||||
button.danger:hover:not(:disabled),
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: rgba(255, 69, 58, 0.16);
|
||||
border-color: rgba(255, 69, 58, 0.6);
|
||||
color: #ffb1a8;
|
||||
}
|
||||
button.tiny,
|
||||
.btn.tiny {
|
||||
padding: 3px 9px;
|
||||
font-size: 11.5px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Form controls.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
input[type="password"],
|
||||
select {
|
||||
font: inherit;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: var(--color-ink-100);
|
||||
border: 1px solid var(--color-stroke);
|
||||
border-radius: 10px;
|
||||
padding: 7px 11px;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
background 150ms ease-out,
|
||||
box-shadow 150ms ease-out;
|
||||
}
|
||||
input[type="text"]::placeholder,
|
||||
input[type="search"]::placeholder {
|
||||
color: var(--color-ink-400);
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
input[type="search"]:focus,
|
||||
input[type="password"]:focus,
|
||||
select:focus {
|
||||
border-color: rgba(10, 132, 255, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(10, 132, 255, 0.16);
|
||||
}
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--color-ink-300) 50%),
|
||||
linear-gradient(135deg, var(--color-ink-300) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 2px),
|
||||
calc(100% - 13px) calc(50% - 2px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 30px;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Tables.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
table.t {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
}
|
||||
table.t thead th {
|
||||
text-align: left;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-400);
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--color-stroke);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.t tbody td {
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--color-stroke-soft);
|
||||
vertical-align: top;
|
||||
color: var(--color-ink-100);
|
||||
}
|
||||
table.t tbody tr {
|
||||
transition: background 120ms ease-out;
|
||||
}
|
||||
table.t tbody tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
table.t tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
table.t td .meta-line {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--color-ink-400);
|
||||
font-size: 11px;
|
||||
}
|
||||
.err-cell {
|
||||
color: #ffa39c;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-width: 460px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Code chips inline.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
code,
|
||||
.code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--color-stroke-soft);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--color-ink-200);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Scrollbar styling on opt-in containers.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.scrollable {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
|
||||
}
|
||||
.scrollable::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollable::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Log viewer.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.logs {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.log-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 60px 160px 1fr;
|
||||
gap: 12px;
|
||||
padding: 4px 16px;
|
||||
border-bottom: 1px solid var(--color-stroke-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.log-row:hover {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.log-row .ts {
|
||||
color: var(--color-ink-400);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-row .lvl {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.log-row .logger {
|
||||
color: var(--color-ink-400);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.log-row .msg {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-ink-100);
|
||||
}
|
||||
.log-row .extras {
|
||||
color: var(--color-ink-300);
|
||||
margin-left: 6px;
|
||||
}
|
||||
.log-row .extras b {
|
||||
color: var(--color-ink-200);
|
||||
font-weight: 500;
|
||||
}
|
||||
.log-row .exc {
|
||||
color: #ffa39c;
|
||||
white-space: pre-wrap;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.lvl.INFO { color: #6fb0ff; }
|
||||
.lvl.DEBUG { color: var(--color-ink-400); }
|
||||
.lvl.WARNING { color: #ffd66b; }
|
||||
.lvl.ERROR { color: #ff8e85; }
|
||||
.lvl.RAW { color: var(--color-ink-400); }
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Reveal animation for the dashboard's first paint. Honour reduced motion.
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes glass-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.glass-rise {
|
||||
animation: glass-rise 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tighter form rows. */
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Card section heading. */
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 18px 10px 18px;
|
||||
}
|
||||
.section-heading h2 {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-300);
|
||||
margin: 0;
|
||||
}
|
||||
.section-heading .accessory {
|
||||
font-size: 11px;
|
||||
color: var(--color-ink-400);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
color: var(--color-ink-400);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Mirrors the JSON shapes emitted by `src/robomp/server.py`. Kept narrow on
|
||||
// purpose: anything `unknown` here is something the backend explicitly does
|
||||
// not promise to keep stable.
|
||||
|
||||
export type EventState = "queued" | "running" | "done" | "failed" | "skipped";
|
||||
|
||||
export type IssueState =
|
||||
| "new"
|
||||
| "reproducing"
|
||||
| "fixing"
|
||||
| "opened"
|
||||
| "merged"
|
||||
| "closed"
|
||||
| "abandoned";
|
||||
|
||||
export interface RuntimeInfo {
|
||||
bot_login: string;
|
||||
repo_allowlist: string[];
|
||||
max_concurrency: number;
|
||||
model: string;
|
||||
thinking_level: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
export interface LatestEvent {
|
||||
delivery_id: string;
|
||||
event_type: string;
|
||||
state: EventState;
|
||||
attempts: number;
|
||||
received_at: string;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface IssueRow {
|
||||
key: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
branch: string | null;
|
||||
pr_number: number | null;
|
||||
state: IssueState | string;
|
||||
classification: string | null;
|
||||
updated_at: string;
|
||||
latest_event: LatestEvent | null;
|
||||
}
|
||||
|
||||
export interface RunningEvent {
|
||||
delivery_id: string;
|
||||
event_type: string;
|
||||
repo: string | null;
|
||||
issue_key: string | null;
|
||||
received_at: string;
|
||||
started_at: string | null;
|
||||
attempts: number;
|
||||
model: string | null;
|
||||
last_tool: string | null;
|
||||
last_tool_ts: string | null;
|
||||
}
|
||||
|
||||
export interface RecentEvent {
|
||||
delivery_id: string;
|
||||
event_type: string;
|
||||
repo: string | null;
|
||||
issue_key: string | null;
|
||||
state: EventState;
|
||||
attempts: number;
|
||||
received_at: string;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
runtime: RuntimeInfo;
|
||||
event_counts: Record<EventState, number>;
|
||||
issue_event_counts: Record<EventState, number>;
|
||||
running_events: RunningEvent[];
|
||||
inflight: string[];
|
||||
issues: IssueRow[];
|
||||
recent_events: RecentEvent[];
|
||||
}
|
||||
|
||||
// Log entries carry arbitrary structured extras. We expose the known fields
|
||||
// with concrete types and leave unknown extras as `unknown` so callers must
|
||||
// narrow before using.
|
||||
export interface LogEntry {
|
||||
ts?: string;
|
||||
level?: string;
|
||||
logger?: string;
|
||||
msg?: string;
|
||||
exc?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
entries: LogEntry[];
|
||||
count: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface BrowseIssue {
|
||||
repo: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: "open" | "closed";
|
||||
author: string;
|
||||
labels: string[];
|
||||
comments: number;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
processed: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseError {
|
||||
repo: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface BrowseCacheMeta {
|
||||
hit: boolean;
|
||||
fetched_at: number;
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
issues: BrowseIssue[];
|
||||
errors: BrowseError[];
|
||||
repos: string[];
|
||||
cache: BrowseCacheMeta;
|
||||
}
|
||||
|
||||
export interface TriggerResponse {
|
||||
delivery: string;
|
||||
state: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface CancelResponse {
|
||||
delivery: string;
|
||||
fired: boolean;
|
||||
previous_state: string;
|
||||
}
|
||||
|
||||
export const TERMINAL_ISSUE_STATES: ReadonlySet<string> = new Set([
|
||||
"merged",
|
||||
"closed",
|
||||
"abandoned",
|
||||
]);
|
||||
|
||||
export const LEVEL_ORDER: Readonly<Record<string, number>> = {
|
||||
DEBUG: 10,
|
||||
INFO: 20,
|
||||
WARNING: 30,
|
||||
ERROR: 40,
|
||||
RAW: 20,
|
||||
};
|
||||
|
||||
export const EVENT_STATE_ORDER: readonly EventState[] = [
|
||||
"queued",
|
||||
"running",
|
||||
"done",
|
||||
"failed",
|
||||
"skipped",
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useDefineForClassFields": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Vite writes the bundle into `web/dist/`. After the rollup stage finishes we
|
||||
// fan the output out into the Python package directory (`src/robomp/static/`)
|
||||
// so FastAPI can mount it directly. Done in a Vite plugin so both `bun run
|
||||
// web:build` and the Docker `web-builder` stage produce an installable layout
|
||||
// without any extra shell glue.
|
||||
const outDir = path.resolve(dirname, "dist");
|
||||
const staticDir = path.resolve(dirname, "..", "src", "robomp", "static");
|
||||
|
||||
const PRESERVED_FILES: ReadonlySet<string> = new Set([".gitkeep"]);
|
||||
|
||||
function syncStaticBundle(): Plugin {
|
||||
return {
|
||||
name: "robomp-sync-static",
|
||||
apply: "build",
|
||||
closeBundle() {
|
||||
if (!existsSync(staticDir)) {
|
||||
mkdirSync(staticDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Clear out previous build output but keep the committed stub anchors
|
||||
// (`.gitkeep`). The build is about to write fresh `index.html` and
|
||||
// `assets/`, so any stale file in there is dead weight.
|
||||
for (const entry of readdirSync(staticDir)) {
|
||||
if (PRESERVED_FILES.has(entry)) continue;
|
||||
const target = path.join(staticDir, entry);
|
||||
const stats = statSync(target);
|
||||
rmSync(target, { recursive: stats.isDirectory(), force: true });
|
||||
}
|
||||
|
||||
cpSync(outDir, staticDir, { recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solid(), tailwindcss(), syncStaticBundle()],
|
||||
base: "/static/",
|
||||
build: {
|
||||
outDir,
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
cssCodeSplit: false,
|
||||
assetsInlineLimit: 0,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Hashed filenames so FastAPI can cache `/static/*` aggressively in
|
||||
// future; today the bundle is small enough that one chunk is fine.
|
||||
entryFileNames: "assets/[name]-[hash].js",
|
||||
chunkFileNames: "assets/[name]-[hash].js",
|
||||
assetFileNames: "assets/[name]-[hash][extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: false,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/healthz": "http://localhost:8080",
|
||||
"/readyz": "http://localhost:8080",
|
||||
"/events": "http://localhost:8080",
|
||||
"/issues": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user