Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50650476ad | ||
|
|
00cca0c903 |
4 changed files with 124 additions and 35 deletions
|
|
@ -1,16 +1,22 @@
|
|||
// <AgentsPage> — the swarm's agent roster, merged with each agent's open
|
||||
// config-PR status in the same table. Two separate asks (a roster listing,
|
||||
// and a per-agent config-PR indicator) that turned out to be one page: a
|
||||
// roster with no per-row detail is thin, and a config-PR panel with no
|
||||
// roster to embed it in has nothing to render against.
|
||||
// config-PR status and swarm-wide health status in the same table. Three
|
||||
// separate asks (a roster listing, a per-agent config-PR indicator, and
|
||||
// per-agent status/liveness) that turned out to be one page: a roster with
|
||||
// no per-row detail is thin, and neither a config-PR panel nor a status
|
||||
// panel has anything to render against without a roster to embed in.
|
||||
//
|
||||
// Two fetches, both on a refresh-interval cadence like HivesPage: `GET
|
||||
// /api/agents` (just names — the identity store is the roster, and
|
||||
// deliberately says nothing about health) and `GET /api/config-prs`
|
||||
// (agent name -> open PR, only agents with one present). Merged
|
||||
// client-side into one row per agent rather than N per-agent config-PR
|
||||
// calls — exactly why the bulk endpoint exists instead of looping the
|
||||
// single-agent one.
|
||||
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
||||
// /api/agents/status` — one row per roster agent (identity store is the
|
||||
// roster, so a never-reported agent still gets a row), each already
|
||||
// carrying its freshness/snapshot *and* its open config PR if any. That
|
||||
// merge used to be three separate fetches (`/api/agents`, `/api/config-prs`,
|
||||
// `/api/agents/status`) joined client-side by name; per operator review
|
||||
// feedback ("the view should be filled by a single backend call") the
|
||||
// join moved server-side instead — see `swarm-controller/src/main.rs`'s
|
||||
// `get_agents_status` handler for where `config_pr` gets merged in and why
|
||||
// that's the handler's job rather than `agent_status::AgentStatusReader`'s.
|
||||
// No more per-page joining left to do here: the wire row *is* the table
|
||||
// row.
|
||||
//
|
||||
// Owns the "+ agent" trigger too — the roster this populates is the
|
||||
// natural home for the action that populates it; a separate top-level
|
||||
|
|
@ -20,10 +26,11 @@
|
|||
import { useState } from 'preact/hooks';
|
||||
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
||||
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
|
||||
import { Badge } from '@hive/shared/badge.js';
|
||||
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
|
||||
import { Button } from '../ui/button/Button.js';
|
||||
import { Dialog } from '../ui/dialog/Dialog.js';
|
||||
import { Panel } from '../ui/panel/Panel.js';
|
||||
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
|
|
@ -37,27 +44,84 @@ interface ConfigPrStatus {
|
|||
html_url: string | null;
|
||||
}
|
||||
|
||||
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
|
||||
|
||||
interface AgentStatusSnapshot {
|
||||
status_text: string | null;
|
||||
status_set_at: number | null;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
// Mirrors `agent_status::AgentStatusRow` field-for-field — this *is* the
|
||||
// table row now, not a shape assembled from it, so there's no separate
|
||||
// join-result type to keep in sync with the wire contract by hand.
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
configPr: ConfigPrStatus | null;
|
||||
hive: string | null;
|
||||
freshness: Freshness;
|
||||
last_seen_unix: number | null;
|
||||
// `age_seconds` deliberately unused here, same reason as HivesPage:
|
||||
// `RelativeTime` recomputes age client-side from `last_seen_unix`
|
||||
// rather than rendering a once-computed-at-fetch value.
|
||||
snapshot: AgentStatusSnapshot | null;
|
||||
config_pr: ConfigPrStatus | null;
|
||||
}
|
||||
|
||||
// Same tone/label pairing as HivesPage — one freshness enum shared by both
|
||||
// endpoints, so the same rendering rule applies to both pages.
|
||||
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
||||
fresh: { tone: 'positive', label: 'fresh' },
|
||||
stale: { tone: 'warning', label: 'stale' },
|
||||
never_reported: { tone: 'neutral', label: 'never reported' },
|
||||
unknown: { tone: 'negative', label: 'unknown' },
|
||||
};
|
||||
|
||||
const COLUMNS: TableColumn<AgentRow>[] = [
|
||||
{ key: 'name', header: 'name', render: (a) => a.name },
|
||||
{ key: 'hive', header: 'hive', render: (a) => a.hive ?? '—' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'status',
|
||||
render: (a) => {
|
||||
const { tone, label } = FRESHNESS[a.freshness];
|
||||
const text = a.snapshot?.status_text;
|
||||
// A `running: false` snapshot always carries `status_text: null`
|
||||
// (the wire contract's own rule, not something this page derives),
|
||||
// so an agent that reported recently but isn't running still shows
|
||||
// a bare freshness badge rather than a stale status string.
|
||||
return (
|
||||
<Badge
|
||||
tone={tone}
|
||||
value={
|
||||
<>
|
||||
{text ? `${text} — ` : ''}
|
||||
{label}
|
||||
{a.last_seen_unix !== null ? (
|
||||
<>
|
||||
{' '}
|
||||
(<RelativeTime epochMs={a.last_seen_unix * 1000} />)
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'config-pr',
|
||||
header: 'config PR',
|
||||
render: (a) =>
|
||||
a.configPr ? (
|
||||
a.config_pr ? (
|
||||
<Badge
|
||||
tone="warning"
|
||||
value={
|
||||
a.configPr.html_url ? (
|
||||
<a href={a.configPr.html_url} target="_blank" rel="noreferrer">
|
||||
#{a.configPr.pr_number}
|
||||
a.config_pr.html_url ? (
|
||||
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
|
||||
#{a.config_pr.pr_number}
|
||||
</a>
|
||||
) : (
|
||||
`#${a.configPr.pr_number}`
|
||||
`#${a.config_pr.pr_number}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
@ -80,18 +144,12 @@ export function AgentsPage() {
|
|||
|
||||
useRefreshInterval(intervalMs, () => {
|
||||
(async () => {
|
||||
const [namesRes, prsRes] = await Promise.all([fetch('/api/agents'), fetch('/api/config-prs')]);
|
||||
if (!namesRes.ok) {
|
||||
setError(await readApiError(namesRes));
|
||||
const res = await fetch('/api/agents/status');
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
if (!prsRes.ok) {
|
||||
setError(await readApiError(prsRes));
|
||||
return;
|
||||
}
|
||||
const names = (await namesRes.json()) as string[];
|
||||
const prs = (await prsRes.json()) as Record<string, ConfigPrStatus>;
|
||||
setRows(names.map((name) => ({ name, configPr: prs[name] ?? null })));
|
||||
setRows((await res.json()) as AgentRow[]);
|
||||
// A refresh that succeeds clears a previous failure — otherwise a
|
||||
// transient error would sit on screen forever after the data itself
|
||||
// has recovered.
|
||||
|
|
|
|||
|
|
@ -45,6 +45,17 @@ pub struct AgentStatusRow {
|
|||
/// failed to decode as [`swarm_queue_client::agent_status::AgentStatus`]
|
||||
/// (logged as a warning naming the agent).
|
||||
pub snapshot: Option<serde_json::Value>,
|
||||
/// The agent's open config PR, if any. Always `None` coming out of
|
||||
/// [`AgentStatusReader::view`] itself — this module has no forge
|
||||
/// client and stays that way, same separation
|
||||
/// [`crate::status`]/[`crate::agent_status`] already keep from each
|
||||
/// other. `GET /api/agents/status`'s handler fills this field in
|
||||
/// after the fact from `AppState::config_prs`, which is the one
|
||||
/// place that already holds both a roster-shaped answer and a
|
||||
/// config-PR cache — see the handler for why merging there, not
|
||||
/// here, is what makes this the single call swarm-ui's agent roster
|
||||
/// page needs.
|
||||
pub config_pr: Option<crate::forge::ConfigPrStatus>,
|
||||
}
|
||||
|
||||
/// A snapshot as retained by the bucket, with the hive it was published
|
||||
|
|
@ -164,6 +175,7 @@ fn render(
|
|||
last_seen_unix: None,
|
||||
age_seconds: None,
|
||||
snapshot: None,
|
||||
config_pr: None,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -206,6 +218,7 @@ fn row(
|
|||
.and_then(|d| i64::try_from(d.as_secs()).ok()),
|
||||
age_seconds: age.map(|age| age.as_secs()),
|
||||
snapshot: offered.payload.clone(),
|
||||
config_pr: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ use forgejo_api::structs::{
|
|||
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
|
||||
use futures_util::{StreamExt as _, TryStreamExt as _};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ use crate::webhook::DeliveryKind;
|
|||
|
||||
/// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it
|
||||
/// and `GET /api/agents/{name}/config-pr` serves it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ConfigPrStatus {
|
||||
pub pr_number: u64,
|
||||
/// Absent only if Forgejo itself omitted the field — every real PR has
|
||||
|
|
|
|||
|
|
@ -889,17 +889,27 @@ async fn get_hives_status(
|
|||
}
|
||||
}
|
||||
|
||||
/// What each agent last said about itself, read from the swarm queue at
|
||||
/// request time.
|
||||
/// What each agent last said about itself, plus its open config PR if any,
|
||||
/// read at request time — the single call swarm-ui's agent roster page
|
||||
/// fills its whole table from (mara, 2026-09-02: "the view should be
|
||||
/// filled by a single backend call").
|
||||
///
|
||||
/// Every agent in the roster gets a row whether or not it has ever
|
||||
/// reported — see the `agent_status` module for why, and for why its hive
|
||||
/// comes from its own bucket key rather than a second lookup.
|
||||
/// comes from its own bucket key rather than a second lookup. `config_pr`
|
||||
/// is merged in here rather than inside `agent_status::AgentStatusReader`:
|
||||
/// that module has no forge client and is not the place to add one just
|
||||
/// for this one field, whereas this handler already holds both a
|
||||
/// roster-shaped status view and `AppState::config_prs` — the one spot
|
||||
/// that has both without a second, avoidable dependency. Absent on every
|
||||
/// row when no forge is configured here, same "absence is the answer"
|
||||
/// shape `get_config_prs` uses — not a reason to fail the whole response
|
||||
/// over a field that already knows how to be missing.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/status",
|
||||
responses(
|
||||
(status = 200, description = "a row per agent, freshness derived now", body = Vec<agent_status::AgentStatusRow>),
|
||||
(status = 200, description = "a row per agent, freshness + config PR derived now", body = Vec<agent_status::AgentStatusRow>),
|
||||
(status = 503, description = "no swarm queue is configured here, no identity bridge is configured, or either store could not be read", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
|
|
@ -923,7 +933,15 @@ async fn get_agents_status(
|
|||
StatusUnavailable(detail)
|
||||
})?;
|
||||
match reader.view(&roster, std::time::SystemTime::now()).await {
|
||||
Ok(rows) => Ok(Json(rows)),
|
||||
Ok(mut rows) => {
|
||||
if let Some(cache) = state.config_prs.as_ref() {
|
||||
let mut config_prs = cache.snapshot();
|
||||
for row in &mut rows {
|
||||
row.config_pr = config_prs.remove(&row.name);
|
||||
}
|
||||
}
|
||||
Ok(Json(rows))
|
||||
}
|
||||
Err(e) => {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "reading the agent-status bucket failed");
|
||||
|
|
|
|||
Loading…
Reference in a new issue