feat(locks): show a lock indicator on the lane card (D)
This commit is contained in:
@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime } from "../../lib/types";
|
||||
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is three git subprocesses server-side, and a branch name does not
|
||||
@@ -28,6 +28,10 @@ const GIT_REFRESH_MS = 30_000;
|
||||
* the lane poll because each call opens a socket per declared port. */
|
||||
const RUNTIME_REFRESH_MS = 10_000;
|
||||
|
||||
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
||||
* as the git facts (slow, since lock state changes infrequently). */
|
||||
const LOCKS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched per card rather than folded into the
|
||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
@@ -95,6 +99,37 @@ function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks held by THIS lane, polled the same way runtime/git facts are.
|
||||
*/
|
||||
function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
const [locks, setLocks] = useState<NamedLock[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!laneSlot) return;
|
||||
let alive = true;
|
||||
const holder = `lane${laneSlot}`;
|
||||
const read = () => {
|
||||
api.locks
|
||||
.list()
|
||||
.then((data) => {
|
||||
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
|
||||
})
|
||||
.catch(() => {
|
||||
/* fails silently, same contract as the git/runtime pollers */
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneSlot]);
|
||||
|
||||
return locks;
|
||||
}
|
||||
|
||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
idle: "bg-surface-4",
|
||||
@@ -125,6 +160,7 @@ export default function LaneCard({
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const git = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
|
||||
/**
|
||||
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
|
||||
@@ -302,6 +338,16 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{locks.length > 0 && (
|
||||
<div
|
||||
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
|
||||
data-testid={`lane-locks-${lane.id}`}
|
||||
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
||||
>
|
||||
🔒 {t("locks.held", { count: locks.length })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
|
||||
{git?.available && (
|
||||
<div data-testid="lane-git" className="space-y-1">
|
||||
|
||||
@@ -24,6 +24,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
},
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -35,6 +38,8 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
});
|
||||
vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
|
||||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
return {
|
||||
@@ -335,3 +340,24 @@ describe("LaneCard — the lane's own application stack", () => {
|
||||
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneCard — named locks", () => {
|
||||
it("shows a lock badge when this lane holds a named lock", async () => {
|
||||
vi.mocked(api.locks.list).mockResolvedValue({
|
||||
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
|
||||
});
|
||||
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
|
||||
expect(screen.getByText(/lock/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no lock badge when locks belong to a different lane", async () => {
|
||||
vi.mocked(api.locks.list).mockResolvedValue({
|
||||
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
|
||||
});
|
||||
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user