feat(frontend): add polling job queue ui
This commit is contained in:
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.vite
|
||||||
1
frontend/.env.sample
Normal file
1
frontend/.env.sample
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8000/api
|
||||||
6
frontend/Dockerfile
Normal file
6
frontend/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM nginx:1.29-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
26
frontend/README.md
Normal file
26
frontend/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Frontend
|
||||||
|
|
||||||
|
Vite React demo UI for the minimal job queue.
|
||||||
|
|
||||||
|
It reuses the visual style from the previous advanced frontend, but only keeps:
|
||||||
|
|
||||||
|
- dashboard
|
||||||
|
- jobs page
|
||||||
|
- job detail page
|
||||||
|
- global events page
|
||||||
|
|
||||||
|
The UI polls:
|
||||||
|
|
||||||
|
- jobs and stats every 2 seconds
|
||||||
|
- events every 1 second
|
||||||
|
|
||||||
|
No WebSockets are used.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The Docker frontend image serves the local `dist/` directory with nginx, so run `npm run build` before `docker compose up --build` if `dist/` is missing or stale.
|
||||||
26
frontend/eslint.config.js
Normal file
26
frontend/eslint.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Minimal Job Queue</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
frontend/nginx.conf
Normal file
11
frontend/nginx.conf
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3281
frontend/package-lock.json
generated
Normal file
3281
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "minimal-job-queue-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"react-router-dom": "^7.18.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"vite": "^7.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@types/node": "^24.10.3",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.50.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/App.tsx
Normal file
26
frontend/src/App.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
|
import { AppLayout } from "./layout/AppLayout";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { EventsPage } from "./pages/EventsPage";
|
||||||
|
import { JobDetailPage } from "./pages/JobDetailPage";
|
||||||
|
import { JobsPage } from "./pages/JobsPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="jobs" element={<JobsPage />} />
|
||||||
|
<Route path="jobs/:jobId" element={<JobDetailPage />} />
|
||||||
|
<Route path="events" element={<EventsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
<Toaster position="top-right" toastOptions={{ duration: 4200 }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/api.ts
Normal file
42
frontend/src/api.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Health, Job, JobEvent, JobStats } from "./types";
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api";
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||||
|
...init
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(body || response.statusText);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateJobBody = {
|
||||||
|
type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
priority?: number;
|
||||||
|
available_at?: string | null;
|
||||||
|
max_attempts?: number;
|
||||||
|
idempotency_key?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
health: () => request<Health>("/health/"),
|
||||||
|
listJobs: () => request<Job[]>("/jobs/"),
|
||||||
|
getJob: (jobId: string) => request<Job>(`/jobs/${jobId}/`),
|
||||||
|
createJob: (body: CreateJobBody) => request<Job>("/jobs/", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
retryJob: (jobId: string) => request<Job>(`/jobs/${jobId}/retry/`, { method: "POST" }),
|
||||||
|
getStats: () => request<JobStats>("/jobs/stats/"),
|
||||||
|
listJobEvents: (jobId: string) => request<JobEvent[]>(`/jobs/${jobId}/events/`),
|
||||||
|
listEvents: (filters: { after_id?: number; limit?: number; job_id?: string; type?: string } = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
|
||||||
|
});
|
||||||
|
const query = params.toString();
|
||||||
|
return request<JobEvent[]>(`/job-events/${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
frontend/src/assets/fonts/Vazirmatn.woff2
Normal file
BIN
frontend/src/assets/fonts/Vazirmatn.woff2
Normal file
Binary file not shown.
20
frontend/src/assets/jobqueue-logo.svg
Normal file
20
frontend/src/assets/jobqueue-logo.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108 64" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">JobQueue</title>
|
||||||
|
<desc id="desc">A gold and cyan job queue mark with connected execution nodes.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="queueGold" x1="16" x2="92" y1="8" y2="58" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F0BB0B"/>
|
||||||
|
<stop offset="1" stop-color="#D89B00"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="108" height="64" rx="14" fill="transparent"/>
|
||||||
|
<g>
|
||||||
|
<rect x="10" y="10" width="58" height="44" rx="12" fill="url(#queueGold)"/>
|
||||||
|
<path d="M26 22h21.5c4.7 0 8.5 3.8 8.5 8.5S52.2 39 47.5 39H34" fill="none" stroke="#232426" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M34 31h-8m0 0 6-6m-6 6 6 6" fill="none" stroke="#232426" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="82" cy="20" r="6" fill="#0BB3F0"/>
|
||||||
|
<circle cx="96" cy="32" r="6" fill="#26A17B"/>
|
||||||
|
<circle cx="82" cy="44" r="6" fill="#0BB3F0"/>
|
||||||
|
<path d="M68 32h14m6-8 8 8-8 8" fill="none" stroke="#7B7D80" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
13
frontend/src/components/DateTime.tsx
Normal file
13
frontend/src/components/DateTime.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { formatDateTimeParts } from "../constants";
|
||||||
|
|
||||||
|
export function DateTime({ value }: { value: string | null }) {
|
||||||
|
const parts = formatDateTimeParts(value);
|
||||||
|
if (!parts) return <span className="muted-text">-</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<time className="datetime" dateTime={value ?? undefined} title={parts.full}>
|
||||||
|
<span>{parts.date}</span>
|
||||||
|
<strong>{parts.time}</strong>
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
frontend/src/components/EmptyState.tsx
Normal file
3
frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function EmptyState({ title }: { title: string }) {
|
||||||
|
return <div className="empty-state">{title}</div>;
|
||||||
|
}
|
||||||
14
frontend/src/components/JsonField.tsx
Normal file
14
frontend/src/components/JsonField.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
type JsonFieldProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function JsonField({ label, value, onChange }: JsonFieldProps) {
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
<textarea className="json-field" spellCheck={false} value={value} onChange={(event) => onChange(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/src/components/Modal.tsx
Normal file
24
frontend/src/components/Modal.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({ title, children, onClose }: ModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||||
|
<section className="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||||
|
<header className="modal-header">
|
||||||
|
<h2 id="modal-title">{title}</h2>
|
||||||
|
<button className="icon-button" type="button" aria-label="close modal" onClick={onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/components/SelectField.tsx
Normal file
62
frontend/src/components/SelectField.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import type { FocusEvent } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type SelectOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectFieldProps = {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SelectField({ label, value, options, placeholder = "Select", onChange }: SelectFieldProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selectedOption = useMemo(() => options.find((option) => option.value === value), [options, value]);
|
||||||
|
const closeOnOutsideBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||||
|
const nextTarget = event.relatedTarget;
|
||||||
|
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const control = (
|
||||||
|
<div className="select-field" onBlur={closeOnOutsideBlur}>
|
||||||
|
<button className={`select-trigger ${open ? "open" : ""}`} type="button" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||||
|
<span>{selectedOption?.label ?? placeholder}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="select-menu" role="listbox">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
className={option.value === value ? "select-option active" : "select-option"}
|
||||||
|
key={option.value}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
aria-selected={option.value === value}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!label) return control;
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
{control}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/StatCard.tsx
Normal file
18
frontend/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
tone?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatCard({ label, value, tone = "neutral", icon }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`stat-card ${tone}`}>
|
||||||
|
<span className="stat-icon">{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/components/StatusBadge.tsx
Normal file
10
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { JobStatus } from "../types";
|
||||||
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: JobStatus }) {
|
||||||
|
return (
|
||||||
|
<span className={`badge ${status}`}>
|
||||||
|
<StatusIcon status={status} /> {status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/components/StatusIcon.tsx
Normal file
10
frontend/src/components/StatusIcon.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Activity, CheckCircle2, Clock3, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import type { JobStatus } from "../types";
|
||||||
|
|
||||||
|
export function StatusIcon({ status, size = 16 }: { status: JobStatus; size?: number }) {
|
||||||
|
if (status === "succeeded") return <CheckCircle2 size={size} />;
|
||||||
|
if (status === "failed") return <XCircle size={size} />;
|
||||||
|
if (status === "running") return <Activity size={size} />;
|
||||||
|
return <Clock3 size={size} />;
|
||||||
|
}
|
||||||
30
frontend/src/constants.ts
Normal file
30
frontend/src/constants.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { JobStatus } from "./types";
|
||||||
|
|
||||||
|
export const STATUSES: JobStatus[] = ["queued", "running", "succeeded", "failed"];
|
||||||
|
|
||||||
|
export function parseJson(value: string): Record<string, unknown> {
|
||||||
|
if (!value.trim()) return {};
|
||||||
|
const parsed = JSON.parse(value) as unknown;
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
throw new Error("JSON payload must be an object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeParts(value: string | null) {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return {
|
||||||
|
date: date.toLocaleDateString(),
|
||||||
|
time: date.toLocaleTimeString(),
|
||||||
|
full: date.toLocaleString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventTitle(type: string) {
|
||||||
|
return type
|
||||||
|
.split("_")
|
||||||
|
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
23
frontend/src/hooks/useTheme.ts
Normal file
23
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
function initialTheme(): Theme {
|
||||||
|
const stored = window.localStorage.getItem("jobQueueTheme");
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(initialTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
window.localStorage.setItem("jobQueueTheme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
toggleTheme: () => setTheme((current) => (current === "dark" ? "light" : "dark"))
|
||||||
|
};
|
||||||
|
}
|
||||||
82
frontend/src/layout/AppLayout.tsx
Normal file
82
frontend/src/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { BriefcaseBusiness, CalendarClock, LayoutDashboard, Moon, PanelLeftClose, PanelLeftOpen, Sun } from "lucide-react";
|
||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import logoUrl from "../assets/jobqueue-logo.svg";
|
||||||
|
import { useTheme } from "../hooks/useTheme";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ to: "/jobs", label: "Jobs", icon: BriefcaseBusiness },
|
||||||
|
{ to: "/events", label: "Events", icon: CalendarClock }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem("jobQueueSidebar") === "collapsed");
|
||||||
|
|
||||||
|
function toggleSidebarCollapsed() {
|
||||||
|
setSidebarCollapsed((current) => {
|
||||||
|
const next = !current;
|
||||||
|
window.localStorage.setItem("jobQueueSidebar", next ? "collapsed" : "expanded");
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleThemeToggle() {
|
||||||
|
toggleTheme();
|
||||||
|
toast.success(theme === "dark" ? "Light mode enabled." : "Dark mode enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""}`}>
|
||||||
|
<header className="topbar">
|
||||||
|
<NavLink className="brand-lockup" to="/">
|
||||||
|
<img className="brand-logo" src={logoUrl} alt="JobQueue" />
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Postgres queue</span>
|
||||||
|
<h1>JobQueue</h1>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<button className="icon-button" type="button" aria-label="toggle dark mode" onClick={handleThemeToggle}>
|
||||||
|
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<span>Navigation</span>
|
||||||
|
<button
|
||||||
|
className="icon-button collapse-button"
|
||||||
|
type="button"
|
||||||
|
aria-label={sidebarCollapsed ? "expand sidebar" : "collapse sidebar"}
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
className={({ isActive }) => `nav-item ${isActive ? "active" : ""}`}
|
||||||
|
end={item.to === "/"}
|
||||||
|
key={item.to}
|
||||||
|
title={item.label}
|
||||||
|
to={item.to}
|
||||||
|
>
|
||||||
|
<item.icon size={18} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="content">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
132
frontend/src/pages/DashboardPage.tsx
Normal file
132
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { AlertTriangle, BriefcaseBusiness, Clock3, RotateCcw } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { api } from "../api";
|
||||||
|
import { eventTitle } from "../constants";
|
||||||
|
import { DateTime } from "../components/DateTime";
|
||||||
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { StatCard } from "../components/StatCard";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
|
import type { Health, Job, JobEvent, JobStats } from "../types";
|
||||||
|
|
||||||
|
const emptyStats: JobStats = {
|
||||||
|
total: 0,
|
||||||
|
by_status: { queued: 0, running: 0, succeeded: 0, failed: 0 },
|
||||||
|
overdue_running: 0,
|
||||||
|
retries_pending: 0,
|
||||||
|
oldest_queued_at: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
const [events, setEvents] = useState<JobEvent[]>([]);
|
||||||
|
const [stats, setStats] = useState<JobStats>(emptyStats);
|
||||||
|
const [health, setHealth] = useState<Health | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const [jobsData, statsData, eventsData, healthData] = await Promise.all([
|
||||||
|
api.listJobs(),
|
||||||
|
api.getStats(),
|
||||||
|
api.listEvents({ limit: 8 }),
|
||||||
|
api.health()
|
||||||
|
]);
|
||||||
|
setJobs(jobsData);
|
||||||
|
setStats({ ...emptyStats, ...statsData, by_status: { ...emptyStats.by_status, ...statsData.by_status } });
|
||||||
|
setEvents(eventsData);
|
||||||
|
setHealth(healthData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
||||||
|
const id = window.setInterval(() => void refresh().catch(() => undefined), 2000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const recentJobs = useMemo(() => jobs.slice(0, 6), [jobs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Overview</span>
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
<span className={`connection ${health?.ok ? "open" : "closed"}`}>DB {health?.database ?? "unknown"}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="stats-grid">
|
||||||
|
<StatCard label="Total jobs" value={stats.total} icon={<BriefcaseBusiness size={16} />} />
|
||||||
|
<StatCard label="Queued" value={stats.by_status.queued} tone="queued" icon={<StatusIcon status="queued" />} />
|
||||||
|
<StatCard label="Running" value={stats.by_status.running} tone="running" icon={<StatusIcon status="running" />} />
|
||||||
|
<StatCard label="Succeeded" value={stats.by_status.succeeded} tone="succeeded" icon={<StatusIcon status="succeeded" />} />
|
||||||
|
<StatCard label="Failed" value={stats.by_status.failed} tone="failed" icon={<StatusIcon status="failed" />} />
|
||||||
|
<StatCard label="Overdue locks" value={stats.overdue_running} tone="failed" icon={<AlertTriangle size={16} />} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dashboard-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Recent Jobs</h3>
|
||||||
|
<Link className="secondary-button" to="/jobs">
|
||||||
|
View jobs
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="compact-list">
|
||||||
|
{recentJobs.map((job) => (
|
||||||
|
<Link className="compact-row" key={job.id} to={`/jobs/${job.id}`}>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
<span>{job.type}</span>
|
||||||
|
<DateTime value={job.created_at} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!recentJobs.length && <EmptyState title="No jobs yet" />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Queue Pressure</h3>
|
||||||
|
<Clock3 size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="compact-list">
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>Retries pending</strong>
|
||||||
|
<span>{stats.retries_pending}</span>
|
||||||
|
<span className="muted-text">backoff</span>
|
||||||
|
</div>
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>Oldest queued</strong>
|
||||||
|
<span>{stats.oldest_queued_at ? "waiting" : "none"}</span>
|
||||||
|
<DateTime value={stats.oldest_queued_at} />
|
||||||
|
</div>
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>Failed jobs</strong>
|
||||||
|
<span>{stats.by_status.failed}</span>
|
||||||
|
<span className="muted-text">retryable</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Recent Events</h3>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="compact-list">
|
||||||
|
{events.map((event) => (
|
||||||
|
<Link className="compact-row" key={event.id} to={`/jobs/${event.job}`}>
|
||||||
|
<strong>{eventTitle(event.type)}</strong>
|
||||||
|
<span>{event.attempt ? `attempt ${event.attempt}` : "new"}</span>
|
||||||
|
<DateTime value={event.created_at} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!events.length && <EmptyState title="No events yet" />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/pages/EventsPage.tsx
Normal file
90
frontend/src/pages/EventsPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export function EventsPage() {
|
||||||
|
const [events, setEvents] = useState<JobEvent[]>([]);
|
||||||
|
|
||||||
|
const refreshInitial = useCallback(async () => {
|
||||||
|
setEvents(await api.listEvents({ limit: 100 }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshInitial().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
||||||
|
}, [refreshInitial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setEvents((current) => {
|
||||||
|
const afterId = current.at(-1)?.id;
|
||||||
|
void api
|
||||||
|
.listEvents({ after_id: afterId, limit: 100 })
|
||||||
|
.then((incoming) => {
|
||||||
|
if (!incoming.length) return;
|
||||||
|
setEvents((latest) => {
|
||||||
|
const seen = new Set(latest.map((event) => event.id));
|
||||||
|
return [...latest, ...incoming.filter((event) => !seen.has(event.id))].slice(-200);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const newestFirst = useMemo(() => [...events].sort((a, b) => b.id - a.id), [events]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Audit log</span>
|
||||||
|
<h2>Events</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<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>
|
||||||
|
{!newestFirst.length && <EmptyState title="No events yet" />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
frontend/src/pages/JobDetailPage.tsx
Normal file
154
frontend/src/pages/JobDetailPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { api } from "../api";
|
||||||
|
import { eventTitle } from "../constants";
|
||||||
|
import { DateTime } from "../components/DateTime";
|
||||||
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { Job, JobEvent } from "../types";
|
||||||
|
|
||||||
|
export function JobDetailPage() {
|
||||||
|
const { jobId } = useParams();
|
||||||
|
const [job, setJob] = useState<Job | null>(null);
|
||||||
|
const [events, setEvents] = useState<JobEvent[]>([]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!jobId) return;
|
||||||
|
const [jobData, eventData] = await Promise.all([api.getJob(jobId), api.listJobEvents(jobId)]);
|
||||||
|
setJob(jobData);
|
||||||
|
setEvents(eventData);
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
||||||
|
const id = window.setInterval(() => void refresh().catch(() => undefined), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const sortedEvents = useMemo(() => [...events].sort((a, b) => b.id - a.id), [events]);
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
if (!job) return;
|
||||||
|
try {
|
||||||
|
await api.retryJob(job.id);
|
||||||
|
await refresh();
|
||||||
|
toast.success("Job returned to the queue.");
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error(caught instanceof Error ? caught.message : String(caught));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<EmptyState title="Loading job" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Job detail</span>
|
||||||
|
<h2>{job.type}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="row-actions">
|
||||||
|
<Link className="secondary-button" to="/jobs">
|
||||||
|
Back
|
||||||
|
</Link>
|
||||||
|
<button type="button" disabled={job.status !== "failed"} onClick={() => void retry()}>
|
||||||
|
<RotateCcw size={16} /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<span>Status</span>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<span>Priority</span>
|
||||||
|
<strong>{job.priority}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<span>Attempts</span>
|
||||||
|
<strong>
|
||||||
|
{job.attempts}/{job.max_attempts}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<span>Locked by</span>
|
||||||
|
<code>{job.locked_by ?? "-"}</code>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dashboard-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Metadata</h3>
|
||||||
|
</div>
|
||||||
|
<div className="compact-list">
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>ID</strong>
|
||||||
|
<code>{job.id}</code>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>Available</strong>
|
||||||
|
<span />
|
||||||
|
<DateTime value={job.available_at} />
|
||||||
|
</div>
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>Locked until</strong>
|
||||||
|
<span />
|
||||||
|
<DateTime value={job.locked_until} />
|
||||||
|
</div>
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>Finished</strong>
|
||||||
|
<span />
|
||||||
|
<DateTime value={job.finished_at} />
|
||||||
|
</div>
|
||||||
|
<div className="compact-row">
|
||||||
|
<strong>Idempotency</strong>
|
||||||
|
<code>{job.idempotency_key ?? "-"}</code>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Payload</h3>
|
||||||
|
</div>
|
||||||
|
<pre>{JSON.stringify(job.payload, null, 2)}</pre>
|
||||||
|
<div className="section-title result-title">
|
||||||
|
<h3>Result</h3>
|
||||||
|
</div>
|
||||||
|
<pre>{JSON.stringify(job.result ?? null, null, 2)}</pre>
|
||||||
|
{job.last_error && <p className="error">{job.last_error}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>Event Timeline</h3>
|
||||||
|
</div>
|
||||||
|
<div className="compact-list">
|
||||||
|
{sortedEvents.map((event) => (
|
||||||
|
<div className="compact-row" key={event.id}>
|
||||||
|
<strong>{eventTitle(event.type)}</strong>
|
||||||
|
<span>{event.worker_id ?? `attempt ${event.attempt}`}</span>
|
||||||
|
<DateTime value={event.created_at} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!sortedEvents.length && <EmptyState title="No events for this job" />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/src/pages/JobsPage.tsx
Normal file
239
frontend/src/pages/JobsPage.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Plus, RotateCcw } from "lucide-react";
|
||||||
|
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { api } from "../api";
|
||||||
|
import { parseJson, STATUSES } from "../constants";
|
||||||
|
import { DateTime } from "../components/DateTime";
|
||||||
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { JsonField } from "../components/JsonField";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import { SelectField, SelectOption } from "../components/SelectField";
|
||||||
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import type { Job, JobStatus } from "../types";
|
||||||
|
|
||||||
|
const jobTypeOptions: SelectOption[] = [
|
||||||
|
{ value: "demo.success", label: "Successful" },
|
||||||
|
{ value: "demo.fail", label: "Failing" },
|
||||||
|
{ value: "demo.slow", label: "Slow with renewal" },
|
||||||
|
{ value: "demo.timeout", label: "Timeout without renewal" },
|
||||||
|
{ value: "demo.flaky", label: "Flaky retry" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function demoPayload(type: string) {
|
||||||
|
if (type === "demo.slow") return { sleep_seconds: 8 };
|
||||||
|
if (type === "demo.timeout") return { sleep_seconds: 45 };
|
||||||
|
if (type === "demo.flaky") return { fail_until_attempt: 2 };
|
||||||
|
if (type === "demo.fail") return { error: "Intentional demo failure" };
|
||||||
|
return { sleep_seconds: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobsPage() {
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
|
const [jobType, setJobType] = useState("demo.success");
|
||||||
|
const [payload, setPayload] = useState(JSON.stringify(demoPayload("demo.success"), null, 2));
|
||||||
|
const [priority, setPriority] = useState(50);
|
||||||
|
const [maxAttempts, setMaxAttempts] = useState(3);
|
||||||
|
const [idempotencyKey, setIdempotencyKey] = useState("");
|
||||||
|
const [availableAt, setAvailableAt] = useState("");
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setJobs(await api.listJobs());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
||||||
|
const id = window.setInterval(() => void refresh().catch(() => undefined), 2000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
return jobs.filter((job) => {
|
||||||
|
if (statusFilter !== "all" && job.status !== statusFilter) return false;
|
||||||
|
if (typeFilter && !job.type.toLowerCase().includes(typeFilter.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [jobs, statusFilter, typeFilter]);
|
||||||
|
|
||||||
|
function setType(nextType: string) {
|
||||||
|
setJobType(nextType);
|
||||||
|
setPayload(JSON.stringify(demoPayload(nextType), null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createJob(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await api.createJob({
|
||||||
|
type: jobType,
|
||||||
|
payload: parseJson(payload),
|
||||||
|
priority,
|
||||||
|
max_attempts: maxAttempts,
|
||||||
|
available_at: availableAt ? new Date(availableAt).toISOString() : null,
|
||||||
|
idempotency_key: idempotencyKey || null
|
||||||
|
});
|
||||||
|
setCreateOpen(false);
|
||||||
|
setIdempotencyKey("");
|
||||||
|
await refresh();
|
||||||
|
toast.success("Job created.");
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error(caught instanceof Error ? caught.message : String(caught));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDemo(type: string, nextPriority = 50, nextMaxAttempts = 3) {
|
||||||
|
try {
|
||||||
|
await api.createJob({
|
||||||
|
type,
|
||||||
|
payload: demoPayload(type),
|
||||||
|
priority: nextPriority,
|
||||||
|
max_attempts: nextMaxAttempts
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
toast.success("Demo job created.");
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error(caught instanceof Error ? caught.message : String(caught));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBatch() {
|
||||||
|
const batch = [
|
||||||
|
["demo.success", 90],
|
||||||
|
["demo.slow", 80],
|
||||||
|
["demo.flaky", 75],
|
||||||
|
["demo.fail", 60],
|
||||||
|
["demo.success", 55],
|
||||||
|
["demo.timeout", 50],
|
||||||
|
["demo.success", 45],
|
||||||
|
["demo.flaky", 40],
|
||||||
|
["demo.fail", 35],
|
||||||
|
["demo.success", 30]
|
||||||
|
] as const;
|
||||||
|
try {
|
||||||
|
await Promise.all(batch.map(([type, nextPriority]) => api.createJob({ type, payload: demoPayload(type), priority: nextPriority, max_attempts: 3 })));
|
||||||
|
await refresh();
|
||||||
|
toast.success("Batch created.");
|
||||||
|
} catch (caught) {
|
||||||
|
toast.error(caught instanceof Error ? caught.message : String(caught));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions: SelectOption[] = [{ value: "all", label: "All statuses" }, ...STATUSES.map((status) => ({ value: status, label: status }))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Lifecycle</span>
|
||||||
|
<h2>Jobs</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={16} /> Create Job
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="row-actions">
|
||||||
|
<button type="button" onClick={() => void createDemo("demo.success", 60)}>
|
||||||
|
Success
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void createDemo("demo.fail", 50, 2)}>
|
||||||
|
Failing
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void createDemo("demo.slow", 80)}>
|
||||||
|
Slow
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void createDemo("demo.timeout", 70, 2)}>
|
||||||
|
Timeout
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void createDemo("demo.flaky", 75, 3)}>
|
||||||
|
Flaky Retry
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void createBatch()}>
|
||||||
|
<RotateCcw size={16} /> 10 Mixed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="toolbar">
|
||||||
|
<SelectField value={statusFilter} options={statusOptions} onChange={(value) => setStatusFilter(value as JobStatus | "all")} />
|
||||||
|
<input placeholder="Filter by type" value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Attempts</th>
|
||||||
|
<th>Available</th>
|
||||||
|
<th>Locked By</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredJobs.map((job) => (
|
||||||
|
<tr key={job.id}>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
<td>{job.type}</td>
|
||||||
|
<td>{job.priority}</td>
|
||||||
|
<td>
|
||||||
|
{job.attempts}/{job.max_attempts}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<DateTime value={job.available_at} />
|
||||||
|
</td>
|
||||||
|
<td>{job.locked_by ?? "-"}</td>
|
||||||
|
<td>
|
||||||
|
<Link className="secondary-button" to={`/jobs/${job.id}`}>
|
||||||
|
Detail
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{!filteredJobs.length && <EmptyState title="No jobs match the current filter" />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{createOpen && (
|
||||||
|
<Modal title="Create Job" onClose={() => setCreateOpen(false)}>
|
||||||
|
<form className="modal-form" onSubmit={createJob}>
|
||||||
|
<SelectField label="Job type" value={jobType} options={jobTypeOptions} onChange={setType} />
|
||||||
|
<JsonField label="Payload JSON" value={payload} onChange={setPayload} />
|
||||||
|
<label>
|
||||||
|
Priority
|
||||||
|
<input type="number" value={priority} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max attempts
|
||||||
|
<input min={1} type="number" value={maxAttempts} onChange={(event) => setMaxAttempts(Number(event.target.value))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Available at
|
||||||
|
<input type="datetime-local" value={availableAt} onChange={(event) => setAvailableAt(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Idempotency key
|
||||||
|
<input value={idempotencyKey} onChange={(event) => setIdempotencyKey(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="secondary-button" type="button" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1180
frontend/src/styles.css
Normal file
1180
frontend/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/src/types.ts
Normal file
45
frontend/src/types.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export type JobStatus = "queued" | "running" | "succeeded" | "failed";
|
||||||
|
|
||||||
|
export type Job = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
status: JobStatus;
|
||||||
|
priority: number;
|
||||||
|
available_at: string;
|
||||||
|
attempts: number;
|
||||||
|
max_attempts: number;
|
||||||
|
idempotency_key: string | null;
|
||||||
|
locked_by: string | null;
|
||||||
|
locked_until: string | null;
|
||||||
|
last_error: string;
|
||||||
|
result: unknown;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
finished_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobEvent = {
|
||||||
|
id: number;
|
||||||
|
job: string;
|
||||||
|
type: string;
|
||||||
|
attempt: number;
|
||||||
|
worker_id: string | null;
|
||||||
|
message: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JobStats = {
|
||||||
|
total: number;
|
||||||
|
by_status: Record<JobStatus, number>;
|
||||||
|
overdue_running: number;
|
||||||
|
retries_pending: number;
|
||||||
|
oldest_queued_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Health = {
|
||||||
|
ok: boolean;
|
||||||
|
database: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
14
frontend/tsconfig.node.json
Normal file
14
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
9
frontend/vite.config.ts
Normal file
9
frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user