Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ui/docs/app-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,9 @@ happy with this application's chrome may still want its own mark on it.

```tsx
branding: {
AppIcon: MyMark, // receives { collapsed }; supplied whole, like everything else
appName: "My Product", // used for the document title
AppIcon: MyMark, // receives { collapsed }; supplied whole, like everything else
appName: "My Product", // used for the document title
faviconUrl: "/my-mark.svg", // the tab icon; a URL, since the browser loads it itself
}
```

Expand Down
7 changes: 7 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kagent</title>
<!--
Declared here rather than left to the browser's guess at /favicon.ico, so the
element exists before any script runs: `applyExtensionBranding` retargets this
link, and a distribution shipping under its own mark has nothing to hook onto
if the tag is implicit.
-->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" data-app-favicon />
<!--
The first paint, before any stylesheet or script has loaded.

Expand Down
11 changes: 9 additions & 2 deletions ui/playwright/tests/substrate/substrate-polling.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,17 @@ test("substrate: the polling interval is the reader's, and zero stops it", async
await expect(page.getByTestId("substrate-poll-interval")).toHaveCount(0);
});

await test.step("2. switching polling on offers one, defaulting to a second", async () => {
await test.step("2. switching polling on offers one, defaulting to the floor", async () => {
await page.getByTestId("substrate-poll-toggle").click();
await expect(interval).toHaveValue("1");
// The fastest this page will ask: someone who turned polling on wants to see movement.
await expect(interval).toHaveValue("0.5");
await expect(page.getByTestId("substrate-poll-interval")).toContainText("seconds");
});

await test.step("2b. one second reads as one, not as ones", async () => {
// Singular for exactly one: "1 seconds" reads as a page not reading its own value.
await interval.fill("1");
await interval.blur();
await expect(page.getByTestId("substrate-poll-interval")).toContainText("second");
});

Expand Down
108 changes: 99 additions & 9 deletions ui/playwright/tests/substrate/substrate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { expectSettled, loadPage, routes } from "../../helpers/app";
*
* The fixture is built for exactly this: `enabled: true` with an `ateApiError` set, two
* worker pools across two namespaces, two templates β€” one Ready in `kagent`, one Pending in
* `platform` β€” three actors and two workers, one of the workers holding nothing. The third
* `platform` β€” eight actors and two workers, one of the workers holding nothing. The crashed
* actor sits last in the fixture and first once sorted, which is what makes the ordering
* testable at all.
*/
Expand All @@ -42,14 +42,74 @@ test("substrate: the inventory renders, and partial runtime data says so", async
// depending on how many there are. Both numbers, or the tile is not worth its space.
await expect(page.getByTestId("substrate-stat-pools-value")).toHaveText("2");
await expect(page.getByTestId("substrate-stat-templates-value")).toHaveText("1/2");
// Two running of four: one of the fixture's actors is `Failed` and another
// `Snapshotting`, which is exactly the case a bare count would hide.
await expect(page.getByTestId("substrate-stat-actors-value")).toHaveText("2/4");
// Two running of eight: the rest are crashed, deleting, paused, resuming, suspended
// and snapshotting, which is exactly the case a bare count would hide.
await expect(page.getByTestId("substrate-stat-actors-value")).toHaveText("2/8");
await expect(page.getByTestId("substrate-stat-workers-value")).toHaveText("1/2");
await expect(page.getByTestId("substrate-stat-ateapi-value")).toHaveText("connected");
await expect(page.getByTestId("substrate-stat-scope-value")).toHaveText("all");
});

await test.step("2b. the bar is the shape of the cluster, not just its running tally", async () => {
const bar = page.getByTestId("substrate-actor-status-counts");
await expect(bar).toBeVisible();

// One segment per actor, so the bar is counted rather than estimated, and ordered by
// the status with everything parked pushed to the end β€” the grey tail is the last
// thing on the bar, not something cutting the active part in half.
await expect(bar.locator("[data-tone]")).toHaveCount(8);
await expect(
bar.locator("[data-tone]").evaluateAll((els) => els.map((el) => el.getAttribute("data-tone"))),
).resolves.toEqual([
"danger",
"warning",
"progress",
"healthy",
"healthy",
"progress",
"idle",
"idle",
]);

// The whole breakdown from anywhere on the bar, rather than one label per segment: a
// reader wanting the shape of the cluster should not have to hover it a piece at a time.
await bar.hover();
const tip = page.locator(".ant-tooltip");
for (const line of [
"Crashed Actors: 1",
"Deleting Actors: 1",
"Resuming Actors: 1",
"Running Actors: 2",
"Snapshotting Actors: 1",
"Paused Actors: 1",
"Suspended Actors: 1",
]) {
await expect(tip).toContainText(line);
}

// The legend says the same numbers without a pointer at all, which is what a reader
// looking at a screenshot or a printed page has.
const legend = page.getByTestId("substrate-actor-status-counts-legend");
await expect(legend).toContainText("Crashed: 1");
await expect(legend).toContainText("Running: 2");
await expect(legend).toContainText("Suspended: 1");

// Every state the controller can report, so a reader learns the vocabulary from the
// page rather than from waiting for something to go wrong.
await expect(legend).toContainText("Pausing: 0");
await expect(legend).toContainText("Unknown: 0");
// `ACTOR_STATE_CRASHED` and a vocabulary entry of `Crashed` are the same status, and
// keying the legend on the wire value listed it twice β€” once at zero.
await expect(legend.getByText(/^Crashed: /)).toHaveCount(1);

// The same summary as text, because hovering needs a pointer and neither a screen
// reader nor a keyboard has one. Colour is never carrying this alone.
await expect(bar).toHaveAttribute(
"aria-label",
"Actor status. Crashed Actors: 1, Deleting Actors: 1, Resuming Actors: 1, Running Actors: 2, Snapshotting Actors: 1, Paused Actors: 1, Suspended Actors: 1",
);
});

await test.step("3. the worker pools the sandboxes run on", async () => {
const pools = page.getByTestId("substrate-pools-table");
await expect(pools).toBeVisible();
Expand Down Expand Up @@ -91,6 +151,14 @@ test("substrate: the inventory renders, and partial runtime data says so", async
// The pod, with its IP appended β€” the two facts an operator needs to go and look.
await expect(actors).toContainText("kagent/ateom-default-pool-0");
await expect(actors).toContainText("10.42.1.19");

// Both wire constants are read to the operator as words β€” a humaniser that only knew
// `CRASHED` would leave the other one showing the controller's vocabulary.
await expect(actors).not.toContainText("ACTOR_STATE_");
await expect(actors).toContainText("Deleting");
await expect(
actors.locator("[data-tone]").filter({ hasText: "Crashed" }),
).toHaveAttribute("data-tone", "danger");
});

await test.step("6. the workers, including the one holding nothing", async () => {
Expand Down Expand Up @@ -195,6 +263,15 @@ test("substrate: an unconfigured ate-api is explained, not reported as broken",
await expect(page.getByTestId("substrate-inventory-error")).toHaveCount(0);
await expect(page.getByTestId("substrate-partial")).toHaveCount(0);

// The bar keeps its track and says why it is empty. Removing it instead would move the
// table under a reader at the moment a cluster drained, which is the moment they are
// watching it.
await expect(page.getByTestId("substrate-actor-status-counts")).toBeVisible();
await expect(page.getByTestId("substrate-actor-status-counts").locator("[data-tone]")).toHaveCount(0);
await expect(page.getByTestId("substrate-actor-status-counts-empty")).toHaveText(
"ate-api is not configured, so there are no actors to show.",
);

// The two runtime sections name the setting to change. The two Kubernetes ones do not β€”
// they are empty for an unrelated reason, and saying "ate-api" over them would send an
// operator to fix the wrong thing.
Expand Down Expand Up @@ -233,12 +310,20 @@ test("substrate: the actor list is ordered, windowed, and bounded", async ({ pag

const actors = page.getByTestId("substrate-actors-table");

// Sorted by status, then by id. `Failed` precedes `Running` precedes `Snapshotting`,
// and the fixture lists them in none of that order.
// Sorted by status, then by id, and the fixture lists them in none of that order.
const ids = await actors.locator(".ant-table-row").evaluateAll((rows) =>
rows.map((row) => row.querySelector(".ant-table-cell")?.textContent?.trim() ?? ""),
);
expect(ids).toEqual(["actor-0aa1", "actor-3b55", "actor-7f21", "actor-9c03"]);
expect(ids).toEqual([
"actor-0aa1",
"actor-2e40",
"actor-5d17",
"actor-8b91",
"actor-3b55",
"actor-7f21",
"actor-9c03",
"actor-c3f5",
]);

// Windowed: antd renders rows into a virtual holder rather than a plain tbody, which
// is what keeps a list of thousands off the page.
Expand Down Expand Up @@ -296,7 +381,7 @@ test("substrate: each list narrows on its own, and a match is found wherever it
// The count beside the heading is now the *matching* total, so the tile is what
// keeps the cluster's own size on screen. A reader who searched and found one
// actor must not conclude their cluster is running one.
await expect(page.getByTestId("substrate-stat-actors")).toContainText("/4");
await expect(page.getByTestId("substrate-stat-actors")).toContainText("/8");
});

await test.step("3. and only that card: the other lists are left alone", async () => {
Expand Down Expand Up @@ -372,7 +457,12 @@ test("substrate: the actor and worker tables offer no sort, and the inline ones
.getByTestId("substrate-actors-table")
.locator(".ant-table-row")
.evaluateAll((rows) =>
rows.map((row) => row.textContent?.match(/Failed|Running|Snapshotting/)?.[0] ?? ""),
rows.map(
(row) =>
row.textContent?.match(
/Crashed|Deleting|Paused|Resuming|Running|Snapshotting|Suspended/,
)?.[0] ?? "",
),
);
expect(statuses).toEqual([...statuses].sort());
});
Expand Down
16 changes: 16 additions & 0 deletions ui/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 48 additions & 1 deletion ui/src/appExtensions/appExtensions.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createElement } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
applyExtensionBranding,
applyExtensionFieldValues,
buildSidebarSections,
defineExtensionFormField,
Expand Down Expand Up @@ -426,6 +427,52 @@ describe("composing several installed extensions", () => {
});
});

describe("applyExtensionBranding", () => {
const favicon = () => document.querySelector<HTMLLinkElement>("link[data-app-favicon]");

beforeEach(() => {
// Head first: replacing its contents drops the <title> element with everything else,
// which resets `document.title` to "".
document.head.innerHTML = '<link rel="icon" data-app-favicon href="/favicon.svg" />';
document.title = "kagent";
});

it("leaves the application's own title and icon alone when an extension sets neither", () => {
applyExtensionBranding({});

// Not reset to a default: every extension would otherwise have to restate the
// branding it was perfectly happy with.
expect(document.title).toBe("kagent");
expect(favicon()?.getAttribute("href")).toBe("/favicon.svg");
});

it("retargets the shipped link rather than adding a second one", () => {
applyExtensionBranding({ appName: "My Product", faviconUrl: "/my-mark.svg" });

expect(document.title).toBe("My Product");
expect(document.querySelectorAll("link[data-app-favicon]")).toHaveLength(1);
expect(favicon()?.href).toContain("/my-mark.svg");
});

it("still applies the icon when the host page shipped no link to retarget", () => {
document.head.innerHTML = "";

applyExtensionBranding({ faviconUrl: "/my-mark.svg" });
// Twice: the link it creates carries the marker, so the second call retargets the
// first rather than leaving two icons for the browser to choose between.
applyExtensionBranding({ faviconUrl: "/other-mark.svg" });

expect(document.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
expect(favicon()?.href).toContain("/other-mark.svg");
});

it("does not advertise SVG for an icon that is not one", () => {
applyExtensionBranding({ faviconUrl: "/my-mark.png" });

expect(favicon()?.getAttribute("type")).toBe("");
});
});

describe("validateAppExtensions", () => {
const page = { path: "/insights", element: createElement("div") };

Expand Down
31 changes: 27 additions & 4 deletions ui/src/appExtensions/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,40 @@ export interface ExtensionBranding {
AppIcon?: ComponentType<ExtensionAppIconProps>;
/** Product name, used for the document title. */
appName?: string;
/**
* Replaces the tab icon. A URL and not a component, unlike `AppIcon`: the browser
* loads this itself from a `<link>`, so there is nothing for us to render.
*/
faviconUrl?: string;
}

/**
* Applies the document title a distribution asked for.
* Applies the title and tab icon a distribution asked for.
*
* Takes the merged branding rather than the install, so the "later extension wins"
* rule is applied once, where every other singular capability applies it β€” see
* `selectors.ts`.
*
* Both are left alone when unset rather than reset to a default: the document already
* carries this application's own, and writing them unconditionally would mean every
* extension had to restate the branding it was happy with.
*/
export function applyExtensionDocumentTitle(
branding: ExtensionBranding | undefined,
): void {
export function applyExtensionBranding(branding: ExtensionBranding | undefined): void {
if (branding?.appName) document.title = branding.appName;
if (!branding?.faviconUrl) return;

// The tag `index.html` ships, or a fresh one if a host page dropped it β€” a missing
// icon is not a reason to leave the extension's branding unapplied. The new one carries
// the marker too, so a second call retargets this link instead of appending another.
let link = document.querySelector<HTMLLinkElement>("link[data-app-favicon]");
if (!link) {
link = document.createElement("link");
link.rel = "icon";
link.dataset.appFavicon = "";
document.head.appendChild(link);
}
link.href = branding.faviconUrl;
// `index.html` declares SVG. Left alone, a PNG would be served under a type that says
// otherwise, which browsers are entitled to act on when choosing between icons.
link.type = branding.faviconUrl.endsWith(".svg") ? "image/svg+xml" : "";
}
2 changes: 1 addition & 1 deletion ui/src/appExtensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export type { ExtensionTableColumn, ExtensionTableId } from "./tableColumns";

// Branding: the product's own name and mark, which is identity rather than
// styling and so should not cost a layout replacement.
export { applyExtensionDocumentTitle } from "./branding";
export { applyExtensionBranding } from "./branding";
export type { ExtensionAppIconProps, ExtensionBranding } from "./branding";

// Navigation overrides: the other half of contributing an entry β€” changing one
Expand Down
4 changes: 2 additions & 2 deletions ui/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { isMockMode } from "./api/config";
import { activeAppExtensions } from "./appExtensions/activeExtensions";
import { extensionBranding, extensionThemes } from "./appExtensions/selectors";
import { loadExtensionStylesheets } from "./appExtensions/theme";
import { applyExtensionDocumentTitle } from "./appExtensions/branding";
import { applyExtensionBranding } from "./appExtensions/branding";
import { AuthProvider } from "./auth";
import { App } from "./App";

Expand All @@ -17,7 +17,7 @@ async function bootstrap() {
// Before the first render: a web font that arrives afterwards reflows
// everything already painted.
loadExtensionStylesheets(extensionThemes(activeAppExtensions));
applyExtensionDocumentTitle(extensionBranding(activeAppExtensions));
applyExtensionBranding(extensionBranding(activeAppExtensions));

// Which backend is serving is decided in one place, `api/config.ts`, and read
// here rather than re-derived: two independent readings of the same env var
Expand Down
16 changes: 15 additions & 1 deletion ui/src/mocks/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,25 @@ export const mockSubstrateStatus: SubstrateStatusResponse = {
// Last in the fixture and first once sorted: ate-api returns actors in no
// particular order, so a fixture that is already in the right order cannot tell
// a page that sorts from one that does not.
{ actorId: "actor-0aa1", status: "Failed", version: 1 },
//
// The raw wire constant, because that is what a real controller sends for a state
// it has no name for β€” a fixture of tidy words would let `ACTOR_STATE_CRASHED`
// reach the page unread and no test object.
{ actorId: "actor-0aa1", status: "ACTOR_STATE_CRASHED", version: 1 },
// Shares "Running" with actor-7f21, which is what makes a two-key sort observable:
// with every status distinct, sorting by status then by id looks the same as
// sorting by status alone.
{ actorId: "actor-3b55", status: "Running", version: 1 },
// Parked rather than broken, and the only status here that reads as neither:
// without it nothing on the page is drawn in the idle tone.
{ actorId: "actor-5d17", status: "Paused", version: 1 },
// The controller's other unnamed state. `ACTOR_STATE_CRASHED` alone would pass a
// humaniser that special-cased that one word; two of them do not.
{ actorId: "actor-2e40", status: "ACTOR_STATE_DELETING", version: 1 },
// A transition, and a word the page recognises by its shape rather than from a
// list β€” the same rule that has to carry `Suspending` and `Pausing`.
{ actorId: "actor-8b91", status: "Resuming", version: 1 },
{ actorId: "actor-c3f5", status: "Suspended", version: 3 },
],
workers: [
{
Expand Down
Loading
Loading