Compare commits

..

15 Commits

Author SHA1 Message Date
205b44099a docs: update presentation slides 2026-06-21 15:26:24 +03:30
2b0b8cb041 chore: configure django debug toolbar 2026-06-21 15:03:09 +03:30
d04dca2646 test: add coverage reporting 2026-06-21 14:25:02 +03:30
24ee55a069 docs: add presentation slide deck 2026-06-21 13:25:16 +03:30
4690166e8d feat(frontend): add Vazirmatn font 2026-06-21 11:31:04 +03:30
b331948491 docs: clarify queue assumptions and tradeoffs 2026-06-21 11:15:20 +03:30
bd358f5e48 docs: add readme architecture diagrams 2026-06-21 11:10:18 +03:30
dd9567deb5 docs(backend): document queue architecture 2026-06-21 10:07:46 +03:30
6bb42001bc fix(frontend): fix styles in Job related pages 2026-06-21 09:57:05 +03:30
1616493655 feat(frontend): show job attempt meter 2026-06-21 09:42:50 +03:30
496b18fc22 fix(events): load older logs on terminal scroll 2026-06-21 09:14:32 +03:30
c72ca7203f feat(jobs): paginate jobs list 2026-06-21 09:12:46 +03:30
a2bd063c9c feat(frontend): smooth chart and sidebar transitions 2026-06-21 09:10:00 +03:30
01edd74e63 feat(frontend): render events as terminal log 2026-06-21 02:42:22 +03:30
bd7916ed45 feat(frontend): polish dashboard chart panels 2026-06-21 02:39:41 +03:30
54 changed files with 2600 additions and 172 deletions

112
README.md
View File

@@ -1,4 +1,4 @@
# Minimal Senior-Level Job Queue # Job Queue
This is a deliberately small PostgreSQL-backed job queue for the interview assignment. This is a deliberately small PostgreSQL-backed job queue for the interview assignment.
@@ -14,6 +14,63 @@ The important parts are:
- idempotent job creation - idempotent job creation
- demo UI with job state and event polling - demo UI with job state and event polling
## Architecture
![prompt for generating an svg image for a minimal PostgreSQL-backed job queue architecture showing React UI talking to Django API, Django API using PostgreSQL, and a separate Django worker process with N configured threads claiming jobs from PostgreSQL with SKIP LOCKED; use clean interview-project style, simple labeled boxes, directional arrows, and callouts for two tables jobs and job_events](assets/images/project-architecture.png)
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
![prompt for generating an svg image for the job status state machine with four states queued, running, succeeded, failed; arrows queued to running, running to succeeded, running to queued for retry after failure or timeout, running to failed when attempts are exhausted, and failed to queued for manual retry; use clear color coding and small labels on each transition](assets/images/job-status-state-machine.png)
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
![prompt for generating an svg image for at-least-once job execution failure recovery showing worker A claims attempt 1, worker A crashes or lease expires, cleanup requeues the job, worker B claims attempt 2, and stale worker A cannot complete attempt 1 because ownership no longer matches; use a horizontal timeline with worker lanes and database state callouts](assets/images/at-least-once-lease-recovery.png)
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 ## Run
```powershell ```powershell
@@ -60,52 +117,6 @@ docker compose up -d --build worker
docker compose logs -f 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 ## Useful Commands
Run backend tests locally with SQLite fallback: Run backend tests locally with SQLite fallback:
@@ -122,6 +133,13 @@ $env:TEST_DATABASE_ENGINE="sqlite"
python -m pytest python -m pytest
``` ```
Run pytest with coverage:
```powershell
$env:TEST_DATABASE_ENGINE="sqlite"
python -m pytest --cov --cov-report=term-missing
```
Run worker locally: Run worker locally:
```powershell ```powershell

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,4 +1,6 @@
DEBUG=true DEBUG=true
ENABLE_DJANGO_DEBUG_TOOLBAR=true
DJANGO_INTERNAL_IPS=127.0.0.1,localhost,host.docker.internal
DJANGO_SECRET_KEY=change-me DJANGO_SECRET_KEY=change-me
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure-job-queue-local-dev-key") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "django-insecure-job-queue-local-dev-key")
DEBUG = os.getenv("DEBUG", "true").lower() in {"1", "true", "yes"} DEBUG = os.getenv("DEBUG", "true").lower() in {"1", "true", "yes"}
ALLOWED_HOSTS = [host.strip() for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")] ALLOWED_HOSTS = [host.strip() for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")]
ENABLE_DJANGO_DEBUG_TOOLBAR = (
DEBUG and os.getenv("ENABLE_DJANGO_DEBUG_TOOLBAR", "true").lower() in {"1", "true", "yes"}
)
INSTALLED_APPS = [ INSTALLED_APPS = [
"unfold", "unfold",
@@ -25,6 +28,9 @@ INSTALLED_APPS = [
"jobs", "jobs",
] ]
if ENABLE_DJANGO_DEBUG_TOOLBAR:
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware", "corsheaders.middleware.CorsMiddleware",
@@ -36,6 +42,9 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
if ENABLE_DJANGO_DEBUG_TOOLBAR:
MIDDLEWARE.insert(2, "debug_toolbar.middleware.DebugToolbarMiddleware")
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
TEMPLATES = [ TEMPLATES = [
@@ -120,6 +129,21 @@ UNFOLD = {
"SITE_SUBHEADER": "Jobs, attempts, leases, and events", "SITE_SUBHEADER": "Jobs, attempts, leases, and events",
} }
INTERNAL_IPS = [
ip.strip()
for ip in os.getenv("DJANGO_INTERNAL_IPS", "127.0.0.1,localhost,host.docker.internal").split(",")
if ip.strip()
]
def show_debug_toolbar(_request):
return ENABLE_DJANGO_DEBUG_TOOLBAR
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": show_debug_toolbar,
}
JOB_WORKER_THREADS = int(os.getenv("JOB_WORKER_THREADS", "4")) JOB_WORKER_THREADS = int(os.getenv("JOB_WORKER_THREADS", "4"))
JOB_WORKER_POLL_INTERVAL_MS = int(os.getenv("JOB_WORKER_POLL_INTERVAL_MS", "500")) JOB_WORKER_POLL_INTERVAL_MS = int(os.getenv("JOB_WORKER_POLL_INTERVAL_MS", "500"))
JOB_WORKER_LEASE_SECONDS = int(os.getenv("JOB_WORKER_LEASE_SECONDS", "30")) JOB_WORKER_LEASE_SECONDS = int(os.getenv("JOB_WORKER_LEASE_SECONDS", "30"))

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
@@ -31,3 +32,6 @@ urlpatterns = [
path("api/config/", ConfigAPIView.as_view(), name="config"), path("api/config/", ConfigAPIView.as_view(), name="config"),
path("api/", include("jobs.urls")), path("api/", include("jobs.urls")),
] ]
if settings.ENABLE_DJANGO_DEBUG_TOOLBAR:
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))

