Skip to content
Merged
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
57 changes: 52 additions & 5 deletions apps/controller/src/console/agents/detail.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -900,9 +900,18 @@ function createVersionDeploymentRecord(
// it never shows the waiting line (finding 1714).
const DIAGNOSTICS_WAITING_DELAY_MS = 250;

function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus = () => null) {
function createVersionDiagnosticsPanel(
context,
path,
revisionId,
recordedStatus = () => null,
operateAllowed = null,
) {
const section = element("section", { className: "agent-card version-diagnostics" });
let diagnostics = null;
// Set when the caller's permission answer denies Agent operate, which diagnostics need: the
// button is disabled with the reason, as Stop and Delete are (finding 1992).
let operateDenied = false;
let error = null;
let loading = false;
// Set once a run outlasts DIAGNOSTICS_WAITING_DELAY_MS: the waiting line replaces the last
Expand Down Expand Up @@ -985,6 +994,10 @@ function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus
: section.lastElementChild.firstElementChild;
focusSaveOutcome(section, [runButton], outcome);
outcomeId = runButton.getAttribute("aria-describedby") ? outcome.id : null;
// Focus stays on the button; a refusal below it still comes into view (finding 1992).
if (error) {
outcome?.scrollIntoView({ block: "nearest" });
}
}
}
}
Expand Down Expand Up @@ -1093,10 +1106,19 @@ function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus
// below is the one announcement, and a changed label inside the Agent view's live
// region was announced as well (finding 1698).
button("Run diagnostics for this version", () => void run(), {
disabled: waitingShown,
disabled: waitingShown || operateDenied,
...(outcomeId ? { "aria-describedby": outcomeId } : {}),
}),
),
...(operateDenied
? [
statusBlock(
"info",
null,
"Your access does not include running diagnostics for this Agent.",
),
]
: []),
element(
"p",
{ className: "muted" },
Expand Down Expand Up @@ -1127,6 +1149,12 @@ function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus
}

render();
void operateAllowed?.then((permitted) => {
if (permitted === false && context.isCurrent()) {
operateDenied = true;
render();
}
});
return { section, render };
}

