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
|
// <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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue