Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
4 changed files with 35 additions and 124 deletions

View file

@ -1,22 +1,16 @@
// <AgentsPage> — the swarm's agent roster, merged with each agent's open
// 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.
// 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.
//
// 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.
// 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.
//
// Owns the "+ agent" trigger too — the roster this populates is the
// natural home for the action that populates it; a separate top-level
@ -26,11 +20,10 @@
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, type BadgeTone } from '@hive/shared/badge.js';
import { Badge } 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,
@ -44,84 +37,27 @@ 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;
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;
configPr: 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.config_pr ? (
a.configPr ? (
<Badge
tone="warning"
value={
a.config_pr.html_url ? (
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
#{a.config_pr.pr_number}
a.configPr.html_url ? (
<a href={a.configPr.html_url} target="_blank" rel="noreferrer">
#{a.configPr.pr_number}
</a>
) : (
`#${a.config_pr.pr_number}`
`#${a.configPr.pr_number}`
)
}
/>
@ -144,12 +80,18 @@ export function AgentsPage() {
useRefreshInterval(intervalMs, () => {
(async () => {
const res = await fetch('/api/agents/status');
if (!res.ok) {
setError(await readApiError(res));
const [namesRes, prsRes] = await Promise.all([fetch('/api/agents'), fetch('/api/config-prs')]);
if (!namesRes.ok) {
setError(await readApiError(namesRes));
return;
}
setRows((await res.json()) as AgentRow[]);
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 })));
// A refresh that succeeds clears a previous failure — otherwise a
// transient error would sit on screen forever after the data itself
// has recovered.

View file

@ -45,17 +45,6 @@ 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
@ -175,7 +164,6 @@ fn render(
last_seen_unix: None,
age_seconds: None,
snapshot: None,
config_pr: None,
},
})
.collect();
@ -218,7 +206,6 @@ 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,
}
}

View file

@ -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::{Deserialize, Serialize};
use serde::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, Deserialize, ToSchema)]
#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)]
pub struct ConfigPrStatus {
pub pr_number: u64,
/// Absent only if Forgejo itself omitted the field — every real PR has

View file

@ -889,27 +889,17 @@ async fn get_hives_status(
}
}
/// 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").
/// What each agent last said about itself, read from the swarm queue at
/// request time.
///
/// 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. `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.
/// comes from its own bucket key rather than a second lookup.
#[utoipa::path(
get,
path = "/api/agents/status",
responses(
(status = 200, description = "a row per agent, freshness + config PR derived now", body = Vec<agent_status::AgentStatusRow>),
(status = 200, description = "a row per agent, freshness 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"
@ -933,15 +923,7 @@ async fn get_agents_status(
StatusUnavailable(detail)
})?;
match reader.view(&roster, std::time::SystemTime::now()).await {
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))
}
Ok(rows) => Ok(Json(rows)),
Err(e) => {
let detail = format!("{e:#}");
tracing::warn!(error = %detail, "reading the agent-status bucket failed");