Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9005e40
fix(clusters): only instances on screen poll their status on the inst…
dawsontoth Oct 11, 2026
6fef406
fix(clusters): a row's visibility belongs to the element that was obs…
dawsontoth Oct 11, 2026
3a341c3
fix(hooks): let useIsOnScreen release an element once it is gone
dawsontoth Oct 12, 2026
fd2e004
fix(clusters): give the instances list's status spinner screen-reader…
dawsontoth Oct 12, 2026
e5c118e
test(clusters): drop a comment the aria-hidden assertion now states
dawsontoth Oct 12, 2026
8c2f6a5
fix(deployments): rejected deployment polls back off, and the page ne…
dawsontoth Oct 11, 2026
beda7f9
fix(deployments): say the page could not load when the instance versi…
dawsontoth Oct 11, 2026
537c82a
feat(deploy): follow a rolling deploy's restart job, and show canary …
dawsontoth Oct 11, 2026
6213350
fix(deploy): never read an unrecognized or buffered rollout as a clea…
dawsontoth Oct 11, 2026
034dd9f
fix(deploy): a failed poll no longer counts toward a restart job's no…
dawsontoth Oct 12, 2026
8eefb69
feat(deployments): filter and page the list, show staged releases and…
dawsontoth Oct 11, 2026
806599e
fix(deployments): no stale rows across a page change, and no staged f…
dawsontoth Oct 11, 2026
23beb6e
fix(deployments): drop a status filter the instance cannot have from …
dawsontoth Oct 11, 2026
63c9f2a
feat(deploy): stage a release now and activate it later (Harper 5.3+)
dawsontoth Oct 11, 2026
c9b7cda
fix(deploy): an activation survives its detail closing, and is never …
dawsontoth Oct 11, 2026
1b1bbdb
fix(deploy): an unknown activation is not also toasted as a failure
dawsontoth Oct 11, 2026
7c13bb0
fix(deploy): resend a live deploy only when Harper refused it, never …
dawsontoth Oct 11, 2026
377d6b0
docs(deploy): say a write may resend a live deploy only on a 4xx refusal
dawsontoth Oct 11, 2026
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
2 changes: 2 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This file collects architecture and design notes for studio that aren't otherwis