Expand Down Expand Up @@ -2621,8 +2649,12 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null
() => diagnosticsPanel?.render(),
{ shared: activityRevisionId === selected && !latestDeploymentError },
);
diagnosticsPanel = createVersionDiagnosticsPanel(context, path, selected, () =>
versionRecord.current(),
diagnosticsPanel = createVersionDiagnosticsPanel(
context,
path,
selected,
() => versionRecord.current(),
callerPermissionsRead.then((permissions) => permissions?.operate),
);
versionEvidence.append(versionRecord.section, diagnosticsPanel.section);
if (latestDeploymentRecord?.revisionId === selected) {
Expand Down Expand Up @@ -4184,6 +4216,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null
editorError.clear();
feedback.textContent = "";
setStatusTone(feedback, null);
feedback.setAttribute("role", "status");
reloadRequired = false;
setDraftEditorState({
dirty: false,
Expand Down Expand Up @@ -4280,6 +4313,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null
editorError.clear();
feedback.textContent = "";
setStatusTone(feedback, null);
feedback.setAttribute("role", "status");
feedbackLocked = false;
reloadRequired = false;
updateState();
Expand All @@ -4288,6 +4322,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null
if (draftEditorNavigationBlock) {
feedback.textContent = draftEditorNavigationBlock;
setStatusTone(feedback, null);
feedback.setAttribute("role", "status");
feedbackLocked = true;
}
};
Expand All @@ -4304,6 +4339,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null
pending = true;
feedback.textContent = "Checking Configuration…";
setStatusTone(feedback, null);
feedback.setAttribute("role", "status");
feedbackLocked = true;
const saveFocused = document.activeElement === save;
updateState();
Expand Down Expand Up @@ -4360,6 +4396,8 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null
context.onExpired();
return;
}
// Every refusal below is a danger block: an alert before its text is written.
feedback.setAttribute("role", "alert");
if (!configurationSaved) {
refusedSave(error, "configuration", mutationStarted);
}
Expand All @@ -4381,10 +4419,19 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null
pending = false;
updateState();
render();
if (container.isConnected) {
const refused = feedback.dataset.tone === "danger";
feedback.setAttribute("role", refused ? "alert" : "status");
if (container.isConnected && refused && !outcomeUnknown && !reloadRequired) {
// A refusal Reload cannot fix (a 403, say) is an alert that takes focus and comes
// into view; under the fold, nothing seemed to happen (finding 1991).
revealStatus(feedback, container);
} else if (container.isConnected) {
// A conflict or failure leaves Reload draft (or Save again) as the next step
// (finding 1481).
focusSaveOutcome(container, [reload, save], feedback);
if (refused && container.contains(document.activeElement)) {
feedback.scrollIntoView({ block: "nearest" });
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions scripts/console-storybook/pages-agent-detail.stories.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const NewVersion = story("newVersion");
export const LongVersionHistory = story("longVersionHistory");
export const FirstDeployment = story("draftAutomaticCredentials");
export const ConfigurationEditor = { ...story("configurationEditor"), name: "Edit Configuration" };
export const ConfigurationSaveRefused = story("configurationSaveRefused");
export const GatewayPasswordAccess = story("gatewayPasswordAccess");
export const GatewayPasswordEnabled = story("gatewayPasswordEnabled");
export const GatewayPasswordSaveDenied = story("gatewayPasswordSaveDenied");
Expand Down Expand Up @@ -65,6 +66,8 @@ export const DeploymentSucceeded = story("deploymentSucceeded");
export const DeploymentUnavailable = story("deploymentUnavailable");
export const DiagnosticsSuccess = story("diagnosticsSuccess");
export const DiagnosticsFast = story("diagnosticsFast");
export const DiagnosticsReader = story("diagnosticsReader");
export const DiagnosticsRefused = story("diagnosticsRefused");
export const DiagnosticsUnknown = story("diagnosticsUnknown");
export const DiagnosticsUnavailable = story("diagnosticsUnavailable");
export const RuntimeLogs = story("runtimeLogs");
Expand Down
38 changes: 38 additions & 0 deletions scripts/console-storybook/public/scenarios.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3237,6 +3237,22 @@ export const scenarios = {
"Edit and save once more. Move focus to the Agents link before the save finishes; focus stays on the link.",
],
},
configurationSaveRefused: {
group: "Pages/Agent detail",
name: "Edit Configuration refused",
path: draft,
rules: [
{
method: "PATCH",
suffix: "/configurations/cfg_00000000-0000-4000-8000-000000000001",
status: 403,
code: "FORBIDDEN",
},
],
actions: [click("Enable gateway password access"), click("Save Configuration")],
description:
"A viewer without Configuration update saves: the refusal is an alert that takes focus and comes into view.",
},
gatewayPasswordAccess: {
group: "Pages/Agent detail",
name: "Enable gateway password access",
Expand Down Expand Up @@ -4138,6 +4154,28 @@ export const scenarios = {
description:
"Diagnostics that answer within 250 ms replace the panel's result directly, without painting the waiting line or moving focus.",
},
diagnosticsReader: {
group: "Pages/Agent detail",
name: "Current observations for a reader without operate",
path: candidateVersion,
deployed: true,
candidateDeploymentStatus: "succeeded",
agentPermissionSummary: { update: true, deploy: true, operate: false, delete: true },
description:
"Diagnostics need Agent operate. For a viewer whose permission answer denies it, Run diagnostics is disabled and says why, as Stop Agent does.",
gap: "The permission answer is simulated; the API still authorizes every request.",
},
diagnosticsRefused: {
group: "Pages/Agent detail",
name: "Current observations refused",
path: candidateVersion,
deployed: true,
candidateDeploymentStatus: "succeeded",
rules: [{ suffix: "/diagnostics", method: "POST", status: 403, code: "FORBIDDEN" }],
actions: [click("Run diagnostics for this version")],
description:
"The API refuses diagnostics. The refusal comes into view below the button, which keeps focus.",
},
diagnosticsUnknown: {
group: "Pages/Agent detail",
name: "Unknown observation for v6",
Expand Down
10 changes: 10 additions & 0 deletions scripts/console-storybook/status-block-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,13 @@ at 390.
The partial-save message is a warning block.
10. Open **Pages / Agent detail / Repository access save refused**. The
refusal under the editor is a danger block with focus.
11. Open **Pages / Agent detail / Edit Configuration refused**. The refusal
under the editor is a danger block with `role="alert"`. It has focus and
is fully on screen at 1440 and 390.
12. Open **Pages / Agent detail / Current observations for a reader without
operate**. Run diagnostics for this version is disabled, and an info
block says "Your access does not include running diagnostics for this
Agent.", as Stop Agent does.
13. Open **Pages / Agent detail / Current observations refused**. Scroll
until Run diagnostics sits at the bottom of the screen and click it. The
refusal below it comes into view; focus stays on the button.
148 changes: 147 additions & 1 deletion tests/browser/console-status-block.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
newPage,
selectSecret,
} from "./console-agents-browser-helpers.mjs";
import { createRepositoryLaunchFixture } from "./console-agents-test-support.mjs";
import {
createRepositoryLaunchFixture,
openAdvancedSettings,
} from "./console-agents-test-support.mjs";

// Errors, warnings and notices share one status block (UX plan C1): a stripe and wash in the
// tone's design-token colours, with the text in the body colour. A refusal after a request takes
Expand Down Expand Up @@ -366,3 +369,146 @@ test("a refused repository access save is a focused danger block", async (t) =>
assert.equal(view.focused, true, "the refusal has focus");
assert.equal(view.inView, true, "the refusal is on screen");
});

// A member who reads the Agent, its versions and its Configuration, and may change none of them.
function createReader(fixture, namespace, name) {
return fixture.createAccountWithPolicy(name, (principal) => {
fixture.policy.roles.push({
id: `role-${name}`,
namespaceId: namespace.id,
permissions: ["namespace", "agent", "agent_revision", "configuration"].map(
(resourceKind) => ({ action: "read", resourceKind }),
),
});
fixture.policy.bindings.push({
id: `binding-${name}`,
namespaceId: namespace.id,
subjectKind: "identity",
subjectId: principal.id,
roleId: `role-${name}`,
});
});
}

const CONFIGURATION_REFUSAL =
"You do not have permission to save this Configuration. Ask an administrator for Configuration update access, plus Secret operate on each Secret it binds.";

// A reader's refused Configuration save was a status line under the editor, neither announced
// as an alert nor focused, and cut by the fold at 1440 (finding 1991).
for (const viewport of [
{ width: 1440, height: 900 },
{ width: 390, height: 844 },
]) {
test(`a reader's refused Configuration save is a focused alert on screen at ${viewport.width}px`, async (t) => {
const fixture = await createConsoleAppFixture(t);
await fixture.bootstrap();
const namespace = await fixture.createNamespace("Status config refusal", { ready: true });
const agent = await fixture.createAgent(namespace.id, "Status config", nativeValues("cfg"));
const reader = await createReader(fixture, namespace, "status-config-reader");
const { page } = await newPage(t, fixture, { browser, context: { viewport } });
await login(
page,
fixture,
detailUrl(fixture, namespace.id, agent.id, "draft", "configuration"),
reader.credentials,
);
await page.getByRole("button", { name: "Edit Configuration" }).click();
await openAdvancedSettings(page);
const editor = page.getByLabel("Configuration JSON");
await editor.fill(JSON.stringify(nativeValues("edited by a reader"), null, 2));
await page.getByRole("button", { name: "Save Configuration" }).click();
const alert = page.getByRole("alert").filter({ hasText: CONFIGURATION_REFUSAL });
await alert.waitFor();
const view = await refusalView(alert);
t.diagnostic(JSON.stringify(view));
assert.equal(view.focused, true, "the refusal has focus");
assert.equal(view.inView, true, "the refusal is on screen");
assertPaint(await statusPaint(alert), "danger", "Configuration refusal");
// An edit clears it; the line is a status again for the next save's progress.
await editor.fill(JSON.stringify(nativeValues("edited again"), null, 2));
assert.equal(
await page.locator("#configuration-editor-feedback").getAttribute("role"),
"status",
);
});
}

// Run diagnostics needs Agent operate. A reader without it saw the button enabled beside a
// disabled Stop and Delete, and its 403 landed below the fold at 1440 (finding 1992).
test("Run diagnostics is disabled with its reason for a reader without operate", async (t) => {
const fixture = await createConsoleAppFixture(t);
await fixture.bootstrap();
const namespace = await fixture.createNamespace("Status diagnostics reader", { ready: true });
const agent = await fixture.createAgent(namespace.id, "Diagnostics reader", nativeValues("dr"));
const active = await fixture.seedActiveAgentRevision(namespace.id, agent.id);
const reader = await createReader(fixture, namespace, "status-diagnostics-reader");
const { page } = await newPage(t, fixture, { browser });
await login(
page,
fixture,
detailUrl(fixture, namespace.id, agent.id, active.revision.id, "configuration"),
reader.credentials,
);
// The same answer that disables Stop disables Run diagnostics.
await page.getByText("Your access does not include stopping this Agent.").waitFor();
const observations = page.locator(".version-diagnostics");
const reason = observations
.getByRole("status")
.filter({ hasText: "Your access does not include running diagnostics for this Agent." });
await reason.waitFor();
assertPaint(await statusPaint(reason), "info", "diagnostics reason");
const run = observations.getByRole("button", { name: "Run diagnostics for this version" });
assert.equal(await run.isDisabled(), true);
});

// A 403 that still happens (the permission answer allowed it, the API refused) comes into view
// below the button, which keeps focus (since #2779).
for (const viewport of [
{ width: 1440, height: 900 },
{ width: 390, height: 844 },
]) {
test(`a refused Run diagnostics brings its alert on screen at ${viewport.width}px`, async (t) => {
const fixture = await createConsoleAppFixture(t);
await fixture.bootstrap();
const namespace = await fixture.createNamespace("Status diagnostics refusal", { ready: true });
const agent = await fixture.createAgent(namespace.id, "Diagnostics denied", nativeValues("dd"));
const active = await fixture.seedActiveAgentRevision(namespace.id, agent.id);
const { page } = await newPage(t, fixture, { browser, context: { viewport } });
await page.route("**/deployments/*/diagnostics", (route) =>
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ error: { code: "FORBIDDEN", message: "Forbidden" } }),
}),
);
await login(
page,
fixture,
detailUrl(fixture, namespace.id, agent.id, active.revision.id, "configuration"),
);
const observations = page.locator(".version-diagnostics");
const run = observations.getByRole("button", { name: "Run diagnostics for this version" });
await run.waitFor();
// As the live walk did: the button at the bottom of the screen, pressed by pointer.
await run.evaluate((node) => node.scrollIntoView({ block: "end" }));
await run.click();
const alert = observations
.getByRole("alert")
.filter({ hasText: "Diagnostics require Agent read and operate access" });
await alert.waitFor();
const view = await refusalView(alert);
t.diagnostic(JSON.stringify(view));
assert.equal(view.inView, true, "the refusal is on screen");
assert.deepEqual(
await run.evaluate((node) => {
const { top, bottom } = node.getBoundingClientRect();
return {
focused: globalThis.document.activeElement === node,
inView: top >= 0 && bottom <= globalThis.innerHeight,
};
}),
{ focused: true, inView: true },
"the button keeps focus, on screen",
);
});
}
Loading