mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
feat: add manifest-driven sub-pages to the automation interface (#16303)
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyDashboardView,
|
||||
computeOverviewTile,
|
||||
deriveAutomationHealth,
|
||||
formatCompactDuration,
|
||||
matchesAutomationSearch,
|
||||
summarizeAutomationRuns,
|
||||
type AutomationHealth,
|
||||
type AutomationRunSummary,
|
||||
type RunSummaryState,
|
||||
} from "#/manifests/automation-insights";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
|
||||
function createAutomation(overrides: Partial<Automation> = {}): Automation {
|
||||
return {
|
||||
id: "widget-1",
|
||||
name: "Widget monitor",
|
||||
trigger: { type: "cron", schedule: "0 9 * * *" },
|
||||
enabled: true,
|
||||
prompt: "Watch the widgets",
|
||||
repository: "acme/widgets",
|
||||
model: "widget-profile",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
status: AutomationRunStatus.COMPLETED,
|
||||
conversation_id: null,
|
||||
bash_command_id: null,
|
||||
error_detail: null,
|
||||
started_at: "2026-01-02T00:00:00Z",
|
||||
completed_at: "2026-01-02T00:01:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function settled(summary: Partial<AutomationRunSummary>): RunSummaryState {
|
||||
return {
|
||||
summary: {
|
||||
total: 0,
|
||||
latestRun: null,
|
||||
recentSuccessRate: null,
|
||||
averageDurationMs: null,
|
||||
...summary,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveAutomationHealth", () => {
|
||||
it.each<[string, Automation, RunSummaryState | undefined, AutomationHealth]>([
|
||||
[
|
||||
"disabled wins over a failed latest run",
|
||||
createAutomation({ enabled: false }),
|
||||
settled({ latestRun: createRun({ status: AutomationRunStatus.FAILED }) }),
|
||||
"disabled",
|
||||
],
|
||||
[
|
||||
"an unsettled summary claims nothing",
|
||||
createAutomation(),
|
||||
{ summary: null, isLoading: true, isError: false },
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"a summary that failed to load claims nothing",
|
||||
createAutomation(),
|
||||
{ summary: null, isLoading: false, isError: true },
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
"no runs yet means never-run",
|
||||
createAutomation(),
|
||||
settled({ latestRun: null }),
|
||||
"never-run",
|
||||
],
|
||||
[
|
||||
"a failed latest run means failing",
|
||||
createAutomation(),
|
||||
settled({ latestRun: createRun({ status: AutomationRunStatus.FAILED }) }),
|
||||
"failing",
|
||||
],
|
||||
[
|
||||
"a pending latest run means running",
|
||||
createAutomation(),
|
||||
settled({
|
||||
latestRun: createRun({ status: AutomationRunStatus.PENDING }),
|
||||
}),
|
||||
"running",
|
||||
],
|
||||
[
|
||||
"a completed latest run means healthy",
|
||||
createAutomation(),
|
||||
settled({ latestRun: createRun() }),
|
||||
"healthy",
|
||||
],
|
||||
[
|
||||
// Statuses the backend added after the dashboard's reference design:
|
||||
// neither a success nor a failure, so not "failing".
|
||||
"a cancelled latest run does not read as failing",
|
||||
createAutomation(),
|
||||
settled({
|
||||
latestRun: createRun({ status: AutomationRunStatus.CANCELLED }),
|
||||
}),
|
||||
"healthy",
|
||||
],
|
||||
])("%s", (_case, automation, state, expected) => {
|
||||
expect(deriveAutomationHealth(automation, state)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompactDuration", () => {
|
||||
it.each<[number | null, string]>([
|
||||
[null, "—"],
|
||||
[12_000, "12s"],
|
||||
[120_000, "2m"],
|
||||
[5_400_000, "1.5h"],
|
||||
])("renders %s as %s", (ms, expected) => {
|
||||
expect(formatCompactDuration(ms)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeAutomationRuns", () => {
|
||||
it("summarizes the sample and keeps the lifetime total", () => {
|
||||
// Arrange — newest first, mixing terminal and non-terminal statuses.
|
||||
const runs = [
|
||||
createRun({
|
||||
id: "run-latest",
|
||||
status: AutomationRunStatus.FAILED,
|
||||
started_at: "2026-01-05T00:00:00Z",
|
||||
completed_at: "2026-01-05T00:00:30Z",
|
||||
}),
|
||||
createRun({ status: AutomationRunStatus.CANCELLED, completed_at: null }),
|
||||
createRun({ status: AutomationRunStatus.SKIPPED, completed_at: null }),
|
||||
createRun({ status: AutomationRunStatus.RUNNING, completed_at: null }),
|
||||
createRun({
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
completed_at: "2026-01-01T00:01:30Z",
|
||||
}),
|
||||
];
|
||||
|
||||
// Act
|
||||
const summary = summarizeAutomationRuns({ runs, total: 40 });
|
||||
|
||||
// Assert — success rate and durations consider COMPLETED and FAILED only;
|
||||
// total is the response's lifetime count, not the sample's length.
|
||||
expect(summary).toEqual({
|
||||
total: 40,
|
||||
latestRun: runs[0],
|
||||
recentSuccessRate: 0.5,
|
||||
averageDurationMs: (30_000 + 90_000) / 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no rate and no duration without terminal runs", () => {
|
||||
// Arrange
|
||||
const runs = [
|
||||
createRun({ status: AutomationRunStatus.RUNNING, completed_at: null }),
|
||||
];
|
||||
|
||||
// Act
|
||||
const summary = summarizeAutomationRuns({ runs, total: 1 });
|
||||
|
||||
// Assert
|
||||
expect({
|
||||
rate: summary.recentSuccessRate,
|
||||
duration: summary.averageDurationMs,
|
||||
}).toEqual({ rate: null, duration: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesAutomationSearch", () => {
|
||||
it.each<[string, string]>([
|
||||
["name", "widget MONITOR"],
|
||||
["prompt", "watch the"],
|
||||
["repository", "acme/"],
|
||||
["model", "widget-profile"],
|
||||
])("matches on %s, case-insensitively", (_field, query) => {
|
||||
expect(matchesAutomationSearch(createAutomation(), query)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an automation matching no field", () => {
|
||||
expect(matchesAutomationSearch(createAutomation(), "gadget")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDashboardView", () => {
|
||||
const failing = createAutomation({ id: "failing", name: "B failing" });
|
||||
const healthy = createAutomation({ id: "healthy", name: "A healthy" });
|
||||
const evented = createAutomation({
|
||||
id: "evented",
|
||||
name: "C evented",
|
||||
trigger: { type: "event", source: "github" },
|
||||
});
|
||||
const all = [failing, healthy, evented];
|
||||
const byId = new Map<string, RunSummaryState>([
|
||||
[
|
||||
"failing",
|
||||
settled({
|
||||
total: 9,
|
||||
latestRun: createRun({
|
||||
status: AutomationRunStatus.FAILED,
|
||||
started_at: "2026-01-03T00:00:00Z",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"healthy",
|
||||
settled({
|
||||
total: 2,
|
||||
latestRun: createRun({ started_at: "2026-01-05T00:00:00Z" }),
|
||||
}),
|
||||
],
|
||||
["evented", settled({ total: 5, latestRun: null })],
|
||||
]);
|
||||
const neutral = {
|
||||
search: "",
|
||||
status: "all",
|
||||
trigger: "all",
|
||||
sort: "name",
|
||||
} as const;
|
||||
|
||||
it("keeps only latest-run failures under the failing status", () => {
|
||||
// Act
|
||||
const visible = applyDashboardView(
|
||||
all,
|
||||
{ ...neutral, status: "failing" },
|
||||
byId,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(visible.map((a) => a.id)).toEqual(["failing"]);
|
||||
});
|
||||
|
||||
it("treats every non-event trigger as a schedule", () => {
|
||||
// Act
|
||||
const scheduled = applyDashboardView(
|
||||
all,
|
||||
{ ...neutral, trigger: "schedule" },
|
||||
byId,
|
||||
);
|
||||
const events = applyDashboardView(
|
||||
all,
|
||||
{ ...neutral, trigger: "event" },
|
||||
byId,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect({
|
||||
scheduled: scheduled.map((a) => a.id),
|
||||
events: events.map((a) => a.id),
|
||||
}).toEqual({ scheduled: ["healthy", "failing"], events: ["evented"] });
|
||||
});
|
||||
|
||||
it("orders by lifetime run count under the runs sort", () => {
|
||||
// Act
|
||||
const visible = applyDashboardView(all, { ...neutral, sort: "runs" }, byId);
|
||||
|
||||
// Assert
|
||||
expect(visible.map((a) => a.id)).toEqual(["failing", "evented", "healthy"]);
|
||||
});
|
||||
|
||||
it("orders by newest activity under the last-run sort, falling back to last_triggered_at", () => {
|
||||
// Arrange — no summary for the third automation, only its own timestamp.
|
||||
const triggered = createAutomation({
|
||||
id: "triggered",
|
||||
name: "D triggered",
|
||||
last_triggered_at: "2026-01-04T00:00:00Z",
|
||||
});
|
||||
|
||||
// Act
|
||||
const visible = applyDashboardView(
|
||||
[failing, healthy, triggered],
|
||||
{ ...neutral, sort: "last-run" },
|
||||
byId,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(visible.map((a) => a.id)).toEqual([
|
||||
"healthy",
|
||||
"triggered",
|
||||
"failing",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeOverviewTile", () => {
|
||||
const enabled = createAutomation({ id: "on" });
|
||||
const disabled = createAutomation({ id: "off", enabled: false });
|
||||
const byId = new Map<string, RunSummaryState>([
|
||||
[
|
||||
"on",
|
||||
settled({
|
||||
total: 7,
|
||||
latestRun: createRun({ status: AutomationRunStatus.FAILED }),
|
||||
averageDurationMs: 30_000,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"off",
|
||||
settled({
|
||||
total: 3,
|
||||
latestRun: createRun({ status: AutomationRunStatus.FAILED }),
|
||||
averageDurationMs: 90_000,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
it("counts automations and exposes the active count to tile copy", () => {
|
||||
// Act
|
||||
const value = computeOverviewTile("automations", [enabled, disabled], byId);
|
||||
|
||||
// Assert
|
||||
expect(value).toEqual({
|
||||
display: "2",
|
||||
isZero: false,
|
||||
placeholderValues: { active: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("counts only failing automations for needs-attention, flagging zero", () => {
|
||||
// Act — the disabled automation's failed history must not count.
|
||||
const attention = computeOverviewTile(
|
||||
"needs-attention",
|
||||
[enabled, disabled],
|
||||
byId,
|
||||
);
|
||||
const calm = computeOverviewTile("needs-attention", [disabled], byId);
|
||||
|
||||
// Assert
|
||||
expect({
|
||||
display: attention.display,
|
||||
calmIsZero: calm.isZero,
|
||||
}).toEqual({ display: "1", calmIsZero: true });
|
||||
});
|
||||
|
||||
it("sums lifetime runs across loaded summaries", () => {
|
||||
// Act
|
||||
const value = computeOverviewTile("total-runs", [enabled, disabled], byId);
|
||||
|
||||
// Assert
|
||||
expect(value.display).toBe("10");
|
||||
});
|
||||
|
||||
it("withholds the run total until a summary has loaded", () => {
|
||||
// Act
|
||||
const value = computeOverviewTile("total-runs", [enabled], new Map());
|
||||
|
||||
// Assert
|
||||
expect(value.display).toBe("—");
|
||||
});
|
||||
|
||||
it("averages per-automation durations into a compact figure", () => {
|
||||
// Act
|
||||
const value = computeOverviewTile(
|
||||
"average-duration",
|
||||
[enabled, disabled],
|
||||
byId,
|
||||
);
|
||||
|
||||
// Assert — mean of 30s and 90s.
|
||||
expect(value.display).toBe("1m");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInterfaceManifest } from "./manifest-test-data";
|
||||
import {
|
||||
createInterfaceManifest,
|
||||
createInterfaceManifestWithSubPages,
|
||||
} from "./manifest-test-data";
|
||||
|
||||
/**
|
||||
* The seam resolves its source once at module load, so each case installs its
|
||||
@@ -104,6 +107,68 @@ describe("the automation interface seam", () => {
|
||||
expect(spec.present).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the sub-page surface absent when the manifest declares none", async () => {
|
||||
// Arrange — an admitted manifest without the sub-page group. The host has
|
||||
// no defaults for this surface, so nothing stands in.
|
||||
const seam = await loadSeam(createInterfaceManifest());
|
||||
|
||||
// Act & Assert
|
||||
expect({
|
||||
subPages: seam.getSubPagesSpec(),
|
||||
dashboard: seam.getDashboardSpec(),
|
||||
templates: seam.getTemplatesPageSpec(),
|
||||
}).toEqual({ subPages: null, dashboard: null, templates: null });
|
||||
});
|
||||
|
||||
it("serves the declared sub-page surface with routes resolved", async () => {
|
||||
// Arrange
|
||||
const seam = await loadSeam(createInterfaceManifestWithSubPages());
|
||||
|
||||
// Act
|
||||
const subPages = seam.getSubPagesSpec();
|
||||
const dashboard = seam.getDashboardSpec();
|
||||
const templates = seam.getTemplatesPageSpec();
|
||||
|
||||
// Assert — navigation items carry the routes the manifest owns, and the
|
||||
// dashboard spec returns the four sections whole.
|
||||
expect({
|
||||
subPages,
|
||||
templatesPath: seam.automationTemplatesPath(),
|
||||
sortDefault: dashboard?.sort.default,
|
||||
tileMetrics: dashboard?.overview.tiles.map((tile) => tile.metric),
|
||||
healthyLabel: dashboard?.insights.health.healthy,
|
||||
templates,
|
||||
}).toEqual({
|
||||
subPages: [
|
||||
{
|
||||
page: "list",
|
||||
to: "/automations",
|
||||
label: "Widget dashboard",
|
||||
icon: "layout-dashboard",
|
||||
},
|
||||
{
|
||||
page: "templates",
|
||||
to: "/automations/templates",
|
||||
label: "Widget templates",
|
||||
icon: "sparkles",
|
||||
},
|
||||
],
|
||||
templatesPath: "/automations/templates",
|
||||
sortDefault: "name",
|
||||
tileMetrics: [
|
||||
"automations",
|
||||
"needs-attention",
|
||||
"total-runs",
|
||||
"average-duration",
|
||||
],
|
||||
healthyLabel: "Fine",
|
||||
templates: {
|
||||
title: "Widget templates",
|
||||
description: "Pick a proven widget to start from.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the defaults, loudly, when a manifest fails admission", async () => {
|
||||
// Arrange
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateInterfaceManifest } from "#/manifests/interface-validation";
|
||||
import type { InterfaceValidationContext } from "#/manifests/interface-validation";
|
||||
import type {
|
||||
InterfaceIconSlug,
|
||||
InterfaceManifest,
|
||||
InterfaceSubPageId,
|
||||
OverviewMetric,
|
||||
} from "#/manifests/types";
|
||||
import {
|
||||
createInterfaceManifest,
|
||||
createInterfaceManifestWith,
|
||||
createInterfaceManifestWithSubPages,
|
||||
} from "./manifest-test-data";
|
||||
|
||||
const CONTEXT: InterfaceValidationContext = {
|
||||
@@ -19,6 +26,15 @@ const CONTEXT: InterfaceValidationContext = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The context of a host that also mounts the templates sub-page. */
|
||||
const SUB_PAGE_CONTEXT: InterfaceValidationContext = {
|
||||
...CONTEXT,
|
||||
mountedRoutes: {
|
||||
...CONTEXT.mountedRoutes,
|
||||
templates: "/automations/templates",
|
||||
},
|
||||
};
|
||||
|
||||
describe("validateInterfaceManifest", () => {
|
||||
it("admits a well-formed manifest", () => {
|
||||
// Arrange
|
||||
@@ -35,10 +51,7 @@ describe("validateInterfaceManifest", () => {
|
||||
// another repository. A manifest that trips any of them reverts the whole
|
||||
// interface to the host's defaults.
|
||||
it.each([
|
||||
[
|
||||
"a version this host cannot interpret",
|
||||
{ version: "2.0" },
|
||||
],
|
||||
["a version this host cannot interpret", { version: "2.0" }],
|
||||
[
|
||||
// The host serves what it has registrations for; a manifest cannot remap
|
||||
// the router table, only own link construction against it.
|
||||
@@ -156,10 +169,7 @@ describe("validateInterfaceManifest", () => {
|
||||
"a responder integration id that is not a lowercase slug",
|
||||
{ responderIntegrationIds: ["GitHub!"] },
|
||||
],
|
||||
[
|
||||
"a key this host does not read",
|
||||
{ dashboards: [] },
|
||||
],
|
||||
["a key this host does not read", { dashboards: [] }],
|
||||
])("refuses %s", (_case, overrides) => {
|
||||
// Arrange
|
||||
const candidate = createInterfaceManifestWith(overrides);
|
||||
@@ -171,6 +181,170 @@ describe("validateInterfaceManifest", () => {
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("admits a manifest declaring the complete sub-page surface", () => {
|
||||
// Arrange
|
||||
const manifest = createInterfaceManifestWithSubPages();
|
||||
|
||||
// Act
|
||||
const result = validateInterfaceManifest(manifest, SUB_PAGE_CONTEXT);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ valid: true, errors: [] });
|
||||
});
|
||||
|
||||
// Each case breaks the sub-page surface in one specific way. As with the
|
||||
// base cases, one bad field reverts the whole manifest to host defaults.
|
||||
it.each<[string, (manifest: InterfaceManifest) => InterfaceManifest]>([
|
||||
[
|
||||
// Navigation, routes, and the dashboard sections describe one surface;
|
||||
// a partial declaration would render navigation to a missing page.
|
||||
"a sub-page surface declared only in part",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
pages: {
|
||||
list: manifest.pages.list,
|
||||
detail: manifest.pages.detail,
|
||||
edit: manifest.pages.edit,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a templates route the host does not mount",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
routes: { ...manifest.routes, templates: "/automations/library" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a sub-page this host does not serve",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
navigation: {
|
||||
...manifest.navigation,
|
||||
subPages: [
|
||||
{
|
||||
page: "workflows" as InterfaceSubPageId,
|
||||
label: "Widget flows",
|
||||
icon: "sparkles",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"an icon outside the host's icon map",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
navigation: {
|
||||
...manifest.navigation,
|
||||
subPages: [
|
||||
{
|
||||
page: "list",
|
||||
label: "Widget dashboard",
|
||||
icon: "rocket" as InterfaceIconSlug,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a tile metric this host does not compute",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
pages: {
|
||||
...manifest.pages,
|
||||
list: {
|
||||
...manifest.pages.list,
|
||||
overview: {
|
||||
label: "Widget overview",
|
||||
tiles: [
|
||||
{
|
||||
metric: "mean-time-between-failures" as OverviewMetric,
|
||||
label: "Widget MTBF",
|
||||
detail: "Recent widgets",
|
||||
icon: "timer",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"tile copy using a placeholder its metric does not expose",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
pages: {
|
||||
...manifest.pages,
|
||||
list: {
|
||||
...manifest.pages.list,
|
||||
overview: {
|
||||
label: "Widget overview",
|
||||
tiles: [
|
||||
{
|
||||
metric: "total-runs",
|
||||
label: "Widget runs",
|
||||
detail: "{{active}} runs",
|
||||
icon: "activity",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
// "all" is the host's initial selection and reset target; without it a
|
||||
// filter could never be neutral.
|
||||
"a filter without the all option",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
pages: {
|
||||
...manifest.pages,
|
||||
list: {
|
||||
...manifest.pages.list,
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "Filter widgets by state",
|
||||
options: [
|
||||
{ value: "active", label: "Live" },
|
||||
{ value: "disabled", label: "Off" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a sort default the options do not offer",
|
||||
(manifest) => ({
|
||||
...manifest,
|
||||
pages: {
|
||||
...manifest.pages,
|
||||
list: {
|
||||
...manifest.pages.list,
|
||||
sort: {
|
||||
label: "Order widgets",
|
||||
default: "name",
|
||||
options: [{ value: "last-run", label: "Latest" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
])("refuses %s", (_case, breakManifest) => {
|
||||
// Arrange
|
||||
const candidate = breakManifest(createInterfaceManifestWithSubPages());
|
||||
|
||||
// Act
|
||||
const result = validateInterfaceManifest(candidate, SUB_PAGE_CONTEXT);
|
||||
|
||||
// Assert
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("reports every problem at once so an author sees the whole picture", () => {
|
||||
// Arrange
|
||||
const candidate = createInterfaceManifestWith({
|
||||
|
||||
@@ -143,3 +143,113 @@ export function createInterfaceManifestWith(
|
||||
): unknown {
|
||||
return { ...createInterfaceManifest(), ...overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* The factory manifest plus a complete sub-page surface. Like the base
|
||||
* factory, every caption differs from the production manifest and from any
|
||||
* host string, so a test that reads one back knows where it came from.
|
||||
*/
|
||||
export function createInterfaceManifestWithSubPages(): InterfaceManifest {
|
||||
const base = createInterfaceManifest();
|
||||
return {
|
||||
...base,
|
||||
routes: { ...base.routes, templates: "/automations/templates" },
|
||||
navigation: {
|
||||
...base.navigation,
|
||||
subPages: [
|
||||
{ page: "list", label: "Widget dashboard", icon: "layout-dashboard" },
|
||||
{ page: "templates", label: "Widget templates", icon: "sparkles" },
|
||||
],
|
||||
},
|
||||
pages: {
|
||||
...base.pages,
|
||||
list: {
|
||||
...base.pages.list,
|
||||
overview: {
|
||||
label: "Widget overview",
|
||||
tiles: [
|
||||
{
|
||||
metric: "automations",
|
||||
label: "Widget count",
|
||||
detail: "{{active}} live",
|
||||
icon: "bot",
|
||||
},
|
||||
{
|
||||
metric: "needs-attention",
|
||||
label: "Widget attention",
|
||||
detail: "Broken widgets",
|
||||
zeroDetail: "No broken widgets",
|
||||
icon: "circle-alert",
|
||||
},
|
||||
{
|
||||
metric: "total-runs",
|
||||
label: "Widget runs",
|
||||
detail: "Counted so far",
|
||||
icon: "activity",
|
||||
},
|
||||
{
|
||||
metric: "average-duration",
|
||||
label: "Widget pace",
|
||||
detail: "Recent finished runs",
|
||||
icon: "timer",
|
||||
},
|
||||
],
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
id: "status",
|
||||
label: "Filter widgets by state",
|
||||
options: [
|
||||
{ value: "all", label: "Every state" },
|
||||
{ value: "active", label: "Live" },
|
||||
{ value: "failing", label: "Broken" },
|
||||
{ value: "disabled", label: "Off" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trigger",
|
||||
label: "Filter widgets by trigger",
|
||||
options: [
|
||||
{ value: "all", label: "Every trigger" },
|
||||
{ value: "schedule", label: "Timed" },
|
||||
{ value: "event", label: "Evented" },
|
||||
],
|
||||
},
|
||||
],
|
||||
sort: {
|
||||
label: "Order widgets",
|
||||
default: "name",
|
||||
options: [
|
||||
{ value: "last-run", label: "Latest" },
|
||||
{ value: "runs", label: "Busiest" },
|
||||
{ value: "name", label: "Alphabetical" },
|
||||
],
|
||||
},
|
||||
insights: {
|
||||
health: {
|
||||
healthy: "Fine",
|
||||
failing: "Broken",
|
||||
running: "Busy",
|
||||
disabled: "Off",
|
||||
neverRun: "Fresh",
|
||||
checking: "Looking",
|
||||
},
|
||||
lastRun: {
|
||||
label: "Last widget run",
|
||||
never: "Not yet",
|
||||
justNow: "Moments ago",
|
||||
},
|
||||
stats: {
|
||||
runs: "Widget runs",
|
||||
recentSuccess: "Widget wins",
|
||||
averageDuration: "Widget pace",
|
||||
},
|
||||
},
|
||||
},
|
||||
templates: {
|
||||
title: "Widget templates",
|
||||
description: "Pick a proven widget to start from.",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AutomationService from "#/api/automation-service/automation-service.api";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import AutomationsList from "#/routes/automations-list";
|
||||
import AutomationTemplates, {
|
||||
clientLoader as templatesLoader,
|
||||
} from "#/routes/automation-templates";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
} from "#/types/automation";
|
||||
|
||||
// Replace the published data source with the widget-themed manifest that
|
||||
// declares the full sub-page surface; admission itself stays real.
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("#/manifests/manifest-sources")>();
|
||||
const { createInterfaceManifestWithSubPages } =
|
||||
await import("../manifests/manifest-test-data");
|
||||
return {
|
||||
...actual,
|
||||
AUTOMATION_INTERFACE_CANDIDATE: createInterfaceManifestWithSubPages(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/api/automation-service/automation-service.api", () => ({
|
||||
default: {
|
||||
getAutomations: vi.fn(),
|
||||
getAutomationRuns: vi.fn(),
|
||||
checkHealth: vi.fn(),
|
||||
toggleAutomation: vi.fn(),
|
||||
updateAutomation: vi.fn(),
|
||||
deleteAutomation: vi.fn(),
|
||||
dispatchAutomation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local-1",
|
||||
name: "Local 1",
|
||||
host: "http://localhost:8000",
|
||||
apiKey: "session-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
function createAutomation(overrides: Partial<Automation>): Automation {
|
||||
return {
|
||||
id: "a-ok",
|
||||
name: "Alpha widget",
|
||||
trigger: { type: "cron", schedule: "0 9 * * *" },
|
||||
enabled: true,
|
||||
prompt: "Watch the widgets",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRun(overrides: Partial<AutomationRun>): AutomationRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
status: AutomationRunStatus.COMPLETED,
|
||||
conversation_id: null,
|
||||
bash_command_id: null,
|
||||
error_detail: null,
|
||||
started_at: "2026-01-02T00:00:00Z",
|
||||
completed_at: "2026-01-02T00:01:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Alphabetically first but least recently run, so the manifest's "name"
|
||||
// default is distinguishable from the host's usual last-run ordering.
|
||||
const okAutomation = createAutomation({});
|
||||
const brokenAutomation = createAutomation({
|
||||
id: "a-broken",
|
||||
name: "Broken widget",
|
||||
});
|
||||
|
||||
function renderAt(path: string, page: React.ReactElement) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ActiveBackendProvider>
|
||||
<MemoryRouter initialEntries={[path]}>{page}</MemoryRouter>
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function renderDashboardWithSettledInsights() {
|
||||
renderAt("/automations", <AutomationsList />);
|
||||
await screen.findByTestId("automation-card-a-ok");
|
||||
// The broken automation's badge carries the manifest's failing caption once
|
||||
// its runs summary settles.
|
||||
await within(
|
||||
await screen.findByTestId("automation-card-a-broken"),
|
||||
).findByText("Broken");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(AutomationService.checkHealth).mockReset();
|
||||
vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" });
|
||||
vi.mocked(AutomationService.getAutomations).mockReset();
|
||||
vi.mocked(AutomationService.getAutomations).mockResolvedValue({
|
||||
automations: [okAutomation, brokenAutomation],
|
||||
total: 2,
|
||||
});
|
||||
vi.mocked(AutomationService.getAutomationRuns).mockReset();
|
||||
vi.mocked(AutomationService.getAutomationRuns).mockImplementation((id) =>
|
||||
id === "a-broken"
|
||||
? Promise.resolve({
|
||||
runs: [
|
||||
createRun({
|
||||
status: AutomationRunStatus.FAILED,
|
||||
started_at: "2026-01-05T00:00:00Z",
|
||||
completed_at: "2026-01-05T00:00:30Z",
|
||||
}),
|
||||
],
|
||||
total: 4,
|
||||
})
|
||||
: Promise.resolve({ runs: [createRun({})], total: 6 }),
|
||||
);
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("AutomationsList — manifest-declared dashboard", () => {
|
||||
it("composes the manifest's sub-page surface around the list", async () => {
|
||||
// Arrange & Act
|
||||
await renderDashboardWithSettledInsights();
|
||||
|
||||
// Assert — navigation, tiles, and controls all carry manifest captions;
|
||||
// the catalog launcher has moved off this page.
|
||||
const nav = screen.getByTestId("automations-navbar-desktop");
|
||||
const automationsTile = screen.getByTestId("overview-tile-automations");
|
||||
expect({
|
||||
navLabels: [
|
||||
within(nav).getByText("Widget dashboard"),
|
||||
within(nav).getByText("Widget templates"),
|
||||
].length,
|
||||
tileCaption: within(automationsTile).getByText("Widget count"),
|
||||
tileDetail: within(automationsTile).getByText("2 live"),
|
||||
statusFilter: screen.getByLabelText("Filter widgets by state"),
|
||||
sortControl: screen.getByLabelText("Order widgets"),
|
||||
statsCaptions: screen.getAllByText("Widget wins").length,
|
||||
launcher: screen.queryByTestId("recommended-automations-section"),
|
||||
}).toMatchObject({
|
||||
navLabels: 2,
|
||||
statsCaptions: 2,
|
||||
launcher: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("orders the list by the manifest's declared sort default", async () => {
|
||||
// Arrange & Act — the widget manifest defaults to the name sort, while
|
||||
// the broken automation has the newer run.
|
||||
await renderDashboardWithSettledInsights();
|
||||
|
||||
// Assert
|
||||
const cards = screen.getAllByTestId(/^automation-card-/);
|
||||
expect(cards.map((card) => card.getAttribute("data-testid"))).toEqual([
|
||||
"automation-card-a-ok",
|
||||
"automation-card-a-broken",
|
||||
]);
|
||||
});
|
||||
|
||||
it("narrows to latest-run failures through the status filter", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
await renderDashboardWithSettledInsights();
|
||||
|
||||
// Act — pick the manifest's "failing" option.
|
||||
await user.click(
|
||||
within(screen.getByTestId("automations-filter-status")).getByTestId(
|
||||
"dropdown-trigger",
|
||||
),
|
||||
);
|
||||
await user.click(screen.getByTestId("automations-filter-status-failing"));
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("automation-card-a-ok")).toBeNull();
|
||||
});
|
||||
expect(screen.getByTestId("automation-card-a-broken")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears search and filters back to a neutral view", async () => {
|
||||
// Arrange — search something no automation matches.
|
||||
const user = userEvent.setup();
|
||||
await renderDashboardWithSettledInsights();
|
||||
const search = screen.getByLabelText(
|
||||
I18nKey.AUTOMATIONS$SEARCH_PLACEHOLDER,
|
||||
);
|
||||
await user.type(search, "gadget");
|
||||
await screen.findByTestId("automations-filtered-empty");
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByTestId("automations-clear-filters"));
|
||||
|
||||
// Assert
|
||||
await screen.findByTestId("automation-card-a-ok");
|
||||
expect((search as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomationTemplates — manifest-declared templates page", () => {
|
||||
it("admits the route and renders the manifest identity above the launcher", async () => {
|
||||
// Arrange & Act
|
||||
expect(templatesLoader()).toBeNull();
|
||||
renderAt("/automations/templates", <AutomationTemplates />);
|
||||
|
||||
// Assert
|
||||
expect({
|
||||
title: await screen.findByText("Widget templates", {
|
||||
selector: "h1",
|
||||
}),
|
||||
description: screen.getByText("Pick a proven widget to start from."),
|
||||
launcher: await screen.findByTestId("recommended-automations-section"),
|
||||
}).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
|
||||
import AutomationService from "#/api/automation-service/automation-service.api";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import AutomationsList from "#/routes/automations-list";
|
||||
import { clientLoader as templatesLoader } from "#/routes/automation-templates";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import type { Automation } from "#/types/automation";
|
||||
|
||||
// Pin the published data source to "no interface manifest", independent of
|
||||
// whatever the pinned package ships: the sub-page surface must stay dark.
|
||||
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("#/manifests/manifest-sources")>();
|
||||
return { ...actual, AUTOMATION_INTERFACE_CANDIDATE: undefined };
|
||||
});
|
||||
|
||||
vi.mock("#/api/automation-service/automation-service.api", () => ({
|
||||
default: {
|
||||
getAutomations: vi.fn(),
|
||||
getAutomationRuns: vi.fn(),
|
||||
checkHealth: vi.fn(),
|
||||
toggleAutomation: vi.fn(),
|
||||
updateAutomation: vi.fn(),
|
||||
deleteAutomation: vi.fn(),
|
||||
dispatchAutomation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local-1",
|
||||
name: "Local 1",
|
||||
host: "http://localhost:8000",
|
||||
apiKey: "session-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const automation: Automation = {
|
||||
id: "auto-1",
|
||||
name: "Daily digest",
|
||||
trigger: { type: "cron", schedule: "0 9 * * *" },
|
||||
enabled: true,
|
||||
prompt: "Summarize yesterday's PRs",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function renderList() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ActiveBackendProvider>
|
||||
<MemoryRouter initialEntries={["/automations"]}>
|
||||
<AutomationsList />
|
||||
</MemoryRouter>
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
vi.mocked(AutomationService.checkHealth).mockReset();
|
||||
vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" });
|
||||
vi.mocked(AutomationService.getAutomations).mockReset();
|
||||
vi.mocked(AutomationService.getAutomations).mockResolvedValue({
|
||||
automations: [automation],
|
||||
total: 1,
|
||||
});
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("the sub-page surface without a declared manifest", () => {
|
||||
it("keeps today's plain list: launcher on the page, no dashboard chrome", async () => {
|
||||
// Arrange & Act
|
||||
renderList();
|
||||
await screen.findByText(automation.name);
|
||||
|
||||
// Assert — nothing of the sub-page surface renders, and the catalog
|
||||
// launcher stays on the list page.
|
||||
expect({
|
||||
nav: screen.queryByTestId("automations-navbar-desktop"),
|
||||
tile: screen.queryByTestId("overview-tile-automations"),
|
||||
statusFilter: screen.queryByTestId("automations-filter-status"),
|
||||
launcher: await screen.findByTestId("recommended-automations-section"),
|
||||
}).toMatchObject({ nav: null, tile: null, statusFilter: null });
|
||||
});
|
||||
|
||||
it("404s the templates route", () => {
|
||||
// Act
|
||||
let thrown: unknown;
|
||||
try {
|
||||
templatesLoader();
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
// Assert — the layout's error boundary renders this as not-found.
|
||||
expect(thrown).toBeInstanceOf(Response);
|
||||
expect((thrown as Response).status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,19 @@ import {
|
||||
import { buildAutomationMetadataPills } from "./build-automation-pills";
|
||||
import { buildAutomationMenuItems } from "./build-automation-menu-items";
|
||||
import { automationRunNowTextButtonClassName } from "./automation-action-button-classes";
|
||||
import { AutomationHealthBadge } from "./automation-health-badge";
|
||||
import { AutomationRunStats, lastRunText } from "./automation-run-insights";
|
||||
import {
|
||||
deriveAutomationHealth,
|
||||
type RunSummaryState,
|
||||
} from "#/manifests/automation-insights";
|
||||
import type { InterfaceListInsights } from "#/manifests/types";
|
||||
|
||||
/** Run insights shown when the manifest declares the dashboard surface. */
|
||||
export interface AutomationInsightsProps {
|
||||
spec: InterfaceListInsights;
|
||||
state: RunSummaryState | undefined;
|
||||
}
|
||||
|
||||
interface AutomationCardProps {
|
||||
automation: Automation;
|
||||
@@ -26,6 +39,7 @@ interface AutomationCardProps {
|
||||
onDelete: (id: string) => void;
|
||||
onExport: (automation: Automation) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
insights?: AutomationInsightsProps;
|
||||
}
|
||||
|
||||
export function AutomationCard({
|
||||
@@ -36,6 +50,7 @@ export function AutomationCard({
|
||||
onDelete,
|
||||
onExport,
|
||||
onEdit,
|
||||
insights,
|
||||
}: AutomationCardProps) {
|
||||
const { navigate } = useNavigation();
|
||||
const { t } = useTranslation("openhands");
|
||||
@@ -122,12 +137,33 @@ export function AutomationCard({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{insights ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<AutomationHealthBadge
|
||||
health={deriveAutomationHealth(automation, insights.state)}
|
||||
labels={insights.spec.health}
|
||||
/>
|
||||
<span data-testid={`automation-last-run-${automation.id}`}>
|
||||
{`${insights.spec.lastRun.label} ${lastRunText(
|
||||
insights.state?.summary?.latestRun?.started_at ??
|
||||
automation.last_triggered_at,
|
||||
insights.spec.lastRun,
|
||||
t(I18nKey.CONVERSATION$AGO),
|
||||
)}`}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pills.length > 0 ? (
|
||||
<SkillCardPillRow
|
||||
pills={pills}
|
||||
testId={`automation-pills-${automation.id}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{insights ? (
|
||||
<AutomationRunStats state={insights.state} copy={insights.spec.stats} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ import {
|
||||
extensionModuleCardGridClassName,
|
||||
extensionModuleCardGridContainerClassName,
|
||||
} from "#/utils/extension-module-card-classes";
|
||||
import type { RunSummaryState } from "#/manifests/automation-insights";
|
||||
import type { InterfaceListInsights } from "#/manifests/types";
|
||||
|
||||
/** Present when the manifest declares the dashboard surface. */
|
||||
interface AutomationGroupInsights {
|
||||
spec: InterfaceListInsights;
|
||||
byId: ReadonlyMap<string, RunSummaryState>;
|
||||
}
|
||||
|
||||
interface AutomationGroupProps {
|
||||
title: string;
|
||||
@@ -23,6 +31,7 @@ interface AutomationGroupProps {
|
||||
onDelete: (id: string) => void;
|
||||
onExport: (automation: Automation) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
insights?: AutomationGroupInsights;
|
||||
}
|
||||
|
||||
export function AutomationGroup({
|
||||
@@ -36,6 +45,7 @@ export function AutomationGroup({
|
||||
onDelete,
|
||||
onExport,
|
||||
onEdit,
|
||||
insights,
|
||||
}: AutomationGroupProps) {
|
||||
if (automations.length === 0) return null;
|
||||
|
||||
@@ -58,13 +68,30 @@ export function AutomationGroup({
|
||||
onDelete={onDelete}
|
||||
onExport={onExport}
|
||||
onEdit={onEdit}
|
||||
insights={
|
||||
insights && {
|
||||
spec: insights.spec,
|
||||
state: insights.byId.get(automation.id),
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(automationListTableClassName, "mt-3")}>
|
||||
<table className="w-full min-w-full [&>tbody>tr:first-child]:border-t-0">
|
||||
<div
|
||||
className={cn(
|
||||
automationListTableClassName,
|
||||
"mt-3",
|
||||
insights && "@container",
|
||||
)}
|
||||
>
|
||||
<table
|
||||
className={cn(
|
||||
"w-full min-w-full [&>tbody>tr:first-child]:border-t-0",
|
||||
insights && "table-fixed",
|
||||
)}
|
||||
>
|
||||
<tbody>
|
||||
{automations.map((automation) => (
|
||||
<AutomationListRow
|
||||
@@ -76,6 +103,12 @@ export function AutomationGroup({
|
||||
onDelete={onDelete}
|
||||
onExport={onExport}
|
||||
onEdit={onEdit}
|
||||
insights={
|
||||
insights && {
|
||||
spec: insights.spec,
|
||||
state: insights.byId.get(automation.id),
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
HEALTH_LABEL_KEYS,
|
||||
type AutomationHealth,
|
||||
} from "#/manifests/automation-insights";
|
||||
import type { InterfaceListInsights } from "#/manifests/types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
/**
|
||||
* The states and their colors are the host's; the captions come from the
|
||||
* manifest's `insights.health` block.
|
||||
*/
|
||||
const HEALTH_STYLES: Record<AutomationHealth, string> = {
|
||||
healthy:
|
||||
"border-[var(--oh-success)]/50 bg-[var(--oh-success)]/10 text-[var(--oh-success)]",
|
||||
failing: "border-[var(--oh-danger)]/50 bg-[var(--oh-danger)]/10 text-danger",
|
||||
running: "border-[var(--oh-border)] bg-surface-raised text-muted",
|
||||
disabled: "border-[var(--oh-border)] bg-surface-raised text-muted",
|
||||
"never-run":
|
||||
"border-[var(--oh-warning)]/50 bg-[var(--oh-warning)]/10 text-[var(--oh-warning)]",
|
||||
unknown: "border-[var(--oh-border)] bg-surface-raised text-muted",
|
||||
};
|
||||
|
||||
interface AutomationHealthBadgeProps {
|
||||
health: AutomationHealth;
|
||||
labels: InterfaceListInsights["health"];
|
||||
}
|
||||
|
||||
export function AutomationHealthBadge({
|
||||
health,
|
||||
labels,
|
||||
}: AutomationHealthBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
data-testid="automation-health-badge"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||
HEALTH_STYLES[health],
|
||||
)}
|
||||
>
|
||||
<span className="size-1.5 rounded-full bg-current" aria-hidden />
|
||||
{labels[HEALTH_LABEL_KEYS[health]]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
automationListRowClassName,
|
||||
automationListCellClassName,
|
||||
} from "./automation-view-mode";
|
||||
import { AutomationHealthBadge } from "./automation-health-badge";
|
||||
import {
|
||||
averageDurationDisplay,
|
||||
lastRunText,
|
||||
runCountDisplay,
|
||||
} from "./automation-run-insights";
|
||||
import { deriveAutomationHealth } from "#/manifests/automation-insights";
|
||||
import type { AutomationInsightsProps } from "./automation-card";
|
||||
|
||||
interface AutomationListRowProps {
|
||||
automation: Automation;
|
||||
@@ -27,6 +35,7 @@ interface AutomationListRowProps {
|
||||
onDelete: (id: string) => void;
|
||||
onExport: (automation: Automation) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
insights?: AutomationInsightsProps;
|
||||
}
|
||||
|
||||
export function AutomationListRow({
|
||||
@@ -37,6 +46,7 @@ export function AutomationListRow({
|
||||
onDelete,
|
||||
onExport,
|
||||
onEdit,
|
||||
insights,
|
||||
}: AutomationListRowProps) {
|
||||
const { navigate } = useNavigation();
|
||||
const { t } = useTranslation("openhands");
|
||||
@@ -106,7 +116,54 @@ export function AutomationListRow({
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className={cn("w-0 whitespace-nowrap", automationListCellClassName)}>
|
||||
{insights ? (
|
||||
<>
|
||||
<td
|
||||
data-testid={`automation-health-${automation.id}`}
|
||||
className={cn(
|
||||
"hidden w-[110px] @min-[36rem]:table-cell",
|
||||
automationListCellClassName,
|
||||
)}
|
||||
>
|
||||
<AutomationHealthBadge
|
||||
health={deriveAutomationHealth(automation, insights.state)}
|
||||
labels={insights.spec.health}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
data-testid={`automation-last-run-${automation.id}`}
|
||||
className={cn(
|
||||
"hidden w-[84px] whitespace-nowrap text-xs text-muted @min-[44rem]:table-cell",
|
||||
automationListCellClassName,
|
||||
)}
|
||||
>
|
||||
{lastRunText(
|
||||
insights.state?.summary?.latestRun?.started_at ??
|
||||
automation.last_triggered_at,
|
||||
insights.spec.lastRun,
|
||||
t(I18nKey.CONVERSATION$AGO),
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
data-testid={`automation-run-stats-${automation.id}`}
|
||||
title={`${insights.spec.stats.runs} · ${insights.spec.stats.averageDuration}`}
|
||||
className={cn(
|
||||
"hidden w-[140px] whitespace-nowrap text-right text-xs text-muted @min-[56rem]:table-cell",
|
||||
automationListCellClassName,
|
||||
)}
|
||||
>
|
||||
{`${runCountDisplay(insights.state)} · ${averageDurationDisplay(insights.state)}`}
|
||||
</td>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<td
|
||||
className={cn(
|
||||
insights ? "w-[90px]" : "w-0",
|
||||
"whitespace-nowrap",
|
||||
automationListCellClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{canManage ? (
|
||||
<StyledTooltip
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
formatCompactDuration,
|
||||
type RunSummaryState,
|
||||
} from "#/manifests/automation-insights";
|
||||
import type { InterfaceListInsights } from "#/manifests/types";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
|
||||
/** `formatTimeDelta` renders seconds as `<n>s`; anything under a minute reads as "just now". */
|
||||
const SECONDS_DELTA_PATTERN = /^\d+s$/;
|
||||
|
||||
/**
|
||||
* "Never", "Just now", or a localized "<delta> ago". The captions are the
|
||||
* manifest's; the delta and its "ago" suffix are the host's translations.
|
||||
*/
|
||||
export function lastRunText(
|
||||
startedAt: string | null | undefined,
|
||||
copy: InterfaceListInsights["lastRun"],
|
||||
agoSuffix: string,
|
||||
): string {
|
||||
if (!startedAt) return copy.never;
|
||||
const delta = formatTimeDelta(startedAt);
|
||||
return SECONDS_DELTA_PATTERN.test(delta)
|
||||
? copy.justNow
|
||||
: `${delta} ${agoSuffix}`;
|
||||
}
|
||||
|
||||
export function runCountDisplay(state: RunSummaryState | undefined): string {
|
||||
if (state?.summary) return state.summary.total.toLocaleString();
|
||||
if (!state || state.isLoading) return "…";
|
||||
return "—";
|
||||
}
|
||||
|
||||
export function successRateDisplay(state: RunSummaryState | undefined): string {
|
||||
const rate = state?.summary?.recentSuccessRate;
|
||||
if (rate === null || rate === undefined) return "—";
|
||||
return `${Math.round(rate * 100)}%`;
|
||||
}
|
||||
|
||||
export function averageDurationDisplay(
|
||||
state: RunSummaryState | undefined,
|
||||
): string {
|
||||
return formatCompactDuration(state?.summary?.averageDurationMs ?? null);
|
||||
}
|
||||
|
||||
interface AutomationRunStatsProps {
|
||||
state: RunSummaryState | undefined;
|
||||
copy: InterfaceListInsights["stats"];
|
||||
}
|
||||
|
||||
/** The three-column run stats footer on an automation card. */
|
||||
export function AutomationRunStats({ state, copy }: AutomationRunStatsProps) {
|
||||
const cells = [
|
||||
{ label: copy.runs, value: runCountDisplay(state) },
|
||||
{ label: copy.recentSuccess, value: successRateDisplay(state) },
|
||||
{ label: copy.averageDuration, value: averageDurationDisplay(state) },
|
||||
];
|
||||
return (
|
||||
<dl
|
||||
data-testid="automation-run-stats"
|
||||
className="grid grid-cols-3 gap-2 border-t border-[var(--oh-border)] pt-3 text-xs"
|
||||
>
|
||||
{cells.map((cell) => (
|
||||
<div key={cell.label} className="min-w-0">
|
||||
<dt className="truncate text-muted">{cell.label}</dt>
|
||||
<dd className="mt-0.5 truncate font-medium text-content">
|
||||
{cell.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { EnumFilterDropdown } from "#/components/shared/filters/enum-filter-dropdown";
|
||||
import type { DashboardSpec } from "#/manifests/automation-interface";
|
||||
import type {
|
||||
DashboardSortValue,
|
||||
DashboardStatusValue,
|
||||
DashboardTriggerValue,
|
||||
} from "#/manifests/types";
|
||||
|
||||
function toLabelMap<T extends string>(
|
||||
options: readonly { value: T; label: string }[],
|
||||
): Record<T, string> {
|
||||
return Object.fromEntries(
|
||||
options.map((option) => [option.value, option.label]),
|
||||
) as Record<T, string>;
|
||||
}
|
||||
|
||||
interface AutomationsDashboardControlsProps {
|
||||
spec: DashboardSpec;
|
||||
status: DashboardStatusValue;
|
||||
trigger: DashboardTriggerValue;
|
||||
sort: DashboardSortValue;
|
||||
onStatusChange: (value: DashboardStatusValue) => void;
|
||||
onTriggerChange: (value: DashboardTriggerValue) => void;
|
||||
onSortChange: (value: DashboardSortValue) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest-declared filter and sort dropdowns, in the manifest's order.
|
||||
* Which filters exist, their options, and every caption are the manifest's;
|
||||
* the predicates and comparators behind the values are the host's.
|
||||
*/
|
||||
export function AutomationsDashboardControls({
|
||||
spec,
|
||||
status,
|
||||
trigger,
|
||||
sort,
|
||||
onStatusChange,
|
||||
onTriggerChange,
|
||||
onSortChange,
|
||||
}: AutomationsDashboardControlsProps) {
|
||||
return (
|
||||
<>
|
||||
{spec.filters.map((filter) =>
|
||||
filter.id === "status" ? (
|
||||
<EnumFilterDropdown
|
||||
key={filter.id}
|
||||
testId="automations-filter-status"
|
||||
value={status}
|
||||
onChange={onStatusChange}
|
||||
options={filter.options.map((option) => option.value)}
|
||||
labelByValue={toLabelMap(filter.options)}
|
||||
ariaLabel={filter.label}
|
||||
/>
|
||||
) : (
|
||||
<EnumFilterDropdown
|
||||
key={filter.id}
|
||||
testId="automations-filter-trigger"
|
||||
value={trigger}
|
||||
onChange={onTriggerChange}
|
||||
options={filter.options.map((option) => option.value)}
|
||||
labelByValue={toLabelMap(filter.options)}
|
||||
ariaLabel={filter.label}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<EnumFilterDropdown
|
||||
testId="automations-sort"
|
||||
value={sort}
|
||||
onChange={onSortChange}
|
||||
options={spec.sort.options.map((option) => option.value)}
|
||||
labelByValue={toLabelMap(spec.sort.options)}
|
||||
ariaLabel={spec.sort.label}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AutomationsFilteredEmptyStateProps {
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automations exist but the active search/filters match none. Chrome copy, so
|
||||
* it stays the host's translations rather than manifest copy.
|
||||
*/
|
||||
export function AutomationsFilteredEmptyState({
|
||||
onClear,
|
||||
}: AutomationsFilteredEmptyStateProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
return (
|
||||
<div
|
||||
data-testid="automations-filtered-empty"
|
||||
className="rounded-xl border border-dashed border-[var(--oh-border)] p-8 text-center"
|
||||
>
|
||||
<p className="text-sm text-muted">
|
||||
{t(I18nKey.AUTOMATIONS$NO_FILTER_MATCHES)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="automations-clear-filters"
|
||||
onClick={onClear}
|
||||
className="mt-2 text-sm text-white underline-offset-2 hover:underline"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$CLEAR_FILTERS)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons";
|
||||
import type { SubPageNavItem } from "#/components/features/manifest/manifest-subpage-layout";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
getInterfaceCopy,
|
||||
getSubPagesSpec,
|
||||
} from "#/manifests/automation-interface";
|
||||
|
||||
export interface AutomationSubPageNav {
|
||||
heading: string;
|
||||
items: SubPageNavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest's sub-page navigation resolved for rendering, or null when the
|
||||
* manifest declares none. The templates item is hidden on cloud backends: the
|
||||
* catalog launcher it hosts is a local-backend feature and renders nothing
|
||||
* there.
|
||||
*/
|
||||
export function useAutomationSubPageNav(): AutomationSubPageNav | null {
|
||||
const { t } = useTranslation("openhands");
|
||||
const active = useActiveBackend();
|
||||
const spec = getSubPagesSpec();
|
||||
if (!spec) return null;
|
||||
|
||||
const isCloudBackend = active.backend.kind === "cloud";
|
||||
return {
|
||||
heading: getInterfaceCopy().sidebarLabel ?? t(I18nKey.SIDEBAR$AUTOMATIONS),
|
||||
items: spec
|
||||
.filter((item) => !(item.page === "templates" && isCloudBackend))
|
||||
.map((item) => ({
|
||||
to: item.to,
|
||||
label: item.label,
|
||||
Icon: MANIFEST_ICON_BY_SLUG[item.icon],
|
||||
testId: `automations-navigation-${item.page}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
CircleAlert,
|
||||
LayoutDashboard,
|
||||
Sparkles,
|
||||
Timer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { InterfaceIconSlug } from "#/manifests/types";
|
||||
|
||||
/**
|
||||
* The artwork behind each icon slug admission accepts. `satisfies` keeps this
|
||||
* map and the validator's closed set in lockstep: a slug without artwork, or
|
||||
* artwork without a slug, fails to compile.
|
||||
*/
|
||||
export const MANIFEST_ICON_BY_SLUG = {
|
||||
"layout-dashboard": LayoutDashboard,
|
||||
sparkles: Sparkles,
|
||||
bot: Bot,
|
||||
"circle-alert": CircleAlert,
|
||||
activity: Activity,
|
||||
timer: Timer,
|
||||
} satisfies Record<InterfaceIconSlug, LucideIcon>;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { extensionModuleCardSurfaceClassName } from "#/utils/extension-module-card-classes";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export interface OverviewTileView {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface ManifestOverviewTilesProps {
|
||||
/** Names the section for assistive technology. */
|
||||
label: string;
|
||||
tiles: OverviewTileView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid of summary tiles. Purely presentational — which tiles exist, their
|
||||
* captions, and their computed values arrive fully resolved from the caller.
|
||||
*/
|
||||
export function ManifestOverviewTiles({
|
||||
label,
|
||||
tiles,
|
||||
}: ManifestOverviewTilesProps) {
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="grid grid-cols-2 gap-3 xl:grid-cols-4"
|
||||
>
|
||||
{tiles.map((tile) => (
|
||||
<div
|
||||
key={tile.key}
|
||||
data-testid={`overview-tile-${tile.key}`}
|
||||
className={cn(extensionModuleCardSurfaceClassName, "p-4")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs text-muted">{tile.label}</span>
|
||||
<tile.Icon
|
||||
className="size-4 shrink-0 text-[var(--oh-muted)]"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-semibold text-content">
|
||||
{tile.value}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted">{tile.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { NavigationLink } from "#/components/shared/navigation-link";
|
||||
import {
|
||||
SIDEBAR_ROW_INTERACTIVE_CLASS,
|
||||
sidebarNavRowClassName,
|
||||
} from "#/components/features/sidebar/sidebar-layout";
|
||||
import { settingsLikeMainScrollClassName } from "#/utils/settings-like-page-layout-classes";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export interface SubPageNavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
interface ManifestSubpageLayoutProps {
|
||||
/** The section heading above the desktop navigation. */
|
||||
heading: string;
|
||||
/** Prefix for the desktop/mobile nav testids. */
|
||||
navTestIdBase: string;
|
||||
items: SubPageNavItem[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SubPageNavLink({ item }: { item: SubPageNavItem }) {
|
||||
return (
|
||||
<NavigationLink
|
||||
to={item.to}
|
||||
end
|
||||
data-testid={item.testId}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
sidebarNavRowClassName(),
|
||||
"truncate whitespace-nowrap",
|
||||
isActive
|
||||
? SIDEBAR_ROW_INTERACTIVE_CLASS.active
|
||||
: SIDEBAR_ROW_INTERACTIVE_CLASS.idle,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="shrink-0 flex items-center justify-center">
|
||||
<item.Icon size={16} aria-hidden />
|
||||
</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</NavigationLink>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell of a manifest-declared sub-page: a sticky desktop aside and a
|
||||
* horizontal mobile strip around a settings-like scrolling content column.
|
||||
* Purely presentational — what the items are, and whether any exist at all,
|
||||
* is the caller's (ultimately the manifest's) statement.
|
||||
*/
|
||||
export function ManifestSubpageLayout({
|
||||
heading,
|
||||
navTestIdBase,
|
||||
items,
|
||||
children,
|
||||
}: ManifestSubpageLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-full gap-4 md:gap-6 md:pl-8 lg:gap-10 lg:pl-10">
|
||||
<aside
|
||||
data-testid={`${navTestIdBase}-desktop`}
|
||||
className="hidden md:flex md:w-[260px] md:shrink-0 md:flex-col md:gap-2 md:sticky md:top-8 md:self-start"
|
||||
>
|
||||
<span className="px-2 text-sm font-normal text-white">{heading}</span>
|
||||
<div className="flex flex-col gap-0.5 pt-0.5">
|
||||
{items.map((item) => (
|
||||
<SubPageNavLink key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<main className={cn(settingsLikeMainScrollClassName, "h-full")}>
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-6">
|
||||
<nav
|
||||
data-testid={`${navTestIdBase}-mobile`}
|
||||
className="md:hidden flex gap-1 overflow-x-auto border-b border-[var(--oh-border)] pb-2"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<SubPageNavLink key={item.to} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,10 @@ interface EnumFilterDropdownProps<T extends string> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: readonly T[];
|
||||
labelKeyByValue: Record<T, I18nKey>;
|
||||
labelKeyByValue?: Record<T, I18nKey>;
|
||||
/** Plain-string labels, e.g. manifest-supplied copy. Wins over the keys. */
|
||||
labelByValue?: Record<T, string>;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function EnumFilterDropdown<T extends string>({
|
||||
@@ -24,6 +27,8 @@ export function EnumFilterDropdown<T extends string>({
|
||||
onChange,
|
||||
options,
|
||||
labelKeyByValue,
|
||||
labelByValue,
|
||||
ariaLabel,
|
||||
}: EnumFilterDropdownProps<T>) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -31,8 +36,14 @@ export function EnumFilterDropdown<T extends string>({
|
||||
setOpen(false),
|
||||
);
|
||||
|
||||
const getOptionLabel = (option: T): string =>
|
||||
labelByValue?.[option] ??
|
||||
(labelKeyByValue ? t(labelKeyByValue[option]) : option);
|
||||
const resolvedAriaLabel =
|
||||
ariaLabel ?? t(I18nKey.CONVERSATION_PANEL$FILTER_LABEL);
|
||||
|
||||
const defaultOption = options[0];
|
||||
const selectedLabel = t(labelKeyByValue[value]);
|
||||
const selectedLabel = getOptionLabel(value);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -45,7 +56,7 @@ export function EnumFilterDropdown<T extends string>({
|
||||
data-testid="dropdown-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={t(I18nKey.CONVERSATION_PANEL$FILTER_LABEL)}
|
||||
aria-label={resolvedAriaLabel}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
dropdownFilterTriggerClassName,
|
||||
@@ -68,7 +79,7 @@ export function EnumFilterDropdown<T extends string>({
|
||||
<div
|
||||
role="menu"
|
||||
data-testid={`${testId}-menu`}
|
||||
aria-label={t(I18nKey.CONVERSATION_PANEL$FILTER_LABEL)}
|
||||
aria-label={resolvedAriaLabel}
|
||||
className={cn(
|
||||
"absolute right-0 top-full z-50 mt-1 min-w-full w-max",
|
||||
"max-h-60 overflow-auto rounded-[6px] bg-tertiary p-1 context-menu-box-shadow",
|
||||
@@ -94,7 +105,7 @@ export function EnumFilterDropdown<T extends string>({
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t(labelKeyByValue[option])}
|
||||
{getOptionLabel(option)}
|
||||
</span>
|
||||
{selected ? (
|
||||
<Check className="h-4 w-4 shrink-0" aria-hidden />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import AutomationService from "#/api/automation-service/automation-service.api";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import { AUTOMATION_RUNS_QUERY_KEY } from "#/hooks/query/use-automation-detail";
|
||||
import {
|
||||
summarizeAutomationRuns,
|
||||
type RunSummaryState,
|
||||
} from "#/manifests/automation-insights";
|
||||
import type { Automation } from "#/types/automation";
|
||||
|
||||
/**
|
||||
* The newest runs sampled per automation. Matches the detail page's default
|
||||
* page, so both surfaces share one cache entry per automation.
|
||||
*/
|
||||
const RECENT_RUN_SAMPLE_SIZE = 20;
|
||||
|
||||
interface UseAutomationRunSummariesOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One runs query per listed automation — a deliberate fan-out, bounded by the
|
||||
* list's page size. Summaries drive the dashboard's tiles, health badges,
|
||||
* filters, and sorts.
|
||||
*/
|
||||
export function useAutomationRunSummaries(
|
||||
automations: readonly Automation[],
|
||||
options: UseAutomationRunSummariesOptions = {},
|
||||
): Map<string, RunSummaryState> {
|
||||
const { enabled = true } = options;
|
||||
const active = useActiveBackend();
|
||||
|
||||
return useQueries({
|
||||
queries: automations.map((automation) => ({
|
||||
queryKey: [
|
||||
...AUTOMATION_RUNS_QUERY_KEY,
|
||||
automation.id,
|
||||
{ limit: RECENT_RUN_SAMPLE_SIZE, offset: 0 },
|
||||
active.backend.id,
|
||||
active.orgId,
|
||||
],
|
||||
queryFn: () =>
|
||||
AutomationService.getAutomationRuns(
|
||||
automation.id,
|
||||
RECENT_RUN_SAMPLE_SIZE,
|
||||
0,
|
||||
),
|
||||
staleTime: 60 * 1000,
|
||||
enabled: enabled && !!automation.id,
|
||||
})),
|
||||
combine: (results) => {
|
||||
const byId = new Map<string, RunSummaryState>();
|
||||
automations.forEach((automation, index) => {
|
||||
const result = results[index];
|
||||
byId.set(automation.id, {
|
||||
summary: result.data ? summarizeAutomationRuns(result.data) : null,
|
||||
isLoading: result.isLoading,
|
||||
isError: result.isError,
|
||||
});
|
||||
});
|
||||
return byId;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13157,6 +13157,40 @@
|
||||
"uk": "Пошук автоматизацій...",
|
||||
"ca": "Cerca automatitzacions..."
|
||||
},
|
||||
"AUTOMATIONS$NO_FILTER_MATCHES": {
|
||||
"en": "No automations match these filters",
|
||||
"ja": "これらのフィルターに一致するオートメーションはありません",
|
||||
"zh-CN": "没有符合这些筛选条件的自动化",
|
||||
"zh-TW": "沒有符合這些篩選條件的自動化",
|
||||
"ko-KR": "이 필터와 일치하는 자동화가 없습니다",
|
||||
"no": "Ingen automatiseringer samsvarer med disse filtrene",
|
||||
"it": "Nessuna automazione corrisponde a questi filtri",
|
||||
"pt": "Nenhuma automação corresponde a esses filtros",
|
||||
"es": "Ninguna automatización coincide con estos filtros",
|
||||
"ar": "لا توجد أتمتة مطابقة لهذه عوامل التصفية",
|
||||
"fr": "Aucune automatisation ne correspond à ces filtres",
|
||||
"tr": "Bu filtrelerle eşleşen otomasyon yok",
|
||||
"de": "Keine Automatisierungen entsprechen diesen Filtern",
|
||||
"uk": "Жодна автоматизація не відповідає цим фільтрам",
|
||||
"ca": "Cap automatització coincideix amb aquests filtres"
|
||||
},
|
||||
"AUTOMATIONS$CLEAR_FILTERS": {
|
||||
"en": "Clear filters",
|
||||
"ja": "フィルターをクリア",
|
||||
"zh-CN": "清除筛选条件",
|
||||
"zh-TW": "清除篩選條件",
|
||||
"ko-KR": "필터 지우기",
|
||||
"no": "Tøm filtre",
|
||||
"it": "Cancella filtri",
|
||||
"pt": "Limpar filtros",
|
||||
"es": "Borrar filtros",
|
||||
"ar": "مسح عوامل التصفية",
|
||||
"fr": "Effacer les filtres",
|
||||
"tr": "Filtreleri temizle",
|
||||
"de": "Filter zurücksetzen",
|
||||
"uk": "Очистити фільтри",
|
||||
"ca": "Esborra els filtres"
|
||||
},
|
||||
"AUTOMATIONS$VIEW_MODE": {
|
||||
"en": "View mode",
|
||||
"ja": "表示モード",
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* The dashboard's automation knowledge, and the only module beside
|
||||
* `automation-setup.ts` that has any.
|
||||
*
|
||||
* The interface manifest's dashboard surface names metrics, filter predicates,
|
||||
* and sort comparators from closed sets; the one implementation of each name
|
||||
* is here, over the automation service's list and runs responses, so the rest
|
||||
* of `src/manifests/` stays about composition rather than about automations.
|
||||
*
|
||||
* Summaries are derived from the newest page of runs (the sample the runs
|
||||
* hook fetches); `total` alone is the response's lifetime count. Success rate
|
||||
* and durations consider only COMPLETED and FAILED runs — a cancelled or
|
||||
* skipped run says nothing about either.
|
||||
*/
|
||||
|
||||
import {
|
||||
AutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationRun,
|
||||
type AutomationRunsResponse,
|
||||
} from "#/types/automation";
|
||||
import type {
|
||||
DashboardSortValue,
|
||||
DashboardStatusValue,
|
||||
DashboardTriggerValue,
|
||||
InterfaceListInsights,
|
||||
OverviewMetric,
|
||||
} from "./types";
|
||||
|
||||
export type AutomationHealth =
|
||||
| "healthy"
|
||||
| "failing"
|
||||
| "running"
|
||||
| "disabled"
|
||||
| "never-run"
|
||||
| "unknown";
|
||||
|
||||
/** Which caption of the manifest's `insights.health` block names each state. */
|
||||
export const HEALTH_LABEL_KEYS: Record<
|
||||
AutomationHealth,
|
||||
keyof InterfaceListInsights["health"]
|
||||
> = {
|
||||
healthy: "healthy",
|
||||
failing: "failing",
|
||||
running: "running",
|
||||
disabled: "disabled",
|
||||
"never-run": "neverRun",
|
||||
unknown: "checking",
|
||||
};
|
||||
|
||||
export interface AutomationRunSummary {
|
||||
/** Lifetime run count, from the response — not the sample's length. */
|
||||
total: number;
|
||||
latestRun: AutomationRun | null;
|
||||
/** COMPLETED over COMPLETED+FAILED in the sample. Null with no terminal runs. */
|
||||
recentSuccessRate: number | null;
|
||||
/** Mean completed_at − started_at over the sample's terminal runs. */
|
||||
averageDurationMs: number | null;
|
||||
}
|
||||
|
||||
/** One automation's summary alongside its query state. */
|
||||
export interface RunSummaryState {
|
||||
summary: AutomationRunSummary | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
type Summaries = ReadonlyMap<string, RunSummaryState>;
|
||||
|
||||
const TERMINAL_STATUSES = new Set<AutomationRunStatus>([
|
||||
AutomationRunStatus.COMPLETED,
|
||||
AutomationRunStatus.FAILED,
|
||||
]);
|
||||
|
||||
export function summarizeAutomationRuns(
|
||||
response: AutomationRunsResponse,
|
||||
): AutomationRunSummary {
|
||||
const terminal = response.runs.filter((run) =>
|
||||
TERMINAL_STATUSES.has(run.status),
|
||||
);
|
||||
const completed = terminal.filter(
|
||||
(run) => run.status === AutomationRunStatus.COMPLETED,
|
||||
).length;
|
||||
|
||||
const durations = terminal.flatMap((run) => {
|
||||
if (!run.completed_at) return [];
|
||||
const ms =
|
||||
new Date(run.completed_at).getTime() - new Date(run.started_at).getTime();
|
||||
return Number.isFinite(ms) && ms >= 0 ? [ms] : [];
|
||||
});
|
||||
|
||||
return {
|
||||
total: response.total,
|
||||
latestRun: response.runs[0] ?? null,
|
||||
recentSuccessRate:
|
||||
terminal.length === 0 ? null : completed / terminal.length,
|
||||
averageDurationMs:
|
||||
durations.length === 0
|
||||
? null
|
||||
: durations.reduce((sum, ms) => sum + ms, 0) / durations.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A disabled automation is disabled no matter its history; while the summary
|
||||
* is unsettled nothing is claimed; after that the latest run speaks.
|
||||
*/
|
||||
export function deriveAutomationHealth(
|
||||
automation: Automation,
|
||||
state: RunSummaryState | undefined,
|
||||
): AutomationHealth {
|
||||
if (!automation.enabled) return "disabled";
|
||||
if (!state || state.isError || state.isLoading || !state.summary) {
|
||||
return "unknown";
|
||||
}
|
||||
const latest = state.summary.latestRun;
|
||||
if (!latest) return "never-run";
|
||||
if (latest.status === AutomationRunStatus.FAILED) return "failing";
|
||||
if (
|
||||
latest.status === AutomationRunStatus.PENDING ||
|
||||
latest.status === AutomationRunStatus.RUNNING
|
||||
) {
|
||||
return "running";
|
||||
}
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
/** "—" unknown, seconds under a minute, minutes under an hour, else "1.5h". */
|
||||
export function formatCompactDuration(ms: number | null): string {
|
||||
if (ms === null || !Number.isFinite(ms)) return "—";
|
||||
if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / 60_000))}m`;
|
||||
return `${(ms / 3_600_000).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
/** The list page's search predicate: name, prompt, repository, or model. */
|
||||
export function matchesAutomationSearch(
|
||||
automation: Automation,
|
||||
query: string,
|
||||
): boolean {
|
||||
const q = query.toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
automation.name.toLowerCase().includes(q) ||
|
||||
(automation.prompt ?? "").toLowerCase().includes(q) ||
|
||||
automation.repository?.toLowerCase().includes(q) === true ||
|
||||
automation.model?.toLowerCase().includes(q) === true
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_PREDICATES: Record<
|
||||
Exclude<DashboardStatusValue, "all">,
|
||||
(automation: Automation, health: AutomationHealth) => boolean
|
||||
> = {
|
||||
active: (automation) => automation.enabled,
|
||||
failing: (_automation, health) => health === "failing",
|
||||
disabled: (automation) => !automation.enabled,
|
||||
};
|
||||
|
||||
const TRIGGER_PREDICATES: Record<
|
||||
Exclude<DashboardTriggerValue, "all">,
|
||||
(automation: Automation) => boolean
|
||||
> = {
|
||||
// The backend emits several scheduled-trigger aliases, so "event" is the
|
||||
// one exact kind and everything else is a schedule.
|
||||
event: (automation) => automation.trigger.type === "event",
|
||||
schedule: (automation) => automation.trigger.type !== "event",
|
||||
};
|
||||
|
||||
function runCount(automation: Automation, byId: Summaries): number {
|
||||
return byId.get(automation.id)?.summary?.total ?? 0;
|
||||
}
|
||||
|
||||
function lastRunTime(automation: Automation, byId: Summaries): number {
|
||||
const startedAt =
|
||||
byId.get(automation.id)?.summary?.latestRun?.started_at ??
|
||||
automation.last_triggered_at;
|
||||
if (!startedAt) return 0;
|
||||
const time = new Date(startedAt).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
const SORT_COMPARATORS: Record<
|
||||
DashboardSortValue,
|
||||
(a: Automation, b: Automation, byId: Summaries) => number
|
||||
> = {
|
||||
"last-run": (a, b, byId) => lastRunTime(b, byId) - lastRunTime(a, byId),
|
||||
runs: (a, b, byId) => runCount(b, byId) - runCount(a, byId),
|
||||
name: (a, b) => a.name.localeCompare(b.name),
|
||||
};
|
||||
|
||||
export interface DashboardViewState {
|
||||
search: string;
|
||||
status: DashboardStatusValue;
|
||||
trigger: DashboardTriggerValue;
|
||||
sort: DashboardSortValue;
|
||||
}
|
||||
|
||||
/** The automations the dashboard shows, filtered and ordered. */
|
||||
export function applyDashboardView(
|
||||
automations: readonly Automation[],
|
||||
view: DashboardViewState,
|
||||
byId: Summaries,
|
||||
): Automation[] {
|
||||
return automations
|
||||
.filter((automation) => {
|
||||
if (!matchesAutomationSearch(automation, view.search)) return false;
|
||||
if (
|
||||
view.status !== "all" &&
|
||||
!STATUS_PREDICATES[view.status](
|
||||
automation,
|
||||
deriveAutomationHealth(automation, byId.get(automation.id)),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
view.trigger !== "all" &&
|
||||
!TRIGGER_PREDICATES[view.trigger](automation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => SORT_COMPARATORS[view.sort](a, b, byId));
|
||||
}
|
||||
|
||||
export interface OverviewTileValue {
|
||||
/** The formatted value the tile displays. */
|
||||
display: string;
|
||||
/** True when the metric's value is zero, which swaps in `zeroDetail`. */
|
||||
isZero: boolean;
|
||||
/** Values the tile's copy may substitute via `{{name}}`. */
|
||||
placeholderValues: Record<string, string | number>;
|
||||
}
|
||||
|
||||
function loadedSummaries(
|
||||
automations: readonly Automation[],
|
||||
byId: Summaries,
|
||||
): AutomationRunSummary[] {
|
||||
return automations.flatMap((automation) => {
|
||||
const state = byId.get(automation.id);
|
||||
return state?.summary && !state.isLoading && !state.isError
|
||||
? [state.summary]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
const TILE_COMPUTATIONS: Record<
|
||||
OverviewMetric,
|
||||
(automations: readonly Automation[], byId: Summaries) => OverviewTileValue
|
||||
> = {
|
||||
automations: (automations) => ({
|
||||
display: automations.length.toLocaleString(),
|
||||
isZero: automations.length === 0,
|
||||
placeholderValues: {
|
||||
active: automations.filter((automation) => automation.enabled).length,
|
||||
},
|
||||
}),
|
||||
"needs-attention": (automations, byId) => {
|
||||
const failing = automations.filter(
|
||||
(automation) =>
|
||||
deriveAutomationHealth(automation, byId.get(automation.id)) ===
|
||||
"failing",
|
||||
).length;
|
||||
return {
|
||||
display: failing.toLocaleString(),
|
||||
isZero: failing === 0,
|
||||
placeholderValues: {},
|
||||
};
|
||||
},
|
||||
"total-runs": (automations, byId) => {
|
||||
const loaded = loadedSummaries(automations, byId);
|
||||
if (loaded.length === 0 && automations.length > 0) {
|
||||
return { display: "—", isZero: false, placeholderValues: {} };
|
||||
}
|
||||
const total = loaded.reduce((sum, summary) => sum + summary.total, 0);
|
||||
return {
|
||||
display: total.toLocaleString(),
|
||||
isZero: total === 0,
|
||||
placeholderValues: {},
|
||||
};
|
||||
},
|
||||
"average-duration": (automations, byId) => {
|
||||
const durations = loadedSummaries(automations, byId).flatMap((summary) =>
|
||||
summary.averageDurationMs === null ? [] : [summary.averageDurationMs],
|
||||
);
|
||||
const mean =
|
||||
durations.length === 0
|
||||
? null
|
||||
: durations.reduce((sum, ms) => sum + ms, 0) / durations.length;
|
||||
return {
|
||||
display: formatCompactDuration(mean),
|
||||
isZero: false,
|
||||
placeholderValues: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** The value behind one manifest-declared tile. */
|
||||
export function computeOverviewTile(
|
||||
metric: OverviewMetric,
|
||||
automations: readonly Automation[],
|
||||
byId: Summaries,
|
||||
): OverviewTileValue {
|
||||
return TILE_COMPUTATIONS[metric](automations, byId);
|
||||
}
|
||||
@@ -9,6 +9,11 @@
|
||||
* `@openhands/extensions` ships one and it passes admission; otherwise the
|
||||
* host's own defaults do, which reproduce today's behavior exactly. Copy
|
||||
* defaults are null, meaning "render the host's own translation".
|
||||
*
|
||||
* The sub-page surface (navigation, overview tiles, filters, sort, run
|
||||
* insights, templates page) is the exception: the host holds no definitions
|
||||
* of its own, so its accessors return null until an admitted manifest
|
||||
* declares it, and the sub-pages simply do not render.
|
||||
*/
|
||||
|
||||
import { AUTOMATION_CATALOG } from "@openhands/extensions/automations";
|
||||
@@ -16,18 +21,26 @@ import { validateInterfaceManifest } from "./interface-validation";
|
||||
import { AUTOMATION_INTERFACE_CANDIDATE } from "./manifest-sources";
|
||||
import type {
|
||||
AutomationAttributeName,
|
||||
InterfaceDashboardFilter,
|
||||
InterfaceDashboardSort,
|
||||
InterfaceEndpointName,
|
||||
InterfaceIconSlug,
|
||||
InterfaceImportExport,
|
||||
InterfaceListInsights,
|
||||
InterfaceManifest,
|
||||
InterfaceOverview,
|
||||
InterfaceRoutes,
|
||||
InterfaceSubPageId,
|
||||
InterfaceTemplatesPage,
|
||||
} from "./types";
|
||||
|
||||
/** The routes this host has registrations for, in `src/routes.ts`. */
|
||||
const MOUNTED_ROUTES: InterfaceRoutes = {
|
||||
const MOUNTED_ROUTES = {
|
||||
list: "/automations",
|
||||
setup: "/automations/new/:automationId",
|
||||
detail: "/automations/:automationId",
|
||||
};
|
||||
templates: "/automations/templates",
|
||||
} satisfies InterfaceRoutes;
|
||||
|
||||
const DEFAULT_ENDPOINTS: InterfaceManifest["endpoints"] = {
|
||||
list: "/v1",
|
||||
@@ -187,6 +200,10 @@ export function automationDetailPath(id: string): string {
|
||||
return substituteRouteParam((ADMITTED?.routes ?? MOUNTED_ROUTES).detail, id);
|
||||
}
|
||||
|
||||
export function automationTemplatesPath(): string {
|
||||
return ADMITTED?.routes.templates ?? MOUNTED_ROUTES.templates;
|
||||
}
|
||||
|
||||
export function getAutomationEndpoint(name: InterfaceEndpointName): string {
|
||||
return (ADMITTED?.endpoints ?? DEFAULT_ENDPOINTS)[name];
|
||||
}
|
||||
@@ -265,3 +282,59 @@ export function getFeaturedAutomationIds(): readonly string[] {
|
||||
export function getResponderIntegrationIds(): readonly string[] {
|
||||
return ADMITTED?.responderIntegrationIds ?? DEFAULT_RESPONDER_INTEGRATION_IDS;
|
||||
}
|
||||
|
||||
export interface SubPageNavSpec {
|
||||
page: InterfaceSubPageId;
|
||||
/** The page's route, resolved through the manifest. */
|
||||
to: string;
|
||||
label: string;
|
||||
icon: InterfaceIconSlug;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-page navigation, or null when the manifest does not declare the
|
||||
* sub-page surface. No manifest, no sub-pages — there is no host default.
|
||||
*/
|
||||
export function getSubPagesSpec(): SubPageNavSpec[] | null {
|
||||
const subPages = ADMITTED?.navigation.subPages;
|
||||
if (!subPages) return null;
|
||||
return subPages.map((item) => ({
|
||||
page: item.page,
|
||||
to:
|
||||
item.page === "templates"
|
||||
? automationTemplatesPath()
|
||||
: automationListPath(),
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface DashboardSpec {
|
||||
overview: InterfaceOverview;
|
||||
filters: InterfaceDashboardFilter[];
|
||||
sort: InterfaceDashboardSort;
|
||||
insights: InterfaceListInsights;
|
||||
}
|
||||
|
||||
/**
|
||||
* The list page's dashboard composition, or null when the manifest does not
|
||||
* declare it. Admission accepts the sub-page surface whole or not at all, so
|
||||
* these four are present together.
|
||||
*/
|
||||
export function getDashboardSpec(): DashboardSpec | null {
|
||||
const list = ADMITTED?.pages.list;
|
||||
if (!list?.overview || !list.filters || !list.sort || !list.insights) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
overview: list.overview,
|
||||
filters: list.filters,
|
||||
sort: list.sort,
|
||||
insights: list.insights,
|
||||
};
|
||||
}
|
||||
|
||||
/** The templates page identity, or null when the manifest does not declare it. */
|
||||
export function getTemplatesPageSpec(): InterfaceTemplatesPage | null {
|
||||
return ADMITTED?.pages.templates ?? null;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,18 @@ import type {
|
||||
AutomationAttributeName,
|
||||
InterfaceAttributeType,
|
||||
InterfaceRoutes,
|
||||
OverviewMetric,
|
||||
} from "./types";
|
||||
import {
|
||||
DASHBOARD_FILTER_IDS,
|
||||
DASHBOARD_FILTER_VALUES,
|
||||
DASHBOARD_SORT_VALUES,
|
||||
INTERFACE_ICON_SLUGS,
|
||||
INTERFACE_SUB_PAGE_IDS,
|
||||
INTERFACE_VERSION,
|
||||
OVERVIEW_METRICS,
|
||||
OVERVIEW_TILE_PLACEHOLDERS,
|
||||
} from "./types";
|
||||
import { INTERFACE_VERSION } from "./types";
|
||||
|
||||
/** Copy must never be able to inject markup into the host. */
|
||||
const MARKUP_PATTERN = /<[A-Za-z/!]/;
|
||||
@@ -75,6 +85,15 @@ function isRecord(value: unknown): value is Rec {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isOneOf<T extends readonly string[]>(
|
||||
value: unknown,
|
||||
values: T,
|
||||
): value is T[number] {
|
||||
return (
|
||||
typeof value === "string" && (values as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
class InterfaceChecker {
|
||||
readonly errors: string[] = [];
|
||||
|
||||
@@ -114,7 +133,7 @@ function checkRoutes(
|
||||
mounted: InterfaceRoutes,
|
||||
): void {
|
||||
if (!check.record(routes, "routes")) return;
|
||||
check.closed(routes, ["list", "setup", "detail"], "routes");
|
||||
check.closed(routes, ["list", "setup", "detail", "templates"], "routes");
|
||||
|
||||
// The host serves what it has registrations for, so a declared route must be
|
||||
// exactly the mounted shape — the manifest owns link construction, not the
|
||||
@@ -124,11 +143,60 @@ function checkRoutes(
|
||||
check.fail(`routes.${name}`, `must be "${mounted[name]}"`);
|
||||
}
|
||||
});
|
||||
if (
|
||||
routes.templates !== undefined &&
|
||||
routes.templates !== mounted.templates
|
||||
) {
|
||||
check.fail(
|
||||
"routes.templates",
|
||||
mounted.templates === undefined
|
||||
? "is not a route this host mounts"
|
||||
: `must be "${mounted.templates}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkSubPages(check: InterfaceChecker, subPages: unknown): void {
|
||||
if (!Array.isArray(subPages) || subPages.length === 0) {
|
||||
check.fail("navigation.subPages", "must be a non-empty array");
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
subPages.forEach((item: unknown, index) => {
|
||||
const path = `navigation.subPages[${index}]`;
|
||||
if (!check.record(item, path)) return;
|
||||
check.closed(item, ["page", "label", "icon"], path);
|
||||
|
||||
const { page, label, icon } = item;
|
||||
if (!isOneOf(page, INTERFACE_SUB_PAGE_IDS)) {
|
||||
check.fail(`${path}.page`, "is not a sub-page this host serves");
|
||||
} else if (seen.has(page)) {
|
||||
check.fail(`${path}.page`, "repeats a page");
|
||||
} else {
|
||||
seen.add(page);
|
||||
}
|
||||
check.copy(label, `${path}.label`);
|
||||
checkIconSlug(check, icon, `${path}.icon`);
|
||||
});
|
||||
}
|
||||
|
||||
function checkIconSlug(
|
||||
check: InterfaceChecker,
|
||||
icon: unknown,
|
||||
path: string,
|
||||
): void {
|
||||
if (!isOneOf(icon, INTERFACE_ICON_SLUGS)) {
|
||||
check.fail(path, "is not an icon this host ships");
|
||||
}
|
||||
}
|
||||
|
||||
function checkNavigation(check: InterfaceChecker, navigation: unknown): void {
|
||||
if (!check.record(navigation, "navigation")) return;
|
||||
check.closed(navigation, ["sidebar", "commandMenu"], "navigation");
|
||||
check.closed(
|
||||
navigation,
|
||||
["sidebar", "commandMenu", "subPages"],
|
||||
"navigation",
|
||||
);
|
||||
|
||||
if (check.record(navigation.sidebar, "navigation.sidebar")) {
|
||||
check.closed(navigation.sidebar, ["label"], "navigation.sidebar");
|
||||
@@ -145,16 +213,35 @@ function checkNavigation(check: InterfaceChecker, navigation: unknown): void {
|
||||
check.copy(menu.description, "navigation.commandMenu.description");
|
||||
check.copy(menu.keywords, "navigation.commandMenu.keywords");
|
||||
}
|
||||
if (navigation.subPages !== undefined) {
|
||||
checkSubPages(check, navigation.subPages);
|
||||
}
|
||||
}
|
||||
|
||||
function checkPages(check: InterfaceChecker, pages: unknown): void {
|
||||
if (!check.record(pages, "pages")) return;
|
||||
check.closed(pages, ["list", "detail", "edit"], "pages");
|
||||
check.closed(pages, ["list", "detail", "edit", "templates"], "pages");
|
||||
|
||||
if (check.record(pages.list, "pages.list")) {
|
||||
check.closed(pages.list, ["title", "subtitle"], "pages.list");
|
||||
check.closed(
|
||||
pages.list,
|
||||
["title", "subtitle", "overview", "filters", "sort", "insights"],
|
||||
"pages.list",
|
||||
);
|
||||
check.copy(pages.list.title, "pages.list.title");
|
||||
check.copy(pages.list.subtitle, "pages.list.subtitle");
|
||||
if (pages.list.overview !== undefined) {
|
||||
checkOverview(check, pages.list.overview);
|
||||
}
|
||||
if (pages.list.filters !== undefined) {
|
||||
checkFilters(check, pages.list.filters);
|
||||
}
|
||||
if (pages.list.sort !== undefined) {
|
||||
checkSort(check, pages.list.sort);
|
||||
}
|
||||
if (pages.list.insights !== undefined) {
|
||||
checkInsights(check, pages.list.insights);
|
||||
}
|
||||
}
|
||||
if (check.record(pages.detail, "pages.detail")) {
|
||||
check.closed(pages.detail, ["backLabel"], "pages.detail");
|
||||
@@ -164,6 +251,244 @@ function checkPages(check: InterfaceChecker, pages: unknown): void {
|
||||
check.closed(pages.edit, ["title"], "pages.edit");
|
||||
check.copy(pages.edit.title, "pages.edit.title");
|
||||
}
|
||||
if (pages.templates !== undefined) {
|
||||
checkTemplatesPage(check, pages.templates);
|
||||
}
|
||||
}
|
||||
|
||||
function checkOverview(check: InterfaceChecker, overview: unknown): void {
|
||||
const path = "pages.list.overview";
|
||||
if (!check.record(overview, path)) return;
|
||||
check.closed(overview, ["label", "tiles"], path);
|
||||
check.copy(overview.label, `${path}.label`);
|
||||
|
||||
const { tiles } = overview;
|
||||
if (!Array.isArray(tiles) || tiles.length === 0) {
|
||||
check.fail(`${path}.tiles`, "must be a non-empty array");
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
tiles.forEach((tile: unknown, index) => {
|
||||
checkOverviewTile(check, tile, seen, `${path}.tiles[${index}]`);
|
||||
});
|
||||
}
|
||||
|
||||
function checkOverviewTile(
|
||||
check: InterfaceChecker,
|
||||
tile: unknown,
|
||||
seen: Set<string>,
|
||||
path: string,
|
||||
): void {
|
||||
if (!check.record(tile, path)) return;
|
||||
check.closed(tile, ["metric", "label", "detail", "zeroDetail", "icon"], path);
|
||||
|
||||
const { metric, label, detail, zeroDetail, icon } = tile;
|
||||
check.copy(label, `${path}.label`);
|
||||
checkIconSlug(check, icon, `${path}.icon`);
|
||||
if (!isOneOf(metric, OVERVIEW_METRICS)) {
|
||||
check.fail(`${path}.metric`, "is not a metric this host computes");
|
||||
check.copy(detail, `${path}.detail`);
|
||||
if (zeroDetail !== undefined) check.copy(zeroDetail, `${path}.zeroDetail`);
|
||||
return;
|
||||
}
|
||||
if (seen.has(metric)) {
|
||||
check.fail(`${path}.metric`, "repeats a metric");
|
||||
} else {
|
||||
seen.add(metric);
|
||||
}
|
||||
checkTileCopy(check, detail, metric, `${path}.detail`);
|
||||
if (zeroDetail !== undefined) {
|
||||
checkTileCopy(check, zeroDetail, metric, `${path}.zeroDetail`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile copy may embed `{{name}}` placeholders, but only the names the metric
|
||||
* exposes — plain substitution, never an expression.
|
||||
*/
|
||||
function checkTileCopy(
|
||||
check: InterfaceChecker,
|
||||
value: unknown,
|
||||
metric: OverviewMetric,
|
||||
path: string,
|
||||
): void {
|
||||
if (!check.copy(value, path)) return;
|
||||
const exposed = OVERVIEW_TILE_PLACEHOLDERS[metric];
|
||||
const unknownPlaceholder =
|
||||
exposed.length === 0
|
||||
? /\{\{/
|
||||
: new RegExp(`\\{\\{(?!(?:${exposed.join("|")})\\}\\})`);
|
||||
if (unknownPlaceholder.test(value as string)) {
|
||||
check.fail(path, "uses a placeholder this metric does not expose");
|
||||
}
|
||||
}
|
||||
|
||||
function checkFilters(check: InterfaceChecker, filters: unknown): void {
|
||||
const path = "pages.list.filters";
|
||||
if (!Array.isArray(filters) || filters.length === 0) {
|
||||
check.fail(path, "must be a non-empty array");
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
filters.forEach((filter: unknown, index) => {
|
||||
checkFilter(check, filter, seen, `${path}[${index}]`);
|
||||
});
|
||||
}
|
||||
|
||||
function checkFilter(
|
||||
check: InterfaceChecker,
|
||||
filter: unknown,
|
||||
seen: Set<string>,
|
||||
path: string,
|
||||
): void {
|
||||
if (!check.record(filter, path)) return;
|
||||
check.closed(filter, ["id", "label", "options"], path);
|
||||
|
||||
const { id, label, options } = filter;
|
||||
check.copy(label, `${path}.label`);
|
||||
if (!isOneOf(id, DASHBOARD_FILTER_IDS)) {
|
||||
check.fail(`${path}.id`, "is not a filter this host implements");
|
||||
return;
|
||||
}
|
||||
if (seen.has(id)) {
|
||||
check.fail(`${path}.id`, "repeats a filter");
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
checkFilterOptions(
|
||||
check,
|
||||
options,
|
||||
DASHBOARD_FILTER_VALUES[id],
|
||||
`${path}.options`,
|
||||
);
|
||||
}
|
||||
|
||||
function checkFilterOptions(
|
||||
check: InterfaceChecker,
|
||||
options: unknown,
|
||||
values: readonly string[],
|
||||
path: string,
|
||||
): void {
|
||||
if (!Array.isArray(options) || options.length < 2) {
|
||||
check.fail(path, "must offer at least two options");
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
options.forEach((option: unknown, index) => {
|
||||
const optionPath = `${path}[${index}]`;
|
||||
if (!check.record(option, optionPath)) return;
|
||||
check.closed(option, ["value", "label"], optionPath);
|
||||
|
||||
const { value, label } = option;
|
||||
if (!isOneOf(value, values)) {
|
||||
check.fail(`${optionPath}.value`, "is not a value this host implements");
|
||||
} else if (seen.has(value)) {
|
||||
check.fail(`${optionPath}.value`, "repeats a value");
|
||||
} else {
|
||||
seen.add(value);
|
||||
}
|
||||
check.copy(label, `${optionPath}.label`);
|
||||
});
|
||||
// "all" is the host's initial selection and what Clear filters resets to,
|
||||
// so a filter that does not offer it could never be neutral.
|
||||
if (!seen.has("all")) {
|
||||
check.fail(path, 'must offer the "all" option');
|
||||
}
|
||||
}
|
||||
|
||||
function checkSort(check: InterfaceChecker, sort: unknown): void {
|
||||
const path = "pages.list.sort";
|
||||
if (!check.record(sort, path)) return;
|
||||
check.closed(sort, ["label", "options", "default"], path);
|
||||
|
||||
const { label, options, default: defaultValue } = sort;
|
||||
check.copy(label, `${path}.label`);
|
||||
const declared = new Set<string>();
|
||||
if (!Array.isArray(options) || options.length === 0) {
|
||||
check.fail(`${path}.options`, "must be a non-empty array");
|
||||
} else {
|
||||
options.forEach((option: unknown, index) => {
|
||||
const optionPath = `${path}.options[${index}]`;
|
||||
if (!check.record(option, optionPath)) return;
|
||||
check.closed(option, ["value", "label"], optionPath);
|
||||
|
||||
if (!isOneOf(option.value, DASHBOARD_SORT_VALUES)) {
|
||||
check.fail(`${optionPath}.value`, "is not a sort this host implements");
|
||||
} else if (declared.has(option.value)) {
|
||||
check.fail(`${optionPath}.value`, "repeats a value");
|
||||
} else {
|
||||
declared.add(option.value);
|
||||
}
|
||||
check.copy(option.label, `${optionPath}.label`);
|
||||
});
|
||||
}
|
||||
if (typeof defaultValue !== "string" || !declared.has(defaultValue)) {
|
||||
check.fail(`${path}.default`, "must be one of the declared option values");
|
||||
}
|
||||
}
|
||||
|
||||
/** Every insight state and stat the host renders must have a caption. */
|
||||
const INSIGHTS_SHAPE = {
|
||||
health: ["healthy", "failing", "running", "disabled", "neverRun", "checking"],
|
||||
lastRun: ["label", "never", "justNow"],
|
||||
stats: ["runs", "recentSuccess", "averageDuration"],
|
||||
} as const;
|
||||
|
||||
function checkInsights(check: InterfaceChecker, insights: unknown): void {
|
||||
const path = "pages.list.insights";
|
||||
if (!check.record(insights, path)) return;
|
||||
check.closed(insights, Object.keys(INSIGHTS_SHAPE), path);
|
||||
|
||||
(Object.keys(INSIGHTS_SHAPE) as (keyof typeof INSIGHTS_SHAPE)[]).forEach(
|
||||
(section) => {
|
||||
const sectionPath = `${path}.${section}`;
|
||||
const value = insights[section];
|
||||
if (!check.record(value, sectionPath)) return;
|
||||
const keys = INSIGHTS_SHAPE[section];
|
||||
check.closed(value, keys, sectionPath);
|
||||
keys.forEach((key) => {
|
||||
check.copy(value[key], `${sectionPath}.${key}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function checkTemplatesPage(check: InterfaceChecker, templates: unknown): void {
|
||||
const path = "pages.templates";
|
||||
if (!check.record(templates, path)) return;
|
||||
check.closed(templates, ["title", "description"], path);
|
||||
check.copy(templates.title, `${path}.title`);
|
||||
check.copy(templates.description, `${path}.description`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-page surface is declared whole or not at all. Navigation that points
|
||||
* at an unrouted page, or a dashboard with filters but no insight captions,
|
||||
* would be a partially-trusted mix, and the host refuses those wholesale.
|
||||
*/
|
||||
function checkSubPageGroup(check: InterfaceChecker, candidate: Rec): void {
|
||||
const routes = isRecord(candidate.routes) ? candidate.routes : {};
|
||||
const navigation = isRecord(candidate.navigation) ? candidate.navigation : {};
|
||||
const pages = isRecord(candidate.pages) ? candidate.pages : {};
|
||||
const list = isRecord(pages.list) ? pages.list : {};
|
||||
|
||||
const pieces: [string, unknown][] = [
|
||||
["routes.templates", routes.templates],
|
||||
["navigation.subPages", navigation.subPages],
|
||||
["pages.templates", pages.templates],
|
||||
["pages.list.overview", list.overview],
|
||||
["pages.list.filters", list.filters],
|
||||
["pages.list.sort", list.sort],
|
||||
["pages.list.insights", list.insights],
|
||||
];
|
||||
const missing = pieces
|
||||
.filter(([, value]) => value === undefined)
|
||||
.map(([name]) => name);
|
||||
if (missing.length === 0 || missing.length === pieces.length) return;
|
||||
check.fail(
|
||||
"interface",
|
||||
`the sub-page surface must be declared whole; missing ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function checkAttribute(
|
||||
@@ -412,5 +737,7 @@ export function validateInterfaceManifest(
|
||||
"responderIntegrationIds",
|
||||
);
|
||||
|
||||
checkSubPageGroup(check, candidate);
|
||||
|
||||
return { valid: check.errors.length === 0, errors: check.errors };
|
||||
}
|
||||
|
||||
@@ -40,3 +40,13 @@ export function interpolateText(template: string, scope: SetupScope): string {
|
||||
toText(getByPath(scope, path)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Substitute placeholders from a flat name → value record. */
|
||||
export function interpolateValues(
|
||||
template: string,
|
||||
values: Record<string, string | number>,
|
||||
): string {
|
||||
return template.replace(PLACEHOLDER_PATTERN, (_match, name: string) =>
|
||||
toText(values[name]),
|
||||
);
|
||||
}
|
||||
|
||||
+163
-1
@@ -199,6 +199,8 @@ export interface InterfaceRoutes {
|
||||
setup: string;
|
||||
/** Carries the `:automationId` segment the host substitutes. */
|
||||
detail: string;
|
||||
/** The templates sub-page. Static: there is no parameter to substitute. */
|
||||
templates?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,17 +234,177 @@ export interface InterfaceImportExport {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-page surface: navigation, overview tiles, filters, sort, and run
|
||||
* insights for the list page, and a templates page. Every value below names
|
||||
* something this host implements from a closed set — a metric it computes, a
|
||||
* predicate it applies, a comparator it runs, an icon it ships — and the
|
||||
* manifest picks and captions it, never defines it. The host carries no
|
||||
* definitions of its own for this surface, so a manifest without it simply
|
||||
* leaves the sub-pages unrendered.
|
||||
*/
|
||||
|
||||
export const INTERFACE_SUB_PAGE_IDS = ["list", "templates"] as const;
|
||||
|
||||
export type InterfaceSubPageId = (typeof INTERFACE_SUB_PAGE_IDS)[number];
|
||||
|
||||
/** Icon names this host maps to artwork. A manifest cannot supply its own. */
|
||||
export const INTERFACE_ICON_SLUGS = [
|
||||
"layout-dashboard",
|
||||
"sparkles",
|
||||
"bot",
|
||||
"circle-alert",
|
||||
"activity",
|
||||
"timer",
|
||||
] as const;
|
||||
|
||||
export type InterfaceIconSlug = (typeof INTERFACE_ICON_SLUGS)[number];
|
||||
|
||||
export interface InterfaceSubPageNavItem {
|
||||
/** The `pages` entry this item navigates to; its route comes from `routes`. */
|
||||
page: InterfaceSubPageId;
|
||||
label: string;
|
||||
icon: InterfaceIconSlug;
|
||||
}
|
||||
|
||||
/** Values this host computes over the loaded automations and their runs. */
|
||||
export const OVERVIEW_METRICS = [
|
||||
"automations",
|
||||
"needs-attention",
|
||||
"total-runs",
|
||||
"average-duration",
|
||||
] as const;
|
||||
|
||||
export type OverviewMetric = (typeof OVERVIEW_METRICS)[number];
|
||||
|
||||
/**
|
||||
* Placeholder names a tile's `detail`/`zeroDetail` copy may reference inside
|
||||
* `{{...}}`, per metric. Plain substitution, like setup copy.
|
||||
*/
|
||||
export const OVERVIEW_TILE_PLACEHOLDERS: Record<
|
||||
OverviewMetric,
|
||||
readonly string[]
|
||||
> = {
|
||||
automations: ["active"],
|
||||
"needs-attention": [],
|
||||
"total-runs": [],
|
||||
"average-duration": [],
|
||||
};
|
||||
|
||||
export interface InterfaceOverviewTile {
|
||||
metric: OverviewMetric;
|
||||
label: string;
|
||||
/** Caption under the value. */
|
||||
detail: string;
|
||||
/** Replaces `detail` while the metric's value is zero. */
|
||||
zeroDetail?: string;
|
||||
icon: InterfaceIconSlug;
|
||||
}
|
||||
|
||||
export interface InterfaceOverview {
|
||||
/** Names the tiles section for assistive technology. */
|
||||
label: string;
|
||||
tiles: InterfaceOverviewTile[];
|
||||
}
|
||||
|
||||
/** Filter values name predicates this host implements, per filter id. */
|
||||
export const DASHBOARD_FILTER_VALUES = {
|
||||
status: ["all", "active", "failing", "disabled"],
|
||||
trigger: ["all", "schedule", "event"],
|
||||
} as const;
|
||||
|
||||
export type DashboardFilterId = keyof typeof DASHBOARD_FILTER_VALUES;
|
||||
|
||||
export const DASHBOARD_FILTER_IDS = Object.keys(
|
||||
DASHBOARD_FILTER_VALUES,
|
||||
) as readonly DashboardFilterId[];
|
||||
|
||||
export type DashboardStatusValue =
|
||||
(typeof DASHBOARD_FILTER_VALUES.status)[number];
|
||||
|
||||
export type DashboardTriggerValue =
|
||||
(typeof DASHBOARD_FILTER_VALUES.trigger)[number];
|
||||
|
||||
export interface InterfaceStatusFilter {
|
||||
id: "status";
|
||||
/** The control's accessible name. */
|
||||
label: string;
|
||||
options: { value: DashboardStatusValue; label: string }[];
|
||||
}
|
||||
|
||||
export interface InterfaceTriggerFilter {
|
||||
id: "trigger";
|
||||
/** The control's accessible name. */
|
||||
label: string;
|
||||
options: { value: DashboardTriggerValue; label: string }[];
|
||||
}
|
||||
|
||||
export type InterfaceDashboardFilter =
|
||||
| InterfaceStatusFilter
|
||||
| InterfaceTriggerFilter;
|
||||
|
||||
/** Sort values name comparators this host implements. */
|
||||
export const DASHBOARD_SORT_VALUES = ["last-run", "runs", "name"] as const;
|
||||
|
||||
export type DashboardSortValue = (typeof DASHBOARD_SORT_VALUES)[number];
|
||||
|
||||
export interface InterfaceDashboardSort {
|
||||
/** The control's accessible name. */
|
||||
label: string;
|
||||
options: { value: DashboardSortValue; label: string }[];
|
||||
/** One of the declared option values. */
|
||||
default: DashboardSortValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for the per-automation run insights on cards and rows. The states,
|
||||
* precedence, sampling, and value formatting are this host's; the manifest
|
||||
* names them.
|
||||
*/
|
||||
export interface InterfaceListInsights {
|
||||
health: {
|
||||
healthy: string;
|
||||
failing: string;
|
||||
running: string;
|
||||
disabled: string;
|
||||
neverRun: string;
|
||||
checking: string;
|
||||
};
|
||||
lastRun: { label: string; never: string; justNow: string };
|
||||
stats: { runs: string; recentSuccess: string; averageDuration: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The templates sub-page identity. Its body — the catalog cards and their
|
||||
* launch behavior — is this host's existing catalog surface.
|
||||
*/
|
||||
export interface InterfaceTemplatesPage {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface InterfaceManifest {
|
||||
version: typeof INTERFACE_VERSION;
|
||||
routes: InterfaceRoutes;
|
||||
navigation: {
|
||||
sidebar: { label: string };
|
||||
commandMenu: { title: string; description: string; keywords: string };
|
||||
/** The ordered sub-page navigation of the interface. */
|
||||
subPages?: InterfaceSubPageNavItem[];
|
||||
};
|
||||
pages: {
|
||||
list: { title: string; subtitle: string };
|
||||
list: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
overview?: InterfaceOverview;
|
||||
/** The filter dropdowns of the list page, in render order. */
|
||||
filters?: InterfaceDashboardFilter[];
|
||||
sort?: InterfaceDashboardSort;
|
||||
insights?: InterfaceListInsights;
|
||||
};
|
||||
detail: { backLabel: string };
|
||||
edit: { title: string };
|
||||
templates?: InterfaceTemplatesPage;
|
||||
};
|
||||
docsUrl: string;
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,7 @@ export default [
|
||||
]),
|
||||
route("oauth/device/verify", "routes/device-verify.tsx"),
|
||||
route("automations", "routes/automations-list.tsx"),
|
||||
route("automations/templates", "routes/automation-templates.tsx"),
|
||||
route("automations/new/:automationId", "routes/automation-setup-route.tsx"),
|
||||
route("automations/:automationId", "routes/automation-detail.tsx"),
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { useAutomationSubPageNav } from "#/components/features/automations/dashboard/use-automation-sub-page-nav";
|
||||
import { RecommendedAutomationsLauncher } from "#/components/features/automations/recommended-automations-launcher";
|
||||
import { SearchInput } from "#/components/features/automations/search-input";
|
||||
import { ManifestSubpageLayout } from "#/components/features/manifest/manifest-subpage-layout";
|
||||
import { getTemplatesPageSpec } from "#/manifests/automation-interface";
|
||||
|
||||
/**
|
||||
* The templates sub-page. It exists only while the admitted interface
|
||||
* manifest declares it — like a setup route for an id no entry claims, an
|
||||
* undeclared page is a 404 rendered by the layout's error boundary.
|
||||
*/
|
||||
export const clientLoader = () => {
|
||||
if (!getTemplatesPageSpec()) {
|
||||
throw new Response(null, { status: 404, statusText: "Not Found" });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function AutomationTemplates() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const spec = getTemplatesPageSpec();
|
||||
const nav = useAutomationSubPageNav();
|
||||
|
||||
if (!spec || !nav) return null;
|
||||
|
||||
return (
|
||||
<ManifestSubpageLayout
|
||||
heading={nav.heading}
|
||||
navTestIdBase="automations-navbar"
|
||||
items={nav.items}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-content">{spec.title}</h1>
|
||||
<p className="mt-1 text-sm text-muted">{spec.description}</p>
|
||||
</div>
|
||||
<div className="flex max-w-xl items-stretch">
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
</div>
|
||||
<RecommendedAutomationsLauncher query={searchQuery} />
|
||||
</ManifestSubpageLayout>
|
||||
);
|
||||
}
|
||||
+256
-132
@@ -4,6 +4,7 @@ import {
|
||||
useCallback,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { FileUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -50,16 +51,50 @@ import {
|
||||
} from "#/utils/automation-export";
|
||||
import {
|
||||
automationDetailPath,
|
||||
getDashboardSpec,
|
||||
getInterfaceCopy,
|
||||
} from "#/manifests/automation-interface";
|
||||
import { downloadBlob } from "#/utils/utils";
|
||||
import {
|
||||
applyDashboardView,
|
||||
computeOverviewTile,
|
||||
matchesAutomationSearch,
|
||||
} from "#/manifests/automation-insights";
|
||||
import { interpolateValues } from "#/manifests/manifest-template";
|
||||
import type {
|
||||
DashboardSortValue,
|
||||
DashboardStatusValue,
|
||||
DashboardTriggerValue,
|
||||
} from "#/manifests/types";
|
||||
import { useAutomationRunSummaries } from "#/hooks/query/use-automation-run-summaries";
|
||||
import { useAutomationSubPageNav } from "#/components/features/automations/dashboard/use-automation-sub-page-nav";
|
||||
import { AutomationsDashboardControls } from "#/components/features/automations/dashboard/automations-dashboard-controls";
|
||||
import { AutomationsFilteredEmptyState } from "#/components/features/automations/dashboard/automations-filtered-empty-state";
|
||||
import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons";
|
||||
import { ManifestOverviewTiles } from "#/components/features/manifest/manifest-overview-tiles";
|
||||
import { ManifestSubpageLayout } from "#/components/features/manifest/manifest-subpage-layout";
|
||||
import { cn, downloadBlob } from "#/utils/utils";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function AutomationsList() {
|
||||
const { t } = useTranslation("openhands");
|
||||
const interfaceCopy = getInterfaceCopy();
|
||||
// Admission is stable for the session; memo just keeps one identity.
|
||||
const dashboardSpec = useMemo(() => getDashboardSpec(), []);
|
||||
const subPageNav = useAutomationSubPageNav();
|
||||
// The manifest's dashboard surface is all-or-nothing, so either both are
|
||||
// present (dashboard mode) or neither is (today's plain list).
|
||||
const dashboard =
|
||||
dashboardSpec && subPageNav
|
||||
? { spec: dashboardSpec, nav: subPageNav }
|
||||
: null;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<DashboardStatusValue>("all");
|
||||
const [triggerFilter, setTriggerFilter] =
|
||||
useState<DashboardTriggerValue>("all");
|
||||
const [sortValue, setSortValue] = useState<DashboardSortValue>(
|
||||
dashboardSpec?.sort.default ?? "last-run",
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<AutomationViewMode>(() =>
|
||||
readStoredAutomationViewMode(),
|
||||
);
|
||||
@@ -92,6 +127,10 @@ export default function AutomationsList() {
|
||||
offset: 0,
|
||||
enabled: isBackendHealthy,
|
||||
});
|
||||
// One runs query per listed automation — dashboard mode only.
|
||||
const runSummaries = useAutomationRunSummaries(data?.automations ?? [], {
|
||||
enabled: isBackendHealthy && dashboard !== null,
|
||||
});
|
||||
const { trackPrebuiltAutomationEnabled, trackAutomationExported } =
|
||||
useTracking();
|
||||
const toggleMutation = useToggleAutomation();
|
||||
@@ -99,27 +138,38 @@ export default function AutomationsList() {
|
||||
const dispatchMutation = useDispatchAutomation();
|
||||
const importMutation = useImportAutomation();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const visible = useMemo(() => {
|
||||
if (!data?.automations) return [];
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (!q) return data.automations;
|
||||
return data.automations.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
(a.prompt ?? "").toLowerCase().includes(q) ||
|
||||
a.repository?.toLowerCase().includes(q) ||
|
||||
a.model?.toLowerCase().includes(q),
|
||||
if (!dashboardSpec) {
|
||||
return data.automations.filter((a) =>
|
||||
matchesAutomationSearch(a, searchQuery),
|
||||
);
|
||||
}
|
||||
return applyDashboardView(
|
||||
data.automations,
|
||||
{
|
||||
search: searchQuery,
|
||||
status: statusFilter,
|
||||
trigger: triggerFilter,
|
||||
sort: sortValue,
|
||||
},
|
||||
runSummaries,
|
||||
);
|
||||
}, [data?.automations, searchQuery]);
|
||||
}, [
|
||||
data?.automations,
|
||||
dashboardSpec,
|
||||
searchQuery,
|
||||
statusFilter,
|
||||
triggerFilter,
|
||||
sortValue,
|
||||
runSummaries,
|
||||
]);
|
||||
|
||||
const activeAutomations = useMemo(
|
||||
() => filtered.filter((a) => a.enabled),
|
||||
[filtered],
|
||||
);
|
||||
const inactive = useMemo(
|
||||
() => filtered.filter((a) => !a.enabled),
|
||||
[filtered],
|
||||
() => visible.filter((a) => a.enabled),
|
||||
[visible],
|
||||
);
|
||||
const inactive = useMemo(() => visible.filter((a) => !a.enabled), [visible]);
|
||||
|
||||
const handleToggle = (id: string, currentEnabled: boolean) => {
|
||||
const willEnable = !currentEnabled;
|
||||
@@ -226,117 +276,187 @@ export default function AutomationsList() {
|
||||
writeStoredAutomationViewMode(view);
|
||||
}, []);
|
||||
|
||||
// Resets what filters to nothing: search and the dropdowns, never the sort.
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("all");
|
||||
setTriggerFilter("all");
|
||||
};
|
||||
|
||||
const overviewTiles = useMemo(() => {
|
||||
if (!dashboardSpec) return [];
|
||||
const automations = data?.automations ?? [];
|
||||
return dashboardSpec.overview.tiles.map((tile) => {
|
||||
const value = computeOverviewTile(tile.metric, automations, runSummaries);
|
||||
const template =
|
||||
value.isZero && tile.zeroDetail ? tile.zeroDetail : tile.detail;
|
||||
return {
|
||||
key: tile.metric,
|
||||
label: tile.label,
|
||||
value: value.display,
|
||||
detail: interpolateValues(template, value.placeholderValues),
|
||||
Icon: MANIFEST_ICON_BY_SLUG[tile.icon],
|
||||
};
|
||||
});
|
||||
}, [dashboardSpec, data?.automations, runSummaries]);
|
||||
|
||||
const groupInsights = dashboard
|
||||
? { spec: dashboard.spec.insights, byId: runSummaries }
|
||||
: undefined;
|
||||
|
||||
// Dashboard mode wraps the page in the manifest's sub-page shell; without a
|
||||
// manifest the wrapper — like everything else — is exactly today's.
|
||||
const renderShell = (content: ReactNode) =>
|
||||
dashboard ? (
|
||||
<ManifestSubpageLayout
|
||||
heading={dashboard.nav.heading}
|
||||
navTestIdBase="automations-navbar"
|
||||
items={dashboard.nav.items}
|
||||
>
|
||||
{content}
|
||||
</ManifestSubpageLayout>
|
||||
) : (
|
||||
<div className="min-h-full">
|
||||
<div className="p-6 max-w-4xl mx-auto">{content}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const hasMore = data ? data.total > data.automations.length : false;
|
||||
const hasNoAutomations =
|
||||
!isLoading && !isError && data?.automations.length === 0;
|
||||
|
||||
// Show loading state while checking health
|
||||
if (isHealthLoading) {
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-xl font-medium text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
return renderShell(
|
||||
<div>
|
||||
<h1 className="text-xl font-medium text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
// Show backend not configured state if health check failed
|
||||
if (!isBackendHealthy) {
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-xl font-medium text-content">
|
||||
return renderShell(
|
||||
<div>
|
||||
<h1 className="text-xl font-medium text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
<BackendNotConfigured onRetry={refetchHealth} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return renderShell(
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
<BackendNotConfigured onRetry={refetchHealth} />
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="automations-import-automation"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
startContent={<FileUp className="size-4" aria-hidden />}
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$IMPORT)}
|
||||
</BrandButton>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
data-testid="automations-import-file"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="automations-add-automation"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setIsAddAutomationOpen(true)}
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$ADD_AUTOMATION)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-content">
|
||||
{interfaceCopy.listTitle ?? t(I18nKey.AUTOMATIONS$TITLE)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
{interfaceCopy.listSubtitle ?? t(I18nKey.AUTOMATIONS$SUBTITLE)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="automations-import-automation"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
startContent={<FileUp className="size-4" aria-hidden />}
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$IMPORT)}
|
||||
</BrandButton>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
data-testid="automations-import-file"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
testId="automations-add-automation"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setIsAddAutomationOpen(true)}
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$ADD_AUTOMATION)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
{/* Overview tiles — dashboard mode only */}
|
||||
{dashboard && (
|
||||
<ManifestOverviewTiles
|
||||
label={dashboard.spec.overview.label}
|
||||
tiles={overviewTiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="mt-6 flex items-stretch gap-2">
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
<AutomationViewToggle
|
||||
view={viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
disabled={hasNoAutomations}
|
||||
{/* Search */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch gap-2",
|
||||
dashboard ? "flex-wrap" : "mt-6",
|
||||
)}
|
||||
>
|
||||
<SearchInput value={searchQuery} onChange={setSearchQuery} />
|
||||
{dashboard && (
|
||||
<AutomationsDashboardControls
|
||||
spec={dashboard.spec}
|
||||
status={statusFilter}
|
||||
trigger={triggerFilter}
|
||||
sort={sortValue}
|
||||
onStatusChange={setStatusFilter}
|
||||
onTriggerChange={setTriggerFilter}
|
||||
onSortChange={setSortValue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AutomationViewToggle
|
||||
view={viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
disabled={hasNoAutomations}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Content */}
|
||||
<div className={cn("flex flex-col gap-6", !dashboard && "mt-6")}>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<AutomationCardSkeleton key={`skeleton-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && <ErrorState onRetry={refetch} />}
|
||||
{isError && !isLoading && <ErrorState onRetry={refetch} />}
|
||||
|
||||
{hasNoAutomations && <EmptyState />}
|
||||
{hasNoAutomations && <EmptyState />}
|
||||
|
||||
{!isLoading && !isError && data && data.automations.length > 0 && (
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
data &&
|
||||
data.automations.length > 0 &&
|
||||
(dashboard && visible.length === 0 ? (
|
||||
<AutomationsFilteredEmptyState onClear={handleClearFilters} />
|
||||
) : (
|
||||
<>
|
||||
<AutomationGroup
|
||||
title={t(I18nKey.AUTOMATIONS$ACTIVE)}
|
||||
@@ -353,6 +473,7 @@ export default function AutomationsList() {
|
||||
onDelete={handleDeleteRequest}
|
||||
onExport={handleExport}
|
||||
onEdit={canEdit ? handleEditRequest : undefined}
|
||||
insights={groupInsights}
|
||||
/>
|
||||
<AutomationGroup
|
||||
title={t(I18nKey.AUTOMATIONS$INACTIVE)}
|
||||
@@ -369,6 +490,7 @@ export default function AutomationsList() {
|
||||
onDelete={handleDeleteRequest}
|
||||
onExport={handleExport}
|
||||
onEdit={canEdit ? handleEditRequest : undefined}
|
||||
insights={groupInsights}
|
||||
/>
|
||||
|
||||
{hasMore && (
|
||||
@@ -381,43 +503,45 @@ export default function AutomationsList() {
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The launcher lives on the templates sub-page in dashboard mode */}
|
||||
{!dashboard && (
|
||||
<div className="mt-6">
|
||||
<RecommendedAutomationsLauncher query={searchQuery} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<DeleteConfirmationModal
|
||||
automationName={deleteTarget?.name ?? ""}
|
||||
isOpen={deleteTarget !== null}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
{/* Delete confirmation modal */}
|
||||
<DeleteConfirmationModal
|
||||
automationName={deleteTarget?.name ?? ""}
|
||||
isOpen={deleteTarget !== null}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{/* Edit modal — local backends only */}
|
||||
{editTarget && (
|
||||
<EditAutomationModal
|
||||
automation={editTarget}
|
||||
isOpen={editTarget !== null}
|
||||
onClose={() => setEditTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit modal — local backends only */}
|
||||
{editTarget && (
|
||||
<EditAutomationModal
|
||||
automation={editTarget}
|
||||
isOpen={editTarget !== null}
|
||||
onClose={() => setEditTarget(null)}
|
||||
/>
|
||||
)}
|
||||
<AddAutomationModal
|
||||
isOpen={isAddAutomationOpen}
|
||||
onClose={() => setIsAddAutomationOpen(false)}
|
||||
/>
|
||||
|
||||
<AddAutomationModal
|
||||
isOpen={isAddAutomationOpen}
|
||||
onClose={() => setIsAddAutomationOpen(false)}
|
||||
/>
|
||||
|
||||
<ImportAutomationModal
|
||||
isOpen={importSpec !== null}
|
||||
spec={importSpec}
|
||||
isImporting={importMutation.isPending}
|
||||
onClose={() => setImportSpec(null)}
|
||||
onImport={handleImportConfirm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ImportAutomationModal
|
||||
isOpen={importSpec !== null}
|
||||
spec={importSpec}
|
||||
isImporting={importMutation.isPending}
|
||||
onClose={() => setImportSpec(null)}
|
||||
onImport={handleImportConfirm}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user