Compare commits
10 Commits
0c15fbfbd2
...
b331948491
| Author | SHA1 | Date | |
|---|---|---|---|
| b331948491 | |||
| bd358f5e48 | |||
| dd9567deb5 | |||
| 6bb42001bc | |||
| 1616493655 | |||
| 496b18fc22 | |||
| c72ca7203f | |||
| a2bd063c9c | |||
| 01edd74e63 | |||
| bd7916ed45 |
105
README.md
@@ -1,4 +1,4 @@
|
||||
# Minimal Senior-Level Job Queue
|
||||
# Job Queue
|
||||
|
||||
This is a deliberately small PostgreSQL-backed job queue for the interview assignment.
|
||||
|
||||
@@ -14,6 +14,63 @@ The important parts are:
|
||||
- idempotent job creation
|
||||
- demo UI with job state and event polling
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
|
||||
There is no queue table and no worker table. Workers are ephemeral process threads with generated ids. The queue is internal and ordered by:
|
||||
|
||||
```text
|
||||
priority DESC, available_at ASC, created_at ASC, id ASC
|
||||
```
|
||||
|
||||
## Statuses
|
||||
|
||||

|
||||
|
||||
The database also validates row shape:
|
||||
|
||||
- queued jobs cannot have locks or finish timestamps
|
||||
- running jobs must have a lock owner and lease deadline
|
||||
- terminal jobs must have a finish timestamp and no lock
|
||||
|
||||
## At-Least-Once Execution
|
||||
|
||||

