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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
51
dashboard/src/components/ImagesView.tsx
Normal file
51
dashboard/src/components/ImagesView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user