feat(frontend): render events as terminal log

This commit is contained in:
2026-06-21 02:42:22 +03:30
parent bd7916ed45
commit 01edd74e63
2 changed files with 262 additions and 36 deletions

View File

@@ -4,7 +4,6 @@ import { toast } from "sonner";
import { api } from "../api";
import { eventTitle } from "../constants";
import { DateTime } from "../components/DateTime";
import { EmptyState } from "../components/EmptyState";
import type { JobEvent } from "../types";
@@ -15,6 +14,21 @@ function mergeEvents(current: JobEvent[], incoming: JobEvent[]) {
return [...current, ...incoming.filter((event) => !seen.has(event.id))].sort((a, b) => b.id - a.id);
}
function eventTone(type: string) {
if (type.includes("fail")) return "danger";
if (type.includes("success")) return "success";
if (type.includes("retry") || type.includes("timeout")) return "warning";
if (type.includes("progress") || type.includes("lease")) return "info";
if (type.includes("claim")) return "accent";
return "neutral";
}
function formatLogTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--:--:--";
return date.toLocaleTimeString(undefined, { hour12: false });
}
export function EventsPage() {
const [events, setEvents] = useState<JobEvent[]>([]);
const [nextPageUrl, setNextPageUrl] = useState<string | null>(null);
@@ -89,42 +103,32 @@ export function EventsPage() {
</div>
</header>
<section className="panel">
<div className="section-title">
<h3>Global Event Feed</h3>
<span className="muted-text">Last refresh {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span>
<section className="terminal-panel">
<div className="terminal-header">
<div className="terminal-window-controls" aria-hidden="true">
<i />
<i />
<i />
</div>
<code>job-events.log</code>
<span>refreshed {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Event</th>
<th>Job</th>
<th>Attempt</th>
<th>Worker</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{newestFirst.map((event) => (
<tr key={event.id}>
<td>{event.id}</td>
<td>{eventTitle(event.type)}</td>
<td>
<Link className="secondary-button" to={`/jobs/${event.job}`}>
{event.job.slice(0, 8)}
</Link>
</td>
<td>{event.attempt}</td>
<td>{event.worker_id ?? "-"}</td>
<td>
<DateTime value={event.created_at} />
</td>
</tr>
))}
</tbody>
</table>
<div className="terminal-feed" role="log" aria-live="polite">
{newestFirst.map((event) => (
<article className={`terminal-line ${eventTone(event.type)}`} key={event.id}>
<time className="terminal-time" dateTime={event.created_at}>
{formatLogTime(event.created_at)}
</time>
<span className="terminal-prompt">$</span>
<strong className="terminal-event">{eventTitle(event.type)}</strong>
<Link className="terminal-job" to={`/jobs/${event.job}`}>
job:{event.job.slice(0, 8)}
</Link>
<span className="terminal-token">attempt:{event.attempt}</span>
<span className="terminal-worker">worker:{event.worker_id ?? "system"}</span>
{event.message && <span className="terminal-message">{event.message}</span>}
</article>
))}
{loading && !newestFirst.length && <EmptyState title="Loading events" />}
{!loading && !newestFirst.length && <EmptyState title="No events yet" />}
</div>

View File

@@ -1076,6 +1076,200 @@ label {
padding-top: 12px;
}
.terminal-panel {
background: #101316;
border: 1px solid #2d3439;
border-radius: 8px;
box-shadow: 0 22px 56px rgb(0 0 0 / 28%);
color: #d8e2e8;
overflow: hidden;
}
.terminal-header {
align-items: center;
background: #171b1f;
border-bottom: 1px solid #2d3439;
display: grid;
gap: 12px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 44px;
padding: 9px 12px;
}
.terminal-window-controls {
display: flex;
gap: 6px;
}
.terminal-window-controls i {
border-radius: 999px;
display: block;
height: 11px;
width: 11px;
}
.terminal-window-controls i:nth-child(1) {
background: #ff5f57;
}
.terminal-window-controls i:nth-child(2) {
background: #ffbd2e;
}
.terminal-window-controls i:nth-child(3) {
background: #28c840;
}
.terminal-header code {
background: transparent;
border: 0;
color: #d8e2e8;
font-size: 13px;
overflow: hidden;
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-header span {
color: #83919b;
font-size: 12px;
font-weight: 900;
}
.terminal-feed {
display: grid;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
gap: 0;
max-height: calc(100vh - 230px);
min-height: 420px;
overflow: auto;
padding: 8px;
}
.terminal-line {
align-items: baseline;
border-left: 3px solid transparent;
display: grid;
gap: 8px;
grid-template-columns: 80px 16px minmax(130px, 170px) minmax(110px, 150px) minmax(88px, 110px) minmax(150px, 220px) minmax(180px, 1fr);
line-height: 1.55;
min-width: 0;
padding: 7px 8px;
}
.terminal-line:nth-child(odd) {
background: rgb(255 255 255 / 2.5%);
}
.terminal-line:hover {
background: rgb(255 255 255 / 6%);
}
.terminal-line.success {
border-left-color: #42d392;
}
.terminal-line.danger {
border-left-color: #ff6b6b;
}
.terminal-line.warning {
border-left-color: #f5b84b;
}
.terminal-line.info {
border-left-color: #55c8ff;
}
.terminal-line.accent {
border-left-color: #b388ff;
}
.terminal-time,
.terminal-token,
.terminal-worker,
.terminal-message {
color: #8d9aa5;
font-size: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
.terminal-prompt {
color: #63717b;
}
.terminal-event {
color: #d8e2e8;
font-size: 12px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.terminal-line.success .terminal-event,
.terminal-line.success .terminal-prompt {
color: #42d392;
}
.terminal-line.danger .terminal-event,
.terminal-line.danger .terminal-prompt {
color: #ff6b6b;
}
.terminal-line.warning .terminal-event,
.terminal-line.warning .terminal-prompt {
color: #f5b84b;
}
.terminal-line.info .terminal-event,
.terminal-line.info .terminal-prompt {
color: #55c8ff;
}
.terminal-line.accent .terminal-event,
.terminal-line.accent .terminal-prompt {
color: #b388ff;
}
.terminal-job {
color: #9ae6ff;
font-size: 12px;
font-weight: 900;
min-width: 0;
overflow-wrap: anywhere;
text-decoration: none;
}
.terminal-job:hover {
color: #ffffff;
}
.terminal-message {
color: #c0ccd4;
}
.terminal-feed .empty-state {
background: rgb(255 255 255 / 4%);
border-color: #2d3439;
color: #8d9aa5;
}
.terminal-panel .infinite-sentinel {
border-top: 1px solid #2d3439;
color: #8d9aa5;
padding: 12px;
}
.terminal-panel .secondary-button {
background: #171b1f;
border-color: #2d3439;
color: #d8e2e8;
}
.filter-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1488,6 +1682,10 @@ code {
.filter-grid {
grid-template-columns: 1fr 1fr;
}
.terminal-line {
grid-template-columns: 80px 16px minmax(120px, 1fr) minmax(100px, 1fr);
}
}
@media (max-width: 720px) {
@@ -1585,4 +1783,28 @@ code {
.dot-chart-row {
grid-template-columns: 1fr;
}
.terminal-header {
grid-template-columns: auto minmax(0, 1fr);
}
.terminal-header span {
grid-column: 2;
}
.terminal-feed {
max-height: none;
min-height: 360px;
}
.terminal-line {
grid-template-columns: 64px 14px minmax(0, 1fr);
}
.terminal-job,
.terminal-token,
.terminal-worker,
.terminal-message {
grid-column: 3;
}
}