- [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, and why a table without a primary key can't be listed.

- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again.
- [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose.
- [`src/router/DESIGN.md`](src/router/DESIGN.md) — why a failed route load is reported and shown only by its route, and why a preload still toasts.

Expand Down Expand Up @@ -75,6 +76,7 @@ Central manager only ever sends the boolean, never the other organizations' ids;
again on the dead session. The guard redirects every signed-out dashboard load, so the spinner only covers the moment
before it does (`endedSessionRouteFailures.test.tsx`, HarperFast/studio#1546).
- **Data fetching**: TanStack React Query 5. Query keys must be instance-scoped when the request targets a specific instance.
- **Polling**: every `refetchInterval` on a query that can reject against an instance or a cluster goes through [`pollUnlessForbidden`](src/react-query/pollUnlessForbidden.ts), which stops the timer on a 403 and backs off a sustained 400 or 5xx; a raw number polls a failing endpoint at full rate for as long as the page is open. When the interval depends on the data, compute it and hand it over per tick: `(query) => pollUnlessForbidden(base(query))(query)` (the back-off state is per query, not per wrapper).
- **Forms**: react-hook-form with a zod `zodResolver`. A form whose submit button waits on `formState.isValid` must not use the default `onSubmit` mode, which keeps `isValid` current but shows errors only after a submit the disabled button never allows, so the user is blocked with no reason given (HarperFast/studio#1550). Use `mode: 'onTouched'`: a field's error appears once it is first left and then follows each change, while `isValid` still tracks every keystroke (`onBlur` recomputes `isValid` only on blur, so the button lags the text). Enter neither submits past a disabled button nor leaves the field, so give the `<form>` `onKeyDown={revealFieldErrorOnEnter(form)}`, which treats Enter in a text input as leaving it. A refine that compares two fields reports on one of them; re-check that field from the other's `onChange` once it has been touched (`getFieldState(name).isTouched`, then `trigger(name)`), because `rules={{ deps }}` fires on the other field's first blur, before the user reaches it. react-hook-form only updates the `formState` fields a render has read, and `disabled={!isDirty || !isValid}` skips `isValid` while the form is pristine, so where one change can complete the form (a single field, a paste, a checkbox) the button stays disabled on a valid form. Read the fields before the JSX (`const { isDirty, isValid } = form.formState;`) and pin it with a test that makes one change and checks the button.
- **Cloud permission gates** ([`usePermissions.ts`](src/hooks/usePermissions.ts)) must mirror the check the central manager endpoint runs, not the nearest role flag. An org admin appears in `/User/current` as `roles[orgId].role === 'admin'`; central manager builds every org role with `permission.super_user: false`, so the member helpers' `super_user` short-circuits never identify one — an admin gets full access there from its all-true flags. Container lifecycle ops are where the two diverge: their endpoints admit only org admins and staff holding `instance:update`, so gate them on `useContainerOpsPermission`, never on cluster `update`.
- **Unit tests never read `.env` files and never open a real WebSocket.** `vitest.config.ts` sets
Expand Down
92 changes: 92 additions & 0 deletions src/features/cluster/InstanceStatusCell.polling.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/** @vitest-environment jsdom */
import type { Instance } from '@/integrations/api/api.patch';
import { FakeIntersectionObserver } from '@/test/fakeIntersectionObserver';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { InstanceStatusCell } from './InstanceStatusCell';

/** Real `getStatusQueryOptions` and a real QueryClient: only the transport is faked, so these
* count the requests the instances list actually sends. */
const state = vi.hoisted(() => ({ requests: [] as string[] }));
vi.mock('@/hooks/usePermissions', () => ({ useOrganizationClusterInstancePermissions: () => ({ update: true }) }));
vi.mock('@/config/useInstanceClient', () => ({
useInstanceClientIdParams: ({ instanceId }: { instanceId: string }) => ({
entityId: instanceId,
instanceClient: {
post: async () => {
state.requests.push(instanceId);
return { data: { systemStatus: [{ id: 'availability', status: 'Available' }], componentStatus: [] } };
},
},
}),
}));
vi.mock('@/integrations/api/instance/status/setStatus', () => ({ useSetStatus: () => ({ mutate: vi.fn() }) }));

const ids = ['ins-0', 'ins-1', 'ins-2'];

beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
state.requests = [];
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
FakeIntersectionObserver.reset();
});

function renderList() {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
{ids.map((id, index) => (
<div key={id} data-row={id}>
<InstanceStatusCell
instance={{ id, status: 'RUNNING', instanceFqdn: `${id}.example.com`, version: '4.7.3' } as Instance}
index={index}
/>
</div>
))}
</QueryClientProvider>,
);
return queryClient;
}

function rowIs(id: string) {
return (target: Element) => target.closest('[data-row]')?.getAttribute('data-row') === id;
}

function requestsFor(id: string) {
return state.requests.filter(r => r === id).length;
}

describe('instances list get_status polling', () => {
it('polls only the rows on screen', async () => {
const queryClient = renderList();
// Past the per-row stagger; React flushes the `ready` update only when an act() ends.
await act(() => vi.advanceTimersByTimeAsync(2_000));
act(() => FakeIntersectionObserver.setIntersecting(true, rowIs('ins-0')));
await act(() => vi.advanceTimersByTimeAsync(60_000));

expect(requestsFor('ins-0')).toBe(7);
expect(requestsFor('ins-1')).toBe(0);
expect(requestsFor('ins-2')).toBe(0);
queryClient.clear();
});

it('fetches a row as soon as it scrolls into view, then keeps its 10s cadence', async () => {
const queryClient = renderList();
await act(() => vi.advanceTimersByTimeAsync(30_000));
expect(state.requests).toHaveLength(0);

act(() => FakeIntersectionObserver.setIntersecting(true, rowIs('ins-1')));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(requestsFor('ins-1')).toBe(1);

await act(() => vi.advanceTimersByTimeAsync(30_000));
expect(requestsFor('ins-1')).toBe(4);
queryClient.clear();
});
});
31 changes: 31 additions & 0 deletions src/features/cluster/InstanceStatusCell.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @vitest-environment jsdom */
import type { Instance } from '@/integrations/api/api.patch';
import { FakeIntersectionObserver } from '@/test/fakeIntersectionObserver';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -25,6 +26,8 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
FakeIntersectionObserver.reset();
});

