Compare commits
15 Commits
0c15fbfbd2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 205b44099a | |||
| 2b0b8cb041 | |||
| d04dca2646 | |||
| 24ee55a069 | |||
| 4690166e8d | |||
| b331948491 | |||
| bd358f5e48 | |||
| dd9567deb5 | |||
| 6bb42001bc | |||
| 1616493655 | |||
| 496b18fc22 | |||
| c72ca7203f | |||
| a2bd063c9c | |||
| 01edd74e63 | |||
| bd7916ed45 |
112
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.
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
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
|
## 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
|
||||||
|
|||||||
BIN
assets/fonts/Vazirmatn.woff2
Normal file
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 |
@@ -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
|
||||||
|
|||||||
1192
backend/README.md
@@ -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"))
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Vite React demo UI for the minimal job queue.
|
Vite React demo UI for the minimal job queue.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
- 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```powershell
|
```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";
|
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" }),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,53 +97,43 @@ 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>
|
||||||
|
<code>job-events.log</code>
|
||||||
|
<span>refreshed {lastRefreshAt ? new Date(lastRefreshAt).toLocaleTimeString() : "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-wrap">
|
<div className="terminal-feed" role="log" aria-live="polite" onScroll={handleFeedScroll} ref={feedRef}>
|
||||||
<table>
|
{newestFirst.map((event) => (
|
||||||
<thead>
|
<article className={`terminal-line ${eventTone(event.type)}`} key={event.id}>
|
||||||
<tr>
|
<time className="terminal-time" dateTime={event.created_at}>
|
||||||
<th>ID</th>
|
{formatLogTime(event.created_at)}
|
||||||
<th>Event</th>
|
</time>
|
||||||
<th>Job</th>
|
<span className="terminal-prompt">$</span>
|
||||||
<th>Attempt</th>
|
<strong className="terminal-event">{eventTitle(event.type)}</strong>
|
||||||
<th>Worker</th>
|
<Link className="terminal-job" to={`/jobs/${event.job}`}>
|
||||||
<th>Created</th>
|
job:{event.job.slice(0, 8)}
|
||||||
</tr>
|
</Link>
|
||||||
</thead>
|
<span className="terminal-token">attempt:{event.attempt}</span>
|
||||||
<tbody>
|
<span className="terminal-worker">worker:{event.worker_id ?? "system"}</span>
|
||||||
{newestFirst.map((event) => (
|
{event.message && <span className="terminal-message">{event.message}</span>}
|
||||||
<tr key={event.id}>
|
</article>
|
||||||
<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>
|
|
||||||
{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 && (
|
<button className="secondary-button" type="button" onClick={() => void loadMore()}>
|
||||||
<button className="secondary-button" type="button" onClick={() => void loadMore()}>
|
Load older events
|
||||||
Load older events
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
{!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>
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ export function JobDetailPage() {
|
|||||||
{job.attempts}/{job.max_attempts}
|
{job.attempts}/{job.max_attempts}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
|
||||||
<span>Locked by</span>
|
|
||||||
<code className="code-token wrap">{job.locked_by ?? "-"}</code>
|
|
||||||
</div>
|
|
||||||
</section>
|
</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="detail-grid">
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
After Width: | Height: | Size: 1.3 MiB |
BIN
slides/images/02-contract.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/03-scope.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/04-architecture.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/05-model.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/06-claim.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/07-state.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
slides/images/08-ownership.png
Normal file
|
After Width: | Height: | Size: 1003 KiB |
BIN
slides/images/09-recovery.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
slides/images/10-retries.png
Normal file
|
After Width: | Height: | Size: 1010 KiB |
BIN
slides/images/11-ui.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
slides/images/12-tests.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/13-tradeoff.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/14-takeaway.png
Normal file
|
After Width: | Height: | Size: 1022 KiB |
19
slides/index.html
Normal 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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||