Compare commits
4 changed files with 35 additions and 124 deletions
|
|
@ -1,22 +1,16 @@
|
||||||
// <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 and swarm-wide health status in the same table. Three
|
// config-PR status in the same table. Two separate asks (a roster listing,
|
||||||
// separate asks (a roster listing, a per-agent config-PR indicator, and
|
// and a per-agent config-PR indicator) that turned out to be one page: a
|
||||||
// per-agent status/liveness) that turned out to be one page: a roster with
|
// roster with no per-row detail is thin, and a config-PR panel with no
|
||||||
// no per-row detail is thin, and neither a config-PR panel nor a status
|
// roster to embed it in has nothing to render against.
|
||||||
// panel has anything to render against without a roster to embed in.
|
|
||||||
//
|
//
|
||||||
// One fetch, on a refresh-interval cadence like HivesPage: `GET
|
// Two fetches, both on a refresh-interval cadence like HivesPage: `GET
|
||||||
// /api/agents/status` — one row per roster agent (identity store is the
|
// /api/agents` (just names — the identity store is the roster, and
|
||||||
// roster, so a never-reported agent still gets a row), each already
|
// deliberately says nothing about health) and `GET /api/config-prs`
|
||||||
// carrying its freshness/snapshot *and* its open config PR if any. That
|
// (agent name -> open PR, only agents with one present). Merged
|
||||||
// merge used to be three separate fetches (`/api/agents`, `/api/config-prs`,
|
// client-side into one row per agent rather than N per-agent config-PR
|
||||||
// `/api/agents/status`) joined client-side by name; per operator review
|
// calls — exactly why the bulk endpoint exists instead of looping the
|
||||||
// feedback ("the view should be filled by a single backend call") the
|
// single-agent one.
|
||||||
// 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
|
||||||
|
|
@ -26,11 +20,10 @@
|
||||||
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, type BadgeTone } from '@hive/shared/badge.js';
|
import { Badge } 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,
|
||||||
|
|
@ -44,84 +37,27 @@ 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;
|
||||||
hive: string | null;
|
configPr: ConfigPrStatus | 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.config_pr ? (
|
a.configPr ? (
|
||||||
<Badge
|
<Badge
|
||||||
tone="warning"
|
tone="warning"
|
||||||
value={
|
value={
|
||||||
a.config_pr.html_url ? (
|
a.configPr.html_url ? (
|
||||||
<a href={a.config_pr.html_url} target="_blank" rel="noreferrer">
|
<a href={a.configPr.html_url} target="_blank" rel="noreferrer">
|
||||||
#{a.config_pr.pr_number}
|
#{a.configPr.pr_number}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
`#${a.config_pr.pr_number}`
|
`#${a.configPr.pr_number}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -144,12 +80,18 @@ export function AgentsPage() {
|
||||||
|
|
||||||
useRefreshInterval(intervalMs, () => {
|
useRefreshInterval(intervalMs, () => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await fetch('/api/agents/status');
|
const [namesRes, prsRes] = await Promise.all([fetch('/api/agents'), fetch('/api/config-prs')]);
|
||||||
if (!res.ok) {
|
if (!namesRes.ok) {
|
||||||
setError(await readApiError(res));
|
setError(await readApiError(namesRes));
|
||||||
return;
|
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
|
// 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,17 +45,6 @@ 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
|
||||||
|
|
@ -175,7 +164,6 @@ 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();
|
||||||
|
|
@ -218,7 +206,6 @@ 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::{Deserialize, Serialize};
|
use serde::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, Deserialize, ToSchema)]
|
#[derive(Clone, Debug, PartialEq, Serialize, 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,27 +889,17 @@ async fn get_hives_status(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What each agent last said about itself, plus its open config PR if any,
|
/// What each agent last said about itself, read from the swarm queue at
|
||||||
/// read at request time — the single call swarm-ui's agent roster page
|
/// request time.
|
||||||
/// 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. `config_pr`
|
/// comes from its own bucket key rather than a second lookup.
|
||||||
/// 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 + 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),
|
(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"
|
||||||
|
|
@ -933,15 +923,7 @@ 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(mut rows) => {
|
Ok(rows) => Ok(Json(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