Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
50650476ad swarm-controller+swarm-ui: merge config-PR into GET /api/agents/status
Per mara's review call on this PR: "the view should be filled by a single
backend call." AgentsPage.tsx was doing three fetches (/api/agents,
/api/config-prs, /api/agents/status) and joining them client-side by name.

Moves the config-PR join server-side instead: AgentStatusRow gains a
config_pr field, populated by get_agents_status's handler from
AppState::config_prs after agent_status::AgentStatusReader::view() returns
- not inside that module, which has no forge client and stays that way (see
the field's doc comment for why the handler is the right layer for this
merge, not the reader).

AgentsPage.tsx now does exactly one fetch and no client-side joining at all
- the wire row is the table row. Dropped the separate AgentStatusRow TS
interface (folded into AgentRow, which now mirrors the backend type
field-for-field) and the /api/agents + /api/config-prs fetches entirely;
neither is needed once /api/agents/status already returns every roster
agent with its config PR attached.

ConfigPrStatus gained Deserialize (previously Serialize-only) since
AgentStatusRow derives both and a struct's derive requires every field to
support it.
2026-09-02 14:18:02 +02:00
iris
00cca0c903 swarm-ui: merge per-agent status into AgentsPage
Continues #3341 item 3, unblocked now that #3568/#3569 (items 1/2)
are merged and GET /api/agents/status is live.

Third fetch alongside the existing roster + config-PR ones, joined
client-side by name same as the config-PR merge. Adds a hive column
and a status column (freshness badge + status_text + relative-time,
same rendering AgentsPage's sibling HivesPage already uses for the
hive-level status endpoint).
2026-09-02 14:18:02 +02:00
4 changed files with 124 additions and 35 deletions

View file

@ -1,16 +1,22 @@
// <AgentsPage> — the swarm's agent roster, merged with each agent's open // <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, // config-PR status and swarm-wide health status in the same table. Three
// and a per-agent config-PR indicator) that turned out to be one page: a // separate asks (a roster listing, a per-agent config-PR indicator, and
// roster with no per-row detail is thin, and a config-PR panel with no // per-agent status/liveness) that turned out to be one page: a roster with
// roster to embed it in has nothing to render against. // 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 // One fetch, on a refresh-interval cadence like HivesPage: `GET
// /api/agents` (just names — the identity store is the roster, and // /api/agents/status` — one row per roster agent (identity store is the
// deliberately says nothing about health) and `GET /api/config-prs` // roster, so a never-reported agent still gets a row), each already
// (agent name -> open PR, only agents with one present). Merged // carrying its freshness/snapshot *and* its open config PR if any. That
// client-side into one row per agent rather than N per-agent config-PR // merge used to be three separate fetches (`/api/agents`, `/api/config-prs`,
// calls — exactly why the bulk endpoint exists instead of looping the // `/api/agents/status`) joined client-side by name; per operator review
// single-agent one. // 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 // Owns the "+ agent" trigger too — the roster this populates is the
// natural home for the action that populates it; a separate top-level // natural home for the action that populates it; a separate top-level
@ -20,10 +26,11 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js'; import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.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 { Button } from '../ui/button/Button.js';
import { Dialog } from '../ui/dialog/Dialog.js'; import { Dialog } from '../ui/dialog/Dialog.js';
import { Panel } from '../ui/panel/Panel.js'; import { Panel } from '../ui/panel/Panel.js';
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
useRefreshInterval, useRefreshInterval,
@ -37,27 +44,84 @@ interface ConfigPrStatus {
html_url: string | null; 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 { interface AgentRow {
name: string; 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>[] = [ const COLUMNS: TableColumn<AgentRow>[] = [
{ key: 'name', header: 'name', render: (a) => a.name }, { 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', key: 'config-pr',
header: 'config PR', header: 'config PR',
render: (a) => render: (a) =>
a.configPr ? ( a.config_pr ? (
<Badge <Badge
tone="warning" tone="warning"
value={ value={
a.configPr.html_url ? ( a.config_pr.html_url ? (
<a href={a.configPr.html_url} target="_blank" rel="noreferrer"> <a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
#{a.configPr.pr_number} #{a.config_pr.pr_number}
</a> </a>
) : ( ) : (
`#${a.configPr.pr_number}` `#${a.config_pr.pr_number}`
) )
} }
/> />
@ -80,18 +144,12 @@ export function AgentsPage() {
useRefreshInterval(intervalMs, () => { useRefreshInterval(intervalMs, () => {
(async () => { (async () => {
const [namesRes, prsRes] = await Promise.all([fetch('/api/agents'), fetch('/api/config-prs')]); const res = await fetch('/api/agents/status');
if (!namesRes.ok) { if (!res.ok) {
setError(await readApiError(namesRes)); setError(await readApiError(res));
return; return;
} }
if (!prsRes.ok) { setRows((await res.json()) as AgentRow[]);
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 })));
// A refresh that succeeds clears a previous failure — otherwise a // A refresh that succeeds clears a previous failure — otherwise a
// transient error would sit on screen forever after the data itself // transient error would sit on screen forever after the data itself
// has recovered. // has recovered.

View file

@ -45,6 +45,17 @@ pub struct AgentStatusRow {
/// failed to decode as [`swarm_queue_client::agent_status::AgentStatus`] /// failed to decode as [`swarm_queue_client::agent_status::AgentStatus`]
/// (logged as a warning naming the agent). /// (logged as a warning naming the agent).
pub snapshot: Option<serde_json::Value>, 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 /// A snapshot as retained by the bucket, with the hive it was published
@ -164,6 +175,7 @@ fn render(
last_seen_unix: None, last_seen_unix: None,
age_seconds: None, age_seconds: None,
snapshot: None, snapshot: None,
config_pr: None,
}, },
}) })
.collect(); .collect();
@ -206,6 +218,7 @@ fn row(
.and_then(|d| i64::try_from(d.as_secs()).ok()), .and_then(|d| i64::try_from(d.as_secs()).ok()),
age_seconds: age.map(|age| age.as_secs()), age_seconds: age.map(|age| age.as_secs()),
snapshot: offered.payload.clone(), snapshot: offered.payload.clone(),
config_pr: None,
} }
} }

View file

@ -27,7 +27,7 @@ use forgejo_api::structs::{
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError}; use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
use futures_util::{StreamExt as _, TryStreamExt as _}; use futures_util::{StreamExt as _, TryStreamExt as _};
use reqwest::StatusCode; use reqwest::StatusCode;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use utoipa::ToSchema; 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 /// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it
/// and `GET /api/agents/{name}/config-pr` serves 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 struct ConfigPrStatus {
pub pr_number: u64, pub pr_number: u64,
/// Absent only if Forgejo itself omitted the field — every real PR has /// Absent only if Forgejo itself omitted the field — every real PR has

View file

@ -889,17 +889,27 @@ async fn get_hives_status(
} }
} }
/// What each agent last said about itself, read from the swarm queue at /// What each agent last said about itself, plus its open config PR if any,
/// request time. /// 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 /// 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 /// 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( #[utoipa::path(
get, get,
path = "/api/agents/status", path = "/api/agents/status",
responses( 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), (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" tag = "agents"
@ -923,7 +933,15 @@ async fn get_agents_status(
StatusUnavailable(detail) StatusUnavailable(detail)
})?; })?;
match reader.view(&roster, std::time::SystemTime::now()).await { 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) => { Err(e) => {
let detail = format!("{e:#}"); let detail = format!("{e:#}");
tracing::warn!(error = %detail, "reading the agent-status bucket failed"); tracing::warn!(error = %detail, "reading the agent-status bucket failed");