swarm-ui: show, sort and filter the hive domain by one value, say when the roster is empty, keep the schedule agent picker current
The domain column showed a dash unless a hive had a dashboard but sorted and filtered on the domain alone, so rows looked mis-sorted. It now shows the domain as text when there is no dashboard to link. An empty hive roster says so. The schedule form's agent names reload with the schedules and the form says when that load failed.
This commit is contained in:
parent
885c051b33
commit
8c67ac01fe
8 changed files with 140 additions and 32 deletions
|
|
@ -203,6 +203,11 @@ agents doesn't, and the form warns when an edit would.
|
|||
A start in the past fires the latest missed time once, right away, in every
|
||||
agent chosen, and the form says so.
|
||||
|
||||
The form offers the agents of the roster that `/agents` lists. The page reloads
|
||||
that roster with the schedules, so an agent created after you opened the page
|
||||
shows up without a reload. If the roster fails to load, the form says so instead
|
||||
of claiming there are no agents.
|
||||
|
||||
Only an operator can change a schedule here or with `swarmctl schedule`; an
|
||||
agent has no way to add one. The page needs the controller's `/api/schedules`
|
||||
routes.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from "../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../ui/polled/usePolled.js";
|
||||
import { Table, type TableColumn } from "../ui/table/Table.js";
|
||||
import { domainCell } from "./hive-domain.js";
|
||||
|
||||
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
|
||||
|
||||
|
|
@ -59,16 +60,19 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
|||
{
|
||||
key: "domain",
|
||||
header: "domain",
|
||||
render: (h) =>
|
||||
h.domain && h.dashboard ? (
|
||||
<a href={`https://${h.dashboard}/`} target="_blank" rel="noreferrer">
|
||||
{h.domain}
|
||||
render: (h) => {
|
||||
const { text, href } = domainCell(h);
|
||||
if (!text) return "—";
|
||||
return href ? (
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{text}
|
||||
</a>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
sortBy: (h) => h.domain ?? "",
|
||||
filterValue: (h) => h.domain ?? "",
|
||||
text
|
||||
);
|
||||
},
|
||||
sortBy: (h) => domainCell(h).text,
|
||||
filterValue: (h) => domainCell(h).text,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
|
|
@ -137,6 +141,7 @@ export function HivesPage() {
|
|||
columns={COLUMNS}
|
||||
rows={hives}
|
||||
rowKey={(h) => h.name}
|
||||
emptyMessage="no hives in the roster yet"
|
||||
storageKey="swarm-ui:hives:table-filters"
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
22
frontend/packages/swarm-ui/src/pages/hive-domain.ts
Normal file
22
frontend/packages/swarm-ui/src/pages/hive-domain.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// What the hives table shows in its domain column. The cell, the sort and the
|
||||
// text filter all read this one value, so a row sorts and filters under the
|
||||
// text it displays.
|
||||
|
||||
export interface HiveDomainFields {
|
||||
domain: string | null;
|
||||
dashboard: string | null;
|
||||
}
|
||||
|
||||
export interface DomainCell {
|
||||
/** The domain, or "" when the hive has none. */
|
||||
text: string;
|
||||
/** The dashboard URL, or null when the hive has no dashboard to open. */
|
||||
href: string | null;
|
||||
}
|
||||
|
||||
export function domainCell(h: HiveDomainFields): DomainCell {
|
||||
return {
|
||||
text: h.domain ?? "",
|
||||
href: h.domain && h.dashboard ? `https://${h.dashboard}/` : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ function nextMinuteUnix(): number {
|
|||
export function ScheduleForm({
|
||||
schedule,
|
||||
agentOptions,
|
||||
agentsError,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
|
|
@ -58,6 +59,8 @@ export function ScheduleForm({
|
|||
schedule?: Schedule;
|
||||
/** Every agent a schedule can target. */
|
||||
agentOptions: string[];
|
||||
/** Why the agent list could not load; unset when it did. */
|
||||
agentsError?: string;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
|
|
@ -187,7 +190,11 @@ export function ScheduleForm({
|
|||
<div class="ui-schedule-form-agents">
|
||||
<span class="ui-form-field-label">agents</span>
|
||||
{agentOptions.length === 0 ? (
|
||||
<span class="ui-schedule-form-hint">no agents to pick from</span>
|
||||
<span class="ui-schedule-form-hint">
|
||||
{agentsError
|
||||
? `could not load the agent list: ${agentsError}`
|
||||
: "no agents to pick from"}
|
||||
</span>
|
||||
) : (
|
||||
<MultiselectFilterOptions
|
||||
options={agentOptions}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
// multiselects). A card being edited keeps its form state across the
|
||||
// periodic refetch because `FilterableView` keys cards by schedule id and
|
||||
// the open edit is held here by id, not by position.
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
|
|
@ -24,6 +24,7 @@ import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
|||
import type { TableColumn } from "../../ui/table/Table.js";
|
||||
import type { AgentRow } from "../../agent/AgentTypes.js";
|
||||
import { ScheduleCard } from "./ScheduleCard.js";
|
||||
import { agentNamesFrom, agentOptionsFor } from "./agent-options.js";
|
||||
import { ScheduleForm } from "./ScheduleForm.js";
|
||||
import { scheduleState, type Schedule } from "./ScheduleTypes.js";
|
||||
|
||||
|
|
@ -56,16 +57,6 @@ const COLUMNS: TableColumn<Schedule>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// The agent names a schedule can target, from the same roster the agents
|
||||
// page lists. A failed load leaves the picker empty rather than blocking
|
||||
// the page: the cards themselves do not need it.
|
||||
async function loadAgentNames(): Promise<string[]> {
|
||||
const r = await fetch("/api/agents/status");
|
||||
if (!r.ok) return [];
|
||||
const rows = (await r.json()) as AgentRow[];
|
||||
return rows.map((a) => a.name).sort();
|
||||
}
|
||||
|
||||
export function SchedulesPage() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
|
|
@ -76,7 +67,14 @@ export function SchedulesPage() {
|
|||
} = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<Schedule[]>("/api/schedules", signal),
|
||||
);
|
||||
const [agentNames, setAgentNames] = useState<string[]>([]);
|
||||
// The agent names a schedule can target, from the roster the agents page
|
||||
// lists, polled on the same cadence so an agent created after this page
|
||||
// opened shows up. A failed load does not block the page, the cards do not
|
||||
// need it; the form says the list could not load.
|
||||
const { data: roster, error: rosterError } = usePolled(intervalMs, (signal) =>
|
||||
fetchJson<AgentRow[]>("/api/agents/status", signal),
|
||||
);
|
||||
const agentNames = useMemo(() => agentNamesFrom(roster), [roster]);
|
||||
// The schedule whose card is currently its edit form, and whether the
|
||||
// "new schedule" form is open above the list.
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -85,10 +83,6 @@ export function SchedulesPage() {
|
|||
const [removeTarget, setRemoveTarget] = useState<Schedule | null>(null);
|
||||
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentNames().then(setAgentNames, () => setAgentNames([]));
|
||||
}, []);
|
||||
|
||||
function markPending(id: string, on: boolean) {
|
||||
setPendingIds((cur) => {
|
||||
const next = new Set(cur);
|
||||
|
|
@ -139,13 +133,10 @@ export function SchedulesPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// A schedule may still target an agent that has since left the roster;
|
||||
// its own agents stay pickable so an edit can keep or drop them.
|
||||
const optionsFor = useMemo(
|
||||
() => (s?: Schedule) =>
|
||||
[...new Set([...agentNames, ...(s?.agents ?? [])])].sort(),
|
||||
[agentNames],
|
||||
);
|
||||
const optionsFor = (s?: Schedule) => agentOptionsFor(agentNames, s);
|
||||
const agentsError = rosterError
|
||||
? (rosterError.detail ?? rosterError.title ?? "the request failed")
|
||||
: undefined;
|
||||
|
||||
function saved() {
|
||||
setCreating(false);
|
||||
|
|
@ -186,6 +177,7 @@ export function SchedulesPage() {
|
|||
{creating ? (
|
||||
<ScheduleForm
|
||||
agentOptions={optionsFor()}
|
||||
agentsError={agentsError}
|
||||
onSaved={saved}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
|
|
@ -204,6 +196,7 @@ export function SchedulesPage() {
|
|||
<ScheduleForm
|
||||
schedule={s}
|
||||
agentOptions={optionsFor(s)}
|
||||
agentsError={agentsError}
|
||||
onSaved={saved}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
// The agent names a schedule form offers.
|
||||
|
||||
/** Sorted names from the agent roster; none while it has not loaded. */
|
||||
export function agentNamesFrom(
|
||||
rows: readonly { name: string }[] | null,
|
||||
): string[] {
|
||||
return (rows ?? []).map((a) => a.name).sort();
|
||||
}
|
||||
|
||||
/** The roster names plus the schedule's own agents, sorted without repeats. A
|
||||
* schedule can still target an agent that has since left the roster, so an
|
||||
* edit keeps those pickable and can drop them. */
|
||||
export function agentOptionsFor(
|
||||
names: readonly string[],
|
||||
schedule?: { agents: readonly string[] },
|
||||
): string[] {
|
||||
return [...new Set([...names, ...(schedule?.agents ?? [])])].sort();
|
||||
}
|
||||
27
frontend/packages/swarm-ui/test/agent-options.test.mjs
Normal file
27
frontend/packages/swarm-ui/test/agent-options.test.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
agentNamesFrom,
|
||||
agentOptionsFor,
|
||||
} from "../src/pages/schedules/agent-options.ts";
|
||||
|
||||
test("agentNamesFrom: sorted names, empty before the roster loads", () => {
|
||||
assert.deepEqual(agentNamesFrom([{ name: "b" }, { name: "a" }]), ["a", "b"]);
|
||||
assert.deepEqual(agentNamesFrom(null), []);
|
||||
});
|
||||
|
||||
test("agentOptionsFor: roster names alone for a new schedule", () => {
|
||||
assert.deepEqual(agentOptionsFor(["b", "a"]), ["a", "b"]);
|
||||
});
|
||||
|
||||
test("agentOptionsFor: keeps an agent that left the roster, once", () => {
|
||||
assert.deepEqual(agentOptionsFor(["a", "b"], { agents: ["b", "gone"] }), [
|
||||
"a",
|
||||
"b",
|
||||
"gone",
|
||||
]);
|
||||
});
|
||||
|
||||
test("agentOptionsFor: an empty roster still offers the schedule's agents", () => {
|
||||
assert.deepEqual(agentOptionsFor([], { agents: ["x"] }), ["x"]);
|
||||
});
|
||||
31
frontend/packages/swarm-ui/test/hive-domain.test.mjs
Normal file
31
frontend/packages/swarm-ui/test/hive-domain.test.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { domainCell } from "../src/pages/hive-domain.ts";
|
||||
|
||||
test("domainCell: a domain with a dashboard links to it", () => {
|
||||
assert.deepEqual(
|
||||
domainCell({ domain: "a.example", dashboard: "d.a.example" }),
|
||||
{
|
||||
text: "a.example",
|
||||
href: "https://d.a.example/",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("domainCell: a domain without a dashboard still shows as text", () => {
|
||||
assert.deepEqual(domainCell({ domain: "a.example", dashboard: null }), {
|
||||
text: "a.example",
|
||||
href: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("domainCell: no domain is empty text and no link", () => {
|
||||
assert.deepEqual(domainCell({ domain: null, dashboard: "d.a.example" }), {
|
||||
text: "",
|
||||
href: null,
|
||||
});
|
||||
assert.deepEqual(domainCell({ domain: null, dashboard: null }), {
|
||||
text: "",
|
||||
href: null,
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue