diff --git a/fournos-ui/.dockerignore b/fournos-ui/.dockerignore new file mode 100644 index 0000000..e632277 --- /dev/null +++ b/fournos-ui/.dockerignore @@ -0,0 +1,19 @@ +__pycache__ +*.pyc +*.pyo +.git +.gitignore +.env +.env.* +*.md +.venv +venv +.mypy_cache +.pytest_cache +.ruff_cache +.DS_Store + +app/mock_data.py + +kustomize/ +resolvers/ diff --git a/fournos-ui/.gitignore b/fournos-ui/.gitignore new file mode 100644 index 0000000..bb12cb0 --- /dev/null +++ b/fournos-ui/.gitignore @@ -0,0 +1,36 @@ +# Secrets - never commit actual credentials +kustomize/overlays/*/postgresql-secret.env +*secret*.env + +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# IDE +.idea/ +.vscode/ + +# Environment +.env +.env.* +!.env.example + +# OS +.DS_Store + +# Dev-only +app/mock_data.py + +# Local overlay overrides (users create their own from *.example) +kustomize/overlays/*/params.env +kustomize/overlays/*/projects.yaml +kustomize/overlays/*/kustomization.yaml +kustomize/overlays/*/rolebinding-*.yaml diff --git a/fournos-ui/Dockerfile b/fournos-ui/Dockerfile new file mode 100644 index 0000000..6f10014 --- /dev/null +++ b/fournos-ui/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim AS base + +RUN groupadd --gid 1001 app && \ + useradd --uid 1001 --gid app --create-home app + +WORKDIR /opt/fournos-dashboard + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ app/ + +RUN chown -R app:app /opt/fournos-dashboard + +USER app + +EXPOSE 8000 + +ENTRYPOINT ["uvicorn", "app.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--workers", "1", \ + "--log-level", "info"] diff --git a/fournos-ui/README.md b/fournos-ui/README.md new file mode 100644 index 0000000..1df9566 --- /dev/null +++ b/fournos-ui/README.md @@ -0,0 +1,136 @@ +# Fournos Dashboard + +A web dashboard for managing [Fournos](https://github.com/openshift-psap/fournos-operator) performance testing jobs on Kubernetes. Submit jobs, monitor live runs, schedule recurring tests, and review historical results -- all from one place. + +## What It Does + +- **Live job monitoring** -- Watch running FournosJobs with real-time log streaming (SSE) and pipeline progress tracking. +- **Job submission** -- Submit new FournosJobs with project, preset, cluster, and config override selection. Optionally pick an open Forge PR to test with -- the dashboard fetches open PRs from GitHub and fills in the commit SHA automatically. +- **GitHub PR integration** -- Lists open pull requests from the [Forge repo](https://github.com/openshift-psap/forge) directly in the submit form. Since Forge is a public repository, no GitHub token is needed. +- **Scheduling** -- Create Kubernetes CronJobs for recurring test runs, with optional version-resolver scripts that dynamically determine parameters at runtime. +- **History** -- Browse completed jobs stored in PostgreSQL with status, duration, and direct links to MLflow artifacts. +- **Schedule tracking** -- See which schedule triggered each job (manual vs. scheduled) and view all runs for a given schedule. + +## Architecture + +``` +┌─────────────┐ ┌──────────────────┐ ┌────────────┐ +│ Browser │────▶│ FastAPI + HTMX │────▶│ Kubernetes │ +│ │◀────│ (Dashboard) │◀────│ API │ +└─────────────┘ └────────┬─────────┘ └────────────┘ + │ + ┌────────▼─────────┐ + │ PostgreSQL │ + │ (job history) │ + └──────────────────┘ +``` + +- **FastAPI** backend with **Jinja2** templates and **HTMX** for dynamic updates. +- **Kubernetes Python client** for watching FournosJob CRs, streaming pod logs, and managing CronJobs. +- **PostgreSQL** (via SQLAlchemy async + asyncpg) for persisting job metadata and schedule tracking. +- A background **watcher thread** monitors FournosJob events and archives them to PostgreSQL automatically. + +## Prerequisites + +- A Kubernetes / OpenShift cluster with the [Fournos Operator](https://github.com/openshift-psap/fournos-operator) installed. +- A container registry to push the dashboard image. +- `kubectl` or `oc` CLI configured with cluster access. + +## Getting Started + +### 1. Clone and configure the overlay + +```bash +cd kustomize/overlays/ocp/ + +# Copy example files +cp kustomization.yaml.example kustomization.yaml +cp projects.yaml.example projects.yaml +cp params.env.example params.env +cp ../../base/postgresql-secret.env.example postgresql-secret.env +``` + +Edit each file with your values: +- **`kustomization.yaml`** -- Set your dashboard image, PostgreSQL image, target namespace, and storage class. +- **`projects.yaml`** -- Define your Forge projects, clusters, and presets. +- **`postgresql-secret.env`** -- Set your database credentials. +- **`params.env`** -- Set your storage class and size. + +### 2. Build and push the dashboard image + +### 3. Deploy to the cluster + +```bash +cd kustomize/overlays/ocp/ + +# Apply the main stack +oc kustomize . | oc apply -f - + +# Apply the cross-namespace RoleBinding (grants dashboard access to the jobs namespace) +oc apply -f rolebinding-psap-automation.yaml +``` + +This creates: +- A `fournos-dashboard` namespace +- PostgreSQL StatefulSet with persistent storage +- Dashboard Deployment, Service, ServiceAccount +- ClusterRole for FournosJob/CronJob/Pod access +- RoleBinding in the target namespace (e.g. `psap-automation`) +- Projects ConfigMap + +### 4. Access the dashboard + +```bash +oc port-forward -n fournos-dashboard svc/fournos-dashboard 8000:8000 +``` + +Open http://localhost:8000 + + +## Configuration + +All configuration is via environment variables (set in the deployment manifest): + +| Variable | Description | Default | +|---|---|---| +| `DATABASE_URL` | PostgreSQL connection string (required) | *none -- must be set* | +| `FOURNOS_NAMESPACE` | Namespace where FournosJobs run | *set via overlay* | +| `PROJECTS_CONFIG_PATH` | Path to projects YAML | `/etc/fournos-dashboard/projects.yaml` | +| `K8S_REQUEST_TIMEOUT` | Timeout for K8s API calls (seconds) | `30` | +| `LOG_LEVEL` | Logging level | `INFO` | +| `KUBECONFIG` | Path to kubeconfig (local dev only) | in-cluster config | +| `FORGE_GITHUB_REPO` | GitHub `owner/repo` for PR listing | `openshift-psap/forge` | + +## Security Considerations + +This dashboard is designed as an **internal tool** and does **not** include built-in authentication or authorization. As described above, the tool is accessible when port-forwarding from the cluster where it's running. Future development may include auth. + +## Local Development + +```bash +pip install -r requirements.txt + +# Set DATABASE_URL and KUBECONFIG, then: +uvicorn app.main:app --reload --port 8000 +``` + +## Project Structure + +``` +fournos-ui/ +├── app/ +│ ├── main.py # FastAPI routes and Jinja2 rendering +│ ├── config.py # Environment-based settings +│ ├── db.py # SQLAlchemy models and queries +│ ├── k8s_client.py # Kubernetes API wrapper (with timeouts) +│ ├── watcher.py # Background FournosJob event watcher +│ ├── forge_discovery.py # Project discovery from ConfigMap +│ ├── models.py # Pydantic/dataclass models +│ ├── static/ # CSS, HTMX, htmx-sse.js +│ └── templates/ # Jinja2 HTML templates +├── kustomize/ +│ ├── base/ # Generic K8s manifests +│ └── overlays/ocp/ # Environment-specific overrides +├── Dockerfile +└── requirements.txt +``` diff --git a/fournos-ui/app/__init__.py b/fournos-ui/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fournos-ui/app/__init__.py @@ -0,0 +1 @@ + diff --git a/fournos-ui/app/config.py b/fournos-ui/app/config.py new file mode 100644 index 0000000..aeef2e2 --- /dev/null +++ b/fournos-ui/app/config.py @@ -0,0 +1,62 @@ +"""Application configuration loaded from environment variables.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Settings: + database_url: str = field( + default_factory=lambda: os.environ["DATABASE_URL"] + ) + + fournos_namespace: str = field( + default_factory=lambda: os.environ.get("FOURNOS_NAMESPACE", "fournos-jobs") + ) + + kubeconfig_path: str | None = field( + default_factory=lambda: os.environ.get("KUBECONFIG") + ) + + forge_repo_path: str | None = field( + default_factory=lambda: os.environ.get("FORGE_REPO_PATH") + ) + + projects_config_path: str = field( + default_factory=lambda: os.environ.get("PROJECTS_CONFIG_PATH", "/etc/fournos-dashboard/projects.yaml") + ) + + fournos_api_group: str = "fournos.dev" + fournos_api_version: str = "v1" + fournos_job_plural: str = "fournosjobs" + + tekton_api_group: str = "tekton.dev" + tekton_api_version: str = "v1" + tekton_pipelinerun_plural: str = "pipelineruns" + + log_level: str = field( + default_factory=lambda: os.environ.get("LOG_LEVEL", "INFO") + ) + + forge_github_repo: str = field( + default_factory=lambda: os.environ.get("FORGE_GITHUB_REPO", "openshift-psap/forge") + ) + + k8s_request_timeout_seconds: int = field( + default_factory=lambda: int(os.environ.get("K8S_REQUEST_TIMEOUT", "30")) + ) + + jobs_poll_interval_seconds: int = 5 + + default_pipelines: tuple[str, ...] = ( + "forge-full", + "forge-prepare-test", + "forge-test-only", + "forge-prepare-only", + "forge-replot", + ) + + +settings = Settings() diff --git a/fournos-ui/app/db.py b/fournos-ui/app/db.py new file mode 100644 index 0000000..b009fe4 --- /dev/null +++ b/fournos-ui/app/db.py @@ -0,0 +1,193 @@ +"""PostgreSQL persistence layer using SQLAlchemy async.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, Sequence +from uuid import uuid4 + +from sqlalchemy import ( + Column, + DateTime, + Float, + ForeignKey, + String, + Text, + func, + select, +) +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, relationship + +from app.config import settings + +logger = logging.getLogger(__name__) + + +class Base(DeclarativeBase): + pass + + +# --------------------------------------------------------------------------- +# ORM Models +# --------------------------------------------------------------------------- + +class Job(Base): + __tablename__ = "jobs" + + id = Column(String, primary_key=True, default=lambda: str(uuid4())) + name = Column(String, unique=True, nullable=False, index=True) + project = Column(String, nullable=False, index=True) + preset = Column(String, default="") + cluster = Column(String, nullable=False, index=True) + pipeline = Column(String, default="") + owner = Column(String, default="", index=True) + status = Column(String, default="Pending", index=True) + message = Column(Text, default="") + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + completed_at = Column(DateTime(timezone=True), nullable=True) + duration_seconds = Column(Float, nullable=True) + mlflow_url = Column(String, default="") + ci_artifacts_url = Column(String, default="") + config_overrides = Column(JSONB, default=dict) + tags = Column(ARRAY(String), default=list) + fjob_spec = Column(JSONB, default=dict) + fjob_status = Column(JSONB, default=dict) + error_message = Column(Text, default="") + triggered_by_schedule = Column(String, nullable=True, index=True) + trigger_type = Column(String, default="manual") + + events = relationship("JobEvent", back_populates="job", cascade="all, delete-orphan") + + +class JobEvent(Base): + __tablename__ = "job_events" + + id = Column(String, primary_key=True, default=lambda: str(uuid4())) + job_id = Column(String, ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False, index=True) + phase = Column(String, nullable=False) + message = Column(Text, default="") + timestamp = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + + job = relationship("Job", back_populates="events") + + +# --------------------------------------------------------------------------- +# Engine & session factory +# --------------------------------------------------------------------------- + +engine = create_async_engine(settings.database_url, echo=False, pool_size=5, max_overflow=10) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def init_db() -> None: + """Create all tables (development convenience -- use Alembic in production).""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables ensured") + + +# --------------------------------------------------------------------------- +# Query helpers +# --------------------------------------------------------------------------- + +async def upsert_job(session: AsyncSession, **kwargs: Any) -> Job: + """Insert or update a job record keyed by name (atomic).""" + if "id" not in kwargs: + kwargs["id"] = str(uuid4()) + + update_cols = {k: v for k, v in kwargs.items() if k not in ("id", "name") and v is not None} + + stmt = ( + pg_insert(Job) + .values(**kwargs) + .on_conflict_do_update(index_elements=["name"], set_=update_cols) + .returning(Job) + ) + result = await session.execute(stmt) + job = result.scalar_one() + return job + + +async def add_job_event( + session: AsyncSession, job_id: str, phase: str, message: str = "" +) -> JobEvent: + """Record a status transition.""" + event = JobEvent(job_id=job_id, phase=phase, message=message) + session.add(event) + await session.flush() + return event + + +async def get_job_by_name(session: AsyncSession, name: str) -> Job | None: + result = await session.execute(select(Job).where(Job.name == name)) + return result.scalar_one_or_none() + + +async def list_jobs( + session: AsyncSession, + *, + project: str | None = None, + cluster: str | None = None, + status: str | None = None, + owner: str | None = None, + limit: int = 50, + offset: int = 0, +) -> tuple[Sequence[Job], int]: + """List archived jobs with optional filters. Returns (jobs, total_count).""" + stmt = select(Job) + count_stmt = select(func.count(Job.id)) + + if project: + stmt = stmt.where(Job.project == project) + count_stmt = count_stmt.where(Job.project == project) + if cluster: + stmt = stmt.where(Job.cluster == cluster) + count_stmt = count_stmt.where(Job.cluster == cluster) + if status: + stmt = stmt.where(Job.status == status) + count_stmt = count_stmt.where(Job.status == status) + if owner: + stmt = stmt.where(Job.owner == owner) + count_stmt = count_stmt.where(Job.owner == owner) + + stmt = stmt.order_by(Job.created_at.desc()).limit(limit).offset(offset) + + result = await session.execute(stmt) + jobs = result.scalars().all() + + count_result = await session.execute(count_stmt) + total = count_result.scalar() or 0 + + return jobs, total + + +async def list_jobs_by_schedule( + session: AsyncSession, schedule_name: str, +) -> Sequence[Job]: + """List all jobs triggered by a specific schedule.""" + result = await session.execute( + select(Job) + .where(Job.triggered_by_schedule == schedule_name) + .order_by(Job.created_at.desc()) + ) + return result.scalars().all() + + +async def delete_job_by_name(session: AsyncSession, name: str) -> bool: + """Delete a job and all related logs/events by name. Returns True if deleted.""" + job = await get_job_by_name(session, name) + if job is None: + return False + await session.delete(job) + await session.flush() + return True + + +async def get_job_events(session: AsyncSession, job_id: str) -> Sequence[JobEvent]: + result = await session.execute( + select(JobEvent).where(JobEvent.job_id == job_id).order_by(JobEvent.timestamp) + ) + return result.scalars().all() diff --git a/fournos-ui/app/forge_discovery.py b/fournos-ui/app/forge_discovery.py new file mode 100644 index 0000000..99715ff --- /dev/null +++ b/fournos-ui/app/forge_discovery.py @@ -0,0 +1,161 @@ +"""Discover Forge projects, presets, and config schemas from the repo.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import yaml + +from app.config import settings +from app.models import ProjectInfo + +logger = logging.getLogger(__name__) + +_cache: dict[str, ProjectInfo] | None = None + + +def _forge_projects_dir() -> Path | None: + """Resolve the forge projects/ directory.""" + if settings.forge_repo_path: + p = Path(settings.forge_repo_path) / "projects" + if p.is_dir(): + return p + return None + + +def discover_projects(force_refresh: bool = False) -> list[ProjectInfo]: + """Discover projects from Forge repo or ConfigMap-backed YAML file.""" + global _cache + if _cache is not None and not force_refresh: + return list(_cache.values()) + + result: dict[str, ProjectInfo] = {} + + projects_dir = _forge_projects_dir() + if projects_dir is not None: + result = _discover_from_repo(projects_dir) + + if not result: + result = _discover_from_configmap() + + _cache = result + logger.info("Discovered %d Forge projects", len(result)) + return list(result.values()) + + +def _discover_from_repo(projects_dir: Path) -> dict[str, ProjectInfo]: + """Scan the Forge repo for available projects.""" + result: dict[str, ProjectInfo] = {} + skip = {"core", "__pycache__"} + + for proj_dir in sorted(projects_dir.iterdir()): + if not proj_dir.is_dir() or proj_dir.name.startswith(".") or proj_dir.name in skip: + continue + + orchestration = proj_dir / "orchestration" + if not orchestration.is_dir(): + continue + + presets = _load_presets(orchestration) + config_keys = _load_config_keys(orchestration) + has_cli = (orchestration / "cli.py").exists() + + result[proj_dir.name] = ProjectInfo( + name=proj_dir.name, + presets=presets, + config_keys=config_keys, + has_cli=has_cli, + ) + + return result + + +def _discover_from_configmap() -> dict[str, ProjectInfo]: + """Load project definitions from a YAML config file (mounted from a ConfigMap).""" + config_path = Path(settings.projects_config_path) + if not config_path.exists(): + logger.warning("No projects config at %s", config_path) + return {} + + try: + with open(config_path) as f: + data = yaml.safe_load(f) + except Exception as exc: + logger.error("Failed to parse projects config: %s", exc) + return {} + + if not isinstance(data, dict) or "projects" not in data: + logger.warning("Projects config missing 'projects' key") + return {} + + result: dict[str, ProjectInfo] = {} + for idx, proj in enumerate(data["projects"]): + if not isinstance(proj, dict): + logger.warning("Skipping malformed project entry at index %d: expected mapping, got %s", idx, type(proj).__name__) + continue + name = proj.get("name", "") + if not name: + continue + result[name] = ProjectInfo( + name=name, + cluster=proj.get("cluster", ""), + presets=proj.get("presets", []), + config_keys=proj.get("config_keys", []), + has_cli=proj.get("has_cli", False), + ) + + logger.info("Loaded %d projects from ConfigMap", len(result)) + return result + + +def get_project(name: str) -> ProjectInfo | None: + """Get info for a specific project.""" + projects = discover_projects() + return next((p for p in projects if p.name == name), None) + + +def get_project_presets(name: str) -> list[str]: + """Get available presets for a project.""" + proj = get_project(name) + return proj.presets if proj else [] + + +def _load_presets(orchestration_dir: Path) -> list[str]: + """Load preset names from presets.d/.""" + presets_dir = orchestration_dir / "presets.d" + if not presets_dir.is_dir(): + return [] + + preset_names: list[str] = [] + for yaml_file in sorted(presets_dir.glob("*.yaml")): + try: + with open(yaml_file) as f: + data = yaml.safe_load(f) + if isinstance(data, dict): + preset_names.extend(data.keys()) + except Exception as exc: + logger.debug("Failed to parse %s: %s", yaml_file, exc) + return preset_names + + +def _load_config_keys(orchestration_dir: Path) -> list[str]: + """Extract top-level config keys from config.yaml and config.d/.""" + keys: list[str] = [] + + config_file = orchestration_dir / "config.yaml" + if config_file.exists(): + try: + with open(config_file) as f: + data = yaml.safe_load(f) + if isinstance(data, dict): + keys.extend(data.keys()) + except Exception: + pass + + config_d = orchestration_dir / "config.d" + if config_d.is_dir(): + for yaml_file in sorted(config_d.glob("*.yaml")): + keys.append(yaml_file.stem) + + return keys diff --git a/fournos-ui/app/k8s_client.py b/fournos-ui/app/k8s_client.py new file mode 100644 index 0000000..abfce55 --- /dev/null +++ b/fournos-ui/app/k8s_client.py @@ -0,0 +1,849 @@ +"""Kubernetes client wrapper for FournosJob, PipelineRun, Pod, and CronJob operations.""" + +from __future__ import annotations + +import json +import logging +import threading +from datetime import datetime, timezone +from typing import Any, Generator + +import yaml +from kubernetes import client, config, watch +from kubernetes.client.rest import ApiException + +from app.config import settings + +logger = logging.getLogger(__name__) + +_api_client: client.ApiClient | None = None +_custom_api: client.CustomObjectsApi | None = None +_core_api: client.CoreV1Api | None = None +_batch_api: client.BatchV1Api | None = None +_lock = threading.Lock() + + +def _ensure_loaded() -> None: + """Load kubeconfig or in-cluster config once.""" + global _api_client, _custom_api, _core_api, _batch_api + if _custom_api is not None: + return + with _lock: + if _custom_api is not None: + return + try: + if settings.kubeconfig_path: + config.load_kube_config(config_file=settings.kubeconfig_path) + else: + try: + config.load_incluster_config() + except config.ConfigException: + config.load_kube_config() + except Exception: + logger.warning("K8s config not available -- running in offline mode") + return + + configuration = client.Configuration.get_default_copy() + timeout = settings.k8s_request_timeout_seconds + configuration.connect_timeout = timeout + configuration.read_timeout = timeout + _api_client = client.ApiClient(configuration=configuration) + _custom_api = client.CustomObjectsApi(_api_client) + _core_api = client.CoreV1Api(_api_client) + _batch_api = client.BatchV1Api(_api_client) + logger.info("Kubernetes client initialised (timeout=%ds)", timeout) + + +def is_connected() -> bool: + """Return True if a K8s client has been successfully loaded.""" + _ensure_loaded() + return _custom_api is not None + + +# --------------------------------------------------------------------------- +# FournosJob operations +# --------------------------------------------------------------------------- + +def list_fournos_jobs(namespace: str | None = None) -> list[dict]: + """List all FournosJob CRs in the given namespace.""" + _ensure_loaded() + if _custom_api is None: + return [] + ns = namespace or settings.fournos_namespace + try: + result = _custom_api.list_namespaced_custom_object( + group=settings.fournos_api_group, + version=settings.fournos_api_version, + namespace=ns, + plural=settings.fournos_job_plural, + ) + return result.get("items", []) + except ApiException as exc: + logger.error("Failed to list FournosJobs: %s", exc.reason) + return [] + + +def get_fournos_job(name: str, namespace: str | None = None) -> dict | None: + """Get a specific FournosJob by name.""" + _ensure_loaded() + if _custom_api is None: + return None + ns = namespace or settings.fournos_namespace + try: + return _custom_api.get_namespaced_custom_object( + group=settings.fournos_api_group, + version=settings.fournos_api_version, + namespace=ns, + plural=settings.fournos_job_plural, + name=name, + ) + except ApiException as exc: + if exc.status == 404: + return None + logger.error("Failed to get FournosJob %s: %s", name, exc.reason) + return None + + +def create_fournos_job(body: dict, namespace: str | None = None) -> dict: + """Create a new FournosJob CR.""" + _ensure_loaded() + if _custom_api is None: + raise RuntimeError("Kubernetes client not available") + ns = namespace or settings.fournos_namespace + return _custom_api.create_namespaced_custom_object( + group=settings.fournos_api_group, + version=settings.fournos_api_version, + namespace=ns, + plural=settings.fournos_job_plural, + body=body, + ) + + +def patch_fournos_job( + name: str, patch: dict, namespace: str | None = None +) -> dict: + """Patch a FournosJob (e.g. set spec.shutdown).""" + _ensure_loaded() + if _custom_api is None: + raise RuntimeError("Kubernetes client not available") + ns = namespace or settings.fournos_namespace + return _custom_api.patch_namespaced_custom_object( + group=settings.fournos_api_group, + version=settings.fournos_api_version, + namespace=ns, + plural=settings.fournos_job_plural, + name=name, + body=patch, + ) + + +def shutdown_fournos_job( + name: str, value: str = "Stop", namespace: str | None = None +) -> dict: + """Set spec.shutdown on a FournosJob to cancel it.""" + return patch_fournos_job(name, {"spec": {"shutdown": value}}, namespace) + + +def watch_fournos_jobs( + namespace: str | None = None, + resource_version: str = "", + timeout: int = 0, +) -> Generator[dict, None, None]: + """Yield watch events for FournosJobs. Blocks until timeout or stream ends.""" + _ensure_loaded() + if _custom_api is None: + return + ns = namespace or settings.fournos_namespace + w = watch.Watch() + kwargs: dict[str, Any] = { + "group": settings.fournos_api_group, + "version": settings.fournos_api_version, + "namespace": ns, + "plural": settings.fournos_job_plural, + } + if resource_version: + kwargs["resource_version"] = resource_version + if timeout: + kwargs["timeout_seconds"] = timeout + try: + for event in w.stream(_custom_api.list_namespaced_custom_object, **kwargs): + yield event + except ApiException as exc: + logger.warning("Watch stream ended: %s", exc.reason) + + +# --------------------------------------------------------------------------- +# Tekton PipelineRun operations +# --------------------------------------------------------------------------- + +def get_pipelinerun(name: str, namespace: str | None = None) -> dict | None: + """Get a Tekton PipelineRun by name.""" + _ensure_loaded() + if _custom_api is None: + return None + ns = namespace or settings.fournos_namespace + try: + return _custom_api.get_namespaced_custom_object( + group=settings.tekton_api_group, + version=settings.tekton_api_version, + namespace=ns, + plural=settings.tekton_pipelinerun_plural, + name=name, + ) + except ApiException as exc: + if exc.status == 404: + return None + logger.error("Failed to get PipelineRun %s: %s", name, exc.reason) + return None + + +def list_pipelineruns_for_job( + job_name: str, namespace: str | None = None +) -> list[dict]: + """List PipelineRuns associated with a FournosJob (by label).""" + _ensure_loaded() + if _custom_api is None: + return [] + ns = namespace or settings.fournos_namespace + try: + result = _custom_api.list_namespaced_custom_object( + group=settings.tekton_api_group, + version=settings.tekton_api_version, + namespace=ns, + plural=settings.tekton_pipelinerun_plural, + label_selector=f"fournos.dev/job-name={job_name}", + ) + return result.get("items", []) + except ApiException as exc: + logger.error("Failed to list PipelineRuns for %s: %s", job_name, exc.reason) + return [] + + +def get_taskrun(name: str, namespace: str | None = None) -> dict | None: + """Get a Tekton TaskRun by name.""" + _ensure_loaded() + if _custom_api is None: + return None + ns = namespace or settings.fournos_namespace + try: + return _custom_api.get_namespaced_custom_object( + group=settings.tekton_api_group, + version=settings.tekton_api_version, + namespace=ns, + plural="taskruns", + name=name, + ) + except ApiException as exc: + if exc.status == 404: + return None + logger.error("Failed to get TaskRun %s: %s", name, exc.reason) + return None + + +def _phase_from_conditions(conditions: list[dict]) -> str: + """Determine task phase from Tekton conditions.""" + if not conditions: + return "Pending" + cond = conditions[0] + reason = cond.get("reason", "") + cond_status = cond.get("status", "") + if reason == "Succeeded" and cond_status == "True": + return "Succeeded" + if reason == "Failed" or cond_status == "False": + return "Failed" + if reason in ("Running", "Started"): + return "Running" + if reason == "TaskRunCancelled": + return "Cancelled" + if reason == "SkippingNoMatch": + return "Skipped" + return "Pending" + + +def get_current_step_for_job( + job_name: str, namespace: str | None = None +) -> dict | None: + """Return the currently running pipeline step for a job, or None. + + Result dict has keys: name, displayName, startTime. + """ + prs = list_pipelineruns_for_job(job_name, namespace) + if not prs: + return None + + child_refs = prs[0].get("status", {}).get("childReferences", []) + for ref in child_refs: + task_run_name = ref.get("name", "") + tr = get_taskrun(task_run_name) + if not tr: + continue + tr_status = tr.get("status", {}) + phase = _phase_from_conditions(tr_status.get("conditions", [])) + if phase == "Running": + task_name = ref.get("pipelineTaskName", task_run_name) + return { + "name": task_name, + "displayName": task_name.replace("-", " ").title(), + "startTime": tr_status.get("startTime"), + } + return None + + +def extract_pipeline_stages(pipelinerun: dict) -> list[dict]: + """Extract stage information from a PipelineRun status for timeline display.""" + status = pipelinerun.get("status", {}) + child_refs = status.get("childReferences", []) + pipeline_spec = status.get("pipelineSpec", {}) + + finally_task_names = set() + for task in pipeline_spec.get("finally", []): + finally_task_names.add(task.get("name", "")) + + stages = [] + for ref in child_refs: + task_name = ref.get("pipelineTaskName", ref.get("name", "unknown")) + task_run_name = ref.get("name", "") + + start_time = None + completion_time = None + task_phase = "Pending" + + tr = get_taskrun(task_run_name) + if tr: + tr_status = tr.get("status", {}) + start_time = tr_status.get("startTime") + completion_time = tr_status.get("completionTime") + task_phase = _phase_from_conditions(tr_status.get("conditions", [])) + + display_name = task_name.replace("-", " ").title() + + stages.append({ + "name": task_name, + "displayName": display_name, + "status": task_phase, + "startTime": start_time, + "completionTime": completion_time, + "finally": task_name in finally_task_names, + }) + + stages.sort(key=lambda s: (s["finally"], s.get("startTime") or "9999")) + return stages + + +# --------------------------------------------------------------------------- +# Pod operations +# --------------------------------------------------------------------------- + +def list_pods_for_job(job_name: str, namespace: str | None = None) -> list[dict]: + """List pods associated with a FournosJob.""" + _ensure_loaded() + if _core_api is None: + return [] + ns = namespace or settings.fournos_namespace + try: + result = _core_api.list_namespaced_pod( + namespace=ns, + label_selector=f"fournos.dev/job-name={job_name}", + ) + pods = [] + for pod in result.items: + created = pod.metadata.creation_timestamp + age_minutes = 0 + if created: + delta = datetime.now(timezone.utc) - created.replace(tzinfo=timezone.utc) + age_minutes = int(delta.total_seconds() / 60) + + container_ready = False + restarts = 0 + if pod.status.container_statuses: + for cs in pod.status.container_statuses: + if cs.ready: + container_ready = True + restarts += cs.restart_count + + if pod.metadata.name.startswith("affinity-assistant"): + continue + + pods.append({ + "name": pod.metadata.name, + "phase": pod.status.phase or "Unknown", + "container": ( + pod.spec.containers[0].name if pod.spec.containers else "unknown" + ), + "ready": container_ready, + "restarts": restarts, + "age_minutes": age_minutes, + "_created": created, + }) + pods.sort(key=lambda p: p["_created"] or datetime.min.replace(tzinfo=timezone.utc)) + return pods + except ApiException as exc: + logger.error("Failed to list pods for %s: %s", job_name, exc.reason) + return [] + + +def read_pod_log( + pod_name: str, + namespace: str | None = None, + container: str | None = None, + follow: bool = False, + tail_lines: int | None = None, +) -> Generator[str, None, None]: + """Stream or read pod logs line by line.""" + _ensure_loaded() + if _core_api is None: + yield "Kubernetes client not available" + return + ns = namespace or settings.fournos_namespace + kwargs: dict[str, Any] = {"name": pod_name, "namespace": ns, "follow": follow} + if container: + kwargs["container"] = container + if tail_lines: + kwargs["tail_lines"] = tail_lines + try: + if follow: + for line in _core_api.read_namespaced_pod_log(**kwargs, _preload_content=False).stream(): + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + yield decoded + else: + log_text = _core_api.read_namespaced_pod_log(**kwargs) + for line in log_text.splitlines(): + yield line + except ApiException as exc: + yield f"Error reading logs: {exc.reason}" + + +def read_pod_log_full( + pod_name: str, + namespace: str | None = None, + container: str | None = None, +) -> str: + """Read entire pod log as a single string (for archival).""" + _ensure_loaded() + if _core_api is None: + return "" + ns = namespace or settings.fournos_namespace + kwargs: dict[str, Any] = {"name": pod_name, "namespace": ns} + if container: + kwargs["container"] = container + try: + return _core_api.read_namespaced_pod_log(**kwargs) + except ApiException as exc: + logger.error("Failed to read full log for %s: %s", pod_name, exc.reason) + return f"Error: {exc.reason}" + + +# --------------------------------------------------------------------------- +# CronJob operations (for scheduling) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- + +SCHEDULE_LABEL = "fournos-launcher/managed-by" +SCHEDULE_LABEL_VALUE = "fournos-dashboard" +PROJECT_LABEL = "fournos-launcher/project" + + +def list_managed_cronjobs(namespace: str | None = None) -> list[dict]: + """List CronJobs managed by the dashboard.""" + _ensure_loaded() + if _batch_api is None: + return [] + ns = namespace or settings.fournos_namespace + try: + result = _batch_api.list_namespaced_cron_job( + namespace=ns, + label_selector=f"{SCHEDULE_LABEL}={SCHEDULE_LABEL_VALUE}", + ) + return [_cronjob_to_dict(cj) for cj in result.items] + except ApiException as exc: + logger.error("Failed to list CronJobs: %s", exc.reason) + return [] + + +def get_managed_cronjob(name: str, namespace: str | None = None) -> dict | None: + """Get a specific managed CronJob.""" + _ensure_loaded() + if _batch_api is None: + return None + ns = namespace or settings.fournos_namespace + try: + cj = _batch_api.read_namespaced_cron_job(name=name, namespace=ns) + return _cronjob_to_dict(cj) + except ApiException as exc: + if exc.status == 404: + return None + logger.error("Failed to get CronJob %s: %s", name, exc.reason) + return None + + +def create_cronjob( + name: str, + schedule: str, + project: str, + cluster: str, + pipeline: str, + preset: str, + image: str, + owner: str = "", + config_overrides: dict | None = None, + resolver_script: str = "", + resolver_image: str = "", + resolver_filename: str = "", + namespace: str | None = None, +) -> dict: + """Create a K8s CronJob that submits a FournosJob on schedule. + + If *resolver_script* is provided, the CronJob pod gets an init container + that runs the script and writes KEY=VALUE pairs to /shared/resolved.env. + The main submit container reads those values and injects them as + configOverrides into the FournosJob spec before submission. + + The file extension of *resolver_filename* determines the interpreter: + .py -> python, .sh (or default) -> sh -c. + """ + _ensure_loaded() + if _batch_api is None: + raise RuntimeError("Kubernetes client not available") + ns = namespace or settings.fournos_namespace + + fjob_spec = { + "apiVersion": f"{settings.fournos_api_group}/{settings.fournos_api_version}", + "kind": "FournosJob", + "metadata": { + "generateName": f"forge-{project.replace('_', '-')}-sched-", + "namespace": ns, + "labels": { + "fournos-launcher/schedule-name": name, + "fournos-launcher/trigger-type": "scheduled", + }, + }, + "spec": { + "cluster": cluster, + "displayName": f"{project} {preset}".strip(), + "owner": owner or "fournos-dashboard/scheduler", + "pipeline": pipeline, + "exclusive": True, + "executionEngine": { + "forge": { + "project": project, + "args": [preset] if preset else [], + "configOverrides": config_overrides or {}, + } + }, + }, + } + fjob_json = json.dumps(fjob_spec) + api_path = ( + f"/apis/{settings.fournos_api_group}/{settings.fournos_api_version}" + f"/namespaces/{ns}/{settings.fournos_job_plural}" + ) + + submit_common = ( + "token = open('/var/run/secrets/kubernetes.io/serviceaccount/token').read()\n" + "ctx = ssl.create_default_context(cafile='/var/run/secrets/kubernetes.io/serviceaccount/ca.crt')\n" + f"url = 'https://kubernetes.default.svc{api_path}'\n" + "print('Submitting FournosJob to', url)\n" + "print('Body:', json.dumps(body, indent=2))\n" + "data = json.dumps(body).encode()\n" + "req = urllib.request.Request(url, data=data, method='POST',\n" + " headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'})\n" + "try:\n" + " resp = urllib.request.urlopen(req, context=ctx)\n" + " result = json.loads(resp.read())\n" + " print('FournosJob submitted:', result['metadata'].get('name', 'unknown'))\n" + "except urllib.error.HTTPError as e:\n" + " print('API error:', e.code, e.reason)\n" + " print(e.read().decode())\n" + " raise\n" + ) + + trigger_override = ( + "trigger = os.environ.get('FOURNOS_TRIGGER_TYPE', 'scheduled')\n" + "body['metadata'].setdefault('labels', {})['fournos-launcher/trigger-type'] = trigger\n" + ) + + if resolver_script: + submit_script = ( + "import json, os, ssl, urllib.request, urllib.error\n" + "body = json.loads(os.environ['FJOB_JSON'])\n" + + trigger_override + + "overrides = body['spec']['executionEngine']['forge'].setdefault('configOverrides', {})\n" + "env_file = '/shared/resolved.env'\n" + "if os.path.exists(env_file):\n" + " with open(env_file) as f:\n" + " for line in f:\n" + " line = line.strip()\n" + " if '=' in line and not line.startswith('#'):\n" + " k, v = line.split('=', 1)\n" + " overrides[k.strip()] = v.strip()\n" + " print(f'Resolved: {k.strip()} = {v.strip()}')\n" + + submit_common + ) + else: + submit_script = ( + "import json, os, ssl, urllib.request, urllib.error\n" + "body = json.loads(os.environ['FJOB_JSON'])\n" + + trigger_override + + submit_common + ) + + submit_container = client.V1Container( + name="submit", + image=image or "python:3.12-slim", + command=["python", "-c", submit_script], + env=[client.V1EnvVar(name="FJOB_JSON", value=fjob_json)], + ) + + init_containers = None + volumes = None + + if resolver_script: + is_python = resolver_filename.lower().endswith(".py") + default_resolver_img = "python:3.12-slim" if is_python else "alpine:latest" + script_key = resolver_filename or ("resolver.py" if is_python else "resolver.sh") + configmap_name = f"{name}-resolver" + + _create_resolver_configmap(configmap_name, script_key, resolver_script, ns) + + script_vol = client.V1Volume( + name="resolver-script", + config_map=client.V1ConfigMapVolumeSource( + name=configmap_name, + default_mode=0o755, + ), + ) + shared_vol = client.V1Volume( + name="shared", + empty_dir=client.V1EmptyDirVolumeSource(), + ) + volumes = [script_vol, shared_vol] + script_mount = client.V1VolumeMount( + name="resolver-script", mount_path="/resolver", read_only=True, + ) + shared_mount = client.V1VolumeMount(name="shared", mount_path="/shared") + submit_container.volume_mounts = [shared_mount] + + if is_python: + resolver_cmd = ["python", f"/resolver/{script_key}"] + else: + resolver_cmd = ["sh", f"/resolver/{script_key}"] + + init_containers = [ + client.V1Container( + name="resolver", + image=resolver_image or default_resolver_img, + command=resolver_cmd, + volume_mounts=[script_mount, shared_mount], + ) + ] + + annotations = { + "fournos-launcher/project": project, + "fournos-launcher/cluster": cluster, + "fournos-launcher/pipeline": pipeline, + "fournos-launcher/preset": preset, + "fournos-launcher/owner": owner, + } + if resolver_script: + annotations["fournos-launcher/resolver-configmap"] = configmap_name + annotations["fournos-launcher/resolver-filename"] = script_key + if resolver_image: + annotations["fournos-launcher/resolver-image"] = resolver_image + + cj_body = client.V1CronJob( + api_version="batch/v1", + kind="CronJob", + metadata=client.V1ObjectMeta( + name=name, + namespace=ns, + labels={ + SCHEDULE_LABEL: SCHEDULE_LABEL_VALUE, + PROJECT_LABEL: project, + }, + annotations=annotations, + ), + spec=client.V1CronJobSpec( + schedule=schedule, + suspend=False, + successful_jobs_history_limit=3, + failed_jobs_history_limit=3, + job_template=client.V1JobTemplateSpec( + spec=client.V1JobSpec( + template=client.V1PodTemplateSpec( + spec=client.V1PodSpec( + init_containers=init_containers, + containers=[submit_container], + volumes=volumes, + service_account_name="fournos-dashboard-sa", + restart_policy="Never", + ) + ), + backoff_limit=0, + ) + ), + ), + ) + + result = _batch_api.create_namespaced_cron_job(namespace=ns, body=cj_body) + return _cronjob_to_dict(result) + + +def _create_resolver_configmap( + cm_name: str, script_key: str, script_content: str, namespace: str +) -> None: + """Create a ConfigMap to hold a resolver script file.""" + _ensure_loaded() + if _core_api is None: + raise RuntimeError("Kubernetes client not available") + cm = client.V1ConfigMap( + api_version="v1", + kind="ConfigMap", + metadata=client.V1ObjectMeta( + name=cm_name, + namespace=namespace, + labels={ + SCHEDULE_LABEL: SCHEDULE_LABEL_VALUE, + "fournos-launcher/type": "resolver-script", + }, + ), + data={script_key: script_content}, + ) + try: + _core_api.create_namespaced_config_map(namespace=namespace, body=cm) + except ApiException as exc: + if exc.status == 409: + _core_api.replace_namespaced_config_map( + name=cm_name, namespace=namespace, body=cm, + ) + else: + raise + + +def get_resolver_script(configmap_name: str, namespace: str | None = None) -> tuple[str, str]: + """Read the resolver script from its ConfigMap. Returns (filename, content).""" + _ensure_loaded() + if _core_api is None: + return "", "" + ns = namespace or settings.fournos_namespace + try: + cm = _core_api.read_namespaced_config_map(name=configmap_name, namespace=ns) + if cm.data: + for key, value in cm.data.items(): + return key, value + except ApiException: + pass + return "", "" + + +def _delete_resolver_configmap(cm_name: str, namespace: str) -> None: + """Delete a resolver ConfigMap (best-effort).""" + _ensure_loaded() + if _core_api is None: + return + try: + _core_api.delete_namespaced_config_map(name=cm_name, namespace=namespace) + except ApiException: + pass + + +def trigger_cronjob(name: str, namespace: str | None = None) -> str: + """Manually trigger a CronJob by creating a one-off Job from its spec.""" + _ensure_loaded() + if _batch_api is None: + raise RuntimeError("Kubernetes client not available") + ns = namespace or settings.fournos_namespace + + cj = _batch_api.read_namespaced_cron_job(name=name, namespace=ns) + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + job_name = f"{name}-manual-{ts}"[:63] + + job_spec = cj.spec.job_template.spec + + trigger_env = client.V1EnvVar(name="FOURNOS_TRIGGER_TYPE", value="manual") + if job_spec.template and job_spec.template.spec and job_spec.template.spec.containers: + for c in job_spec.template.spec.containers: + if c.env is None: + c.env = [] + c.env.append(trigger_env) + + job_body = client.V1Job( + api_version="batch/v1", + kind="Job", + metadata=client.V1ObjectMeta( + name=job_name, + namespace=ns, + labels={ + SCHEDULE_LABEL: SCHEDULE_LABEL_VALUE, + "fournos-launcher/triggered-by": "manual", + }, + annotations={"cronjob.kubernetes.io/instantiate": "manual"}, + ), + spec=job_spec, + ) + + _batch_api.create_namespaced_job(namespace=ns, body=job_body) + return job_name + + +def delete_cronjob(name: str, namespace: str | None = None) -> None: + """Delete a managed CronJob and its resolver ConfigMap if present.""" + _ensure_loaded() + if _batch_api is None: + raise RuntimeError("Kubernetes client not available") + ns = namespace or settings.fournos_namespace + cj = get_managed_cronjob(name, ns) + _batch_api.delete_namespaced_cron_job( + name=name, + namespace=ns, + propagation_policy="Foreground", + ) + if cj and cj.get("resolver_configmap"): + _delete_resolver_configmap(cj["resolver_configmap"], ns) + + +def patch_cronjob_suspend( + name: str, suspend: bool, namespace: str | None = None +) -> dict: + """Pause or resume a CronJob by toggling spec.suspend.""" + _ensure_loaded() + if _batch_api is None: + raise RuntimeError("Kubernetes client not available") + ns = namespace or settings.fournos_namespace + result = _batch_api.patch_namespaced_cron_job( + name=name, + namespace=ns, + body={"spec": {"suspend": suspend}}, + ) + return _cronjob_to_dict(result) + + +def _cronjob_to_dict(cj: Any) -> dict: + """Serialise a V1CronJob into a plain dict for templates.""" + meta = cj.metadata + annotations = meta.annotations or {} + resolver_configmap = annotations.get("fournos-launcher/resolver-configmap", "") + resolver_image = annotations.get("fournos-launcher/resolver-image", "") + resolver_filename = annotations.get("fournos-launcher/resolver-filename", "") + return { + "name": meta.name, + "namespace": meta.namespace, + "schedule": cj.spec.schedule, + "suspend": cj.spec.suspend or False, + "project": annotations.get("fournos-launcher/project", ""), + "cluster": annotations.get("fournos-launcher/cluster", ""), + "pipeline": annotations.get("fournos-launcher/pipeline", ""), + "preset": annotations.get("fournos-launcher/preset", ""), + "owner": annotations.get("fournos-launcher/owner", ""), + "resolver_configmap": resolver_configmap, + "resolver_image": resolver_image, + "resolver_filename": resolver_filename, + "has_resolver": bool(resolver_configmap), + "created_at": meta.creation_timestamp.isoformat() if meta.creation_timestamp else "", + "last_schedule": ( + cj.status.last_schedule_time.isoformat() + if cj.status and cj.status.last_schedule_time + else "" + ), + "active_count": len(cj.status.active) if cj.status and cj.status.active else 0, + } diff --git a/fournos-ui/app/main.py b/fournos-ui/app/main.py new file mode 100644 index 0000000..72e51a0 --- /dev/null +++ b/fournos-ui/app/main.py @@ -0,0 +1,930 @@ +"""Fournos Launcher Dashboard -- production FastAPI application.""" + +from __future__ import annotations + +import asyncio +import logging +import re +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from jinja2 import Environment, FileSystemLoader + +from app import db, k8s_client, watcher +from app.config import settings +from app.forge_discovery import discover_projects, get_project_presets + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Lifespan +# --------------------------------------------------------------------------- + +@asynccontextmanager +async def lifespan(app: FastAPI): + logging.basicConfig(level=getattr(logging, settings.log_level)) + await db.init_db() + watcher.start_watcher() + yield + +app = FastAPI(title="Fournos Launcher Dashboard", lifespan=lifespan) + +BASE_DIR = Path(__file__).resolve().parent +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") + +_jinja_env = Environment( + loader=FileSystemLoader(str(BASE_DIR / "templates")), + autoescape=True, + auto_reload=False, +) + +# --------------------------------------------------------------------------- +# Template helpers +# --------------------------------------------------------------------------- + +def _format_age(timestamp_str: str) -> str: + from dateutil.parser import parse + + try: + created = parse(timestamp_str) + except Exception: + return "?" + delta = datetime.now(timezone.utc) - created + total_seconds = int(delta.total_seconds()) + if total_seconds < 0: + return "0s" + if total_seconds < 60: + return f"{total_seconds}s" + if total_seconds < 3600: + return f"{total_seconds // 60}m" + hours = total_seconds // 3600 + mins = (total_seconds % 3600) // 60 + if hours < 24: + return f"{hours}h {mins}m" + days = hours // 24 + return f"{days}d {hours % 24}h" + + +def _format_duration(seconds: float | None) -> str: + if seconds is None: + return "-" + s = int(seconds) + h, remainder = divmod(s, 3600) + m, sec = divmod(remainder, 60) + return f"{h:02d}h {m:02d}m {sec:02d}s" + + +def _phase_class(phase: str) -> str: + return { + "Running": "phase-running", + "Succeeded": "phase-succeeded", + "Failed": "phase-failed", + "Stopped": "phase-stopped", + "Resolving": "phase-resolving", + "Pending": "phase-resolving", + }.get(phase, "phase-unknown") + + +def _extract_forge_info(job: dict) -> dict: + forge = job.get("spec", {}).get("executionEngine", {}).get("forge", {}) + env = job.get("spec", {}).get("env", {}) + pr_number = env.get("PULL_NUMBER", "") + pr_title = env.get("PULL_TITLE", "") + repo_owner = env.get("REPO_OWNER", "") + repo_name = env.get("REPO_NAME", "") + pr_url = f"https://github.com/{repo_owner}/{repo_name}/pull/{pr_number}" if pr_number else "" + return { + "project": forge.get("project", ""), + "args": forge.get("args", []), + "config_overrides": forge.get("configOverrides", {}), + "pr_number": pr_number, + "pr_title": pr_title, + "pr_url": pr_url, + } + + +def _parse_task_progress(message: str) -> dict | None: + m = re.search( + r"Tasks Completed:\s*(\d+)\s*\(Failed:\s*(\d+),\s*Cancelled\s*(\d+)\),\s*Incomplete:\s*(\d+),\s*Skipped:\s*(\d+)", + message, + ) + if not m: + return None + return { + "completed": int(m.group(1)), + "failed": int(m.group(2)), + "cancelled": int(m.group(3)), + "incomplete": int(m.group(4)), + "skipped": int(m.group(5)), + "total": int(m.group(1)) + int(m.group(4)) + int(m.group(5)), + } + + +def _build_timeline(stages: list[dict]) -> list[dict]: + from dateutil.parser import parse + + now = datetime.now(timezone.utc) + n = len(stages) or 1 + equal_pct = 100.0 / n + + result = [] + for s in stages: + start = parse(s["startTime"]) if s["startTime"] else None + end = parse(s["completionTime"]) if s["completionTime"] else None + if start and end: + dur = (end - start).total_seconds() + elif start: + dur = (now - start).total_seconds() + else: + dur = 0 + dur = max(dur, 0) + + if dur < 60: + dur_label = f"{int(dur)}s" + elif dur < 3600: + dur_label = f"{int(dur // 60)}m {int(dur % 60)}s" + else: + dur_label = f"{int(dur // 3600)}h {int((dur % 3600) // 60)}m" + + status_class = { + "Succeeded": "ptl-ok", + "Running": "ptl-run", + "Failed": "ptl-err", + "Pending": "ptl-wait", + "Cancelled": "ptl-cancel", + "Skipped": "ptl-skip", + }.get(s["status"], "ptl-wait") + + result.append({ + **s, + "width_pct": equal_pct, + "min_width": 8, + "duration_label": dur_label if s["startTime"] else "", + "status_class": status_class, + }) + return result + + +def _extract_mlflow_url(status: dict) -> str: + """Extract MLflow run URL from FournosJob status.""" + mlflow = ( + status.get("engineStatus", {}) + .get("forge", {}) + .get("exportArtifacts", {}) + .get("caliper_artifacts_export", {}) + .get("backends", {}) + .get("mlflow", {}) + ) + return mlflow.get("run_url", "") if mlflow else "" + + +_CACHE_BUST = str(int(datetime.now(timezone.utc).timestamp())) + +_jinja_env.globals.update( + format_age=_format_age, + format_duration=_format_duration, + phase_class=_phase_class, + extract_forge_info=_extract_forge_info, + parse_task_progress=_parse_task_progress, + build_timeline=_build_timeline, + extract_mlflow_url=_extract_mlflow_url, + url_for=lambda name, **kw: app.url_path_for(name, **kw), + cache_bust=_CACHE_BUST, +) + + +_NAV_MAP = { + "jobs_list.html": "jobs", + "job_detail.html": "jobs", + "components/jobs_table_body.html": "jobs", + "submit_job.html": "submit", + "schedules.html": "schedules", + "schedule_runs.html": "schedules", +} + + +def _render(template_name: str, **context: Any) -> HTMLResponse: + context.setdefault("active_nav", _NAV_MAP.get(template_name, "")) + tpl = _jinja_env.get_template(template_name) + return HTMLResponse(tpl.render(**context)) + + +# --------------------------------------------------------------------------- +# Data fetching helpers +# --------------------------------------------------------------------------- + +_COMPLETED_GRACE_SECONDS = 180 # keep completed jobs on Live tab for 3 minutes + + +def _get_live_jobs_sync() -> list[dict]: + """Get FournosJobs from K8s, sorted newest-first, hiding old completed jobs.""" + from dateutil.parser import parse + + jobs = k8s_client.list_fournos_jobs() + now = datetime.now(timezone.utc) + visible: list[dict] = [] + for j in jobs: + phase = j.get("status", {}).get("phase", "") + if phase in ("Succeeded", "Failed", "Stopped"): + conditions = j.get("status", {}).get("conditions", []) + last_ts = None + for c in conditions: + ts_str = c.get("lastTransitionTime") + if ts_str: + try: + last_ts = parse(ts_str) + except Exception: + pass + if last_ts and (now - last_ts).total_seconds() > _COMPLETED_GRACE_SECONDS: + continue + visible.append(j) + + visible.sort( + key=lambda j: j.get("metadata", {}).get("creationTimestamp", ""), + reverse=True, + ) + return visible + + +async def _get_live_jobs() -> list[dict]: + """Async wrapper -- offloads blocking K8s I/O to a thread.""" + return await asyncio.to_thread(_get_live_jobs_sync) + + +def _compute_current_steps_sync(jobs: list[dict]) -> dict[str, dict]: + """For each running job, fetch the currently active pipeline step.""" + steps: dict[str, dict] = {} + for j in jobs: + phase = j.get("status", {}).get("phase", "") + if phase not in ("Running", "Admitted"): + continue + name = j.get("metadata", {}).get("name", "") + try: + step = k8s_client.get_current_step_for_job(name) + if step: + steps[name] = step + except Exception: + pass + return steps + + +async def _compute_current_steps(jobs: list[dict]) -> dict[str, dict]: + """Async wrapper -- offloads blocking K8s I/O to a thread.""" + return await asyncio.to_thread(_compute_current_steps_sync, jobs) + + +def _get_pipeline_stages_sync(job: dict) -> list[dict]: + """Get pipeline stages for a job from its PipelineRun.""" + job_name = job.get("metadata", {}).get("name", "") + + pr_name = job.get("status", {}).get("pipelineRun", "") + if pr_name: + pr = k8s_client.get_pipelinerun(pr_name) + if pr: + return k8s_client.extract_pipeline_stages(pr) + + prs = k8s_client.list_pipelineruns_for_job(job_name) + if prs: + return k8s_client.extract_pipeline_stages(prs[0]) + + return [] + + +async def _get_pipeline_stages(job: dict) -> list[dict]: + """Async wrapper -- offloads blocking K8s I/O to a thread.""" + return await asyncio.to_thread(_get_pipeline_stages_sync, job) + + +# --------------------------------------------------------------------------- +# Routes: Jobs +# --------------------------------------------------------------------------- + +@app.get("/", response_class=HTMLResponse) +async def jobs_list( + request: Request, + tab: str = Query("live", pattern="^(live|history)$"), + project: str = Query("", alias="project"), + cluster: str = Query("", alias="cluster"), + status: str = Query("", alias="status"), + owner: str = Query("", alias="owner"), + page: int = Query(1, ge=1), +): + per_page = 50 + filters = {"project": project, "cluster": cluster, "status": status, "owner": owner} + + if tab == "live": + jobs = await _get_live_jobs() + if project: + jobs = [j for j in jobs if _extract_forge_info(j).get("project") == project] + if cluster: + jobs = [j for j in jobs if j.get("spec", {}).get("cluster") == cluster] + if status: + jobs = [j for j in jobs if j.get("status", {}).get("phase") == status] + if owner: + jobs = [j for j in jobs if j.get("spec", {}).get("owner") == owner] + total = len(jobs) + all_clusters = _collect_clusters(jobs) + offset = (page - 1) * per_page + jobs = jobs[offset:offset + per_page] + history_jobs = [] + total_history = 0 + else: + jobs = [] + all_clusters = [] + async with db.async_session() as session: + history_jobs_db, total_history = await db.list_jobs( + session, + project=project or None, + cluster=cluster or None, + status=status or None, + owner=owner or None, + limit=per_page, + offset=(page - 1) * per_page, + ) + history_jobs = [_db_job_to_dict(j) for j in history_jobs_db] + total = total_history + + projects_list = [p.name for p in discover_projects()] + clusters = all_clusters + current_steps = await _compute_current_steps(jobs) if tab == "live" else {} + + return _render( + "jobs_list.html", + jobs=jobs, + history_jobs=history_jobs, + tab=tab, + filters=filters, + projects=projects_list, + clusters=clusters, + page=page, + per_page=per_page, + total=total, + current_steps=current_steps, + ) + + +@app.get("/api/jobs-table", response_class=HTMLResponse) +async def jobs_table_partial( + request: Request, + project: str = Query(""), + cluster: str = Query(""), + status: str = Query(""), + owner: str = Query(""), +): + jobs = await _get_live_jobs() + if project: + jobs = [j for j in jobs if _extract_forge_info(j).get("project") == project] + if cluster: + jobs = [j for j in jobs if j.get("spec", {}).get("cluster") == cluster] + if status: + jobs = [j for j in jobs if j.get("status", {}).get("phase") == status] + if owner: + jobs = [j for j in jobs if j.get("spec", {}).get("owner") == owner] + current_steps = await _compute_current_steps(jobs) + return _render("components/jobs_table_body.html", jobs=jobs, current_steps=current_steps) + + +@app.get("/jobs/{job_name}", response_class=HTMLResponse) +async def job_detail(request: Request, job_name: str): + job = None + source = "live" + pods: list[dict] = [] + stages: list[dict] = [] + + job = await asyncio.to_thread(k8s_client.get_fournos_job, job_name) + if job: + pods = await asyncio.to_thread(k8s_client.list_pods_for_job, job_name) + stages = await _get_pipeline_stages(job) + + if not job: + source = "history" + async with db.async_session() as session: + db_job = await db.get_job_by_name(session, job_name) + if db_job is None: + raise HTTPException(status_code=404, detail="Job not found") + + job = _db_job_to_fjob_dict(db_job) + + return _render( + "job_detail.html", + job=job, + pods=pods, + stages=stages, + source=source, + ) + + +@app.get("/api/jobs/{job_name}/detail-partial", response_class=HTMLResponse) +async def job_detail_partial(request: Request, job_name: str): + """Return the dynamic portions of the job detail page for HTMX polling.""" + job = await asyncio.to_thread(k8s_client.get_fournos_job, job_name) + if not job: + return HTMLResponse("") + pods = await asyncio.to_thread(k8s_client.list_pods_for_job, job_name) + stages = await _get_pipeline_stages(job) + return _render( + "components/job_detail_dynamic.html", + job=job, + pods=pods, + stages=stages, + ) + + +@app.post("/api/jobs/{job_name}/cancel") +async def cancel_job(job_name: str): + try: + await asyncio.to_thread(k8s_client.shutdown_fournos_job, job_name) + return {"status": "ok", "message": f"Shutdown requested for {job_name}"} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@app.post("/api/jobs/{job_name}/rerun") +async def rerun_job(job_name: str): + """Clone an existing FournosJob's spec into a brand-new job.""" + job = await _get_job_for_rerun(job_name) + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + + spec = dict(job.get("spec", {})) + forge = spec.get("executionEngine", {}).get("forge", {}) + project = forge.get("project", "unknown") + + spec.pop("shutdown", None) + + new_name = sanitize_job_name(f"forge-{project}") + body = { + "apiVersion": f"{settings.fournos_api_group}/{settings.fournos_api_version}", + "kind": "FournosJob", + "metadata": { + "name": new_name, + "namespace": settings.fournos_namespace, + }, + "spec": spec, + } + + try: + created = await asyncio.to_thread(k8s_client.create_fournos_job, body) + created_name = created.get("metadata", {}).get("name", new_name) + return {"status": "ok", "job_name": created_name, "redirect": f"/jobs/{created_name}"} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +async def _get_job_for_rerun(job_name: str) -> dict | None: + """Fetch a FournosJob by name from live K8s or DB history.""" + live = await asyncio.to_thread(k8s_client.get_fournos_job, job_name) + if live: + return live + async with db.async_session() as session: + db_job = await db.get_job_by_name(session, job_name) + if db_job: + return _db_job_to_fjob_dict(db_job) + return None + + +@app.delete("/api/history/{job_name}") +async def delete_history_job(job_name: str): + """Delete a job from the history database.""" + async with db.async_session() as session: + async with session.begin(): + deleted = await db.delete_job_by_name(session, job_name) + if not deleted: + raise HTTPException(status_code=404, detail="Job not found in history") + return {"status": "ok"} + + +@app.get("/api/jobs/{job_name}/logs/{pod_name}") +async def stream_logs(job_name: str, pod_name: str): + """Stream live pod logs via SSE (only for running jobs).""" + job_pods = await asyncio.to_thread(k8s_client.list_pods_for_job, job_name) + pod_names = {p["name"] for p in job_pods} + if pod_name not in pod_names: + raise HTTPException(status_code=404, detail="Pod not found for this job") + + async def generate(): + stop = asyncio.Event() + queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=64) + loop = asyncio.get_event_loop() + + def _reader(): + try: + for line in k8s_client.read_pod_log(pod_name, follow=True): + if stop.is_set(): + break + try: + loop.call_soon_threadsafe(queue.put_nowait, line) + except asyncio.QueueFull: + pass + finally: + loop.call_soon_threadsafe(queue.put_nowait, None) + + task = asyncio.get_event_loop().run_in_executor(None, _reader) + try: + while True: + line = await queue.get() + if line is None: + break + yield f"data: {line}\n\n" + finally: + stop.set() + + return StreamingResponse(generate(), media_type="text/event-stream") + + + + +# --------------------------------------------------------------------------- +# Routes: Submit Job +# --------------------------------------------------------------------------- + +@app.get("/submit", response_class=HTMLResponse) +async def submit_form(request: Request): + projects = discover_projects() + return _render( + "submit_job.html", + projects=projects, + pipelines=list(settings.default_pipelines), + ) + + +@app.get("/api/project-info/{project_name}") +async def project_info_api(project_name: str): + from app.forge_discovery import get_project + proj = get_project(project_name) + if proj is None: + return {"presets": [], "cluster": ""} + return {"presets": proj.presets, "cluster": proj.cluster} + + +def _fetch_github_open_prs() -> list[dict]: + """Blocking call to the GitHub API -- run via asyncio.to_thread.""" + import urllib.request + import json as _json + + url = f"https://api.github.com/repos/{settings.forge_github_repo}/pulls?state=open&per_page=100" + req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + prs = _json.loads(resp.read()) + + return [ + { + "number": pr["number"], + "title": pr["title"], + "author": pr["user"]["login"], + "head_sha": pr["head"]["sha"], + "branch": pr["head"]["ref"], + "draft": pr["draft"], + } + for pr in prs + ] + + +@app.get("/api/github/open-prs") +async def github_open_prs(): + """Fetch open pull requests from the Forge GitHub repo (public, no token needed).""" + try: + return await asyncio.to_thread(_fetch_github_open_prs) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"GitHub API error: {exc}") + + +@app.post("/submit") +async def submit_job( + request: Request, + project: str = Form(...), + cluster: str = Form(...), + pipeline: str = Form("forge-test-only"), + preset: str = Form(""), + version: str = Form(""), + owner: str = Form(""), + exclusive: str = Form("false"), + config_overrides_raw: str = Form(""), + pull_sha: str = Form(""), +): + exclusive_bool = exclusive.lower() in ("true", "on", "1", "yes") + + config_overrides: dict[str, Any] = {} + if config_overrides_raw.strip(): + for line in config_overrides_raw.strip().splitlines(): + line = line.strip() + if ":" in line: + k, v = line.split(":", 1) + config_overrides[k.strip()] = v.strip() + + if version: + version_key = _get_version_config_key(project) + config_overrides[version_key] = version + + args = [preset] if preset else [] + + job_name = sanitize_job_name(f"forge-{project}") + + pull_sha = pull_sha.strip() + env: dict[str, str] = {} + if pull_sha: + env["PULL_PULL_SHA"] = pull_sha + + body = { + "apiVersion": f"{settings.fournos_api_group}/{settings.fournos_api_version}", + "kind": "FournosJob", + "metadata": { + "name": job_name, + "namespace": settings.fournos_namespace, + }, + "spec": { + "cluster": cluster, + "displayName": f"{project} {preset}".strip(), + "owner": owner or "fournos-dashboard", + "pipeline": pipeline, + "exclusive": exclusive_bool, + "executionEngine": { + "forge": { + "project": project, + "args": args, + "configOverrides": config_overrides, + } + }, + }, + } + + if env: + body["spec"]["env"] = env + + try: + created = await asyncio.to_thread(k8s_client.create_fournos_job, body) + except Exception as exc: + projects = discover_projects() + return _render( + "submit_job.html", + projects=projects, + pipelines=list(settings.default_pipelines), + error=str(exc), + ) + + created_name = created.get("metadata", {}).get("name", job_name) + + try: + async with db.async_session() as session: + async with session.begin(): + await db.upsert_job( + session, + name=created_name, + project=project, + preset=preset, + cluster=cluster, + pipeline=pipeline, + owner=owner or "fournos-dashboard", + status="Pending", + config_overrides=config_overrides, + fjob_spec=body.get("spec", {}), + ) + except Exception as exc: + logger.error("DB upsert failed for job %s (job was created in K8s): %s", created_name, exc) + + return RedirectResponse(url=f"/jobs/{created_name}", status_code=303) + + +# --------------------------------------------------------------------------- +# Routes: Schedules +# --------------------------------------------------------------------------- + +@app.get("/schedules", response_class=HTMLResponse) +async def schedules_list(request: Request): + cronjobs = await asyncio.to_thread(k8s_client.list_managed_cronjobs) + projects = discover_projects() + return _render( + "schedules.html", + cronjobs=cronjobs, + projects=projects, + pipelines=list(settings.default_pipelines), + ) + + +@app.get("/schedules/{name}/runs", response_class=HTMLResponse) +async def schedule_runs(request: Request, name: str): + """Show all jobs triggered by a specific schedule.""" + async with db.async_session() as session: + jobs = await db.list_jobs_by_schedule(session, name) + runs = [] + for j in jobs: + runs.append({ + "name": j.name, + "status": j.status, + "preset": j.preset, + "trigger_type": j.trigger_type or "scheduled", + "duration_seconds": j.duration_seconds, + "mlflow_url": j.mlflow_url, + "created_at": j.created_at.isoformat() if j.created_at else "", + }) + return _render("schedule_runs.html", schedule_name=name, runs=runs) + + +@app.post("/schedules") # handles both create and edit +async def create_schedule( + request: Request, + name: str = Form(...), + project: str = Form(...), + cluster: str = Form(...), + pipeline: str = Form("forge-test-only"), + preset: str = Form(""), + cron_expr: str = Form(...), + image_source: str = Form(""), + owner: str = Form(""), + resolver_script: str = Form(""), + resolver_image: str = Form(""), + resolver_filename: str = Form(""), + edit_target: str = Form(""), +): + try: + if edit_target and edit_target != name: + await asyncio.to_thread( + k8s_client.create_cronjob, + name=name, + schedule=cron_expr, + project=project, + cluster=cluster, + pipeline=pipeline, + preset=preset, + image=image_source, + owner=owner, + resolver_script=resolver_script.strip().replace("\r\n", "\n").replace("\r", "\n"), + resolver_image=resolver_image.strip(), + resolver_filename=resolver_filename.strip(), + ) + try: + await asyncio.to_thread(k8s_client.delete_cronjob, edit_target) + except Exception as del_exc: + logger.warning("Failed to delete old schedule %s after replacement: %s", edit_target, del_exc) + else: + if edit_target: + await asyncio.to_thread(k8s_client.delete_cronjob, edit_target) + await asyncio.to_thread( + k8s_client.create_cronjob, + name=name, + schedule=cron_expr, + project=project, + cluster=cluster, + pipeline=pipeline, + preset=preset, + image=image_source, + owner=owner, + resolver_script=resolver_script.strip().replace("\r\n", "\n").replace("\r", "\n"), + resolver_image=resolver_image.strip(), + resolver_filename=resolver_filename.strip(), + ) + + return RedirectResponse(url="/schedules", status_code=303) + except Exception as exc: + cronjobs = await asyncio.to_thread(k8s_client.list_managed_cronjobs) + projects = discover_projects() + return _render( + "schedules.html", + cronjobs=cronjobs, + projects=projects, + pipelines=list(settings.default_pipelines), + error=str(exc), + ) + + +@app.post("/api/schedules/{name}/toggle") +async def toggle_schedule(name: str): + cj = await asyncio.to_thread(k8s_client.get_managed_cronjob, name) + if cj is None: + raise HTTPException(404, "Schedule not found") + await asyncio.to_thread(k8s_client.patch_cronjob_suspend, name, not cj["suspend"]) + return {"status": "ok"} + + +@app.get("/api/schedules/{name}/resolver") +async def get_resolver_script(name: str): + """Return the resolver script content for a schedule.""" + cj = await asyncio.to_thread(k8s_client.get_managed_cronjob, name) + if cj is None: + raise HTTPException(404, "Schedule not found") + cm_name = cj.get("resolver_configmap", "") + if not cm_name: + raise HTTPException(404, "No resolver script configured for this schedule") + filename, content = await asyncio.to_thread(k8s_client.get_resolver_script, cm_name) + if not content: + raise HTTPException(404, "Resolver ConfigMap not found") + return {"filename": filename, "content": content} + + +@app.post("/api/schedules/{name}/trigger") +async def trigger_schedule(name: str): + """Manually trigger a CronJob by creating a one-off Job from it.""" + try: + job = await asyncio.to_thread(k8s_client.trigger_cronjob, name) + return {"status": "ok", "job_name": job} + except Exception as exc: + raise HTTPException(500, str(exc)) + + +@app.post("/api/schedules/{name}/delete") +async def delete_schedule(name: str): + try: + await asyncio.to_thread(k8s_client.delete_cronjob, name) + return {"status": "ok"} + except Exception as exc: + raise HTTPException(500, str(exc)) + + +# --------------------------------------------------------------------------- +# Conversion helpers +# --------------------------------------------------------------------------- + +def _collect_clusters(live_jobs: list[dict]) -> list[str]: + """Collect unique cluster names from live jobs.""" + clusters = set() + for j in live_jobs: + c = j.get("spec", {}).get("cluster", "") + if c: + clusters.add(c) + return sorted(clusters) + + +def _db_job_to_dict(job: db.Job) -> dict: + """Convert a DB Job row to a dict suitable for the history table template.""" + return { + "name": job.name, + "project": job.project, + "preset": job.preset, + "cluster": job.cluster, + "pipeline": job.pipeline, + "owner": job.owner, + "phase": job.status, + "message": job.message, + "created_at": job.created_at.isoformat() if job.created_at else "", + "completed_at": job.completed_at.isoformat() if job.completed_at else "", + "duration_seconds": job.duration_seconds, + "mlflow_url": job.mlflow_url, + "error_message": job.error_message, + "triggered_by_schedule": job.triggered_by_schedule, + "trigger_type": job.trigger_type or "manual", + "source": "history", + } + + +def _db_job_to_fjob_dict(job: db.Job) -> dict: + """Convert a DB Job row to a FournosJob-like dict for the detail template.""" + spec = job.fjob_spec or {} + status = job.fjob_status or {} + + forge = spec.get("executionEngine", {}).get("forge", {}) + if not forge: + forge = {"project": job.project, "args": job.preset.split() if job.preset else [], "configOverrides": job.config_overrides or {}} + spec.setdefault("executionEngine", {})["forge"] = forge + + spec.setdefault("cluster", job.cluster) + spec.setdefault("pipeline", job.pipeline) + spec.setdefault("owner", job.owner) + spec.setdefault("displayName", f"{job.project} {job.preset}".strip()) + spec.setdefault("exclusive", True) + spec.setdefault("env", {}) + spec.setdefault("secretRefs", []) + + status.setdefault("phase", job.status) + status.setdefault("message", job.message) + status.setdefault("conditions", []) + + return { + "metadata": { + "name": job.name, + "namespace": settings.fournos_namespace, + "creationTimestamp": job.created_at.isoformat() if job.created_at else "", + "uid": job.id, + }, + "spec": spec, + "status": status, + "_source": "history", + "_duration_seconds": job.duration_seconds, + "_mlflow_url": job.mlflow_url, + "_ci_artifacts_url": job.ci_artifacts_url, + } + + +_PROJECT_VERSION_KEYS: dict[str, str] = { + "mcp_gateway": "infrastructure.mcp_gateway_version", +} + + +def _get_version_config_key(project: str) -> str: + """Return the configOverrides key used to pass the version for a project.""" + return _PROJECT_VERSION_KEYS.get(project, "infrastructure.version") + + +def sanitize_job_name(prefix: str) -> str: + """Generate a K8s-safe job name with timestamp.""" + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + name = f"{prefix}-{ts}".lower() + name = re.sub(r"[^a-z0-9-]", "-", name) + name = re.sub(r"-+", "-", name).strip("-") + return name[:63] + + diff --git a/fournos-ui/app/models.py b/fournos-ui/app/models.py new file mode 100644 index 0000000..1bc83e9 --- /dev/null +++ b/fournos-ui/app/models.py @@ -0,0 +1,15 @@ +"""Pydantic models for the Fournos Dashboard.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ProjectInfo(BaseModel): + """Discovered Forge project metadata.""" + + name: str + cluster: str = "" + presets: list[str] = Field(default_factory=list) + config_keys: list[str] = Field(default_factory=list) + has_cli: bool = False diff --git a/fournos-ui/app/static/htmx-sse.js b/fournos-ui/app/static/htmx-sse.js new file mode 100644 index 0000000..462382d --- /dev/null +++ b/fournos-ui/app/static/htmx-sse.js @@ -0,0 +1,386 @@ +/* +Server Sent Events Extension +============================ +This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. + +*/ + +(function() { + + /** @type {import("../htmx").HtmxInternalApi} */ + var api; + + htmx.defineExtension("sse", { + + /** + * Init saves the provided reference to the internal HTMX API. + * + * @param {import("../htmx").HtmxInternalApi} api + * @returns void + */ + init: function(apiRef) { + // store a reference to the internal API. + api = apiRef; + + // set a function in the public API for creating new EventSource objects + if (htmx.createEventSource == undefined) { + htmx.createEventSource = createEventSource; + } + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + * @returns void + */ + onEvent: function(name, evt) { + + var parent = evt.target || evt.detail.elt; + switch (name) { + + case "htmx:beforeCleanupElement": + var internalData = api.getInternalData(parent) + // Try to remove remove an EventSource when elements are removed + if (internalData.sseEventSource) { + internalData.sseEventSource.close(); + } + + return; + + // Try to create EventSources when elements are processed + case "htmx:afterProcessNode": + ensureEventSourceOnElement(parent); + } + } + }); + + /////////////////////////////////////////////// + // HELPER FUNCTIONS + /////////////////////////////////////////////// + + + /** + * createEventSource is the default method for creating new EventSource objects. + * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. + * + * @param {string} url + * @returns EventSource + */ + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }); + } + + function splitOnWhitespace(trigger) { + return trigger.trim().split(/\s+/); + } + + function getLegacySSEURL(elt) { + var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); + if (legacySSEValue) { + var values = splitOnWhitespace(legacySSEValue); + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/); + if (value[0] === "connect") { + return value[1]; + } + } + } + } + + function getLegacySSESwaps(elt) { + var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); + var returnArr = []; + if (legacySSEValue != null) { + var values = splitOnWhitespace(legacySSEValue); + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/); + if (value[0] === "swap") { + returnArr.push(value[1]); + } + } + } + return returnArr; + } + + /** + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * + * @param {HTMLElement} elt + */ + function registerSSE(elt) { + // Add message handlers for every `sse-swap` attribute + queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function (child) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(child, hasEventSource); + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null; // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement); + var source = internalData.sseEventSource; + + var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); + if (sseSwapAttr) { + var sseEventNames = sseSwapAttr.split(","); + } else { + var sseEventNames = getLegacySSESwaps(child); + } + + for (var i = 0; i < sseEventNames.length; i++) { + var sseEventName = sseEventNames[i].trim(); + var listener = function(event) { + + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { + return; + } + + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + return; + } + + // swap the response into the DOM and trigger a notification + if(!api.triggerEvent(elt, "htmx:sseBeforeMessage", event)) { + return; + } + swap(child, event.data); + api.triggerEvent(elt, "htmx:sseMessage", event); + }; + + // Register the new listener + api.getInternalData(child).sseEventListener = listener; + source.addEventListener(sseEventName, listener); + } + }); + + // Add message handlers for every `hx-trigger="sse:*"` attribute + queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(child, hasEventSource); + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null; // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement); + var source = internalData.sseEventSource; + + var sseEventName = api.getAttributeValue(child, "hx-trigger"); + if (sseEventName == null) { + return; + } + + // Only process hx-triggers for events with the "sse:" prefix + if (sseEventName.slice(0, 4) != "sse:") { + return; + } + + // remove the sse: prefix from here on out + sseEventName = sseEventName.substr(4); + + var listener = function(event) { + if (maybeCloseSSESource(sourceElement)) { + return; + } + + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + return; + } + + api.triggerEvent(child, "htmx:sseMessage", event); + htmx.trigger(child, sseEventName, event); + }; + + api.getInternalData(child).sseEventListener = listener; + source.addEventListener(sseEventName, listener); + }); + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + + if (elt == null) { + return null; + } + + // handle extension source creation attribute + queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { + var sseURL = api.getAttributeValue(child, "sse-connect"); + if (sseURL == null) { + return; + } + + ensureEventSource(child, sseURL, retryCount); + }); + + // handle legacy sse, remove for HTMX2 + queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { + var sseURL = getLegacySSEURL(child); + if (sseURL == null) { + return; + } + + ensureEventSource(child, sseURL, retryCount); + }); + + registerSSE(elt); + } + + function ensureEventSource(elt, url, retryCount) { + var internalData = api.getInternalData(elt); + var existingSource = internalData.sseEventSource; + if (existingSource && existingSource.readyState !== EventSource.CLOSED) { + return; + } + if (existingSource) { + existingSource.close(); + internalData.sseEventSource = null; + } + + var source = htmx.createEventSource(url); + + source.onerror = function(err) { + + // Log an error event + api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return; + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0; + var timeout = Math.random() * Math.pow(2, retryCount) * 500; + window.setTimeout(function() { + ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); + }, timeout); + } + }; + + source.onopen = function(evt) { + api.triggerEvent(elt, "htmx:sseOpen", { source: source }); + } + + api.getInternalData(elt).sseEventSource = source; + } + + /** + * maybeCloseSSESource confirms that the parent element still exists. + * If not, then any associated SSE source is closed and the function returns true. + * + * @param {HTMLElement} elt + * @returns boolean + */ + function maybeCloseSSESource(elt) { + if (!api.bodyContains(elt)) { + var source = api.getInternalData(elt).sseEventSource; + if (source != undefined) { + source.close(); + // source = null + return true; + } + } + return false; + } + + /** + * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. + * + * @param {HTMLElement} elt + * @param {string} attributeName + */ + function queryAttributeOnThisOrChildren(elt, attributeName) { + + var result = []; + + // If the parent element also contains the requested attribute, then add it to the results too. + if (api.hasAttribute(elt, attributeName)) { + result.push(elt); + } + + // Search all child nodes that match the requested attribute + elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { + result.push(node); + }); + + return result; + } + + /** + * @param {HTMLElement} elt + * @param {string} content + */ + function swap(elt, content) { + + api.withExtensions(elt, function(extension) { + content = extension.transformResponse(content, null, elt); + }); + + var swapSpec = api.getSwapSpecification(elt); + var target = api.getTarget(elt); + var settleInfo = api.makeSettleInfo(elt); + + api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); + + settleInfo.elts.forEach(function(elt) { + if (elt.classList) { + elt.classList.add(htmx.config.settlingClass); + } + api.triggerEvent(elt, 'htmx:beforeSettle'); + }); + + // Handle settle tasks (with delay if requested) + if (swapSpec.settleDelay > 0) { + setTimeout(doSettle(settleInfo), swapSpec.settleDelay); + } else { + doSettle(settleInfo)(); + } + } + + /** + * doSettle mirrors much of the functionality in htmx that + * settles elements after their content has been swapped. + * TODO: this should be published by htmx, and not duplicated here + * @param {import("../htmx").HtmxSettleInfo} settleInfo + * @returns () => void + */ + function doSettle(settleInfo) { + + return function() { + settleInfo.tasks.forEach(function(task) { + task.call(); + }); + + settleInfo.elts.forEach(function(elt) { + if (elt.classList) { + elt.classList.remove(htmx.config.settlingClass); + } + api.triggerEvent(elt, 'htmx:afterSettle'); + }); + } + } + + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null; + } + +})(); diff --git a/fournos-ui/app/static/htmx.min.js b/fournos-ui/app/static/htmx.min.js new file mode 100644 index 0000000..de5f0f1 --- /dev/null +++ b/fournos-ui/app/static/htmx.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
",1);case"col":return s(""+n+"
",2);case"tr":return s(""+n+"
",2);case"td":case"th":return s(""+n+"
",3);case"script":case"style":return s("
"+n+"
",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file diff --git a/fournos-ui/app/static/style.css b/fournos-ui/app/static/style.css new file mode 100644 index 0000000..9733a1b --- /dev/null +++ b/fournos-ui/app/static/style.css @@ -0,0 +1,1005 @@ +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --border: #30363d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-red: #f85149; + --accent-yellow: #d29922; + --accent-purple: #bc8cff; + --accent-orange: #f0883e; + --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + --radius: 6px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; +} + +a { color: var(--accent-blue); text-decoration: none; } +a:hover { text-decoration: underline; } + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 0 24px; +} + +/* Header */ +header { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 12px 0; + position: sticky; + top: 0; + z-index: 100; +} + +header .container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.header-nav { + display: flex; + align-items: center; + gap: 24px; +} + +.logo { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.3px; + color: var(--text-primary); + text-decoration: none; +} + +.logo:hover { text-decoration: none; } + +.logo span { + color: var(--text-secondary); + font-weight: 400; +} + +.nav-links { + display: flex; + gap: 4px; +} + +.nav-link { + padding: 6px 14px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + text-decoration: none; + transition: background 0.15s, color 0.15s; +} + +.nav-link:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + text-decoration: none; +} + +.nav-link.active { + background: rgba(88, 166, 255, 0.1); + color: var(--accent-blue); +} + +.header-stats { + display: flex; + gap: 16px; + margin-left: auto; + font-size: 13px; + color: var(--text-secondary); +} + +.header-stats .stat { + display: flex; + align-items: center; + gap: 4px; +} + +.header-stats .dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.dot-running { background: var(--accent-yellow); } +.dot-succeeded { background: var(--accent-green); } +.dot-failed { background: var(--accent-red); } + +/* Main content */ +main { padding: 24px 0; } + +/* Filter bar */ +.filter-bar { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.filter-bar select, +.filter-bar input { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + padding: 6px 12px; + font-size: 13px; + outline: none; +} + +.filter-bar select:focus, +.filter-bar input:focus { + border-color: var(--accent-blue); +} + +/* Jobs table wrapper: handles border + radius */ +.table-wrap { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: visible; + position: relative; +} + +/* Jobs table */ +.jobs-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + background: var(--bg-secondary); +} + +.jobs-table th { + text-align: left; + padding: 10px 16px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + background: var(--bg-tertiary); +} + +.jobs-table th:first-child { border-top-left-radius: var(--radius); } +.jobs-table th:last-child { border-top-right-radius: var(--radius); } + +.jobs-table td { + padding: 12px 16px; + font-size: 14px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.jobs-table tr:last-child td { border-bottom: none; } +.jobs-table tbody tr:last-child td:first-child { border-bottom-left-radius: var(--radius); } +.jobs-table tbody tr:last-child td:last-child { border-bottom-right-radius: var(--radius); } + +.jobs-table tr:hover td { background: rgba(88, 166, 255, 0.04); } + +.job-name { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; +} + +/* Phase badges */ +.phase-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.phase-running { + background: rgba(210, 153, 34, 0.15); + color: var(--accent-yellow); + border: 1px solid rgba(210, 153, 34, 0.3); +} + +.phase-succeeded { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); + border: 1px solid rgba(63, 185, 80, 0.3); +} + +.phase-failed { + background: rgba(248, 81, 73, 0.15); + color: var(--accent-red); + border: 1px solid rgba(248, 81, 73, 0.3); +} + +.phase-stopped { + background: rgba(139, 148, 158, 0.15); + color: var(--text-secondary); + border: 1px solid rgba(139, 148, 158, 0.3); +} + +.phase-resolving { + background: rgba(188, 140, 255, 0.15); + color: var(--accent-purple); + border: 1px solid rgba(188, 140, 255, 0.3); +} + +.phase-unknown { + background: var(--bg-tertiary); + color: var(--text-muted); + border: 1px solid var(--border); +} + +.pulse { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: pulse-anim 2s infinite; +} + +@keyframes pulse-anim { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Progress bar */ +.progress-bar { + display: flex; + height: 6px; + border-radius: 3px; + overflow: hidden; + background: var(--bg-tertiary); + min-width: 100px; +} + +.progress-bar .segment { + height: 100%; + transition: width 0.3s; +} + +.progress-bar .completed { background: var(--accent-green); } +.progress-bar .failed { background: var(--accent-red); } +.progress-bar .incomplete { background: var(--accent-yellow); } + +/* Detail page */ +.detail-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.detail-header h2 { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; +} + +.back-link { + font-size: 14px; + color: var(--text-secondary); +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 24px; +} + +@media (max-width: 900px) { + .detail-grid { grid-template-columns: 1fr; } +} + +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.card-header { + padding: 12px 16px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} + +.card-body { padding: 16px; } + +.card-body dl { + display: grid; + grid-template-columns: 140px 1fr; + gap: 8px 12px; + font-size: 14px; +} + +.card-body dt { color: var(--text-secondary); } +.card-body dd { color: var(--text-primary); word-break: break-all; } +.card-body dd code { + font-family: var(--font-mono); + font-size: 13px; + background: var(--bg-tertiary); + padding: 1px 6px; + border-radius: 3px; +} + +/* Conditions timeline */ +.conditions-list { list-style: none; } + +.conditions-list li { + padding: 10px 0; + border-bottom: 1px solid var(--border); + display: flex; + align-items: flex-start; + gap: 12px; + font-size: 13px; +} + +.conditions-list li:last-child { border-bottom: none; } + +.condition-icon { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + flex-shrink: 0; + margin-top: 2px; +} + +.condition-true { background: rgba(63, 185, 80, 0.2); color: var(--accent-green); } +.condition-false { background: rgba(248, 81, 73, 0.2); color: var(--accent-red); } +.condition-unknown { background: rgba(210, 153, 34, 0.2); color: var(--accent-yellow); } + +.condition-info { flex: 1; } +.condition-type { font-weight: 600; color: var(--text-primary); } +.condition-message { color: var(--text-secondary); margin-top: 2px; } +.condition-time { color: var(--text-muted); font-size: 12px; } + +/* Pods table */ +.pods-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.pods-table th { + text-align: left; + padding: 8px 12px; + color: var(--text-secondary); + font-weight: 600; + border-bottom: 1px solid var(--border); +} + +.pods-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 12px; +} + +.pods-table tr:last-child td { border-bottom: none; } + +/* Log viewer */ +.log-viewer { + background: #010409; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + max-height: 500px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + color: var(--text-secondary); + margin-top: 20px; +} + +.log-viewer .log-line { display: block; } +.log-viewer .log-info { color: #58a6ff; } +.log-viewer .log-warn { color: #d29922; } +.log-viewer .log-error { color: #f85149; } +.log-viewer .log-task { color: #3fb950; font-weight: 600; } +.log-viewer .log-command { color: #bc8cff; } +.log-viewer .log-separator { color: #30363d; } + +.owner-badge { + font-size: 12px; + padding: 2px 8px; + border-radius: 12px; + background: rgba(88, 166, 255, 0.1); + color: var(--accent-blue); + border: 1px solid rgba(88, 166, 255, 0.2); +} + +.project-badge { + font-size: 12px; + padding: 2px 8px; + border-radius: 12px; + background: rgba(188, 140, 255, 0.1); + color: var(--accent-purple); + border: 1px solid rgba(188, 140, 255, 0.2); + font-family: var(--font-mono); +} + +.cluster-name { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); +} + +.text-muted { color: var(--text-muted); font-size: 12px; } + +.htmx-indicator { opacity: 0; transition: opacity 200ms; } +.htmx-request .htmx-indicator { opacity: 1; } + +/* Pipeline Timeline */ +.pipeline-timeline { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + margin-bottom: 24px; +} + +.pipeline-timeline-header { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 16px; +} + +.timeline-bar { + display: flex; + height: 36px; + border-radius: 6px; + overflow: hidden; + gap: 2px; + margin-bottom: 4px; +} + +.timeline-segment { + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 8px; + position: relative; + transition: all 0.2s; + cursor: default; +} + +.timeline-segment:hover { + filter: brightness(1.2); + z-index: 1; +} + +.tl-succeeded { + background: rgba(63, 185, 80, 0.25); + color: var(--accent-green); + border: 1px solid rgba(63, 185, 80, 0.4); +} + +.tl-running { + background: rgba(210, 153, 34, 0.25); + color: var(--accent-yellow); + border: 1px solid rgba(210, 153, 34, 0.4); + animation: tl-pulse 2s infinite; +} + +@keyframes tl-pulse { + 0%, 100% { background: rgba(210, 153, 34, 0.25); } + 50% { background: rgba(210, 153, 34, 0.15); } +} + +.tl-failed { + background: rgba(248, 81, 73, 0.25); + color: var(--accent-red); + border: 1px solid rgba(248, 81, 73, 0.4); +} + +.tl-pending { + background: var(--bg-tertiary); + color: var(--text-muted); + border: 1px solid var(--border); +} + +.tl-cancelled { + background: rgba(139, 148, 158, 0.15); + color: var(--text-secondary); + border: 1px solid rgba(139, 148, 158, 0.3); +} + +.tl-skipped { + background: rgba(139, 148, 158, 0.08); + color: var(--text-muted); + border: 1px dashed var(--border); +} + +.timeline-labels { + display: flex; + gap: 2px; + margin-top: 6px; +} + +.timeline-label { + display: flex; + flex-direction: column; + align-items: center; + font-size: 11px; + overflow: hidden; +} + +.timeline-label-name { + color: var(--text-secondary); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.timeline-label-dur { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; +} + +.timeline-finally-sep { + width: 1px; + background: var(--border); + margin: 0 2px; + align-self: stretch; +} + +.timeline-legend { + display: flex; + gap: 16px; + margin-top: 12px; + flex-wrap: wrap; +} + +.timeline-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-secondary); +} + +.timeline-legend-dot { + width: 10px; + height: 10px; + border-radius: 3px; +} + +/* Tabs */ +.tabs { + display: flex; + gap: 2px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.tab { + padding: 8px 20px; + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + text-decoration: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} + +.tab:hover { color: var(--text-primary); text-decoration: none; } +.tab.active { color: var(--accent-blue); border-bottom-color: var(--accent-blue); } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} + +.btn:hover { background: var(--bg-tertiary); text-decoration: none; } + +.btn-primary { + background: rgba(88, 166, 255, 0.15); + color: var(--accent-blue); + border-color: rgba(88, 166, 255, 0.3); +} + +.btn-primary:hover { background: rgba(88, 166, 255, 0.25); } + +.btn-secondary { color: var(--text-secondary); } + +.btn-danger { + color: var(--accent-red); + border-color: rgba(248, 81, 73, 0.3); +} + +.btn-danger:hover { background: rgba(248, 81, 73, 0.1); } + +.btn-sm { padding: 4px 10px; font-size: 12px; } + +/* Forms */ +.page-header { margin-bottom: 24px; } +.page-header h2 { margin-bottom: 4px; } + +.submit-form { max-width: 900px; } + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 24px; +} + +@media (max-width: 700px) { .form-grid { grid-template-columns: 1fr; } } + +.form-full-width { grid-column: 1 / -1; } + +.form-group { display: flex; flex-direction: column; gap: 4px; } + +.form-group label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +.form-group select, +.form-group input, +.form-group textarea { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + padding: 8px 12px; + font-size: 14px; + font-family: var(--font-sans); + outline: none; + transition: border-color 0.15s; +} + +.form-group select:focus, +.form-group input:focus, +.form-group textarea:focus { + border-color: var(--accent-blue); +} + +.form-group textarea { + font-family: var(--font-mono); + font-size: 13px; + resize: vertical; +} + +.form-hint { + font-size: 12px; + color: var(--text-muted); +} + +.input-readonly { + background: var(--bg-tertiary) !important; + cursor: not-allowed; + opacity: 0.85; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.form-actions { + display: flex; + gap: 12px; + padding-top: 8px; +} + +/* Alerts */ +.alert { + padding: 12px 16px; + border-radius: var(--radius); + font-size: 14px; + margin-bottom: 20px; +} + +.alert-error { + background: rgba(248, 81, 73, 0.1); + border: 1px solid rgba(248, 81, 73, 0.3); + color: var(--accent-red); +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-state p { + margin-bottom: 16px; + font-size: 15px; +} + +/* Pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 20px 0; +} + +/* Status banner */ +.status-banner { + padding: 12px 20px; + border-radius: var(--radius); + font-size: 15px; + font-weight: 600; + margin-bottom: 20px; +} + +.banner-success { + background: rgba(63, 185, 80, 0.1); + border: 1px solid rgba(63, 185, 80, 0.3); + color: var(--accent-green); +} + +.banner-error { + background: rgba(248, 81, 73, 0.1); + border: 1px solid rgba(248, 81, 73, 0.3); + color: var(--accent-red); +} + +.banner-stopped { + background: rgba(139, 148, 158, 0.1); + border: 1px solid rgba(139, 148, 158, 0.3); + color: var(--text-secondary); +} + +/* Results links */ +.results-links { + display: flex; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.result-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + color: var(--accent-blue); + transition: background 0.15s; +} + +.result-link:hover { background: var(--bg-tertiary); text-decoration: none; } + +.result-icon { font-size: 16px; } + +/* Duration badge */ +.duration-badge { + font-family: var(--font-mono); + font-size: 12px; + padding: 3px 10px; + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +/* Source badge */ +.source-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 12px; + background: rgba(240, 136, 62, 0.1); + color: var(--accent-orange); + border: 1px solid rgba(240, 136, 62, 0.2); +} + +/* Current step info (live table) */ +.current-step-info { + display: flex; + align-items: center; + gap: 8px; +} + +.current-step-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + color: var(--accent-yellow); +} + +.pulse-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-yellow); + animation: pulse-anim 2s infinite; + flex-shrink: 0; +} + +.current-step-dur { + font-family: var(--font-mono); + font-size: 11px; + padding: 1px 6px; + border-radius: 10px; + background: rgba(210, 153, 34, 0.12); + color: var(--accent-yellow); + border: 1px solid rgba(210, 153, 34, 0.25); + white-space: nowrap; +} + +/* Actions dropdown */ +.actions-cell { + position: relative; + display: flex; + justify-content: center; +} + +.actions-trigger { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px 6px; + border-radius: var(--radius); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.actions-trigger:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.actions-menu { + display: none; + position: absolute; + right: 0; + top: 100%; + z-index: 50; + min-width: 160px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + padding: 4px 0; + flex-direction: column; +} + +.actions-menu.open { + display: flex; +} + +.actions-menu.open-upward { + top: auto; + bottom: 100%; +} + +.actions-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + background: none; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + transition: background 0.12s; + font-family: var(--font-sans); +} + +.actions-menu-item:hover { + background: var(--bg-tertiary); +} + +.actions-menu-item svg { + flex-shrink: 0; + opacity: 0.7; +} + +.actions-menu-item.actions-danger { + color: var(--accent-red); +} + +.actions-menu-item.actions-danger:hover { + background: rgba(248, 81, 73, 0.08); +} + +/* Toast notifications */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 1000; + padding: 12px 20px; + border-radius: var(--radius); + font-size: 13px; + font-weight: 500; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + opacity: 0; + transform: translateY(12px); + transition: opacity 0.25s, transform 0.25s; + pointer-events: none; +} + +.toast-visible { + opacity: 1; + transform: translateY(0); +} + +.toast-error { + border-color: rgba(248, 81, 73, 0.3); + color: var(--accent-red); +} diff --git a/fournos-ui/app/templates/base.html b/fournos-ui/app/templates/base.html new file mode 100644 index 0000000..0c53bed --- /dev/null +++ b/fournos-ui/app/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}Fournos Dashboard{% endblock %} + + + + + +
+
+ + {% block header_extra %}{% endblock %} +
+
+
+
+ {% block content %}{% endblock %} +
+
+ + diff --git a/fournos-ui/app/templates/components/job_detail_dynamic.html b/fournos-ui/app/templates/components/job_detail_dynamic.html new file mode 100644 index 0000000..6bd1ef7 --- /dev/null +++ b/fournos-ui/app/templates/components/job_detail_dynamic.html @@ -0,0 +1,159 @@ +{% set info = extract_forge_info(job) %} +{% set phase = job.status.phase %} +{% set progress = parse_task_progress(job.status.message) %} +{% set mlflow_url = extract_mlflow_url(job.get('status', {})) %} + +{# OOB swap: keep the header phase badge and cancel button in sync #} + + + {% if phase == "Running" %}{% endif %} + {{ phase }} + +{% if phase in ('Succeeded', 'Failed', 'Stopped') and job.status.get('completionTime') and job.status.get('startTime') %} +{{ format_age(job.status.startTime) }} +{% endif %} +{% if phase == "Running" %} + +{% endif %} + + +{# OOB swap: show status banner when job completes #} +
+{% if phase in ('Succeeded', 'Failed', 'Stopped') %} +
+ {% if phase == 'Succeeded' %}Execution succeeded{% elif phase == 'Failed' %}Execution failed{% else %}Execution stopped{% endif %} +
+{% endif %} +
+ +{# OOB swap: show results links when they become available #} + + +{# Pipeline timeline #} +{% if stages %} +{% set tl = build_timeline(stages) %} +
+
Pipeline Timeline {{ job.spec.pipeline }}
+
+ {% set finally_started = [] %} + {% for s in tl %} + {% if s['finally'] and not finally_started %}{% if finally_started.append(1) %}{% endif %}
{% endif %} +
+ {{ s.displayName }} +
+ {% endfor %} +
+
+ {% set finally_started2 = [] %} + {% for s in tl %} + {% if s['finally'] and not finally_started2 %}{% if finally_started2.append(1) %}{% endif %}
{% endif %} +
+ {{ s.displayName }} + {{ s.duration_label }} +
+ {% endfor %} +
+
+
Succeeded
+
Running
+
Failed
+
Pending
+
Cancelled
+
Skipped
+
+
+{% elif progress %} +
+
+ {% if progress.total > 0 %} +
+
+
+ {% endif %} +
+ + {{ progress.completed }} completed, {{ progress.failed }} failed, {{ progress.incomplete }} incomplete, {{ progress.skipped }} skipped + +
+{% endif %} + +{# Conditions card #} +
+
Conditions
+
+ {% if job.status.conditions %} +
    + {% for c in job.status.conditions %} +
  • +
    + {% if c.status == "True" %}✓{% elif c.status == "False" %}✗{% else %}⋯{% endif %} +
    +
    +
    {{ c.type }}
    +
    {{ c.message }}
    +
    {{ c.reason }} · {{ format_age(c.lastTransitionTime) }} ago
    +
    +
  • + {% endfor %} +
+ {% else %} + No conditions available + {% endif %} +
+
+ +{# Pods table #} +{% if pods %} +
+
Pods
+
+ + + + + + + + + + + + + {% for pod in pods %} + + + + + + + + + {% endfor %} + +
NameStatusReadyRestartsAgeLogs
{{ pod.name }} + + {% if pod.phase == "Running" %}{% endif %} + {{ pod.phase }} + + {{ "Yes" if pod.ready else "No" }}{{ pod.restarts }}{{ pod.age_minutes }}m + {% if phase in ("Running", "Pending", "Admitted") %} + View logs + {% else %} + - + {% endif %} +
+
+
+{% endif %} diff --git a/fournos-ui/app/templates/components/jobs_table_body.html b/fournos-ui/app/templates/components/jobs_table_body.html new file mode 100644 index 0000000..6bb225f --- /dev/null +++ b/fournos-ui/app/templates/components/jobs_table_body.html @@ -0,0 +1,79 @@ +{% for job in jobs %} +{% set phase = job.status.phase %} +{% set info = extract_forge_info(job) %} +{% set progress = parse_task_progress(job.status.message) %} + + + + {% if phase == "Running" %}{% endif %} + {{ phase }} + + + + {{ job.metadata.name }} + + {{ info.project }} + {{ info.args | join(", ") or "default" }} + {{ job.spec.owner }} + {{ job.spec.cluster }} + + {% set step_info = (current_steps or {}).get(job.metadata.name) %} + {% if step_info %} +
+ + + {{ step_info.displayName }} + + {% if step_info.startTime %} + {{ format_age(step_info.startTime) }} + {% endif %} +
+ {% elif progress %} +
+
+ {% if progress.total > 0 %} +
+
+
+ {% endif %} +
+ {{ progress.completed }}/{{ progress.total }} +
+ {% else %} + {{ job.status.message | truncate(40) }} + {% endif %} + + {{ format_age(job.metadata.creationTimestamp) }} + + {% if info.pr_number %} + #{{ info.pr_number }} + {% else %} + - + {% endif %} + + +
+ +
+ {% if phase in ('Running', 'Pending', 'Resolving') %} + + {% endif %} + +
+
+ + +{% endfor %} diff --git a/fournos-ui/app/templates/job_detail.html b/fournos-ui/app/templates/job_detail.html new file mode 100644 index 0000000..a947e0f --- /dev/null +++ b/fournos-ui/app/templates/job_detail.html @@ -0,0 +1,202 @@ +{% extends "base.html" %} +{% set info = extract_forge_info(job) %} +{% set phase = job.status.phase %} +{% set progress = parse_task_progress(job.status.message) %} +{% set is_history = (source == 'history') %} + +{% block title %}{{ job.metadata.name }} - Fournos{% endblock %} + +{% block content %} +
+ ← All Jobs +

{{ job.metadata.name }}

+ + + {% if phase == "Running" %}{% endif %} + {{ phase }} + + {% if is_history and job.get('_duration_seconds') is not none %} + {{ format_duration(job['_duration_seconds']) }} + {% endif %} + {% if not is_history and phase == "Running" %} + + {% endif %} + {% if is_history %} + Archived + {% endif %} + +
+ +{# Status banner for completed jobs #} +
+{% if is_history and phase in ('Succeeded', 'Failed', 'Stopped') %} +
+ {% if phase == 'Succeeded' %}Execution succeeded{% elif phase == 'Failed' %}Execution failed{% else %}Execution stopped{% endif %} + {% if job.get('_duration_seconds') is not none %} after {{ format_duration(job['_duration_seconds']) }}{% endif %} +
+{% endif %} +
+ +{# MLflow and artifacts links #} +{% set mlflow_url = job.get('_mlflow_url', '') or extract_mlflow_url(job.get('status', {})) %} +{% set ci_url = job.get('_ci_artifacts_url', '') %} + + + + +{# Dynamic section: auto-refreshes for live jobs #} +{% if not is_history %} +
+ {% include "components/job_detail_dynamic.html" %} +
+{% else %} +{% include "components/job_detail_dynamic.html" %} +{% endif %} + +
+
Test Configuration
+
+
+
Display Name
+
{{ job.spec.displayName }}
+
Owner
+
{{ job.spec.owner }}
+
Pipeline
+
{{ job.spec.pipeline }}
+
Cluster
+
{{ job.spec.cluster }}
+
Exclusive
+
{{ "Yes" if job.spec.exclusive else "No" }}
+
Project
+
{{ info.project }}
+
Args (Presets)
+
{{ info.args | join(", ") or "none" }}
+ {% if info.config_overrides %} +
Config Overrides
+
+ {% for k, v in info.config_overrides.items() %} + {{ k }}: {{ v }}{% if not loop.last %}
{% endif %} + {% endfor %} +
+ {% endif %} + {% if info.pr_number %} +
Pull Request
+
#{{ info.pr_number }}: {{ info.pr_title }}
+ {% endif %} + {% if job.get('spec', {}).get('secretRefs') %} +
Secrets
+
+ {% for s in job.spec.secretRefs %} + {{ s }}{% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} +
+
+
+ +{% if not is_history and phase in ("Running", "Pending", "Admitted") %} +
+
Logs
+
+
+ Select a pod above to view its logs. +
+
+
+{% endif %} + + +{% endblock %} diff --git a/fournos-ui/app/templates/jobs_list.html b/fournos-ui/app/templates/jobs_list.html new file mode 100644 index 0000000..1c1d0a7 --- /dev/null +++ b/fournos-ui/app/templates/jobs_list.html @@ -0,0 +1,283 @@ +{% extends "base.html" %} + +{% block header_extra %} +{% if tab == 'live' and jobs %} +
+ {% set running = jobs | selectattr("status.phase", "equalto", "Running") | list | length %} + {% set succeeded = jobs | selectattr("status.phase", "equalto", "Succeeded") | list | length %} + {% set failed = jobs | selectattr("status.phase", "equalto", "Failed") | list | length %} + {% if running %} {{ running }} running{% endif %} + {% if succeeded %} {{ succeeded }} succeeded{% endif %} + {% if failed %} {{ failed }} failed{% endif %} + {{ jobs | length }} total +
+{% endif %} +{% endblock %} + +{% block content %} +
+ Live + History +
+ +
+ + + + +
+ +{% if tab == 'live' %} +
+
+ + + + + + + + + + + + + + + + + {% include "components/jobs_table_body.html" %} + +
StatusJobProjectPresetOwnerClusterProgressAgePR
+
+
+ +{% if not jobs %} +
+

No live FournosJobs found.

+ Submit a new job +
+{% endif %} + +{% else %} +
+ + + + + + + + + + + + + + + + + + {% for job in history_jobs %} + + + + + + + + + + + + + + {% endfor %} + {% if not history_jobs %} + + + + {% endif %} + +
StatusJobProjectPresetOwnerClusterTriggerDurationDateMLflow
+ {{ job.phase }} + + {{ job.name }} + {{ job.project }}{{ job.preset or "default" }}{{ job.owner }}{{ job.cluster }} + {% if job.trigger_type == "scheduled" %} + scheduled + {% elif job.triggered_by_schedule %} + manual + {% else %} + direct + {% endif %} + {% if job.triggered_by_schedule %} + {{ job.triggered_by_schedule }} + {% endif %} + {{ format_duration(job.duration_seconds) }}{{ format_age(job.created_at) }} + {% if job.mlflow_url %} + View + {% else %} + - + {% endif %} + +
+ +
+ + +
+
+
+ No archived jobs found. +
+
+ +{% if total > per_page %} + +{% endif %} +{% endif %} + + +{% endblock %} diff --git a/fournos-ui/app/templates/schedule_runs.html b/fournos-ui/app/templates/schedule_runs.html new file mode 100644 index 0000000..5290baa --- /dev/null +++ b/fournos-ui/app/templates/schedule_runs.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %}{{ schedule_name }} Runs - Fournos{% endblock %} + +{% block content %} + + +{% if runs %} + + + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + + {% endfor %} + +
StatusJob NamePresetTriggerDurationMLflowCreated
{{ run.status }}{{ run.name }}{{ run.preset or "default" }} + {% if run.trigger_type == "scheduled" %} + scheduled + {% else %} + manual + {% endif %} + + {% if run.duration_seconds %} + {{ format_duration(run.duration_seconds) }} + {% else %} + -- + {% endif %} + + {% if run.mlflow_url %} + View in MLflow + {% else %} + -- + {% endif %} + + {% if run.created_at %} + {{ format_age(run.created_at) }} ago + {% else %} + -- + {% endif %} +
+{% else %} +
+

No runs found for this schedule yet.

+
+{% endif %} +{% endblock %} diff --git a/fournos-ui/app/templates/schedules.html b/fournos-ui/app/templates/schedules.html new file mode 100644 index 0000000..4805b5e --- /dev/null +++ b/fournos-ui/app/templates/schedules.html @@ -0,0 +1,451 @@ +{% extends "base.html" %} + +{% block title %}Schedules - Fournos{% endblock %} + +{% block content %} + + +{% if error %} +
{{ error }}
+{% endif %} + + + + +{% if cronjobs %} + + + + + + + + + + + + + + + + + {% for cj in cronjobs %} + + + + + + + + + + + + + {% endfor %} + +
StatusNameProjectPresetScheduleClusterResolverOwnerLast RunActions
+ {% if cj.suspend %} + Paused + {% else %} + Active + {% endif %} + {{ cj.name }}{{ cj.project }}{{ cj.preset or "default" }}{{ cj.schedule }}{{ cj.cluster }} + {% if cj.has_resolver %} +
+ View + +
+ {% else %} + -- + {% endif %} +
{{ cj.owner }} + {% if cj.last_schedule %} + {{ format_age(cj.last_schedule) }} ago + {% else %} + Never + {% endif %} + + +
+{% else %} +
+

No scheduled runs configured.

+
+{% endif %} + + + + + + + + + + +{% endblock %} diff --git a/fournos-ui/app/templates/submit_job.html b/fournos-ui/app/templates/submit_job.html new file mode 100644 index 0000000..62a9076 --- /dev/null +++ b/fournos-ui/app/templates/submit_job.html @@ -0,0 +1,227 @@ +{% extends "base.html" %} + +{% block title %}Submit Job - Fournos{% endblock %} + +{% block content %} + + +{% if error %} +
{{ error }}
+{% endif %} + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Select a project first to see available presets. +
+ + + +
+ + +
+ +
+ + + + + Forge will build from this commit instead of the default image. +
+ +
+ + + Same as /var directives. One key: value per line. +
+ +
+ +
+
+ +
+ + Cancel +
+
+ + +{% endblock %} diff --git a/fournos-ui/app/watcher.py b/fournos-ui/app/watcher.py new file mode 100644 index 0000000..fde6429 --- /dev/null +++ b/fournos-ui/app/watcher.py @@ -0,0 +1,262 @@ +"""Background K8s watcher that archives FournosJobs to PostgreSQL.""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import time +from datetime import datetime, timezone + +from dateutil.parser import parse as parse_dt +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app import db, k8s_client +from app.config import settings + +logger = logging.getLogger(__name__) + +_watcher_engine = None +_watcher_session: async_sessionmaker | None = None + +SYNC_INTERVAL_SECONDS = 60 +TERMINAL_PHASES = {"Succeeded", "Failed", "Stopped"} + + +def _init_watcher_db(loop: asyncio.AbstractEventLoop) -> None: + """Create a separate DB engine for the watcher's own event loop.""" + global _watcher_engine, _watcher_session + _watcher_engine = create_async_engine( + settings.database_url, echo=False, pool_size=3, max_overflow=5, + ) + _watcher_session = async_sessionmaker( + _watcher_engine, class_=AsyncSession, expire_on_commit=False, + ) + + +def _extract_forge_fields(job: dict) -> dict: + """Pull project/preset/cluster/owner/mlflow fields from a FournosJob dict.""" + meta = job.get("metadata", {}) + spec = job.get("spec", {}) + forge = spec.get("executionEngine", {}).get("forge", {}) + status = job.get("status", {}) + + mlflow_info = ( + status.get("engineStatus", {}) + .get("forge", {}) + .get("exportArtifacts", {}) + .get("caliper_artifacts_export", {}) + .get("backends", {}) + .get("mlflow", {}) + ) + mlflow_url = mlflow_info.get("run_url", "") if mlflow_info else "" + + args = forge.get("args", []) + preset = " ".join(args) if args else "" + + created_str = meta.get("creationTimestamp", "") + created_at = None + if created_str: + try: + created_at = parse_dt(created_str) + except Exception: + pass + + completed_at = None + duration_seconds = None + conditions = status.get("conditions", []) + for cond in conditions: + if cond.get("type") == "PipelineRunReady" and cond.get("status") in ("True", "False"): + try: + completed_at = parse_dt(cond["lastTransitionTime"]) + except Exception: + pass + + if created_at and completed_at: + duration_seconds = (completed_at - created_at).total_seconds() + + labels = meta.get("labels", {}) + schedule_name = labels.get("fournos-launcher/schedule-name", "") + trigger_type = labels.get("fournos-launcher/trigger-type", "manual") + + return { + "name": meta.get("name", ""), + "project": forge.get("project", ""), + "preset": preset, + "cluster": spec.get("cluster", ""), + "pipeline": spec.get("pipeline", ""), + "owner": spec.get("owner", ""), + "status": status.get("phase", "Unknown"), + "message": status.get("message", ""), + "created_at": created_at, + "completed_at": completed_at, + "duration_seconds": duration_seconds, + "mlflow_url": mlflow_url, + "config_overrides": forge.get("configOverrides", {}), + "fjob_spec": spec, + "fjob_status": status, + "triggered_by_schedule": schedule_name or None, + "trigger_type": trigger_type, + } + + +async def _archive_job(job: dict) -> None: + """Archive a single FournosJob to PostgreSQL.""" + if _watcher_session is None: + logger.warning("Watcher DB not initialised -- skipping archive") + return + fields = _extract_forge_fields(job) + job_name = fields.get("name") + if not job_name: + logger.warning("Skipping FournosJob with missing name") + return + + async with _watcher_session() as session: + async with session.begin(): + existing = await db.get_job_by_name(session, job_name) + previous_phase = existing.status if existing else None + previous_message = existing.message if existing else None + + db_job = await db.upsert_job(session, **fields) + + if fields["status"] != previous_phase or fields["message"] != previous_message: + await db.add_job_event( + session, + job_id=db_job.id, + phase=fields["status"], + message=fields["message"], + ) + + logger.info("Archived FournosJob %s (phase=%s)", job_name, fields["status"]) + + +async def _full_sync() -> None: + """List all FournosJobs from K8s and upsert each into PostgreSQL. + + This is a safety-net that runs periodically so jobs are never lost + even if individual watch events fail to process. + """ + if _watcher_session is None: + return + + try: + all_jobs = k8s_client.list_fournos_jobs() + except Exception as exc: + logger.warning("Full sync: failed to list FournosJobs: %s", exc) + return + + if not all_jobs: + return + + synced = 0 + errors = 0 + for job in all_jobs: + try: + await _archive_job(job) + synced += 1 + except Exception as exc: + name = job.get("metadata", {}).get("name", "?") + logger.warning("Full sync: failed to archive %s: %s", name, exc) + errors += 1 + + logger.info("Full sync complete: %d synced, %d errors (out of %d)", synced, errors, len(all_jobs)) + + +def _run_watch_loop(loop: asyncio.AbstractEventLoop) -> None: + """Blocking watch loop that runs in a background thread.""" + asyncio.set_event_loop(loop) + _init_watcher_db(loop) + + # Validate DB connectivity before starting the watch + try: + loop.run_until_complete(_validate_db()) + except Exception as exc: + logger.error("Watcher DB validation failed: %s", exc) + + # Initial full sync to catch any jobs created before the watcher started + try: + loop.run_until_complete(_full_sync()) + except Exception as exc: + logger.warning("Initial full sync failed: %s", exc) + + resource_version = "" + last_sync = time.monotonic() + + while True: + try: + logger.info("Starting FournosJob watch (rv=%s)", resource_version or "latest") + for event in k8s_client.watch_fournos_jobs( + resource_version=resource_version, + timeout=300, + ): + obj = event.get("object", {}) + rv = obj.get("metadata", {}).get("resourceVersion", "") + if rv: + resource_version = rv + + event_type = event.get("type", "") + if event_type in ("ADDED", "MODIFIED"): + try: + loop.run_until_complete(_archive_job(obj)) + except Exception as exc: + name = obj.get("metadata", {}).get("name", "?") + logger.error( + "Failed to archive event for %s (type=%s): %s", + name, event_type, exc, + ) + elif event_type == "DELETED": + name = obj.get("metadata", {}).get("name", "") + logger.info("FournosJob %s deleted from cluster", name) + + # Periodic full sync as safety net + if time.monotonic() - last_sync > SYNC_INTERVAL_SECONDS: + try: + loop.run_until_complete(_full_sync()) + except Exception as exc: + logger.warning("Periodic sync failed: %s", exc) + last_sync = time.monotonic() + + except Exception as exc: + logger.warning("Watch stream error (will restart in 5s): %s", exc) + time.sleep(5) + resource_version = "" + + # Run a full sync between watch reconnections + try: + loop.run_until_complete(_full_sync()) + except Exception as exc: + logger.warning("Reconnect sync failed: %s", exc) + last_sync = time.monotonic() + + +async def _validate_db() -> None: + """Quick check that the watcher can talk to PostgreSQL.""" + if _watcher_session is None: + raise RuntimeError("Session not initialised") + async with _watcher_session() as session: + await session.execute(db.select(db.func.count(db.Job.id))) + logger.info("Watcher DB connection validated") + + +_watch_thread: threading.Thread | None = None + + +def start_watcher() -> None: + """Start the background watcher thread (idempotent).""" + global _watch_thread + if _watch_thread is not None and _watch_thread.is_alive(): + return + + if not k8s_client.is_connected(): + logger.warning("K8s not connected -- watcher not started") + return + + loop = asyncio.new_event_loop() + _watch_thread = threading.Thread( + target=_run_watch_loop, + args=(loop,), + daemon=True, + name="fjob-watcher", + ) + _watch_thread.start() + logger.info("FournosJob watcher started") diff --git a/fournos-ui/kustomize/base/dashboard-clusterrole.yaml b/fournos-ui/kustomize/base/dashboard-clusterrole.yaml new file mode 100644 index 0000000..9886260 --- /dev/null +++ b/fournos-ui/kustomize/base/dashboard-clusterrole.yaml @@ -0,0 +1,28 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: fournos-dashboard + labels: + app.kubernetes.io/name: fournos-dashboard +rules: + - apiGroups: ["fournos.dev"] + resources: ["fournosjobs"] + verbs: ["get", "list", "watch", "create", "patch"] + - apiGroups: ["tekton.dev"] + resources: ["pipelineruns", "taskruns"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] + - apiGroups: ["batch"] + resources: ["cronjobs"] + verbs: ["get", "list", "create", "patch", "delete"] + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "create"] + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "create", "update", "delete"] diff --git a/fournos-ui/kustomize/base/dashboard-deployment.yaml b/fournos-ui/kustomize/base/dashboard-deployment.yaml new file mode 100644 index 0000000..57977e7 --- /dev/null +++ b/fournos-ui/kustomize/base/dashboard-deployment.yaml @@ -0,0 +1,117 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fournos-dashboard + namespace: fournos-dashboard + labels: + app.kubernetes.io/name: fournos-dashboard +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: fournos-dashboard + template: + metadata: + labels: + app.kubernetes.io/name: fournos-dashboard + spec: + serviceAccountName: fournos-dashboard + securityContext: + runAsNonRoot: true + initContainers: + - name: wait-for-postgresql + image: postgres:15-alpine + command: + - /bin/sh + - -c + - | + until pg_isready -h $PGHOST -p $PGPORT -U $PGUSER; do + echo "Waiting for PostgreSQL..." + sleep 2 + done + echo "PostgreSQL is ready" + envFrom: + - secretRef: + name: postgresql-secret + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + containers: + - name: dashboard + image: YOUR_REGISTRY/fournos-dashboard:latest + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + ports: + - containerPort: 8000 + name: http + protocol: TCP + env: + - name: PGHOST + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGHOST + - name: PGPORT + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGPORT + - name: PGUSER + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGUSER + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGPASSWORD + - name: PGDATABASE + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGDATABASE + - name: DATABASE_URL + value: "postgresql+asyncpg://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE)" + - name: FOURNOS_NAMESPACE + value: SET_VIA_OVERLAY + - name: PROJECTS_CONFIG_PATH + value: /etc/fournos-dashboard/projects.yaml + - name: LOG_LEVEL + value: INFO + volumeMounts: + - name: projects-config + mountPath: /etc/fournos-dashboard + readOnly: true + resources: + requests: + memory: 128Mi + cpu: 50m + limits: + memory: 512Mi + cpu: 500m + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 15 + timeoutSeconds: 5 + periodSeconds: 30 + failureThreshold: 5 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + timeoutSeconds: 5 + periodSeconds: 10 + failureThreshold: 3 + volumes: + - name: projects-config + configMap: + name: fournos-projects diff --git a/fournos-ui/kustomize/base/dashboard-projects-configmap-data.yaml b/fournos-ui/kustomize/base/dashboard-projects-configmap-data.yaml new file mode 100644 index 0000000..550bffe --- /dev/null +++ b/fournos-ui/kustomize/base/dashboard-projects-configmap-data.yaml @@ -0,0 +1,11 @@ +projects: + - name: example-project + cluster: my-cluster + presets: + - smoke + - nightly + - default + - name: skeleton + cluster: dev-cluster + presets: + - quick_test diff --git a/fournos-ui/kustomize/base/dashboard-projects-configmap.yaml b/fournos-ui/kustomize/base/dashboard-projects-configmap.yaml new file mode 100644 index 0000000..83fa20a --- /dev/null +++ b/fournos-ui/kustomize/base/dashboard-projects-configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: fournos-projects + namespace: fournos-dashboard + labels: + app.kubernetes.io/name: fournos-dashboard +data: + projects.yaml: | + projects: + - name: example-project + cluster: my-cluster + presets: + - smoke + - nightly + - default + - name: skeleton + cluster: dev-cluster + presets: + - quick_test diff --git a/fournos-ui/kustomize/base/dashboard-rolebinding.yaml b/fournos-ui/kustomize/base/dashboard-rolebinding.yaml new file mode 100644 index 0000000..51a0177 --- /dev/null +++ b/fournos-ui/kustomize/base/dashboard-rolebinding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: fournos-dashboard + namespace: SET_VIA_OVERLAY + labels: + app.kubernetes.io/name: fournos-dashboard +subjects: + - kind: ServiceAccount + name: fournos-dashboard + namespace: fournos-dashboard +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: fournos-dashboard diff --git a/fournos-ui/kustomize/base/dashboard-service.yaml b/fournos-ui/kustomize/base/dashboard-service.yaml new file mode 100644 index 0000000..356d956 --- /dev/null +++ b/fournos-ui/kustomize/base/dashboard-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: fournos-dashboard + namespace: fournos-dashboard + labels: + app.kubernetes.io/name: fournos-dashboard +spec: + type: ClusterIP + ports: + - name: http + port: 8000 + protocol: TCP + targetPort: 8000 + selector: + app.kubernetes.io/name: fournos-dashboard diff --git a/fournos-ui/kustomize/base/dashboard-serviceaccount.yaml b/fournos-ui/kustomize/base/dashboard-serviceaccount.yaml new file mode 100644 index 0000000..12ec6d3 --- /dev/null +++ b/fournos-ui/kustomize/base/dashboard-serviceaccount.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: fournos-dashboard + namespace: fournos-dashboard + labels: + app.kubernetes.io/name: fournos-dashboard diff --git a/fournos-ui/kustomize/base/kustomization.yaml b/fournos-ui/kustomize/base/kustomization.yaml new file mode 100644 index 0000000..8b498b4 --- /dev/null +++ b/fournos-ui/kustomize/base/kustomization.yaml @@ -0,0 +1,22 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: fournos-dashboard + +resources: + - namespace.yaml + - postgresql-statefulset.yaml + - postgresql-service.yaml + - dashboard-serviceaccount.yaml + - dashboard-deployment.yaml + - dashboard-service.yaml + - dashboard-clusterrole.yaml + +configMapGenerator: + - name: fournos-projects + files: + - projects.yaml=dashboard-projects-configmap-data.yaml + options: + disableNameSuffixHash: true + labels: + app.kubernetes.io/name: fournos-dashboard diff --git a/fournos-ui/kustomize/base/namespace.yaml b/fournos-ui/kustomize/base/namespace.yaml new file mode 100644 index 0000000..461ee1a --- /dev/null +++ b/fournos-ui/kustomize/base/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: fournos-dashboard + labels: + app.kubernetes.io/name: fournos-dashboard + app.kubernetes.io/part-of: fournos diff --git a/fournos-ui/kustomize/base/postgresql-secret.env.example b/fournos-ui/kustomize/base/postgresql-secret.env.example new file mode 100644 index 0000000..4a85c6a --- /dev/null +++ b/fournos-ui/kustomize/base/postgresql-secret.env.example @@ -0,0 +1,5 @@ +PGHOST=postgresql +PGPORT=5432 +PGDATABASE=fournos +PGUSER=fournos +PGPASSWORD=replace-with-strong-password diff --git a/fournos-ui/kustomize/base/postgresql-service.yaml b/fournos-ui/kustomize/base/postgresql-service.yaml new file mode 100644 index 0000000..ab85897 --- /dev/null +++ b/fournos-ui/kustomize/base/postgresql-service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgresql + namespace: fournos-dashboard + labels: + app.kubernetes.io/name: postgresql + app.kubernetes.io/part-of: fournos-dashboard +spec: + type: ClusterIP + ports: + - name: postgresql + port: 5432 + protocol: TCP + targetPort: 5432 + selector: + app.kubernetes.io/name: postgresql diff --git a/fournos-ui/kustomize/base/postgresql-statefulset.yaml b/fournos-ui/kustomize/base/postgresql-statefulset.yaml new file mode 100644 index 0000000..46922f6 --- /dev/null +++ b/fournos-ui/kustomize/base/postgresql-statefulset.yaml @@ -0,0 +1,86 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgresql + namespace: fournos-dashboard + labels: + app.kubernetes.io/name: postgresql + app.kubernetes.io/part-of: fournos-dashboard +spec: + serviceName: postgresql + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: postgresql + template: + metadata: + labels: + app.kubernetes.io/name: postgresql + app.kubernetes.io/part-of: fournos-dashboard + spec: + securityContext: + runAsNonRoot: true + containers: + - name: postgresql + image: postgres:15-alpine + ports: + - containerPort: 5432 + name: postgresql + protocol: TCP + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGUSER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGPASSWORD + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: postgresql-secret + key: PGDATABASE + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 1Gi + cpu: "1" + volumeMounts: + - name: postgresql-data + mountPath: /var/lib/postgresql/data + livenessProbe: + exec: + command: + - pg_isready + - -U + - $(POSTGRES_USER) + - -d + - $(POSTGRES_DB) + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - pg_isready + - -U + - $(POSTGRES_USER) + - -d + - $(POSTGRES_DB) + initialDelaySeconds: 5 + periodSeconds: 10 + volumeClaimTemplates: + - metadata: + name: postgresql-data + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi diff --git a/fournos-ui/kustomize/overlays/ocp/kustomization.yaml.example b/fournos-ui/kustomize/overlays/ocp/kustomization.yaml.example new file mode 100644 index 0000000..1c6d7fe --- /dev/null +++ b/fournos-ui/kustomize/overlays/ocp/kustomization.yaml.example @@ -0,0 +1,73 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: fournos-dashboard + +resources: + - ../../base + +secretGenerator: + - name: postgresql-secret + envs: + - postgresql-secret.env + options: + disableNameSuffixHash: true + +configMapGenerator: + - name: fournos-projects + behavior: replace + files: + - projects.yaml + options: + disableNameSuffixHash: true + labels: + app.kubernetes.io/name: fournos-dashboard + +patches: + # Dashboard container image -- replace with your registry + - target: + kind: Deployment + name: fournos-dashboard + patch: | + - op: replace + path: /spec/template/spec/containers/0/image + value: YOUR_REGISTRY/fournos-dashboard:latest + # Init container image (optional -- override if you need a subscription-gated image) + - target: + kind: Deployment + name: fournos-dashboard + patch: | + - op: replace + path: /spec/template/spec/initContainers/0/image + value: postgres:15-alpine + # FOURNOS_NAMESPACE -- the namespace where FournosJobs run (env[6] in base) + - target: + kind: Deployment + name: fournos-dashboard + patch: | + - op: replace + path: /spec/template/spec/containers/0/env/6/value + value: YOUR_FOURNOS_NAMESPACE + # PostgreSQL image (optional) + - target: + kind: StatefulSet + name: postgresql + patch: | + - op: replace + path: /spec/template/spec/containers/0/image + value: postgres:15-alpine + # Storage class -- replace with your cluster's storage class + - target: + kind: StatefulSet + name: postgresql + patch: | + - op: replace + path: /spec/volumeClaimTemplates/0/spec/storageClassName + value: YOUR_STORAGE_CLASS + +# NOTE: The cross-namespace RoleBinding (granting access to YOUR_FOURNOS_NAMESPACE) +# must be applied separately since kustomize's global namespace override interferes: +# +# cp rolebinding-psap-automation.yaml rolebinding-YOUR_NAMESPACE.yaml +# # Edit the file: set metadata.namespace to YOUR_FOURNOS_NAMESPACE +# oc apply -f rolebinding-YOUR_NAMESPACE.yaml diff --git a/fournos-ui/kustomize/overlays/ocp/params.env.example b/fournos-ui/kustomize/overlays/ocp/params.env.example new file mode 100644 index 0000000..78d372e --- /dev/null +++ b/fournos-ui/kustomize/overlays/ocp/params.env.example @@ -0,0 +1,2 @@ +POSTGRES_STORAGE_CLASS=STORAGE_CLASS_NAME +POSTGRES_STORAGE_SIZE=10Gi diff --git a/fournos-ui/kustomize/overlays/ocp/projects.yaml.example b/fournos-ui/kustomize/overlays/ocp/projects.yaml.example new file mode 100644 index 0000000..9313649 --- /dev/null +++ b/fournos-ui/kustomize/overlays/ocp/projects.yaml.example @@ -0,0 +1,7 @@ +projects: + - name: my-project + cluster: my-cluster + presets: + - smoke + - nightly + - default diff --git a/fournos-ui/requirements.txt b/fournos-ui/requirements.txt new file mode 100644 index 0000000..554927b --- /dev/null +++ b/fournos-ui/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.32,<1.0 +jinja2>=3.1,<4.0 +python-dateutil>=2.9,<3.0 +kubernetes>=31.0,<33.0 +pyyaml>=6.0,<7.0 +sqlalchemy[asyncio]>=2.0,<3.0 +asyncpg>=0.30,<1.0 +alembic>=1.14,<2.0 +pydantic>=2.9,<3.0 +python-multipart>=0.0.12,<1.0