From 60edcf2936ccdb647d1a044f5740c7edefa29274 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 12 May 2026 11:08:56 +0300 Subject: [PATCH] feat(dashboard): split queue/images views, add queued job detail Split the queue view into /queue (queue depth + job table) and /images (runner images table). The queue view now lists each queued job with repo, workflow, job name, labels, queue time, and links to the Gitea job and workflow pages. New /v1/dashboard/queued-jobs endpoint fetches detailed job info from Gitea's API (repo, run number, job name, labels, html_url, workflow path, created_at) instead of just aggregated label counts. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/gongfoo-controller/src/dashboard.rs | 30 +++++- crates/gongfoo-controller/src/gitea.rs | 108 +++++++++++++++++++++ crates/gongfoo-controller/src/main.rs | 4 + crates/gongfoo-proto/src/dashboard.rs | 12 +++ dashboard/src/App.tsx | 1 + dashboard/src/api.ts | 3 + dashboard/src/app.css | 10 ++ dashboard/src/components/ImagesView.tsx | 51 ++++++++++ dashboard/src/components/QueueView.tsx | 89 +++++++++++++---- dashboard/src/main.tsx | 2 + dashboard/src/types.ts | 11 +++ 11 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 dashboard/src/components/ImagesView.tsx diff --git a/crates/gongfoo-controller/src/dashboard.rs b/crates/gongfoo-controller/src/dashboard.rs index dc1056e..fff9336 100644 --- a/crates/gongfoo-controller/src/dashboard.rs +++ b/crates/gongfoo-controller/src/dashboard.rs @@ -12,7 +12,8 @@ use uuid::Uuid; use crate::gitea::GiteaClient; use gongfoo_proto::{ DashboardActivityBucket, DashboardGiteaRunner, DashboardHost, DashboardImage, - DashboardQueueEntry, DashboardRunner, DashboardScalingEntry, DashboardSummary, + DashboardQueueEntry, DashboardQueuedJob, DashboardRunner, DashboardScalingEntry, + DashboardSummary, }; pub struct DashboardState { @@ -365,6 +366,33 @@ pub async fn handle_queue(State(state): State>) -> impl Into Json(entries) } +/// `GET /v1/dashboard/queued-jobs` +pub async fn handle_queued_jobs(State(state): State>) -> impl IntoResponse { + let jobs = match state.gitea.list_queued_jobs().await { + Ok(j) => j, + Err(e) => { + tracing::error!("failed to list queued jobs: {e}"); + return Json(Vec::::new()); + } + }; + + let result: Vec = jobs + .into_iter() + .map(|j| DashboardQueuedJob { + repo: j.repo, + run_number: j.run_number, + job_name: j.job_name, + labels: j.labels, + workflow: j.workflow, + job_url: j.job_url, + workflow_url: j.workflow_url, + created_at: j.created_at, + }) + .collect(); + + Json(result) +} + /// `GET /v1/dashboard/activity` pub async fn handle_activity(State(state): State>) -> impl IntoResponse { let rows: Vec<(chrono::DateTime, i64, i64)> = sqlx::query_as( diff --git a/crates/gongfoo-controller/src/gitea.rs b/crates/gongfoo-controller/src/gitea.rs index 0245900..d5c9472 100644 --- a/crates/gongfoo-controller/src/gitea.rs +++ b/crates/gongfoo-controller/src/gitea.rs @@ -37,6 +37,19 @@ pub struct GiteaLabel { pub name: String, } +/// A queued job with full detail for the dashboard. +#[derive(Debug)] +pub struct QueuedJobDetail { + pub repo: String, + pub run_number: i64, + pub job_name: String, + pub labels: Vec, + pub workflow: String, + pub job_url: String, + pub workflow_url: String, + pub created_at: String, +} + #[derive(Debug, Deserialize)] struct RepoSearchResult { data: Option>, @@ -51,6 +64,10 @@ struct Repo { struct WorkflowRun { id: i64, status: String, + #[serde(default)] + run_number: i64, + #[serde(default)] + path: String, } /// Gitea returns `{"workflow_runs": [...]}` in newer versions, @@ -66,6 +83,12 @@ struct Job { status: String, #[serde(default)] labels: Vec, + #[serde(default)] + name: String, + #[serde(default)] + html_url: String, + #[serde(default)] + created_at: String, } #[derive(Debug, Deserialize)] @@ -301,6 +324,91 @@ impl GiteaClient { Ok(runners) } + /// List all queued/waiting jobs across all repos with full detail. + pub async fn list_queued_jobs(&self) -> anyhow::Result> { + let repos = self.list_repos().await?; + let mut jobs = Vec::new(); + + for repo in &repos { + let url = self.api_url(&format!("/repos/{repo}/actions/runs")); + let body = match self + .client + .get(&url) + .query(&[("status", "queued"), ("limit", "30")]) + .header("Authorization", format!("token {}", self.token)) + .send() + .await + { + Ok(resp) => resp.error_for_status()?.text().await?, + Err(e) => { + tracing::warn!(repo = %repo, "failed to fetch runs: {e}"); + continue; + } + }; + + let runs: Vec = + if let Ok(wrapper) = serde_json::from_str::(&body) { + wrapper.workflow_runs + } else if let Ok(arr) = serde_json::from_str::>(&body) { + arr + } else { + continue; + }; + + for run in &runs { + if run.status != "waiting" && run.status != "queued" { + continue; + } + + let workflow = run.path.split('@').next().unwrap_or(&run.path).to_owned(); + + let workflow_url = + format!("{}/{}/actions/?workflow={}", self.base_url, repo, workflow); + + let jobs_url = self.api_url(&format!("/repos/{repo}/actions/runs/{}/jobs", run.id)); + let jobs_body = match self + .client + .get(&jobs_url) + .header("Authorization", format!("token {}", self.token)) + .send() + .await + { + Ok(resp) => resp.error_for_status()?.text().await?, + Err(e) => { + tracing::warn!(repo = %repo, run_id = run.id, "failed to fetch jobs: {e}"); + continue; + } + }; + + let run_jobs: Vec = + if let Ok(wrapper) = serde_json::from_str::(&jobs_body) { + wrapper.jobs + } else if let Ok(arr) = serde_json::from_str::>(&jobs_body) { + arr + } else { + continue; + }; + + for job in &run_jobs { + if job.status == "waiting" || job.status == "queued" { + jobs.push(QueuedJobDetail { + repo: repo.clone(), + run_number: run.run_number, + job_name: job.name.clone(), + labels: job.labels.clone(), + workflow: workflow.clone(), + job_url: job.html_url.clone(), + workflow_url: workflow_url.clone(), + created_at: job.created_at.clone(), + }); + } + } + } + } + + Ok(jobs) + } + /// Delete a runner from Gitea by its Gitea runner ID. pub async fn delete_runner(&self, runner_id: i64) -> anyhow::Result<()> { let status = self diff --git a/crates/gongfoo-controller/src/main.rs b/crates/gongfoo-controller/src/main.rs index 510afc5..423f2ff 100644 --- a/crates/gongfoo-controller/src/main.rs +++ b/crates/gongfoo-controller/src/main.rs @@ -98,6 +98,10 @@ async fn main() -> anyhow::Result<()> { "/v1/dashboard/gitea-runners", get(dashboard::handle_gitea_runners), ) + .route( + "/v1/dashboard/queued-jobs", + get(dashboard::handle_queued_jobs), + ) .with_state(dashboard_state), ); diff --git a/crates/gongfoo-proto/src/dashboard.rs b/crates/gongfoo-proto/src/dashboard.rs index 1dfbb82..3b32eec 100644 --- a/crates/gongfoo-proto/src/dashboard.rs +++ b/crates/gongfoo-proto/src/dashboard.rs @@ -73,6 +73,18 @@ pub struct DashboardActivityBucket { pub failed: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardQueuedJob { + pub repo: String, + pub run_number: i64, + pub job_name: String, + pub labels: Vec, + pub workflow: String, + pub job_url: String, + pub workflow_url: String, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DashboardGiteaRunner { pub gitea_id: i64, diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 21c4e65..7f0d3b8 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -7,6 +7,7 @@ const tabs = [ { to: "/runners", label: "runners" }, { to: "/gitea", label: "gitea" }, { to: "/queue", label: "queue" }, + { to: "/images", label: "images" }, { to: "/scaling", label: "scaling" }, { to: "/activity", label: "activity" }, ]; diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index e5c970d..4247bd3 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -5,6 +5,7 @@ import type { DashboardRunner, DashboardQueueEntry, DashboardActivityBucket, + DashboardQueuedJob, DashboardGiteaRunner, DashboardScalingEntry, } from "./types"; @@ -28,6 +29,8 @@ export const api = { fetchApi("/v1/dashboard/activity"), scaling: () => fetchApi("/v1/dashboard/scaling"), + queuedJobs: () => + fetchApi("/v1/dashboard/queued-jobs"), giteaRunners: () => fetchApi("/v1/dashboard/gitea-runners"), }; diff --git a/dashboard/src/app.css b/dashboard/src/app.css index 497551b..1b3b49d 100644 --- a/dashboard/src/app.css +++ b/dashboard/src/app.css @@ -233,6 +233,16 @@ h3 { transform: rotate(180deg); } +.ext-link { + color: var(--accent); + text-decoration: none; + font-size: 13px; +} + +.ext-link:hover { + text-decoration: underline; +} + .label-chip { display: inline-block; background: var(--border); diff --git a/dashboard/src/components/ImagesView.tsx b/dashboard/src/components/ImagesView.tsx new file mode 100644 index 0000000..fcc2b0c --- /dev/null +++ b/dashboard/src/components/ImagesView.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import type { DashboardImage } from "../types"; + +export function ImagesView() { + const [images, setImages] = useState([]); + + useEffect(() => { + const load = () => { + api.images().then(setImages).catch(console.error); + }; + load(); + const id = setInterval(load, 5000); + return () => clearInterval(id); + }, []); + + return ( +
+ + + + + + + + + + + + {images.map((img) => ( + + + + + + + + ))} + +
namelabelscpumem (MiB)image ref
{img.name} + {img.labels.map((l) => ( + + {l} + + ))} + {img.cpu_request}{img.mem_request_mb} + {img.image_ref} +
+
+ ); +} diff --git a/dashboard/src/components/QueueView.tsx b/dashboard/src/components/QueueView.tsx index 7b6ae45..d991e08 100644 --- a/dashboard/src/components/QueueView.tsx +++ b/dashboard/src/components/QueueView.tsx @@ -1,15 +1,23 @@ import { useEffect, useState } from "react"; import { api } from "../api"; -import type { DashboardQueueEntry, DashboardImage } from "../types"; +import type { DashboardQueueEntry, DashboardQueuedJob } from "../types"; + +function timeAgo(iso: string): string { + if (!iso) return "—"; + const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; + return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`; +} export function QueueView() { const [queue, setQueue] = useState([]); - const [images, setImages] = useState([]); + const [jobs, setJobs] = useState([]); useEffect(() => { const load = () => { api.queue().then(setQueue).catch(console.error); - api.images().then(setImages).catch(console.error); + api.queuedJobs().then(setJobs).catch(console.error); }; load(); const id = setInterval(load, 5000); @@ -18,20 +26,27 @@ export function QueueView() { return (
-

queue depth

+

queue depth by label set

{queue.map((q) => (
0 ? "3px solid #f59e0b" : "3px solid #374151", + borderLeft: + q.queued_count > 0 + ? "3px solid #f59e0b" + : "3px solid #374151", }} >
{q.queued_count}
{q.label_set}
- observed {Math.floor((Date.now() - new Date(q.observed_at).getTime()) / 1000)}s ago + observed{" "} + {Math.floor( + (Date.now() - new Date(q.observed_at).getTime()) / 1000 + )} + s ago
))} @@ -42,29 +57,65 @@ export function QueueView() { )}
-

runner images

+

queued jobs

- + + + - - - + + - {images.map((img) => ( - - - - - - + + + + + + ))} + {jobs.length === 0 && ( + + + + )}
namerepoworkflowjob labelscpumem (MiB)image refqueuedlinks
{img.name}{img.labels.join(", ")}{img.cpu_request}{img.mem_request_mb} - {img.image_ref} + {jobs.map((j, i) => ( +
{j.repo} + + {j.workflow} + + {j.job_name} + {j.labels.map((l) => ( + + {l} + + ))} + + {timeAgo(j.created_at)} + + + job +
+ no queued jobs +
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 5b8cdbc..840e8d1 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -7,6 +7,7 @@ import { HostsView } from "./components/HostsView"; import { RunnersView } from "./components/RunnersView"; import { GiteaRunnersView } from "./components/GiteaRunnersView"; import { QueueView } from "./components/QueueView"; +import { ImagesView } from "./components/ImagesView"; import { ScalingView } from "./components/ScalingView"; import { ActivityChart } from "./components/ActivityChart"; @@ -23,6 +24,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index fdd6f56..c065aca 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -61,6 +61,17 @@ export interface DashboardActivityBucket { failed: number; } +export interface DashboardQueuedJob { + repo: string; + run_number: number; + job_name: string; + labels: string[]; + workflow: string; + job_url: string; + workflow_url: string; + created_at: string; +} + export interface DashboardGiteaRunner { gitea_id: number; name: string;