feat(dashboard): split queue/images views, add queued job detail
Some checks failed
build / check (push) Successful in 2m52s
build / clippy (push) Has been cancelled
build / test (push) Has been cancelled
build / fmt (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 11:08:56 +03:00
parent 9b8b837279
commit 60edcf2936
11 changed files with 301 additions and 20 deletions

View File

@@ -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<Arc<DashboardState>>) -> impl Into
Json(entries)
}
/// `GET /v1/dashboard/queued-jobs`
pub async fn handle_queued_jobs(State(state): State<Arc<DashboardState>>) -> 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::<DashboardQueuedJob>::new());
}
};
let result: Vec<DashboardQueuedJob> = 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<Arc<DashboardState>>) -> impl IntoResponse {
let rows: Vec<(chrono::DateTime<Utc>, i64, i64)> = sqlx::query_as(

View File

@@ -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<String>,
pub workflow: String,
pub job_url: String,
pub workflow_url: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
struct RepoSearchResult {
data: Option<Vec<Repo>>,
@@ -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<String>,
#[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<Vec<QueuedJobDetail>> {
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<WorkflowRun> =
if let Ok(wrapper) = serde_json::from_str::<WorkflowRunsWrapper>(&body) {
wrapper.workflow_runs
} else if let Ok(arr) = serde_json::from_str::<Vec<WorkflowRun>>(&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<Job> =
if let Ok(wrapper) = serde_json::from_str::<JobsWrapper>(&jobs_body) {
wrapper.jobs
} else if let Ok(arr) = serde_json::from_str::<Vec<Job>>(&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

View File

@@ -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),
);

View File

@@ -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<String>,
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,

View File

@@ -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" },
];

View File

@@ -5,6 +5,7 @@ import type {
DashboardRunner,
DashboardQueueEntry,
DashboardActivityBucket,
DashboardQueuedJob,
DashboardGiteaRunner,
DashboardScalingEntry,
} from "./types";
@@ -28,6 +29,8 @@ export const api = {
fetchApi<DashboardActivityBucket[]>("/v1/dashboard/activity"),
scaling: () =>
fetchApi<DashboardScalingEntry[]>("/v1/dashboard/scaling"),
queuedJobs: () =>
fetchApi<DashboardQueuedJob[]>("/v1/dashboard/queued-jobs"),
giteaRunners: () =>
fetchApi<DashboardGiteaRunner[]>("/v1/dashboard/gitea-runners"),
};

View File

@@ -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);

View File

@@ -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<DashboardImage[]>([]);
useEffect(() => {
const load = () => {
api.images().then(setImages).catch(console.error);
};
load();
const id = setInterval(load, 5000);
return () => clearInterval(id);
}, []);
return (
<div className="view">
<table>
<thead>
<tr>
<th>name</th>
<th>labels</th>
<th>cpu</th>
<th>mem (MiB)</th>
<th>image ref</th>
</tr>
</thead>
<tbody>
{images.map((img) => (
<tr key={img.id}>
<td>{img.name}</td>
<td>
{img.labels.map((l) => (
<span key={l} className="label-chip">
{l}
</span>
))}
</td>
<td>{img.cpu_request}</td>
<td>{img.mem_request_mb}</td>
<td className="mono" style={{ fontSize: 12 }}>
{img.image_ref}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -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<DashboardQueueEntry[]>([]);
const [images, setImages] = useState<DashboardImage[]>([]);
const [jobs, setJobs] = useState<DashboardQueuedJob[]>([]);
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 (
<div className="view">
<h3>queue depth</h3>
<h3>queue depth by label set</h3>
<div className="cards">
{queue.map((q) => (
<div
key={q.label_set}
className="card"
style={{
borderLeft: q.queued_count > 0 ? "3px solid #f59e0b" : "3px solid #374151",
borderLeft:
q.queued_count > 0
? "3px solid #f59e0b"
: "3px solid #374151",
}}
>
<div className="card-value">{q.queued_count}</div>
<div className="card-label">{q.label_set}</div>
<div className="card-detail">
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
</div>
</div>
))}
@@ -42,29 +57,65 @@ export function QueueView() {
)}
</div>
<h3>runner images</h3>
<h3>queued jobs</h3>
<table>
<thead>
<tr>
<th>name</th>
<th>repo</th>
<th>workflow</th>
<th>job</th>
<th>labels</th>
<th>cpu</th>
<th>mem (MiB)</th>
<th>image ref</th>
<th>queued</th>
<th>links</th>
</tr>
</thead>
<tbody>
{images.map((img) => (
<tr key={img.id}>
<td>{img.name}</td>
<td>{img.labels.join(", ")}</td>
<td>{img.cpu_request}</td>
<td>{img.mem_request_mb}</td>
<td className="mono" style={{ fontSize: 12 }}>
{img.image_ref}
{jobs.map((j, i) => (
<tr key={`${j.repo}-${j.run_number}-${i}`}>
<td>{j.repo}</td>
<td>
<a
href={j.workflow_url}
target="_blank"
rel="noopener noreferrer"
className="ext-link"
>
{j.workflow}
</a>
</td>
<td>{j.job_name}</td>
<td>
{j.labels.map((l) => (
<span key={l} className="label-chip">
{l}
</span>
))}
</td>
<td style={{ color: "#9ca3af", fontSize: 13 }}>
{timeAgo(j.created_at)}
</td>
<td>
<a
href={j.job_url}
target="_blank"
rel="noopener noreferrer"
className="ext-link"
>
job
</a>
</td>
</tr>
))}
{jobs.length === 0 && (
<tr>
<td
colSpan={6}
style={{ textAlign: "center", color: "#6b7280" }}
>
no queued jobs
</td>
</tr>
)}
</tbody>
</table>
</div>

View File

@@ -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(
<Route path="gitea" element={<Navigate to="/gitea/all" replace />} />
<Route path="gitea/:filter" element={<GiteaRunnersView />} />
<Route path="queue" element={<QueueView />} />
<Route path="images" element={<ImagesView />} />
<Route path="scaling" element={<ScalingView />} />
<Route path="activity" element={<ActivityChart />} />
<Route path="*" element={<Navigate to="/summary" replace />} />

View File

@@ -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;