feat(frontend): render events as terminal log
This commit is contained in:
@@ -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>
|
||||
<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>
|
||||
<code>job-events.log</code>
|
||||
<span>refreshed {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span>
|
||||
</div>
|
||||
<div className="terminal-feed" role="log" aria-live="polite">
|
||||
{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)}
|
||||
<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>
|
||||
</td>
|
||||
<td>{event.attempt}</td>
|
||||
<td>{event.worker_id ?? "-"}</td>
|
||||
<td>
|
||||
<DateTime value={event.created_at} />
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{loading && !newestFirst.length && <EmptyState title="Loading events" />}
|
||||
{!loading && !newestFirst.length && <EmptyState title="No events yet" />}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user