View File

@@ -6,6 +6,36 @@ from rest_framework.test import APIClient
from jobs.services import create_job 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 @pytest.mark.django_db
def test_global_events_use_cursor_pagination(): def test_global_events_use_cursor_pagination():
create_job(job_type="demo.success") create_job(job_type="demo.success")

View File

@@ -1,7 +1,7 @@
from django.db import connection from django.db import connection
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.exceptions import NotFound, ValidationError 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.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -17,6 +17,11 @@ class JobEventCursorPagination(CursorPagination):
ordering = "-id" ordering = "-id"
class JobLimitOffsetPagination(LimitOffsetPagination):
default_limit = 25
max_limit = 100
class HealthAPIView(APIView): class HealthAPIView(APIView):
def get(self, request): def get(self, request):
try: try:
@@ -35,9 +40,15 @@ class JobListCreateAPIView(APIView):
def get(self, request): def get(self, request):
queryset = Job.objects.order_by("-created_at") queryset = Job.objects.order_by("-created_at")
status_filter = request.query_params.get("status") status_filter = request.query_params.get("status")
type_filter = request.query_params.get("type")
if status_filter: if status_filter:
queryset = queryset.filter(status=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): def post(self, request):
serializer = JobCreateSerializer(data=request.data) serializer = JobCreateSerializer(data=request.data)

View File

@@ -9,3 +9,25 @@ select = ["E", "W", "F", "I", "B", "C4", "UP", "DJ", "SIM"]
"jobs/models.py" = ["DJ001"] "jobs/models.py" = ["DJ001"]
"jobs/migrations/*.py" = ["E501"] "jobs/migrations/*.py" = ["E501"]
"jobs/tests/*.py" = ["DJ001"] "jobs/tests/*.py" = ["DJ001"]
[tool.coverage.run]
branch = true
source = ["config", "jobs"]
omit = [
"config/asgi.py",
"config/wsgi.py",
"jobs/management/*",
"*/migrations/*",
"*/tests/*",
"manage.py",
]
[tool.coverage.report]
show_missing = true
skip_covered = true
fail_under = 60
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.:",
"raise NotImplementedError",
]

View File

@@ -5,6 +5,8 @@ drf-spectacular>=0.28,<0.29
python-dotenv>=1.1,<2.0 python-dotenv>=1.1,<2.0
psycopg[binary]>=3.2,<4.0 psycopg[binary]>=3.2,<4.0
django-unfold>=0.76,<1.0 django-unfold>=0.76,<1.0
django-debug-toolbar>=5.0,<6.0
pytest>=8.0,<9.0 pytest>=8.0,<9.0
pytest-django>=4.9,<5.0 pytest-django>=4.9,<5.0
pytest-cov>=6.0,<7.0
ruff>=0.15,<0.16 ruff>=0.15,<0.16

View File

@@ -22,6 +22,7 @@ services:
command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
environment: environment:
DEBUG: "true" DEBUG: "true"
ENABLE_DJANGO_DEBUG_TOOLBAR: "true"
DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1,0.0.0.0 DJANGO_ALLOWED_HOSTS: localhost,127.0.0.1,0.0.0.0
DJANGO_CORS_ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173 DJANGO_CORS_ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173
POSTGRES_DB: ${POSTGRES_DB:-job_queue} POSTGRES_DB: ${POSTGRES_DB:-job_queue}

View File

@@ -2,6 +2,8 @@
Vite React demo UI for the minimal job queue. Vite React demo UI for the minimal job queue.
![prompt for generating an svg image for the React frontend information architecture of a job queue demo UI with four routes: dashboard, jobs page, job detail page, and global events page; show shared AppLayout with topbar/sidebar, API client calling Django REST endpoints, and visual panels for stats, job table, detail timeline, and terminal-style event log; use clean product UI diagram style](../assets/images/frontend/ui-architecture.png)
It reuses the visual style from the previous advanced frontend, but only keeps: It reuses the visual style from the previous advanced frontend, but only keeps:
- dashboard - dashboard
@@ -11,11 +13,15 @@ It reuses the visual style from the previous advanced frontend, but only keeps:
The UI polls: The UI polls:
![prompt for generating an svg image for frontend polling and pagination data flow: dashboard polls jobs stats and recent events every 2 seconds, job detail polls job and job events every 1 second, events page fetches first cursor page then loads older pages on terminal scroll, jobs page uses limit-offset pagination with page size and page number; show React pages on left and API endpoints on right with interval labels](../assets/images/frontend/data-flow.png)
- jobs and stats every 2 seconds - jobs and stats every 2 seconds
- events every 1 second - events every 1 second
No WebSockets are used. No WebSockets are used.
![prompt for generating an svg image for a terminal-style event log UI component with dark console background, colored rows by event type, cursor-paginated first page, older events loaded only when scrolling inside the terminal, and custom theme-aware scrollbar; include labels for live polling, de-duplication, and infinite scroll boundary](../assets/images/frontend/terminal-event-feed.png)
## Run ## Run
```powershell ```powershell

