Add 'python/robomp/' from commit '553fd1cfcf59e4c501c54fc81bc083ffd2ca007b'

git-subtree-dir: python/robomp
git-subtree-mainline: 4f6e70f779
git-subtree-split: 553fd1cfcf
This commit is contained in:
can1357
2026-05-16 21:00:42 +02:00
108 changed files with 28172 additions and 0 deletions
+19
View File
@@ -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>
+24
View File
@@ -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"
}
}
+52
View File
@@ -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 &amp; fix · polling every 3s
</footer>
</main>
</div>
);
}
+84
View File
@@ -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>);
},
};
+220
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+24
View File
@@ -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 &amp; 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>
);
}
+38
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+57
View File
@@ -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", "");
}
+11
View File
@@ -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);
+119
View File
@@ -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: "" });
}
+551
View File
@@ -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;
}
+161
View File
@@ -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",
];
+24
View File
@@ -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"]
}
+75
View File
@@ -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",
},
},
});