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
50 changes: 38 additions & 12 deletions dashboard/src/v2/components/DockerStatusMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { FunctionComponent } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { Box, Info, Play, Square, Terminal } from "lucide-preact";
import { Box, CircleAlert, Play, Square, Terminal } from "lucide-preact";
import { useFocusTrap } from "../hooks/use-focus-trap";
import { fetchOnboardingReadiness } from "../../lib/api/dashboard-api.js";
import type { OnboardingRuntimeReadiness } from "../../types.js";
Expand Down Expand Up @@ -127,13 +127,33 @@ export const DockerStatusMenu: FunctionComponent = () => {

const activeContainers = containers.filter(c => c.state === "running");
const clusterNotReady = readiness?.cluster.status === "not_ready";
const dockerStatusLabel = clusterNotReady
? `Docker Status: runtime not ready, ${activeContainers.length} active containers`
: `Docker Status: ${activeContainers.length} active containers`;

return (
<div className="relative inline-flex items-center gap-2" ref={containerRef}>
{clusterNotReady ? (
<div className="hidden h-9 items-center gap-1.5 rounded-full border border-status-amber/25 bg-status-amber/10 px-3 text-[10px] font-bold uppercase tracking-[0.14em] text-status-amber md:inline-flex">
<Info className="h-3.5 w-3.5" strokeWidth={2.4} />
Cluster not ready
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
className="sr-only rounded-full border border-status-red/35 bg-status-red/[0.12] px-3 text-[10px] font-bold uppercase tracking-[0.14em] text-status-red shadow-[0_0_18px_rgba(211,47,47,0.16)] dark:bg-status-red/15 md:not-sr-only md:inline-flex md:h-9 md:items-center md:gap-2"
>
<span
className="relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white"
aria-hidden="true"
data-testid="runtime-critical-marker"
>
<span className="absolute inset-0 rounded-full bg-status-red/35 motion-safe:animate-ping motion-reduce:animate-none" />
<span
className="relative flex h-5 w-5 items-center justify-center rounded-full bg-status-red text-[13px] font-black leading-none text-white shadow-[0_0_10px_rgba(211,47,47,0.36)] motion-safe:animate-pulse motion-reduce:animate-none"
data-testid="runtime-critical-glyph"
>
!
</span>
</span>
Runtime not ready
</div>
) : null}
<div
Expand All @@ -145,7 +165,7 @@ export const DockerStatusMenu: FunctionComponent = () => {
<button
type="button"
data-tour-id="docker-containers"
aria-label={`Docker Status: ${activeContainers.length} active containers`}
aria-label={dockerStatusLabel}
aria-haspopup="dialog"
aria-expanded={interactionState !== 'closed'}
aria-controls={interactionState !== 'closed' ? menuId : undefined}
Expand Down Expand Up @@ -180,11 +200,17 @@ export const DockerStatusMenu: FunctionComponent = () => {
>
<div className="relative">
<Box aria-hidden="true" className={`w-4 h-4 transition-colors ${
activeContainers.length > 0
clusterNotReady
? "text-status-red"
: activeContainers.length > 0
? "text-signal-500 dark:text-signal-400"
: "text-slate-500 dark:text-slate-400"
} group-hover:text-slate-900 dark:group-hover:text-white`} strokeWidth={1.5} />
{activeContainers.length > 0 && (
{clusterNotReady ? (
<span className="absolute -top-2 -right-2 flex h-4 w-4 items-center justify-center rounded-full bg-status-red text-[10px] font-black leading-none text-white shadow-[0_0_10px_rgba(211,47,47,0.35)] ring-1 ring-[#F9F8F4] motion-safe:animate-pulse motion-reduce:animate-none dark:ring-void-900" aria-hidden="true">
!
</span>
) : activeContainers.length > 0 && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-signal-500 shadow-[0_0_6px_rgba(0,224,160,0.8)] ring-1 ring-[#F9F8F4] dark:ring-void-900" />
)}
</div>
Expand All @@ -204,8 +230,8 @@ export const DockerStatusMenu: FunctionComponent = () => {
Docker Containers
</span>
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-black/[0.03] dark:bg-white/[0.03]">
<span className={`w-1.5 h-1.5 rounded-full ${clusterNotReady ? "bg-status-amber" : "bg-signal-500 motion-safe:animate-pulse"}`} />
<span className="text-[10px] font-mono font-medium text-slate-500 dark:text-slate-400">
<span className={`w-1.5 h-1.5 rounded-full ${clusterNotReady ? "bg-status-red motion-safe:animate-pulse motion-reduce:animate-none" : "bg-signal-500 motion-safe:animate-pulse"}`} />
<span className={`text-[10px] font-mono font-medium ${clusterNotReady ? "text-status-red" : "text-slate-500 dark:text-slate-400"}`}>
{clusterNotReady ? "Not Ready" : `${activeContainers.length} Active`}
</span>
</div>
Expand All @@ -218,8 +244,8 @@ export const DockerStatusMenu: FunctionComponent = () => {
</div>
) : clusterNotReady ? (
<div className="flex flex-col gap-3 px-4 py-5">
<div className="flex items-start gap-3 rounded-xl border border-status-amber/20 bg-status-amber/10 p-3">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-status-amber" strokeWidth={2.4} />
<div className="flex items-start gap-3 rounded-xl border border-status-red/25 bg-status-red/10 p-3">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-status-red" strokeWidth={2.4} aria-hidden="true" />
<div>
<div className="text-sm font-bold text-slate-800 dark:text-slate-100">Docker is mandatory</div>
<div className="mt-1 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
Expand All @@ -231,7 +257,7 @@ export const DockerStatusMenu: FunctionComponent = () => {
<div key={dependency.id} className="rounded-xl border border-black/[0.05] bg-black/[0.02] p-3 dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs font-bold text-slate-700 dark:text-slate-200">{dependency.label}</span>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] ${dependency.status === "ready" ? "bg-signal-500/10 text-signal-600 dark:text-signal-300" : "bg-status-amber/10 text-status-amber"}`}>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] ${dependency.status === "ready" ? "bg-signal-500/10 text-signal-600 dark:text-signal-300" : "bg-status-red/10 text-status-red"}`}>
{dependency.status}
</span>
</div>
Expand Down
95 changes: 74 additions & 21 deletions dashboard/tests/v2/components/DockerStatusMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @vitest-environment jsdom */
/** @jsx h */
import { h } from "preact";
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/preact";
import { render, screen, waitFor, fireEvent, cleanup, within } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { DockerStatusMenu } from "../../../src/v2/components/DockerStatusMenu.js";
Expand Down Expand Up @@ -40,6 +40,26 @@ const mockReadiness = {
providers: [],
};

const mockNotReadyReadiness = {
checkedAt: "2026-05-12T00:00:00.000Z",
cluster: {
status: "not_ready",
label: "Cluster not ready",
detail: "Required local runtime dependencies are available.",
},
dependencies: [
{
id: "docker-daemon",
label: "Docker daemon",
status: "missing",
required: true,
description: "Docker daemon is not available to the dashboard runtime.",
resolution: "Start Docker Desktop or the Docker Engine service, then retry once `docker ps` succeeds.",
},
],
providers: [],
};

const mockFetchResponses = (containers: unknown, readiness: unknown = mockReadiness) => {
vi.mocked(fetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
Expand Down Expand Up @@ -77,6 +97,53 @@ describe("DockerStatusMenu", () => {
expect(screen.getByRole("button", { name: /Docker Status: /i })).toBeInTheDocument();
});

it("announces a red critical runtime warning when readiness is not ready", async () => {
mockFetchResponses([], mockNotReadyReadiness);

render(<DockerStatusMenu />);

const alert = await screen.findByRole("alert");
expect(alert).toHaveAttribute("aria-live", "assertive");
expect(alert).toHaveAttribute("aria-atomic", "true");
expect(alert).toHaveTextContent("Runtime not ready");
expect(alert.className).toContain("border-status-red");
expect(alert.className).toContain("bg-status-red");
expect(alert.className).toContain("text-status-red");
expect(within(alert).getByText("!")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /runtime not ready/i })).toBeInTheDocument();
});

it("uses a motion-safe animated marker with a static exclamation fallback", async () => {
mockFetchResponses([], mockNotReadyReadiness);

render(<DockerStatusMenu />);

await screen.findByRole("alert");
const marker = screen.getByTestId("runtime-critical-marker");
const glyph = screen.getByTestId("runtime-critical-glyph");

expect(marker).toHaveAttribute("aria-hidden", "true");
expect(glyph).toHaveTextContent("!");
expect(glyph.className).toContain("motion-safe:animate-pulse");
expect(glyph.className).toContain("motion-reduce:animate-none");
expect(marker.querySelector(".motion-safe\\:animate-ping")).toBeInTheDocument();
});

it("does not render the critical warning when readiness is ready", async () => {
mockFetchResponses([], mockReadiness);

render(<DockerStatusMenu />);

await waitFor(() => {
expect(fetch).toHaveBeenCalled();
});

expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("Runtime not ready")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /Docker Status: 0 active containers/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /runtime not ready/i })).not.toBeInTheDocument();
});

it("opens popover on click, displays containers, and traps focus", async () => {
mockFetchResponses(mockContainers);

Expand Down Expand Up @@ -184,34 +251,20 @@ describe("DockerStatusMenu", () => {
});

it("shows cluster-not-ready guidance when Docker is unavailable", async () => {
mockFetchResponses([], {
checkedAt: "2026-05-12T00:00:00.000Z",
cluster: {
status: "not_ready",
label: "Cluster not ready",
detail: "Required local runtime dependencies are available.",
},
dependencies: [
{
id: "docker-daemon",
label: "Docker daemon",
status: "missing",
required: true,
description: "Docker daemon is not available to the dashboard runtime.",
resolution: "Start Docker Desktop or the Docker Engine service, then retry once `docker ps` succeeds.",
},
],
providers: [],
});
mockFetchResponses([], mockNotReadyReadiness);

render(<DockerStatusMenu />);
fireEvent.click(screen.getByRole("button", { name: /Docker Status: /i }));

await waitFor(() => {
expect(screen.getAllByText("Cluster not ready").length).toBeGreaterThan(0);
expect(screen.getByText("Runtime not ready")).toBeInTheDocument();
expect(screen.getByText("Docker is mandatory")).toBeInTheDocument();
expect(screen.getByText("Not Ready").className).toContain("text-status-red");
expect(screen.getByText("missing").className).toContain("text-status-red");
expect(screen.getByText(/Start Docker Desktop/)).toBeInTheDocument();
});

expect(screen.getAllByRole("alert")).toHaveLength(1);
});
});

Expand Down
2 changes: 2 additions & 0 deletions docs-web/content/docs/user-troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ The Docker daemon is unreachable, or the worker image cannot be pulled.

**Fix:** verify `docker ps` works. Pre-pull the image: `docker pull node:24-bookworm`. For preview/file-browser issues specifically, triage routes through preview host middleware (`src/server/preview-host-middleware.ts`) and cleanup/rebuild/restart steps. Ensure any commands used are safe and avoid exposing local DB contents, tokens, hostnames, or private paths.

If the header Docker status control shows the red `Runtime not ready` warning, open the Docker status menu for the dependency list. The warning is tied to `GET /api/onboarding/readiness` and reflects required Docker CLI, Docker daemon, and Git CLI checks; it clears only after the runtime reports those required checks as ready.

For packaged Windows builds, Docker errors that show `C:\...` as a container `--workdir`, `HOME`, or mount target indicate an outdated build. Current preview containers mount Windows/macOS/Linux host runtime storage at Linux container paths under `/code-ux-preview-runtime`.

For packaged Windows builds, `spawn ENAMETOOLONG` during Docker provider launch indicates an outdated build or a launch path still passing a large prompt through the host command line. Current Docker provider runs mount provider arguments from a generated file so large prompts do not become `docker run` arguments.
Expand Down
2 changes: 2 additions & 0 deletions docs-web/user/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ The Docker daemon is unreachable, or the worker image cannot be pulled.

**Fix:** verify `docker ps` works. Pre-pull the image: `docker pull node:24-bookworm`. For preview/file-browser issues specifically, triage routes through preview host middleware (`src/server/preview-host-middleware.ts`) and cleanup/rebuild/restart steps. Ensure any commands used are safe and avoid exposing local DB contents, tokens, hostnames, or private paths.

If the header Docker status control shows the red `Runtime not ready` warning, open the Docker status menu for the dependency list. The warning is tied to `GET /api/onboarding/readiness` and reflects required Docker CLI, Docker daemon, and Git CLI checks; it clears only after the runtime reports those required checks as ready.

For packaged Windows builds, Docker errors that show `C:\...` as a container `--workdir`, `HOME`, or mount target indicate an outdated build. Current preview containers mount Windows/macOS/Linux host runtime storage at Linux container paths under `/code-ux-preview-runtime`.

For packaged Windows builds, `spawn ENAMETOOLONG` during Docker provider launch indicates an outdated build or a launch path still passing a large prompt through the host command line. Current Docker provider runs mount provider arguments from a generated file so large prompts do not become `docker run` arguments.
Expand Down
2 changes: 2 additions & 0 deletions docs/dashboard/dashboard-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ Legacy runtime:
- External key hints from env/json
- `GET /api/onboarding/readiness`
- First-run onboarding readiness payload with Docker/Git dependency checks and local provider auth detection
- Also drives the header Docker status control: `cluster.status === "not_ready"` renders the red `Runtime not ready` alert badge, updates the icon-only trigger's accessible name, and exposes Docker/Git dependency resolution details in the popover.
- `GET /api/local-directories?path=/absolute/path`
- Lists child directories for the local Add Project directory picker, including current, parent, root, and home paths for browser-style navigation
- `GET /api/git-status`
Expand Down Expand Up @@ -889,6 +890,7 @@ This dashboard enforces accessibility best practices to ensure an inclusive expe
- **Accessible Names**: Icon-only controls, preview controls, task actions, command actions, settings toggles, provider-instance actions, telemetry rows, compact mobile controls, and Stats mode buttons must have explicit names that include the target when repeated. Decorative icons stay `aria-hidden`.
- **Live Regions**: Non-visual state changes (like toast notifications or saving states) are announced using `aria-live="polite"` or `aria-live="assertive"`. Loading spinners use `aria-hidden="true"` with a visually hidden fallback, while their containers use `aria-busy="true"`.
- **Async State Communication**: Loading, empty, low-data, success, pending, reconnecting, stale-data, and background-refresh states use polite `role="status"` or live regions. Blocking errors, failed saves, disconnected runtime transport, and unavailable preview containers use `role="alert"` or assertive live behavior. Controls that initiate async work expose `aria-busy` or disabled/`aria-disabled`.
- **Header Runtime Readiness**: The Docker status control consumes `GET /api/onboarding/readiness`. When required Docker or Git checks make `cluster.status` `not_ready`, the header renders a red `Runtime not ready` alert badge with a static exclamation marker plus motion-safe animation, and the trigger accessible name includes that the runtime is not ready. The popover remains the keyboard-accessible dependency detail surface and does not add another assertive live region during hover refreshes.
- **Tables & Ledgers**: Complex data displays like the Sprint Ledger, Stats ledgers, system invocation tables, and shared `Table` displays use semantic HTML (`<table>`, `<th>`, `<td>`) or explicit ARIA grid roles to support screen reader cell navigation. They preserve captions or labels, `aria-sort` on active sortable columns, and mobile labels when rows collapse into cards.
- **Charts**: Data visualizations are wrapped in a region with `role="region"` and an `aria-label`, providing an accessible name for the visual content.
- **Stats & Analytics**: Analytics controls (like visual mode tabs and time windows) use semantic `role="group"` with `aria-pressed` states. Charts and sparklines include `sr-only` descriptive summaries of their data, allowing non-visual users to understand distributions and trends.
Expand Down
Loading