View File

@@ -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"; 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; 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 = { export const api = {
health: () => request<Health>("/health/"), 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}/`), getJob: (jobId: string) => request<Job>(`/jobs/${jobId}/`),
createJob: (body: CreateJobBody) => request<Job>("/jobs/", { method: "POST", body: JSON.stringify(body) }), createJob: (body: CreateJobBody) => request<Job>("/jobs/", { method: "POST", body: JSON.stringify(body) }),
retryJob: (jobId: string) => request<Job>(`/jobs/${jobId}/retry/`, { method: "POST" }), retryJob: (jobId: string) => request<Job>(`/jobs/${jobId}/retry/`, { method: "POST" }),

View File

@@ -26,7 +26,6 @@ export function AppLayout() {
function handleThemeToggle() { function handleThemeToggle() {
toggleTheme(); toggleTheme();
toast.success(theme === "dark" ? "Light mode enabled." : "Dark mode enabled.");
} }
return ( return (

View File

@@ -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); const max = Math.max(...data.map((item) => item.value), 1);
return ( return (
<div className="histogram" aria-label="histogram"> <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() { export function DashboardPage() {
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [events, setEvents] = useState<JobEvent[]>([]); 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" })); .map(([type, value]) => ({ label: eventTitle(type), value, tone: type.includes("fail") ? "failed" : type.includes("success") ? "succeeded" : "running" }));
}, [events]); }, [events]);
const maxPressure = Math.max(...pressureData.map((item) => item.value), 1); 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 ( return (
<div className="page"> <div className="page">
@@ -131,7 +150,7 @@ export function DashboardPage() {
</section> </section>
<section className="dashboard-grid"> <section className="dashboard-grid">
<section className="panel"> <section className="panel chart-panel">
<div className="section-title"> <div className="section-title">
<h3>Recent Jobs</h3> <h3>Recent Jobs</h3>
<Link className="secondary-button" to="/jobs"> <Link className="secondary-button" to="/jobs">
@@ -140,6 +159,11 @@ export function DashboardPage() {
</div> </div>
{recentJobs.length ? ( {recentJobs.length ? (
<> <>
<div className="chart-summary-row">
<span>{recentJobs.length} latest jobs</span>
<strong>{completionRate}% succeeded</strong>
</div>
<StatusStack data={recentJobStatusData} />
<MiniHistogram data={recentJobStatusData} /> <MiniHistogram data={recentJobStatusData} />
<div className="job-tick-chart" aria-label="latest jobs"> <div className="job-tick-chart" aria-label="latest jobs">
{recentJobs.slice(0, 16).map((job) => ( {recentJobs.slice(0, 16).map((job) => (
@@ -152,11 +176,15 @@ export function DashboardPage() {
)} )}
</section> </section>
<section className="panel"> <section className="panel chart-panel">
<div className="section-title"> <div className="section-title">
<h3>Queue Pressure</h3> <h3>Queue Pressure</h3>
<Clock3 size={16} /> <Clock3 size={16} />
</div> </div>
<div className="chart-summary-row">
<span>Active pressure</span>
<strong>{activePressure}</strong>
</div>
<div className="metric-bars"> <div className="metric-bars">
{pressureData.map((item) => ( {pressureData.map((item) => (
<BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} /> <BarMetric key={item.label} label={item.label} max={maxPressure} tone={item.tone} value={item.value} />
@@ -167,12 +195,22 @@ export function DashboardPage() {
</div> </div>
</section> </section>
<section className="panel"> <section className="panel chart-panel">
<div className="section-title"> <div className="section-title">
<h3>Recent Events</h3> <h3>Recent Events</h3>
<RotateCcw size={16} /> <RotateCcw size={16} />
</div> </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>
</section> </section>
</div> </div>

View File

@@ -4,7 +4,6 @@ import { toast } from "sonner";
import { api } from "../api"; import { api } from "../api";
import { eventTitle } from "../constants"; import { eventTitle } from "../constants";
import { DateTime } from "../components/DateTime";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import type { JobEvent } from "../types"; 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); 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() { export function EventsPage() {
const [events, setEvents] = useState<JobEvent[]>([]); const [events, setEvents] = useState<JobEvent[]>([]);
const [nextPageUrl, setNextPageUrl] = useState<string | null>(null); const [nextPageUrl, setNextPageUrl] = useState<string | null>(null);
@@ -22,6 +36,7 @@ export function EventsPage() {
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [lastRefreshAt, setLastRefreshAt] = useState<string | null>(null); const [lastRefreshAt, setLastRefreshAt] = useState<string | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(null);
const feedRef = useRef<HTMLDivElement | null>(null);
const refreshFirstPage = useCallback(async (replace = false) => { const refreshFirstPage = useCallback(async (replace = false) => {
if (replace) setLoading(true); if (replace) setLoading(true);
@@ -60,19 +75,12 @@ export function EventsPage() {
} }
}, [loadingMore, nextPageUrl]); }, [loadingMore, nextPageUrl]);
useEffect(() => { const handleFeedScroll = useCallback(() => {
const node = sentinelRef.current; const node = feedRef.current;
if (!node || !nextPageUrl) return undefined; if (!node || !nextPageUrl || loadingMore) return;
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
const observer = new IntersectionObserver( if (distanceFromBottom <= 140) void loadMore();
(entries) => { }, [loadMore, loadingMore, nextPageUrl]);
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
},
{ rootMargin: "280px 0px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [loadMore, nextPageUrl]);
const newestFirst = useMemo(() => [...events].sort((a, b) => b.id - a.id), [events]); const newestFirst = useMemo(() => [...events].sort((a, b) => b.id - a.id), [events]);
@@ -89,45 +97,34 @@ export function EventsPage() {
</div> </div>
</header> </header>
<section className="panel"> <section className="terminal-panel">
<div className="section-title"> <div className="terminal-header">
<h3>Global Event Feed</h3> <div className="terminal-window-controls" aria-hidden="true">
<span className="muted-text">Last refresh {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span> <i />
<i />
<i />
</div> </div>
<div className="table-wrap"> <code>job-events.log</code>
<table> <span>refreshed {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span>
<thead> </div>
<tr> <div className="terminal-feed" role="log" aria-live="polite" onScroll={handleFeedScroll} ref={feedRef}>
<th>ID</th>
<th>Event</th>
<th>Job</th>
<th>Attempt</th>
<th>Worker</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{newestFirst.map((event) => ( {newestFirst.map((event) => (
<tr key={event.id}> <article className={`terminal-line ${eventTone(event.type)}`} key={event.id}>
<td>{event.id}</td> <time className="terminal-time" dateTime={event.created_at}>
<td>{eventTitle(event.type)}</td> {formatLogTime(event.created_at)}
<td> </time>
<Link className="secondary-button" to={`/jobs/${event.job}`}> <span className="terminal-prompt">$</span>
{event.job.slice(0, 8)} <strong className="terminal-event">{eventTitle(event.type)}</strong>
<Link className="terminal-job" to={`/jobs/${event.job}`}>
job:{event.job.slice(0, 8)}
</Link> </Link>
</td> <span className="terminal-token">attempt:{event.attempt}</span>
<td>{event.attempt}</td> <span className="terminal-worker">worker:{event.worker_id ?? "system"}</span>
<td>{event.worker_id ?? "-"}</td> {event.message && <span className="terminal-message">{event.message}</span>}
<td> </article>
<DateTime value={event.created_at} />
</td>
</tr>
))} ))}
</tbody>
</table>
{loading && !newestFirst.length && <EmptyState title="Loading events" />} {loading && !newestFirst.length && <EmptyState title="Loading events" />}
{!loading && !newestFirst.length && <EmptyState title="No events yet" />} {!loading && !newestFirst.length && <EmptyState title="No events yet" />}
</div>
<div className="infinite-sentinel" ref={sentinelRef}> <div className="infinite-sentinel" ref={sentinelRef}>
{loadingMore && <span>Loading older events...</span>} {loadingMore && <span>Loading older events...</span>}
{!loadingMore && nextPageUrl && ( {!loadingMore && nextPageUrl && (
@@ -137,6 +134,7 @@ export function EventsPage() {
)} )}
{!loadingMore && !nextPageUrl && newestFirst.length > 0 && <span>End of event history</span>} {!loadingMore && !nextPageUrl && newestFirst.length > 0 && <span>End of event history</span>}
</div> </div>
</div>
</section> </section>
</div> </div>
); );

View File

@@ -117,11 +117,11 @@ export function JobDetailPage() {
{job.attempts}/{job.max_attempts} {job.attempts}/{job.max_attempts}
</strong> </strong>
</div> </div>
</section>
<div className="stat-card"> <div className="stat-card">
<span>Locked by</span> <span>Locked by</span>
<code className="code-token wrap">{job.locked_by ?? "-"}</code> <code className="code-token wrap">{job.locked_by ?? "-"}</code>
</div> </div>
</section>
<section className="detail-grid"> <section className="detail-grid">
<section className="panel"> <section className="panel">

View File

@@ -1,5 +1,5 @@
import { Eye, Plus, RotateCcw } from "lucide-react"; import { Check, ChevronLeft, ChevronRight, Eye, Plus, RotateCcw, X } from "lucide-react";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { FormEvent, useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -21,6 +21,12 @@ const jobTypeOptions: SelectOption[] = [
{ value: "demo.flaky", label: "Flaky retry" } { 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) { function demoPayload(type: string) {
if (type === "demo.slow") return { sleep_seconds: 8 }; if (type === "demo.slow") return { sleep_seconds: 8 };
if (type === "demo.timeout") return { sleep_seconds: 45 }; if (type === "demo.timeout") return { sleep_seconds: 45 };
@@ -29,11 +35,41 @@ function demoPayload(type: string) {
return { sleep_seconds: 1 }; 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() { export function JobsPage() {
const [jobs, setJobs] = useState<Job[]>([]); const [jobs, setJobs] = useState<Job[]>([]);
const [totalJobs, setTotalJobs] = useState(0);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("all"); const [statusFilter, setStatusFilter] = useState<JobStatus | "all">("all");
const [typeFilter, setTypeFilter] = useState(""); const [typeFilter, setTypeFilter] = useState("");
const [pageSize, setPageSize] = useState(25);
const [currentPage, setCurrentPage] = useState(1);
const [jobType, setJobType] = useState("demo.success"); const [jobType, setJobType] = useState("demo.success");
const [payload, setPayload] = useState(JSON.stringify(demoPayload("demo.success"), null, 2)); const [payload, setPayload] = useState(JSON.stringify(demoPayload("demo.success"), null, 2));
const [priority, setPriority] = useState(50); const [priority, setPriority] = useState(50);
@@ -42,8 +78,15 @@ export function JobsPage() {
const [availableAt, setAvailableAt] = useState(""); const [availableAt, setAvailableAt] = useState("");
const refresh = useCallback(async () => { 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(() => { useEffect(() => {
void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught))); void refresh().catch((caught) => toast.error(caught instanceof Error ? caught.message : String(caught)));
@@ -51,13 +94,17 @@ export function JobsPage() {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [refresh]); }, [refresh]);
const filteredJobs = useMemo(() => { useEffect(() => {
return jobs.filter((job) => { setCurrentPage(1);
if (statusFilter !== "all" && job.status !== statusFilter) return false; }, [pageSize, statusFilter, typeFilter]);
if (typeFilter && !job.type.toLowerCase().includes(typeFilter.toLowerCase())) return false;
return true; const totalPages = Math.max(1, Math.ceil(totalJobs / pageSize));
}); const firstItem = totalJobs ? (currentPage - 1) * pageSize + 1 : 0;
}, [jobs, statusFilter, typeFilter]); const lastItem = Math.min(totalJobs, currentPage * pageSize);
useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages]);
function setType(nextType: string) { function setType(nextType: string) {
setJobType(nextType); setJobType(nextType);
@@ -156,7 +203,7 @@ export function JobsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredJobs.map((job) => ( {jobs.map((job) => (
<tr key={job.id}> <tr key={job.id}>
<td> <td>
<StatusBadge status={job.status} /> <StatusBadge status={job.status} />
@@ -166,7 +213,7 @@ export function JobsPage() {
</td> </td>
<td>{job.priority}</td> <td>{job.priority}</td>
<td> <td>
{job.attempts}/{job.max_attempts} <AttemptMeter job={job} />
</td> </td>
<td> <td>
<DateTime value={job.available_at} /> <DateTime value={job.available_at} />
@@ -181,13 +228,33 @@ export function JobsPage() {
))} ))}
</tbody> </tbody>
</table> </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> </div>
</section> </section>
{createOpen && ( {createOpen && (
<Modal title="Create Job" onClose={() => setCreateOpen(false)}> <Modal title="Create Job" onClose={() => setCreateOpen(false)}>
<form className="modal-form" onSubmit={createJob}> <form className="modal-form" onSubmit={createJob}>
<label>Quick Create</label>
<div className="preset-grid"> <div className="preset-grid">
<button className="secondary-button" type="button" onClick={() => setType("demo.success")}> <button className="secondary-button" type="button" onClick={() => setType("demo.success")}>
Success Success
@@ -204,7 +271,7 @@ export function JobsPage() {
<button className="secondary-button" type="button" onClick={() => setType("demo.flaky")}> <button className="secondary-button" type="button" onClick={() => setType("demo.flaky")}>
Flaky Retry Flaky Retry
</button> </button>
<button className="secondary-button" type="button" onClick={() => void createBatch()}> <button className="primary-button" type="button" onClick={() => void createBatch()}>
<RotateCcw size={16} /> 10 Mixed <RotateCcw size={16} /> 10 Mixed
</button> </button>
</div> </div>
@@ -227,9 +294,6 @@ export function JobsPage() {
<input value={idempotencyKey} onChange={(event) => setIdempotencyKey(event.target.value)} /> <input value={idempotencyKey} onChange={(event) => setIdempotencyKey(event.target.value)} />
</label> </label>
<div className="modal-actions"> <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)}> <button className="secondary-button" type="button" onClick={() => setCreateOpen(false)}>
Cancel Cancel
</button> </button>

View File

@@ -35,6 +35,9 @@
--panel-raised: #f9fbfc; --panel-raised: #f9fbfc;
--background: #f4f7f9; --background: #f4f7f9;
--shadow: 0 18px 46px rgb(35 36 38 / 8%); --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); color: var(--ink);
background: var(--background); background: var(--background);
font-synthesis: none; font-synthesis: none;
@@ -60,6 +63,9 @@
--panel-raised: #25282b; --panel-raised: #25282b;
--background: #141618; --background: #141618;
--shadow: 0 18px 46px rgb(0 0 0 / 28%); --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-columns: 248px minmax(0, 1fr);
grid-template-rows: 72px minmax(0, 1fr); grid-template-rows: 72px minmax(0, 1fr);
min-height: 100vh; min-height: 100vh;
transition: grid-template-columns 260ms cubic-bezier(0.22, 1, 0.36, 1);
} }
.app-shell.sidebar-collapsed { .app-shell.sidebar-collapsed {
@@ -255,6 +262,10 @@ label {
padding: 16px; padding: 16px;
position: sticky; position: sticky;
top: 72px; top: 72px;
transition:
background-color 220ms ease,
border-color 220ms ease,
padding 260ms cubic-bezier(0.22, 1, 0.36, 1);
} }
.sidebar-header { .sidebar-header {
@@ -268,6 +279,17 @@ label {
text-transform: uppercase; 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 { .sidebar nav {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -283,10 +305,26 @@ label {
gap: 10px; gap: 10px;
min-height: 42px; min-height: 42px;
padding: 10px 12px; 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 { .nav-item span {
max-width: 140px;
min-width: 0; 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 { .nav-item.active {
@@ -301,10 +339,13 @@ label {
.sidebar-collapsed .sidebar-header span, .sidebar-collapsed .sidebar-header span,
.sidebar-collapsed .nav-item span { .sidebar-collapsed .nav-item span {
display: none; max-width: 0;
opacity: 0;
transform: translateX(-4px);
} }
.sidebar-collapsed .nav-item { .sidebar-collapsed .nav-item {
gap: 0;
justify-content: center; justify-content: center;
padding: 10px; padding: 10px;
} }
@@ -313,6 +354,14 @@ label {
margin: 0 auto; 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 { .content {
min-width: 0; min-width: 0;
padding: 20px; padding: 20px;
@@ -442,6 +491,49 @@ label {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 { .detail-grid {
display: grid; display: grid;
gap: 16px; gap: 16px;
@@ -645,18 +737,36 @@ label {
display: flex; display: flex;
height: 112px; height: 112px;
overflow: hidden; overflow: hidden;
position: relative;
width: 100%; 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, .histogram-column i,
.metric-track i { .metric-track i {
background: var(--accent); background: var(--accent);
display: block; 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 { .histogram-column i {
border-radius: 6px 6px 0 0; border-radius: 6px 6px 0 0;
position: relative;
width: 100%; width: 100%;
z-index: 1;
} }
.histogram-column i.queued, .histogram-column i.queued,
@@ -703,11 +813,53 @@ label {
margin-top: 14px; 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 { .job-tick {
background: var(--subtle); background: var(--subtle);
border-radius: 5px; border-radius: 5px;
display: block; display: block;
height: 32px; height: 32px;
transition:
background-color 240ms ease,
transform 220ms ease;
}
.job-tick:hover {
transform: translateY(-2px);
} }
.job-tick.running { .job-tick.running {
@@ -968,6 +1120,92 @@ label {
justify-content: flex-end; 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 { .event-feed-meta {
align-items: center; align-items: center;
display: flex; display: flex;
@@ -987,6 +1225,225 @@ label {
padding-top: 12px; 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 { .filter-grid {
grid-template-columns: repeat(4, minmax(0, 1fr)); 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) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -1399,6 +1866,10 @@ code {
.filter-grid { .filter-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.terminal-line {
grid-template-columns: 80px 16px minmax(120px, 1fr) minmax(100px, 1fr);
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -1496,4 +1967,28 @@ code {
.dot-chart-row { .dot-chart-row {
grid-template-columns: 1fr; 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;
}
} }

View File

@@ -36,6 +36,13 @@ export type CursorPage<T> = {
results: T[]; results: T[];
}; };
export type LimitOffsetPage<T> = {
count: number;
next: string | null;
previous: string | null;
results: T[];
};
export type JobStats = { export type JobStats = {
total: number; total: number;
by_status: Record<JobStatus, number>; by_status: Record<JobStatus, number>;

BIN
slides/images/01-cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
slides/images/03-scope.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
slides/images/05-model.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
slides/images/06-claim.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
slides/images/07-state.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

BIN
slides/images/11-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
slides/images/12-tests.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

19
slides/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minimal PostgreSQL Job Queue - Interview Presentation</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="top-progress" aria-hidden="true">
<span id="progressFill"></span>
</div>
<main class="deck" aria-live="polite"></main>
<aside class="notes" id="notes" aria-label="speaker notes"></aside>
<script src="slides.js"></script>
</body>
</html>

259
slides/slides.js Normal file
View File

@@ -0,0 +1,259 @@
const slides = [
{
eyebrow: '01 / Opening',
title: 'Minimal PostgreSQL Job Queue',
subtitle: 'A small queue that proves safe claim, deterministic state, and visible execution.',
image: 'images/01-cover.png',
notes: [
'Open by framing the assignment: build an internal job queue, not a full queue product.',
'The main promise is correctness: multiple workers can run, but a single job receives a single execution owner.',
'The implementation is intentionally small so every important behavior is explainable during the interview.'
]
},
{
eyebrow: '02 / Problem',
title: 'What the Assignment Really Tests',
subtitle: 'Create jobs, claim them safely, manage states, and trace what happened.',
image: 'images/02-contract.png',
notes: [
'The assignment asks for job creation, worker claim, job status management, and execution tracking.',
'The central invariant is: one job must not be claimed and executed by two workers at the same time.',
'It also asks that worker failure behavior and the main status transitions are clear and testable.'
]
},
{
eyebrow: '03 / Scope',
title: 'Small Queue, Not a Queue Platform',
subtitle: 'The design keeps the model minimal and moves complexity into deterministic rules.',
image: 'images/03-scope.png',
notes: [
'There is one internal priority queue, not user-defined queues.',
'There is no queue table and no worker table. Workers are ephemeral process threads configured through settings and environment variables.',
'Authentication and advanced operator permissions are deliberately omitted for interview simplicity, but documented as production follow-ups.'
]
},
{
eyebrow: '04 / Architecture',
title: '1 Database, 2 Django Roles, 1 UI',
subtitle: 'The API exposes queue operations; the worker process claims jobs from PostgreSQL.',
image: 'images/04-architecture.png',
notes: [
'React is only the demo and observability surface. It does not own queue state.',
'Django API creates jobs, lists jobs and events, exposes stats, and supports manual retry of failed jobs.',
'The worker is a separate Django process with N configured threads; each thread claims work from PostgreSQL.'
]
},
{
eyebrow: '05 / Model',
title: 'Two Tables Are Enough',
subtitle: 'The job row carries state; the event row explains the history.',
image: 'images/05-model.png',
notes: [
'<code>jobs</code> stores type, payload, status, priority, available time, attempts, max attempts, lock owner, lock deadline, result, and timestamps.',
'<code>job_events</code> is append-only observability: created, claimed, progress, lease renewed, retry scheduled, timeout requeued, succeeded, failed, and manual retry.',
'Database constraints validate row shape: queued jobs have no lock, running jobs must have a lock, and terminal jobs must have a finish timestamp.'
]
},
{
eyebrow: '06 / State',
title: 'Status Transitions Are Explicit',
subtitle: 'The queue allows only meaningful movement between queued, running, succeeded, and failed.',
image: 'images/07-state.png',
notes: [
'The normal path is queued -> running -> succeeded.',
'Failure or timeout can move running back to queued when attempts remain.',
'When attempts are exhausted, the job becomes failed. Manual retry can move failed back to queued and resets the attempt count.'
]
},
{
eyebrow: '07 / Safe Claim',
title: 'PostgreSQL Chooses the Winner',
subtitle: 'Atomic claim uses row locks and SKIP LOCKED to avoid double execution.',
image: 'images/06-claim.png',
notes: [
'The worker selects the next eligible queued job ordered by priority, available_at, created_at, and id.',
'Inside one transaction, it uses <code>SELECT ... FOR UPDATE SKIP LOCKED</code>, then marks the row running, increments attempts, and sets the lease.',
'When many threads race, one locks the row; the others skip it instead of waiting and accidentally claiming the same job.'
]
},
{
eyebrow: '08 / Ownership',
title: 'Current Owner + Attempt Is the Write Token',
subtitle: 'A stale worker cannot complete or fail a job after ownership changes.',
image: 'images/08-ownership.png',
notes: [
'Completion, failure, progress, and lease renewal all filter by job id, running status, locked_by, and attempt.',
'That means a worker that crashed and later wakes up cannot complete attempt 1 after the job was requeued and claimed as attempt 2.',
'This is the practical guardrail that makes lease recovery safe.'
]
},
{
eyebrow: '09 / Recovery',
title: 'Worker Failure Is a Timeout, Not a Mystery',
subtitle: 'Expired leases are cleaned up deterministically.',
image: 'images/09-recovery.png',
notes: [
'Workers periodically run cleanup for running jobs whose locked_until is in the past.',
'If attempts remain, the job is requeued and a timeout event is recorded. If attempts are exhausted, it becomes failed.',
'This queue provides at-least-once execution, not exactly-once execution. External side-effect handlers should be idempotent.'
]
},
{
eyebrow: '10 / Retry',
title: 'Retries Are Bounded and Scheduled',
subtitle: 'Failures return to the queue with deterministic exponential backoff.',
image: 'images/10-retries.png',
notes: [
'<code>fail_job</code> either schedules the next attempt or marks the job failed when max_attempts is reached.',
'Backoff is deterministic and stored through available_at, so workers do not need hidden memory.',
'Idempotency keys prevent duplicate producer submissions for the same logical job.'
]
},
{
eyebrow: '11 / UI',
title: 'Demo-Friendly Observability',
subtitle: 'The UI shows state, pressure, attempts, leases, and event history while workers run.',
image: 'images/11-ui.png',
notes: [
'Dashboard polls jobs, stats, events, and health every 2 seconds.',
'Jobs page uses limit-offset pagination, status/type filters, and refreshes every 2 seconds.',
'Job detail polls the job and its timeline every 1 second; global events use cursor pagination and fetch older pages as the terminal log scrolls.'
]
},
{
eyebrow: '12 / Tests',
title: 'Tests Target the Risky Parts',
subtitle: 'The most important tests are around ownership, concurrency, and recovery.',
image: 'images/12-tests.png',
notes: [
'There are service-level tests for successful claim, deterministic ordering, idempotent creation, retry scheduling, exhausted failure, timeout cleanup, stale worker rejection, and lease renewal ownership.',
'The PostgreSQL concurrency test runs multiple claimers and asserts only one claimed event exists.',
'API tests cover job pagination, filters, cursor-based global events, and older-page loading.'
]
},
{
eyebrow: '13 / Trade-off',
title: 'PostgreSQL Now, Broker Later',
subtitle: 'PostgreSQL is ideal for this interview; Redis or a broker is the scale path.',
image: 'images/13-tradeoff.png',
notes: [
'The assignment requires PostgreSQL, and PostgreSQL makes the correctness story compact and inspectable.',
'For very high throughput, cross-service distribution, or advanced queue features, I would move to a Redis-backed or dedicated broker design.',
'The important point is that I did not hide over-engineering in the interview implementation; I documented it as a next architecture.'
]
},
{
eyebrow: '14 / Close',
title: 'What This Proves',
subtitle: 'Minimal implementation with production-aware guarantees.',
image: 'images/14-takeaway.png',
notes: [
'The system is safe because PostgreSQL owns the claim decision.',
'It is deterministic because state transitions and row shapes are explicit and validated.',
'It is demoable because the UI and events make execution visible without adding unnecessary queue models.'
]
}
];
const deck = document.querySelector('.deck');
const notes = document.getElementById('notes');
const progressFill = document.getElementById('progressFill');
let current = Math.max(0, Math.min(slides.length - 1, Number(location.hash.replace('#', '')) - 1 || 0));
let notesOpen = false;
function renderDeck() {
deck.innerHTML = slides.map((slide, index) => `
<section class="slide" data-index="${index}" aria-hidden="${index === current ? 'false' : 'true'}">
<header class="slide-header">
<span class="eyebrow">${slide.eyebrow}</span>
<h1>${slide.title}</h1>
<p class="subtitle">${slide.subtitle}</p>
</header>
<div class="visual"><img src="${slide.image}" alt="${slide.title} visual" /></div>
</section>
`).join('');
update(false);
}
function update(shouldUpdateHash = true) {
deck.querySelectorAll('.slide').forEach((slide, index) => {
slide.classList.toggle('active', index === current);
slide.setAttribute('aria-hidden', index === current ? 'false' : 'true');
});
progressFill.style.transform = `scaleX(${(current + 1) / slides.length})`;
if (shouldUpdateHash) {
location.hash = String(current + 1);
}
renderNotes();
}
function renderNotes() {
const slide = slides[current];
notes.innerHTML = `<h2>Speaker notes - ${String(current + 1).padStart(2, '0')}. ${slide.title}</h2><ul>${slide.notes.map((note) => `<li>${note}</li>`).join('')}</ul>`;
notes.classList.toggle('open', notesOpen);
}
function next() {
if (current < slides.length - 1) {
current += 1;
update();
}
}
function prev() {
if (current > 0) {
current -= 1;
update();
}
}
function toggleNotes() {
notesOpen = !notesOpen;
renderNotes();
}
document.addEventListener('keydown', (event) => {
if (['ArrowRight', 'PageDown', ' '].includes(event.key)) {
event.preventDefault();
next();
}
if (['ArrowLeft', 'PageUp'].includes(event.key)) {
event.preventDefault();
prev();
}
if (event.key === 'Home') {
current = 0;
update();
}
if (event.key === 'End') {
current = slides.length - 1;
update();
}
if (event.key.toLowerCase() === 'n') {
toggleNotes();
}
if (event.key === 'Escape' && notesOpen) {
notesOpen = false;
renderNotes();
}
});
window.addEventListener('hashchange', () => {
const nextIndex = Number(location.hash.replace('#', '')) - 1;
if (Number.isFinite(nextIndex) && nextIndex >= 0 && nextIndex < slides.length && nextIndex !== current) {
current = nextIndex;
update(false);
}
});
renderDeck();

290
slides/styles.css Normal file
View File

@@ -0,0 +1,290 @@
:root {
--bg: #f8fbff;
--ink: #0f172a;
--muted: #64748b;
--accent: #2563eb;
--accent-strong: #7c3aed;
--line: #e2e8f0;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body {
overflow: hidden;
background:
linear-gradient(180deg, #ffffff 0%, var(--bg) 42%, #eef6ff 100%);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
}
.top-progress {
position: fixed;
top: 0;
left: 0;
z-index: 30;
width: 100vw;
height: 5px;
overflow: hidden;
background: rgba(226, 232, 240, .82);
}
.top-progress span {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
transform: scaleX(0);
transform-origin: left center;
transition: transform 420ms cubic-bezier(.22, 1, .36, 1);
}
.deck {
position: relative;
width: 100vw;
height: 100dvh;
}
.slide {
position: absolute;
inset: 0;
display: grid;
grid-template-rows: max-content minmax(0, 1fr);
gap: clamp(14px, 2vh, 24px);
height: 100dvh;
padding: clamp(34px, 5vh, 58px) clamp(20px, 4.8vw, 74px) clamp(20px, 4vh, 42px);
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition:
opacity 180ms ease,
transform 220ms ease;
}
.slide.active {
z-index: 2;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.slide-header {
width: 100%;
margin: 0;
text-align: center;
}
.eyebrow {
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
margin-bottom: 16px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, .86);
color: var(--accent);
font-size: 13px;
font-weight: 800;
letter-spacing: .12em;
text-transform: uppercase;
box-shadow: 0 10px 26px rgba(15, 23, 42, .05);
}
h1 {
margin: 0;
font-size: clamp(42px, 5vw, 72px);
font-weight: 900;
letter-spacing: -0.055em;
line-height: .96;
}
.subtitle {
margin: 18px auto 0;
color: var(--muted);
font-size: clamp(18px, 2vw, 25px);
font-weight: 650;
line-height: 1.35;
}
.visual {
display: flex;
justify-content: center;
width: 100%;
height: 100%;
min-height: 0;
}
.visual img {
display: block;
width: auto;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.notes {
position: fixed;
left: 50%;
bottom: 34px;
z-index: 20;
width: min(1060px, calc(100vw - 60px));
max-height: 38vh;
overflow: auto;
padding: 22px 26px;
border: 1px solid rgba(255, 255, 255, .13);
border-radius: 24px;
background: rgba(15, 23, 42, .94);
box-shadow: 0 24px 60px rgba(2, 6, 23, .35);
color: #e2e8f0;
opacity: 0;
pointer-events: none;
transform: translateX(-50%) translateY(24px);
transition: opacity 180ms ease, transform 180ms ease;
}
.notes.open {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.notes h2 {
margin: 0 0 10px;
color: #fff;
font-size: 18px;
letter-spacing: .01em;
}
.notes ul {
display: grid;
gap: 7px;
margin: 0;
padding-left: 22px;
}
.notes li {
color: #cbd5e1;
font-size: 15px;
line-height: 1.5;
}
.notes code {
padding: 1px 5px;
border: 1px solid rgba(147, 197, 253, .15);
border-radius: 6px;
background: rgba(37, 99, 235, .16);
color: #bfdbfe;
}
@media (max-width: 800px) {
.slide {
padding: 34px 24px 24px;
}
.visual img {
height: 100%;
max-width: 100%;
}
}
@media (max-width: 1536px), (max-height: 850px) {
.eyebrow {
height: 28px;
margin-bottom: 12px;
font-size: 11px;
}
h1 {
font-size: clamp(32px, 4.1vw, 56px);
}
.subtitle {
margin-top: 12px;
font-size: clamp(15px, 1.55vw, 20px);
}
}
@media (max-height: 720px) {
.slide {
gap: 12px;
padding-top: 28px;
padding-bottom: 18px;
}
.eyebrow {
height: 26px;
margin-bottom: 10px;
font-size: 11px;
}
h1 {
font-size: clamp(30px, 6vh, 48px);
}
.subtitle {
margin-top: 10px;
font-size: clamp(14px, 2.4vh, 18px);
}
}
@media (max-height: 560px) {
.slide {
padding-top: 18px;
padding-bottom: 14px;
}
.eyebrow {
display: none;
}
h1 {
font-size: clamp(28px, 7vh, 42px);
}
.subtitle {
font-size: clamp(14px, 3vh, 17px);
}
}
@media (prefers-reduced-motion: reduce) {
.top-progress span,
.slide,
.notes {
transition: none;
}
}
@media print {
body {
overflow: visible;
background: #fff;
}
.top-progress,
.notes {
display: none;
}
.deck {
height: auto;
}
.slide {
position: relative;
min-height: 100vh;
page-break-after: always;
opacity: 1;
pointer-events: auto;
transform: none;
}
}