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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user