async function renderCell(status: string, version: string | undefined = '4.7.3') {
Expand Down Expand Up @@ -87,3 +90,31 @@ describe('InstanceStatusCell get_status poll', () => {
expect(screen.queryByRole('button')).toBeNull();
});
});

describe('InstanceStatusCell off-screen gating', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
});

it('does not poll a row until it scrolls into view', async () => {
await renderCell('RUNNING');
expect(state.enabled.at(-1)).toBe(false);

act(() => FakeIntersectionObserver.setIntersecting(true));
expect(state.enabled.at(-1)).toBe(true);
});

it('stops polling a row once it scrolls out of view', async () => {
await renderCell('RUNNING');
act(() => FakeIntersectionObserver.setIntersecting(true));
act(() => FakeIntersectionObserver.setIntersecting(false));
expect(state.enabled.at(-1)).toBe(false);
});

it('shows a spinner rather than an unknown status for a row that has not loaded yet', async () => {
await renderCell('RUNNING');
expect(screen.queryByText('Offline')).toBeNull();
expect(document.querySelector('.animate-spin')?.getAttribute('aria-hidden')).toBe('true');
expect(screen.getByText('Loading status')).toBeTruthy();
});
});
18 changes: 14 additions & 4 deletions src/features/cluster/InstanceStatusCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { isOperationsProxyRefused, isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus';
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
import { useIsOnScreen } from '@/hooks/useIsOnScreen';
import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions';
import { Instance } from '@/integrations/api/api.patch';
import {
Expand Down Expand Up @@ -30,6 +31,10 @@ export function InstanceStatusCell(
const { mutate: setStatus, isPending: isSettingStatus } = useSetStatus();

const [ready, setReady] = useState(false);
const [cellElement, setCellElement] = useState<HTMLDivElement | null>(null);
// A row scrolled out of view stops polling, so a large cluster costs what is on screen rather than
// one request per instance every 10s. The margin starts a row's fetch just before it scrolls in.
const onScreen = useIsOnScreen(cellElement, '200px');

// Spread the status requests out.
useEffect(() => {
Expand All @@ -39,7 +44,7 @@ export function InstanceStatusCell(

const notProxied = isOperationsProxyRefused(instance.status);
const statusSupported = supportsGetStatus(instance.version);
const statusPollEnabled = ready && canManage && !notProxied && statusSupported;
const statusPollEnabled = ready && onScreen && canManage && !notProxied && statusSupported;
const { data: statusResponse, isLoading, isFetching } = useQuery(
getStatusQueryOptions(instanceParams, statusPollEnabled),
);
Expand Down Expand Up @@ -75,12 +80,17 @@ export function InstanceStatusCell(
}

return (
<div className="flex items-center gap-2">
<div ref={setCellElement} className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
{isLoading || !ready || (isFetching && !statusResponse)
? <LoaderCircleIcon className="animate-spin size-5 text-muted-foreground" />
{isLoading || !ready || ((isFetching || !onScreen) && !statusResponse)
? (
<>
<LoaderCircleIcon className="animate-spin size-5 text-muted-foreground" />
<span className="sr-only">Loading status</span>
</>
)
: (
Comment thread
dawsontoth marked this conversation as resolved.
<Badge
variant={isAvailable ? 'success' : isUnavailable ? 'destructive' : 'default'}
Expand Down
57 changes: 57 additions & 0 deletions src/features/instance/applications/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Applications — design notes

## When a deploy is done

Studio deploys with `restart: 'rolling'`. The node that receives the deploy answers once its own part is done and hands
the other nodes to a `restart_service` job, named in the response's `restartJobId` (Harper 4.6+). The deployment record
does not help: only the receiving node writes it, so it reads `success` while that job runs and after it fails.

So [`deployAndFollowComponent`](../../../integrations/api/instance/applications/deployComponent.ts) follows the job with
`get_job` before the mutation settles ([`restartJob.ts`](../../../integrations/api/instance/applications/restartJob.ts)):

- **A failed job rejects with `DeployRolloutError`.** The release is live on the receiving node; the error and the
progress panel name each node that did not take it. Its message quotes those nodes' errors, so Datadog withholds it
like `SSEOperationError`.
- **A job Studio could not follow resolves, as `rollout.state === 'unfollowable'`, and is never reported as success.**
That covers a job that ended with a result in neither shape below. [`reportDeployOutcome`](modals/reportDeployHealth.ts)
skips the post-deploy health check for it, because that check reads the one node Studio reaches, which can be healthy
on the release a rejecting node kept; `reportDeployResult` is the same policy for a caller that does not health-check.
Any new caller of the deploy mutation reports through one of the two.
- **The job may not be there.** `hdb_job` is a non-replicating system table, so only the receiving node can answer for
it. Central manager's cluster proxy sends every operation to the first running instance, so over the proxy the deploy
and its polls reach the same node; a direct connection to a cluster's address can reach another node, which answers
`[]`. A row missing for 20 seconds counts as `not-found`.
- **Every node's outcome arrives at once.** Harper writes the job's row when it starts and when it ends, never in between.

The job comes in two shapes. Harper 5.4's certified rollout activates the staged release on each other node, and each
certifies it with a canary of its own: `result.activated` on success, `message: { error, activated }` on failure. Older
versions, and a 5.4 deploy whose release was not armed for a canary, run the plain restart: `result.replicated`, where a
node the job could not reach is listed with its error and the job still completes, so that node reads `unconfirmed`.

Canary certification is shown only when the response carries it, which only 5.4+ does. There is no version check on
the deploy path: `registration_info` is denied to a role with an operations allowlist, which is exactly the kind of
role that may be allowed to deploy, so a version gate there would hide the result from it.

## Staging and activation are gated the other way

Stage only (`activate: false`) and Activate (`deployment_id`) are offered only when `registration_info` reads 5.3.0 or
later (`useStagedDeploymentsAvailable`), and an unreadable version offers neither. The difference from certification is
what happens when the guess is wrong: a node older than 5.3.0 accepts `activate: false` as an unknown field and makes
the release live, which is the opposite of what the person asked for. Certification only adds a field to a response.

All three kinds of request are built in one place, [`deployRequestBody`](../../../integrations/api/instance/applications/deployComponent.ts),
because Harper's validator refuses the wrong mix outright: a stage with `restart`, or an activation carrying `package`,
install options or `activate`. A stage succeeds only once every other node confirmed staging it, so a stage restarts
nothing and is reported without a health check. Rollback to a kept release (#1232) is the same activation call.

## A live deploy is sent once

A live (SSE) deploy that gets no event stream goes out again as the buffered POST only when its status proves Harper
never started it: a 4xx. Harper refuses a request before the operation starts (authentication, permissions,
validation) with a 4xx, and answers one it started with a 200 event stream at once. Everything else can follow a deploy
that is already running: a gateway's 5xx after forwarding, a 2xx that is not a stream, a dropped connection, the idle
watchdog firing before headers. Harper's own request-queue 503 never started anything either, but status alone cannot
tell it from a gateway's. So [`onDeployComponentSubmit`](../../../integrations/api/instance/applications/deployComponent.ts)
turns those into `SSEInconclusiveError`, which every deploy caller already answers by checking rather than resending.
`streamOperation` stays neutral: it puts the `status` on `SSEUnsupportedError` and leaves the decision to the caller,
because a read (`get_deployment`) may fall back on anything.
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ import { useEditorFileContent } from '@/features/instance/applications/context/e
import { isDirectory } from '@/features/instance/applications/context/isDirectory';
import { useEditorView } from '@/features/instance/applications/hooks/useEditorView';
import { useEntryActions } from '@/features/instance/applications/hooks/useEntryActions';
import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions';
import {
useDeploymentsAvailable,
useStagedDeploymentsAvailable,
} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE';
import { useInstanceBrowseManagePermission, useInstanceManagePermission } from '@/hooks/usePermissions';
import { useRestartInstanceClick } from '@/hooks/useRestartInstanceClick';
import { emitToListeners, useEmitToListeners } from '@/lib/events/listener';
import { useSetWatchedValue, useWatchedValue } from '@/lib/events/watcher';
import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage';
import { useNavigate, useParams } from '@tanstack/react-router';
import {
ArrowLeftIcon,
ArrowRightIcon,
Expand All @@ -28,12 +34,14 @@ import {
DownloadIcon,
FilePlusIcon,
FolderPlusIcon,
HistoryIcon,
LockIcon,
PackageIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PencilIcon,
PlusIcon,
RocketIcon,
RotateCcwIcon,
SaveIcon,
TrashIcon,
Expand Down Expand Up @@ -73,6 +81,14 @@ export function ContentActions({
const { openedEntryContents, openedEntry, isSavingFile, restrictPackageModification } = useEditorView();
const { content: updatedFileContent } = useEditorFileContent(openedEntry?.path);
const canManageBrowseInstance = useInstanceBrowseManagePermission();
const canManageInstance = useInstanceManagePermission();
const deploymentsAvailable = useDeploymentsAvailable();
const stagingAvailable = useStagedDeploymentsAvailable();
const canViewDeployments = canManageInstance && deploymentsAvailable;
const navigate = useNavigate();
const routeParams: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({
strict: false,
});
const { onRestartClick, isRestartPending } = useRestartInstanceClick({
operation: 'restart_service',
instanceClient: instanceParams.instanceClient,
Expand Down Expand Up @@ -133,7 +149,8 @@ export function ContentActions({
useEntryActions(openedEntry);

const showFileMenu = canEditFile || canRename || canAddEntries || canAddTable || canDeleteEntry;
const showApplicationMenu = canDownload || canManageBrowseInstance || canRedeploy;
const deploymentsProject = canViewDeployments && openedEntry?.project ? openedEntry.project : undefined;
const showApplicationMenu = canDownload || canManageBrowseInstance || canRedeploy || !!deploymentsProject;
// The "File" menu acts on whatever is selected, so name it for that thing.
const fileMenuLabel = openedEntry && isDirectory(openedEntry) ? 'Directory' : 'File';

Expand Down Expand Up @@ -389,6 +406,30 @@ export function ContentActions({
Redeploy Package
</DropdownMenuItem>
)}
{deploymentsProject && (
<DropdownMenuItem
onSelect={() =>
void navigate({
to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'),
search: { project: deploymentsProject },
})}
>
<HistoryIcon />
View Deployments
</DropdownMenuItem>
)}
{deploymentsProject && stagingAvailable && (
<DropdownMenuItem
onSelect={() =>
void navigate({
to: buildAbsoluteLinkToPage(routeParams, 'config/deployments'),
search: { project: deploymentsProject, status: 'staged' },
})}
>
<RocketIcon />
Staged Releases
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
Expand Down
Loading
Loading