|
||||
|
||||
This queue provides at-least-once execution, not exactly-once execution.
|
||||
|
||||
A worker can perform an external side effect and crash before marking a job succeeded. The lease will expire and the job can run again. Real handlers should therefore be idempotent.
|
||||
|
||||
## Why PostgreSQL
|
||||
|
||||
The assignment requires PostgreSQL, and PostgreSQL gives a compact solution for safe concurrent claiming through `SELECT ... FOR UPDATE SKIP LOCKED`. This keeps the implementation transactional, inspectable, and easy to demo.
|
||||
|
||||
For a high-throughput distributed production queue, Redis-backed systems such as BullMQ or Sidekiq-style designs are common. That is documented as the next architecture, not implemented here.
|
||||
|
||||
## Assumptions And Interview Simplifications
|
||||
|
||||
This project is intentionally scoped as an internal single-queue system, not a multi-tenant queue platform. There is no queue CRUD, queue table, or dynamic routing model because the assignment focuses on safe claim semantics and deterministic job state.
|
||||
|
||||
PostgreSQL is used because the assignment requires it and because it makes transactional state easy to inspect during a demo. Redis, BullMQ, Sidekiq-style designs, or a dedicated broker would be better for very high throughput or broader distributed queue use cases.
|
||||
|
||||
The system provides at-least-once execution, not exactly-once execution. Handlers that perform external side effects must be idempotent because a worker can crash after the side effect and before marking the job succeeded.
|
||||
|
||||
The UI is demo and observability oriented. It shows queue pressure, job state, retries, leases, and event logs, but it is not a full production operator console.
|
||||
|
||||
Authentication and authorization are intentionally omitted from the public API for interview simplicity. A production deployment would protect all write endpoints and usually restrict operator actions by role.
|
||||
|
||||
## Production Follow-Ups
|
||||
|
||||
- Add authentication, authorization, and role-based permissions.
|
||||
- Add metrics, alerting, and dashboards for queue depth, age, throughput, failures, and retry rate.
|
||||
- Add event retention, archival, or partitioning so `JobEvent` does not grow forever.
|
||||
- Add rate limits, producer quotas, and backpressure controls.
|
||||
- Add cancellation or cooperative stop support for jobs.
|
||||
- Add dead-letter metadata or a dead-letter inspection view.
|
||||
- Consider Redis or a dedicated broker if throughput or cross-service distribution becomes the main requirement.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
@@ -60,52 +117,6 @@ docker compose up -d --build worker
|
||||
docker compose logs -f worker
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
React UI
|
||||
|
|
||||
Django API ---- PostgreSQL
|
||||
|
|
||||
Django worker process
|
||||
|
|
||||
N worker threads from env
|
||||
```
|
||||
|
||||
There is no queue table and no worker table. Workers are ephemeral process threads with generated ids. The queue is internal and ordered by:
|
||||
|
||||
```text
|
||||
priority DESC, available_at ASC, created_at ASC, id ASC
|
||||
```
|
||||
|
||||
## Statuses
|
||||
|
||||
```text
|
||||
queued -> running
|
||||
running -> succeeded
|
||||
running -> queued retry after failure or timeout
|
||||
running -> failed attempts exhausted
|
||||
failed -> queued manual retry
|
||||
```
|
||||
|
||||
The database also validates row shape:
|
||||
|
||||
- queued jobs cannot have locks or finish timestamps
|
||||
- running jobs must have a lock owner and lease deadline
|
||||
- terminal jobs must have a finish timestamp and no lock
|
||||
|
||||
## At-Least-Once Execution
|
||||
|
||||
This queue provides at-least-once execution, not exactly-once execution.
|
||||
|
||||
A worker can perform an external side effect and crash before marking a job succeeded. The lease will expire and the job can run again. Real handlers should therefore be idempotent.
|
||||
|
||||
## Why PostgreSQL
|
||||
|
||||
The assignment requires PostgreSQL, and PostgreSQL gives a compact solution for safe concurrent claiming through `SELECT ... FOR UPDATE SKIP LOCKED`. This keeps the implementation transactional, inspectable, and easy to demo.
|
||||
|
||||
For a high-throughput distributed production queue, Redis-backed systems such as BullMQ or Sidekiq-style designs are common. That is documented as the next architecture, not implemented here.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
Run backend tests locally with SQLite fallback:
|
||||
|
||||
BIN
assets/images/at-least-once-lease-recovery.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/backend/api-surface.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/backend/database-erd.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/backend/database-indexes.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/backend/expired-lease-cleanup.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/images/backend/exponential-backoff.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/backend/high-level-architecture.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/backend/job-state-machine.png
Normal file
|
After Width: | Height: | Size: 1013 KiB |
BIN
assets/images/backend/lease-renewal.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/images/backend/ownership-stale-worker.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/backend/skip-locked-claim.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
assets/images/backend/worker-runtime-loop.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/frontend/data-flow.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/frontend/terminal-event-feed.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/images/frontend/ui-architecture.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
assets/images/job-status-state-machine.png
Normal file
|
After Width: | Height: | Size: 914 KiB |
BIN
assets/images/project-architecture.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
1183
backend/README.md
@@ -6,6 +6,36 @@ from rest_framework.test import APIClient
|
||||
from jobs.services import create_job
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_jobs_use_limit_offset_pagination():
|
||||
create_job(job_type="demo.success")
|
||||
create_job(job_type="demo.fail")
|
||||
create_job(job_type="demo.slow")
|
||||
|
||||
response = APIClient().get("/api/jobs/?limit=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["count"] == 3
|
||||
assert body["next"] is not None
|
||||
assert body["previous"] is None
|
||||
assert len(body["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_jobs_pagination_supports_status_and_type_filters():
|
||||
create_job(job_type="demo.success")
|
||||
create_job(job_type="demo.fail")
|
||||
create_job(job_type="demo.slow")
|
||||
|
||||
response = APIClient().get("/api/jobs/?status=queued&type=success&limit=10")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["count"] == 1
|
||||
assert body["results"][0]["type"] == "demo.success"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_global_events_use_cursor_pagination():
|
||||
create_job(job_type="demo.success")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.db import connection
|
||||
from rest_framework import generics, status
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.pagination import CursorPagination
|
||||
from rest_framework.pagination import CursorPagination, LimitOffsetPagination
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
@@ -17,6 +17,11 @@ class JobEventCursorPagination(CursorPagination):
|
||||
ordering = "-id"
|
||||
|
||||
|
||||
class JobLimitOffsetPagination(LimitOffsetPagination):
|
||||
default_limit = 25
|
||||
max_limit = 100
|
||||
|
||||
|
||||
class HealthAPIView(APIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
@@ -35,9 +40,15 @@ class JobListCreateAPIView(APIView):
|
||||
def get(self, request):
|
||||
queryset = Job.objects.order_by("-created_at")
|
||||
status_filter = request.query_params.get("status")
|
||||
type_filter = request.query_params.get("type")
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
return Response(JobSerializer(queryset, many=True).data)
|
||||
if type_filter:
|
||||
queryset = queryset.filter(type__icontains=type_filter)
|
||||
|
||||
paginator = JobLimitOffsetPagination()
|
||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||
return paginator.get_paginated_response(JobSerializer(page, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
serializer = JobCreateSerializer(data=request.data)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Vite React demo UI for the minimal job queue.
|
||||
|
||||

|
||||
|
||||
It reuses the visual style from the previous advanced frontend, but only keeps:
|
||||
|
||||
- dashboard
|
||||
@@ -11,11 +13,15 @@ It reuses the visual style from the previous advanced frontend, but only keeps:
|
||||
|
||||
The UI polls:
|
||||
|
||||

|
||||
|
||||
- jobs and stats every 2 seconds
|
||||
- events every 1 second
|
||||
|
||||
No WebSockets are used.
|
||||
|
||||

|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CursorPage, Health, Job, JobEvent, JobStats } from "./types";
|
||||
import type { CursorPage, Health, Job, JobEvent, JobStats, LimitOffsetPage } from "./types";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000/api";
|
||||
|
||||
@@ -31,9 +31,24 @@ function normalizeCursorPage<T>(value: CursorPage<T> | T[]): CursorPage<T> {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeLimitOffsetPage<T>(value: LimitOffsetPage<T> | T[]): LimitOffsetPage<T> {
|
||||
if (Array.isArray(value)) {
|
||||
return { count: value.length, next: null, previous: null, results: value };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request<Health>("/health/"),
|
||||
listJobs: () => request<Job[]>("/jobs/"),
|
||||
listJobs: () => api.listJobsPage({ limit: 100 }).then((page) => page.results),
|
||||
listJobsPage: (filters: { limit?: number; offset?: number; status?: 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<LimitOffsetPage<Job> | Job[]>(`/jobs/${query ? `?${query}` : ""}`).then(normalizeLimitOffsetPage);
|
||||
},
|
||||
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" }),
|
||||
|
||||
@@ -26,7 +26,6 @@ export function AppLayout() {
|
||||
|
||||
function handleThemeToggle() {
|
||||
toggleTheme();
|
||||
toast.success(theme === "dark" ? "Light mode enabled." : "Dark mode enabled.");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,9 @@ function BarMetric({ label, value, max, tone = "neutral" }: { label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
function MiniHistogram({ data }: { data: Array<{ label: string; value: number; tone: string }> }) {
|
||||
type ChartPoint = { label: string; value: number; tone: string };
|
||||
|
||||
function MiniHistogram({ data }: { data: ChartPoint[] }) {
|
||||
const max = Math.max(...data.map((item) => item.value), 1);
|
||||
return (
|
||||
<div className="histogram" aria-label="histogram">
|
||||
@@ -51,6 +53,18 @@ function MiniHistogram({ data }: { data: Array<{ label: string; value: number; t
|
||||
);
|
||||
}
|
||||
|
||||
function StatusStack({ data }: { data: ChartPoint[] }) {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||
return (
|
||||
<div className="status-stack" aria-label="status distribution">
|
||||
{data.map((item) => {
|
||||
const width = total > 0 ? Math.max(6, Math.round((item.value / total) * 100)) : 25;
|
||||
return <i className={item.tone} key={item.label} style={{ width: `${width}%` }} title={`${item.label}: ${item.value}`} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [events, setEvents] = useState<JobEvent[]>([]);
|
||||
@@ -110,6 +124,11 @@ export function DashboardPage() {
|
||||
.map(([type, value]) => ({ label: eventTitle(type), value, tone: type.includes("fail") ? "failed" : type.includes("success") ? "succeeded" : "running" }));
|
||||
}, [events]);
|
||||
const maxPressure = Math.max(...pressureData.map((item) => item.value), 1);
|
||||
const activePressure = stats.by_status.queued + stats.by_status.running + stats.retries_pending + stats.overdue_running;
|
||||
const completionRate = recentJobs.length
|
||||
? Math.round((recentJobs.filter((job) => job.status === "succeeded").length / recentJobs.length) * 100)
|
||||
: 0;
|
||||
const topEvent = eventData.at(0);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -131,7 +150,7 @@ export function DashboardPage() {
|
||||
</section>
|
||||
|
||||
<section className="dashboard-grid">
|
||||
<section className="panel">
|
||||
<section className="panel chart-panel">
|
||||
<div className="section-title">
|
||||
<h3>Recent Jobs</h3>
|
||||
<Link className="secondary-button" to="/jobs">
|
||||
@@ -140,6 +159,11 @@ export function DashboardPage() {
|
||||
</div>
|
||||
{recentJobs.length ? (
|
||||
<>
|
||||
<div className="chart-summary-row">
|
||||
<span>{recentJobs.length} latest jobs</span>
|
||||
<strong>{completionRate}% succeeded</strong>
|
||||
</div>
|
||||
<StatusStack data={recentJobStatusData} />
|
||||
<MiniHistogram data={recentJobStatusData} />
|
||||
<div className="job-tick-chart" aria-label="latest jobs">
|
||||
{recentJobs.slice(0, 16).map((job) => (
|
||||
@@ -152,11 +176,15 @@ export function DashboardPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<section className="panel chart-panel">
|
||||
<div className="section-title">
|
||||
<h3>Queue Pressure</h3>
|
||||
<Clock3 size={16} />
|
||||
</div>
|
||||
<div className="chart-summary-row">
|
||||
<span>Active pressure</span>
|
||||
<strong>{activePressure}</strong>
|
||||
</div>
|
||||
<div className="metric-bars">
|
||||
{pressureData.map((item) => (
|
||||
<BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} />
|
||||
@@ -167,12 +195,22 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<section className="panel chart-panel">
|
||||
<div className="section-title">
|
||||
<h3>Recent Events</h3>
|
||||
<RotateCcw size={16} />
|
||||
</div>
|
||||
{eventData.length ? <MiniHistogram data={eventData} /> : <EmptyState title="No events yet" />}
|
||||
{eventData.length ? (
|
||||
<>
|
||||
<div className="chart-summary-row">
|
||||
<span>{events.length} latest events</span>
|
||||
<strong>{topEvent ? topEvent.label : "No events"}</strong>
|
||||
</div>
|
||||
<MiniHistogram data={eventData} />
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="No events yet" />
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
@@ -22,6 +36,7 @@ export function EventsPage() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<string | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const feedRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const refreshFirstPage = useCallback(async (replace = false) => {
|
||||
if (replace) setLoading(true);
|
||||
@@ -60,19 +75,12 @@ export function EventsPage() {
|
||||
}
|
||||
}, [loadingMore, nextPageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node || !nextPageUrl) return undefined;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ rootMargin: "280px 0px" }
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, nextPageUrl]);
|
||||
const handleFeedScroll = useCallback(() => {
|
||||
const node = feedRef.current;
|
||||
if (!node || !nextPageUrl || loadingMore) return;
|
||||
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
|
||||
if (distanceFromBottom <= 140) void loadMore();
|
||||
}, [loadMore, loadingMore, nextPageUrl]);
|
||||
|
||||
const newestFirst = useMemo(() => [...events].sort((a, b) => b.id - a.id), [events]);
|
||||
|
||||
@@ -89,53 +97,43 @@ 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" onScroll={handleFeedScroll} ref={feedRef}>
|
||||
{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>
|
||||
<div className="infinite-sentinel" ref={sentinelRef}>
|
||||
{loadingMore && <span>Loading older events...</span>}
|
||||
{!loadingMore && nextPageUrl && (
|
||||
<button className="secondary-button" type="button" onClick={() => void loadMore()}>
|
||||
Load older events
|
||||
</button>
|
||||
)}
|
||||
{!loadingMore && !nextPageUrl && newestFirst.length > 0 && <span>End of event history</span>}
|
||||
<div className="infinite-sentinel" ref={sentinelRef}>
|
||||
{loadingMore && <span>Loading older events...</span>}
|
||||
{!loadingMore && nextPageUrl && (
|
||||
<button className="secondary-button" type="button" onClick={() => void loadMore()}>
|
||||
Load older events
|
||||
</button>
|
||||
)}
|
||||
{!loadingMore && !nextPageUrl && newestFirst.length > 0 && <span>End of event history</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -117,11 +117,11 @@ export function JobDetailPage() {
|
||||
{job.attempts}/{job.max_attempts}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span>Locked by</span>
|
||||
<code className="code-token wrap">{job.locked_by ?? "-"}</code>
|
||||
</div>
|
||||
</section>
|
||||
<div className="stat-card">
|
||||
<span>Locked by</span>
|
||||
<code className="code-token wrap">{job.locked_by ?? "-"}</code>
|
||||
</div>
|
||||
|
||||
<section className="detail-grid">
|
||||
<section className="panel">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Eye, Plus, RotateCcw } from "lucide-react";
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronLeft, ChevronRight, Eye, Plus, RotateCcw, X } from "lucide-react";
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -21,6 +21,12 @@ const jobTypeOptions: SelectOption[] = [
|
||||
{ value: "demo.flaky", label: "Flaky retry" }
|
||||
];
|
||||
|
||||
const pageSizeOptions: SelectOption[] = [
|
||||
{ value: "10", label: "10 / page" },
|
||||
{ value: "25", label: "25 / page" },
|
||||
{ value: "50", label: "50 / page" }
|
||||
];
|
||||
|
||||
function demoPayload(type: string) {
|
||||
if (type === "demo.slow") return { sleep_seconds: 8 };
|
||||
if (type === "demo.timeout") return { sleep_seconds: 45 };
|
||||
@@ -29,11 +35,41 @@ function demoPayload(type: string) {
|
||||
return { sleep_seconds: 1 };
|
||||
}
|
||||
|
||||
function attemptState(job: Job, attemptNumber: number) {
|
||||
if (attemptNumber > job.attempts) return "empty";
|
||||
if (job.status === "succeeded") return attemptNumber === job.attempts ? "succeeded" : "failed";
|
||||
if (job.status === "running" && attemptNumber === job.attempts) return "running";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function AttemptMeter({ job }: { job: Job }) {
|
||||
return (
|
||||
<div className="attempt-meter" aria-label={`${job.attempts} of ${job.max_attempts} attempts used`}>
|
||||
{Array.from({ length: job.max_attempts }, (_, index) => {
|
||||
const attemptNumber = index + 1;
|
||||
const state = attemptState(job, attemptNumber);
|
||||
return (
|
||||
<span className={`attempt-square ${state}`} key={attemptNumber} title={`Attempt ${attemptNumber}: ${state}`}>
|
||||
{state === "succeeded" && <Check size={12} strokeWidth={3} />}
|
||||
{state === "failed" && <X size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{/* <span className="attempt-count">
|
||||
{job.attempts}/{job.max_attempts}
|
||||
</span> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobsPage() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [totalJobs, setTotalJobs] = useState(0);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("all");
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [jobType, setJobType] = useState("demo.success");
|
||||
const [payload, setPayload] = useState(JSON.stringify(demoPayload("demo.success"), null, 2));
|
||||
const [priority, setPriority] = useState(50);
|
||||
@@ -42,8 +78,15 @@ export function JobsPage() {
|
||||
const [availableAt, setAvailableAt] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setJobs(await api.listJobs());
|
||||
}, []);
|
||||
const page = await api.listJobsPage({
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
type: typeFilter
|
||||
});
|
||||
setJobs(page.results);
|
||||
setTotalJobs(page.count);
|
||||
}, [currentPage, pageSize, statusFilter, typeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
|
||||
@@ -51,13 +94,17 @@ export function JobsPage() {
|
||||
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]);
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [pageSize, statusFilter, typeFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalJobs / pageSize));
|
||||
const firstItem = totalJobs ? (currentPage - 1) * pageSize + 1 : 0;
|
||||
const lastItem = Math.min(totalJobs, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
function setType(nextType: string) {
|
||||
setJobType(nextType);
|
||||
@@ -156,7 +203,7 @@ export function JobsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredJobs.map((job) => (
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id}>
|
||||
<td>
|
||||
<StatusBadge status={job.status} />
|
||||
@@ -166,7 +213,7 @@ export function JobsPage() {
|
||||
</td>
|
||||
<td>{job.priority}</td>
|
||||
<td>
|
||||
{job.attempts}/{job.max_attempts}
|
||||
<AttemptMeter job={job} />
|
||||
</td>
|
||||
<td>
|
||||
<DateTime value={job.available_at} />
|
||||
@@ -181,13 +228,33 @@ export function JobsPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!filteredJobs.length && <EmptyState title="No jobs match the current filter" />}
|
||||
{!jobs.length && <EmptyState title="No jobs match the current filter" />}
|
||||
</div>
|
||||
<div className="pagination-bar">
|
||||
<span>
|
||||
Showing {firstItem}-{lastItem} of {totalJobs}
|
||||
</span>
|
||||
<div className="pagination-controls">
|
||||
<SelectField value={String(pageSize)} options={pageSizeOptions} onChange={(value) => setPageSize(Number(value))} />
|
||||
<button className="secondary-button" disabled={currentPage <= 1} type="button" onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}>
|
||||
<ChevronLeft size={16} /> Previous
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={currentPage >= totalPages}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||
>
|
||||
Next <ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{createOpen && (
|
||||
<Modal title="Create Job" onClose={() => setCreateOpen(false)}>
|
||||
<form className="modal-form" onSubmit={createJob}>
|
||||
<label>Quick Create</label>
|
||||
<div className="preset-grid">
|
||||
<button className="secondary-button" type="button" onClick={() => setType("demo.success")}>
|
||||
Success
|
||||
@@ -204,7 +271,7 @@ export function JobsPage() {
|
||||
<button className="secondary-button" type="button" onClick={() => setType("demo.flaky")}>
|
||||
Flaky Retry
|
||||
</button>
|
||||
<button className="secondary-button" type="button" onClick={() => void createBatch()}>
|
||||
<button className="primary-button" type="button" onClick={() => void createBatch()}>
|
||||
<RotateCcw size={16} /> 10 Mixed
|
||||
</button>
|
||||
</div>
|
||||
@@ -227,9 +294,6 @@ export function JobsPage() {
|
||||
<input value={idempotencyKey} onChange={(event) => setIdempotencyKey(event.target.value)} />
|
||||
</label>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" type="button" onClick={() => void createDemo(jobType, priority, maxAttempts)}>
|
||||
Quick Create
|
||||
</button>
|
||||
<button className="secondary-button" type="button" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
--panel-raised: #f9fbfc;
|
||||
--background: #f4f7f9;
|
||||
--shadow: 0 18px 46px rgb(35 36 38 / 8%);
|
||||
--terminal-scroll-thumb: #0bb3f0;
|
||||
--terminal-scroll-thumb-hover: #f0bb0b;
|
||||
--terminal-scroll-track: #171b1f;
|
||||
color: var(--ink);
|
||||
background: var(--background);
|
||||
font-synthesis: none;
|
||||
@@ -60,6 +63,9 @@
|
||||
--panel-raised: #25282b;
|
||||
--background: #141618;
|
||||
--shadow: 0 18px 46px rgb(0 0 0 / 28%);
|
||||
--terminal-scroll-thumb: #f0bb0b;
|
||||
--terminal-scroll-thumb-hover: #55c8ff;
|
||||
--terminal-scroll-track: #101316;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -175,6 +181,7 @@ label {
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
grid-template-rows: 72px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
transition: grid-template-columns 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.app-shell.sidebar-collapsed {
|
||||
@@ -255,6 +262,10 @@ label {
|
||||
padding: 16px;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
transition:
|
||||
background-color 220ms ease,
|
||||
border-color 220ms ease,
|
||||
padding 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -268,6 +279,17 @@ label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-header span {
|
||||
max-width: 120px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-width 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 160ms ease,
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -283,10 +305,26 @@ label {
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 10px 12px;
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
color 180ms ease,
|
||||
gap 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
justify-content 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
padding 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
max-width: 140px;
|
||||
min-width: 0;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
max-width 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 160ms ease,
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
@@ -301,10 +339,13 @@ label {
|
||||
|
||||
.sidebar-collapsed .sidebar-header span,
|
||||
.sidebar-collapsed .nav-item span {
|
||||
display: none;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-item {
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -313,6 +354,14 @@ label {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.collapse-button {
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
border-color 180ms ease,
|
||||
margin 240ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
@@ -442,6 +491,49 @@ label {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-panel::before {
|
||||
background: linear-gradient(90deg, var(--cyan), var(--green), var(--orange), var(--red));
|
||||
content: "";
|
||||
height: 3px;
|
||||
left: 14px;
|
||||
opacity: 0.78;
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.chart-summary-row {
|
||||
align-items: center;
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
min-height: 42px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.chart-summary-row span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.chart-summary-row strong {
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -645,18 +737,36 @@ label {
|
||||
display: flex;
|
||||
height: 112px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.histogram-column::before {
|
||||
background:
|
||||
linear-gradient(to top, transparent 24%, rgb(127 139 147 / 18%) 25%, transparent 26%),
|
||||
linear-gradient(to top, transparent 49%, rgb(127 139 147 / 18%) 50%, transparent 51%),
|
||||
linear-gradient(to top, transparent 74%, rgb(127 139 147 / 18%) 75%, transparent 76%);
|
||||
content: "";
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.histogram-column i,
|
||||
.metric-track i {
|
||||
background: var(--accent);
|
||||
display: block;
|
||||
transition:
|
||||
height 520ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
width 520ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-color 180ms ease;
|
||||
}
|
||||
|
||||
.histogram-column i {
|
||||
border-radius: 6px 6px 0 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.histogram-column i.queued,
|
||||
@@ -703,11 +813,53 @@ label {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 14px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.status-stack i {
|
||||
background: var(--subtle);
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
min-width: 6px;
|
||||
transition:
|
||||
width 520ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-color 180ms ease;
|
||||
}
|
||||
|
||||
.status-stack i.queued,
|
||||
.status-stack i.running {
|
||||
background: var(--cyan);
|
||||
}
|
||||
|
||||
.status-stack i.succeeded {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.status-stack i.failed {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.job-tick {
|
||||
background: var(--subtle);
|
||||
border-radius: 5px;
|
||||
display: block;
|
||||
height: 32px;
|
||||
transition:
|
||||
background-color 240ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.job-tick:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.job-tick.running {
|
||||
@@ -968,6 +1120,92 @@ label {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-controls .select-field {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.page-number-field {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.page-number-field input {
|
||||
min-height: 38px;
|
||||
text-align: center;
|
||||
width: 74px;
|
||||
}
|
||||
|
||||
.attempt-meter {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.attempt-square {
|
||||
align-items: center;
|
||||
background: var(--panel-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
height: 22px;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.attempt-square.succeeded {
|
||||
background: var(--green-soft);
|
||||
border-color: color-mix(in srgb, var(--green) 42%, var(--border));
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.attempt-square.failed {
|
||||
background: var(--red-soft);
|
||||
border-color: color-mix(in srgb, var(--red) 42%, var(--border));
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.attempt-square.running {
|
||||
animation: attempt-running 960ms ease-in-out infinite alternate;
|
||||
background: var(--orange-soft);
|
||||
border-color: color-mix(in srgb, var(--orange) 42%, var(--border));
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.attempt-count {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
margin-left: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-feed-meta {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -987,6 +1225,225 @@ 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;
|
||||
scrollbar-color: var(--terminal-scroll-thumb) var(--terminal-scroll-track);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.terminal-feed::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.terminal-feed::-webkit-scrollbar-track {
|
||||
background: var(--terminal-scroll-track);
|
||||
}
|
||||
|
||||
.terminal-feed::-webkit-scrollbar-thumb {
|
||||
background: var(--terminal-scroll-thumb);
|
||||
border: 2px solid var(--terminal-scroll-track);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.terminal-feed::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--terminal-scroll-thumb-hover);
|
||||
}
|
||||
|
||||
.terminal-feed::-webkit-scrollbar-corner {
|
||||
background: var(--terminal-scroll-track);
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
@@ -1327,6 +1784,16 @@ code {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes attempt-running {
|
||||
from {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--orange) 18%, transparent);
|
||||
}
|
||||
|
||||
to {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--orange) 4%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -1399,6 +1866,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) {
|
||||
@@ -1496,4 +1967,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ export type CursorPage<T> = {
|
||||
results: T[];
|
||||
};
|
||||
|
||||
export type LimitOffsetPage<T> = {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: T[];
|
||||
};
|
||||
|
||||
export type JobStats = {
|
||||
total: number;
|
||||
by_status: Record<JobStatus, number>;
|
||||
|
||||