mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Surface operator-set per-resource state on the resource detail drawer
Frontend wedge for the per-resource operator-state feature: an operator working on a resource can now toggle Intentionally offline and Never auto-remediate without curling the API. The section lives on the overview tab next to the action audit history so the "what overrides has the operator set" and "what actions has Pulse taken" stories read together. NeverAutoRemediate is a safety override — flipping it on requires an explicit confirmation prompt naming what the lock means, while flipping it back off is permissive (releasing a lock is the recoverable action). Maintenance windows are surfaced read-only this slice; scheduling lives in a follow-up that owns the date-picker UX. Uses createNonSuspendingQuery rather than createResource so the drawer's parent Suspense boundary does not flicker the page-level "Loading view..." fallback while operator state is in flight. The save path preserves any currently-persisted maintenance-window data so this toggle slice does not clobber the future window-scheduler slice. Adds a TS API client (getResourceOperatorState / setResourceOperatorState / clearResourceOperatorState) that mirrors the canonical Go shape from slice 30, with 404 -> null normalization on the GET path so callers see "no state" as a clean default.
This commit is contained in:
@@ -1343,6 +1343,18 @@ the canonical monitored-system blocked payload.
|
||||
|
||||
## Current State
|
||||
|
||||
The TS client `frontend-modern/src/api/resourceOperatorState.ts`
|
||||
mirrors the canonical Go shape from
|
||||
`internal/unifiedresources/resource_operator_state.go` and exposes
|
||||
`getResourceOperatorState`, `setResourceOperatorState`, and
|
||||
`clearResourceOperatorState` against the
|
||||
`/api/resources/{id}/operator-state` endpoint. The GET path
|
||||
normalizes the server's `404 operator_state_not_set` response into
|
||||
`null` so callers see "no state recorded" as a clean default rather
|
||||
than a thrown error; non-404 errors propagate. The PUT path
|
||||
percent-encodes the canonical resource id segment so colon-bearing
|
||||
ids round-trip safely through URL routing.
|
||||
|
||||
The router wires the operator-state adapter into the findings runtime
|
||||
at startup: `internal/api/router.go` calls
|
||||
`patrol.GetFindings().SetResourceOperatorStateProvider(...)` with a
|
||||
|
||||
@@ -1247,6 +1247,15 @@ prompt explain the same operator-facing priority.
|
||||
|
||||
## Current State
|
||||
|
||||
`ResourceOperatorStateSection.tsx` on the resource detail drawer
|
||||
overview tab uses `createNonSuspendingQuery` to fetch
|
||||
`/api/resources/{id}/operator-state` so the drawer's parent
|
||||
Suspense boundary does not flicker the page-level "Loading view…"
|
||||
fallback while operator-set state is in flight. New self-fetching
|
||||
sections inside the drawer must follow the same pattern (or wrap in
|
||||
their own local Suspense) rather than relying on `createResource`,
|
||||
which propagates suspension to the closest ancestor.
|
||||
|
||||
The Patrol page header copy lives in a single canonical helper at
|
||||
`frontend-modern/src/utils/patrolPagePresentation.ts`. The page-title
|
||||
tooltip on `PatrolIntelligenceHeader.tsx` must read from
|
||||
|
||||
@@ -1175,6 +1175,25 @@ and Explain entry points. Investigation evidence and rollback plans
|
||||
are intentionally omitted from the clipboard shape — those are
|
||||
conversation context for the Assistant flow, not "share this
|
||||
finding" context for chat or tickets.
|
||||
The resource detail drawer now exposes the operator-set state via the
|
||||
`ResourceOperatorStateSection` component on the Overview tab. The
|
||||
section sits alongside `ResourceActionHistory` so the "what overrides
|
||||
has the operator set" and "what actions has Pulse taken" stories read
|
||||
together, and routes through the canonical
|
||||
`@/api/resourceOperatorState` TS client with no parallel fetch path.
|
||||
The two boolean toggles (`IntentionallyOffline`,
|
||||
`NeverAutoRemediate`) are dirty-tracked locally with explicit
|
||||
Save/Discard actions; flipping `NeverAutoRemediate` true requires an
|
||||
explicit confirmation prompt because it's a safety override that
|
||||
locks the resource against all automated remediation, while flipping
|
||||
it false (releasing the lock) is permissive. Maintenance windows are
|
||||
displayed read-only this slice — when an active window covers `now`,
|
||||
the section badges it; scheduling lives in a separate slice that
|
||||
owns the date-picker UX. The section uses `createNonSuspendingQuery`
|
||||
rather than `createResource` so the drawer's parent Suspense
|
||||
boundary does not flicker the page-level fallback while operator
|
||||
state is in flight.
|
||||
|
||||
The findings store also consumes per-resource operator-set state via
|
||||
the narrow `ResourceOperatorStateProvider` interface installed by
|
||||
`SetResourceOperatorStateProvider`. The API layer wires an adapter
|
||||
|
||||
@@ -579,9 +579,20 @@ and lower-cases the criticality value before persistence. The
|
||||
`ClearResourceOperatorState`; both the SQLite (table
|
||||
`resource_operator_state` keyed on `canonical_id`) and Memory stores
|
||||
implement the same upsert + idempotent-clear contract. The
|
||||
`/api/resources/{id}/operator-state` API surface (GET / PUT / DELETE)
|
||||
in `internal/api/resources_operator_state.go` is the operator-facing
|
||||
consumer of this contract; the URL canonical_id always wins over the
|
||||
operator-facing surface for this contract is
|
||||
`ResourceOperatorStateSection.tsx` on the resource detail drawer
|
||||
overview tab, which routes through the canonical TS client at
|
||||
`frontend-modern/src/api/resourceOperatorState.ts` to
|
||||
`/api/resources/{id}/operator-state`. The drawer integration is
|
||||
read-only on maintenance windows (the section badges an active
|
||||
window when `now` falls within it but does not let the operator
|
||||
schedule one — that lives in a follow-up slice). The two boolean
|
||||
toggles (intentionally offline, never auto-remediate) are the
|
||||
operator-facing primitives this slice surfaces.
|
||||
|
||||
The `/api/resources/{id}/operator-state` API surface (GET / PUT /
|
||||
DELETE) in `internal/api/resources_operator_state.go` is the
|
||||
operator-facing consumer of this contract; the URL canonical_id always wins over the
|
||||
body, server-side `setAt` / `setBy` populate from request time and
|
||||
authenticated identity, and validation rejections surface a stable
|
||||
`operator_state_invalid` error code. The action broker
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetchJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearResourceOperatorState,
|
||||
getResourceOperatorState,
|
||||
setResourceOperatorState,
|
||||
type ResourceOperatorState,
|
||||
type ResourceOperatorStateInput,
|
||||
} from '@/api/resourceOperatorState';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
describe('resourceOperatorState api', () => {
|
||||
const apiFetchJSONMock = vi.mocked(apiFetchJSON);
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchJSONMock.mockReset();
|
||||
});
|
||||
|
||||
it('encodes the resource id segment so colon-bearing canonical ids round-trip safely', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
canonicalId: 'instance:node:101',
|
||||
intentionallyOffline: false,
|
||||
neverAutoRemediate: false,
|
||||
setAt: '2026-05-09T10:00:00Z',
|
||||
} satisfies ResourceOperatorState);
|
||||
|
||||
await getResourceOperatorState('instance:node:101');
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
// colons are reserved in URL paths and must be percent-encoded
|
||||
// before the canonical id reaches the server router.
|
||||
'/api/resources/instance%3Anode%3A101/operator-state',
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the server reports operator_state_not_set as 404', async () => {
|
||||
apiFetchJSONMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Not found'), { status: 404 }),
|
||||
);
|
||||
|
||||
await expect(getResourceOperatorState('vm:101')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rethrows non-404 errors so the caller can surface them to the operator', async () => {
|
||||
apiFetchJSONMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Internal server error'), { status: 500 }),
|
||||
);
|
||||
|
||||
await expect(getResourceOperatorState('vm:101')).rejects.toThrow('Internal server error');
|
||||
});
|
||||
|
||||
it('PUTs the canonical body shape and returns the read-after-write record', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
canonicalId: 'vm:101',
|
||||
intentionallyOffline: true,
|
||||
neverAutoRemediate: false,
|
||||
setAt: '2026-05-09T11:00:00Z',
|
||||
setBy: 'operator:richard',
|
||||
} satisfies ResourceOperatorState);
|
||||
|
||||
const input: ResourceOperatorStateInput = {
|
||||
intentionallyOffline: true,
|
||||
neverAutoRemediate: false,
|
||||
};
|
||||
const result = await setResourceOperatorState('vm:101', input);
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
'/api/resources/vm%3A101/operator-state',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
// The returned record carries server-populated attribution
|
||||
// (setAt, setBy) — never echo the input verbatim.
|
||||
expect(result.setAt).toBe('2026-05-09T11:00:00Z');
|
||||
expect(result.setBy).toBe('operator:richard');
|
||||
});
|
||||
|
||||
it('DELETEs without expecting a body response', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce(undefined as never);
|
||||
|
||||
await expect(clearResourceOperatorState('vm:101')).resolves.toBeUndefined();
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith(
|
||||
'/api/resources/vm%3A101/operator-state',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
/**
|
||||
* Operator-set per-resource intent. Mirrors the canonical Go shape from
|
||||
* `internal/unifiedresources/resource_operator_state.go` with explicit JSON
|
||||
* field names so the TS surface stays decoupled from the storage type's
|
||||
* evolution. See the patrol-intelligence and ai-runtime subsystem contracts
|
||||
* for the suppression / refusal semantics each field drives.
|
||||
*/
|
||||
export interface ResourceOperatorState {
|
||||
canonicalId: string;
|
||||
/**
|
||||
* When true, new findings raised against this resource get
|
||||
* auto-acknowledged with reason=expected_behavior — Patrol stops
|
||||
* notifying about a resource the operator has marked
|
||||
* "intentionally offline" (e.g. a deprecated workload, dev environment
|
||||
* shut down on purpose, archived host).
|
||||
*/
|
||||
intentionallyOffline: boolean;
|
||||
/**
|
||||
* When true, the action broker refuses to dispatch automated
|
||||
* remediation against this resource even with a valid approval and
|
||||
* matching plan hash. The refusal is persisted as a Failed audit
|
||||
* record with `resource_remediation_locked:` prefix on the error.
|
||||
*/
|
||||
neverAutoRemediate: boolean;
|
||||
/**
|
||||
* Maintenance window — when present and `now` falls within it, all
|
||||
* new findings on this resource get auto-acknowledged with
|
||||
* reason=expected_behavior + cause=maintenance_window. Both start
|
||||
* and end must be set together (server validates).
|
||||
*/
|
||||
maintenanceStartAt?: string;
|
||||
maintenanceEndAt?: string;
|
||||
maintenanceReason?: string;
|
||||
/**
|
||||
* Optional operator hint that affects finding sort order. One of
|
||||
* `'high' | 'medium' | 'low' | ''` (empty = default).
|
||||
*/
|
||||
criticality?: 'high' | 'medium' | 'low' | '';
|
||||
note?: string;
|
||||
setAt: string;
|
||||
setBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PUT body shape — same as the read model but with attribution
|
||||
* stripped because the server populates `setAt` and `setBy` from the
|
||||
* authenticated identity, ignoring any client-supplied values.
|
||||
*/
|
||||
export type ResourceOperatorStateInput = Omit<
|
||||
ResourceOperatorState,
|
||||
'canonicalId' | 'setAt' | 'setBy'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Read the operator-set state for a resource. Resolves to null when
|
||||
* the server returns 404 (no entry recorded — the default no-state
|
||||
* posture). Throws on other errors.
|
||||
*/
|
||||
export async function getResourceOperatorState(
|
||||
resourceId: string,
|
||||
): Promise<ResourceOperatorState | null> {
|
||||
try {
|
||||
return await apiFetchJSON<ResourceOperatorState>(
|
||||
`/api/resources/${encodeURIComponent(resourceId)}/operator-state`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
} catch (err) {
|
||||
// The 404 response shape is `{ error: 'operator_state_not_set', ... }`.
|
||||
// Translating into null lets the caller treat "no state" as a clean
|
||||
// default rather than a thrown error.
|
||||
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the operator-set state for a resource. The server populates
|
||||
* `setAt` and `setBy` server-side, so the input shape excludes them.
|
||||
* Returns the persisted record (read-after-write) so the caller can
|
||||
* see exactly what landed, including the attribution fields.
|
||||
*/
|
||||
export async function setResourceOperatorState(
|
||||
resourceId: string,
|
||||
state: ResourceOperatorStateInput,
|
||||
): Promise<ResourceOperatorState> {
|
||||
return apiFetchJSON<ResourceOperatorState>(
|
||||
`/api/resources/${encodeURIComponent(resourceId)}/operator-state`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(state),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any operator-set state for the resource. Idempotent — resolves
|
||||
* cleanly whether or not an entry was present.
|
||||
*/
|
||||
export async function clearResourceOperatorState(resourceId: string): Promise<void> {
|
||||
await apiFetchJSON(
|
||||
`/api/resources/${encodeURIComponent(resourceId)}/operator-state`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { ResourceCorrelationSummary } from './ResourceCorrelationSummary';
|
||||
import { ResourceChangeSummary } from './ResourceChangeSummary';
|
||||
import { ResourceFacetSummary } from './ResourceFacetSummary';
|
||||
import { ResourceActionHistory } from './ResourceActionHistory';
|
||||
import { ResourceOperatorStateSection } from './ResourceOperatorStateSection';
|
||||
import {
|
||||
RESOURCE_CHANGE_KIND_ORDER,
|
||||
RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER,
|
||||
@@ -561,6 +562,15 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
/>
|
||||
</Show>
|
||||
|
||||
{/* Operator-set per-resource state (intentionally offline,
|
||||
never auto-remediate) sits next to the action history so
|
||||
the "what overrides has the operator set" and "what actions
|
||||
has Pulse taken" stories read together. The section is
|
||||
self-fetching keyed on the canonical resource id. */}
|
||||
<Show when={resource.id}>
|
||||
<ResourceOperatorStateSection resourceId={resource.id} />
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.actionAuditAvailable()}>
|
||||
<ResourceActionHistory
|
||||
audits={drawer.sortedActionAudits()}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { Component, Show, createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import { Toggle } from '@/components/shared/Toggle';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import {
|
||||
type ResourceOperatorState,
|
||||
type ResourceOperatorStateInput,
|
||||
clearResourceOperatorState,
|
||||
getResourceOperatorState,
|
||||
setResourceOperatorState,
|
||||
} from '@/api/resourceOperatorState';
|
||||
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
/**
|
||||
* ResourceOperatorStateSection surfaces the operator-set per-resource
|
||||
* intent (`/api/resources/{id}/operator-state`) on the resource detail
|
||||
* drawer so operators can:
|
||||
* - Mark a resource as intentionally offline (suppress
|
||||
* "X is offline" findings)
|
||||
* - Lock the resource against automated remediation (action broker
|
||||
* refuses dispatch with resource_remediation_locked:)
|
||||
* - See whether a maintenance window is currently active (read-only;
|
||||
* scheduling lives in a follow-up slice that owns the date-picker
|
||||
* UX)
|
||||
*
|
||||
* The section stays compact and out of the way until the operator has
|
||||
* something to say about the resource — fresh-install resources see a
|
||||
* collapsed "Operator overrides" hint with no toggles in the active
|
||||
* state.
|
||||
*/
|
||||
interface ResourceOperatorStateSectionProps {
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectionProps> = (
|
||||
props,
|
||||
) => {
|
||||
// Fetch the persisted state via the non-suspending helper so the
|
||||
// drawer's parent Suspense boundary does not flicker the page-level
|
||||
// fallback while operator-state is in flight. null means "no entry"
|
||||
// (the default no-state posture).
|
||||
const query = createNonSuspendingQuery<ResourceOperatorState | null, string>({
|
||||
source: () => props.resourceId || null,
|
||||
fetcher: async (id: string) => {
|
||||
try {
|
||||
return await getResourceOperatorState(id);
|
||||
} catch (err) {
|
||||
notificationStore.error(
|
||||
err instanceof Error ? err.message : 'Failed to load operator state',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
initialValue: null,
|
||||
cacheKey: (id: string) => `resource-operator-state:${id}`,
|
||||
});
|
||||
const persisted = query.value;
|
||||
|
||||
// Local edit state, hydrated from the persisted record. Operators can
|
||||
// toggle either flag and the section dirty-tracks until they hit Save
|
||||
// or Discard.
|
||||
const [intentionallyOffline, setIntentionallyOffline] = createSignal(false);
|
||||
const [neverAutoRemediate, setNeverAutoRemediate] = createSignal(false);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [confirmingLock, setConfirmingLock] = createSignal(false);
|
||||
|
||||
// Hydrate edit state from persisted record on first load and on resource change.
|
||||
createEffect(() => {
|
||||
const current = persisted();
|
||||
if (current === undefined) return;
|
||||
setIntentionallyOffline(current?.intentionallyOffline ?? false);
|
||||
setNeverAutoRemediate(current?.neverAutoRemediate ?? false);
|
||||
setConfirmingLock(false);
|
||||
});
|
||||
|
||||
const isDirty = createMemo(() => {
|
||||
const current = persisted();
|
||||
const persistedOffline = current?.intentionallyOffline ?? false;
|
||||
const persistedLocked = current?.neverAutoRemediate ?? false;
|
||||
return (
|
||||
intentionallyOffline() !== persistedOffline ||
|
||||
neverAutoRemediate() !== persistedLocked
|
||||
);
|
||||
});
|
||||
|
||||
// The lock toggle is a safety override — confirm before flipping to
|
||||
// true. Flipping back to false from true is just a release and does
|
||||
// not need confirmation.
|
||||
const handleNeverAutoRemediateToggle = (next: boolean) => {
|
||||
if (next && !neverAutoRemediate()) {
|
||||
setConfirmingLock(true);
|
||||
return;
|
||||
}
|
||||
setNeverAutoRemediate(next);
|
||||
};
|
||||
|
||||
const confirmLockToggle = () => {
|
||||
setNeverAutoRemediate(true);
|
||||
setConfirmingLock(false);
|
||||
};
|
||||
|
||||
const cancelLockToggle = () => {
|
||||
setConfirmingLock(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const current = persisted();
|
||||
const input: ResourceOperatorStateInput = {
|
||||
intentionallyOffline: intentionallyOffline(),
|
||||
neverAutoRemediate: neverAutoRemediate(),
|
||||
// Preserve any maintenance-window data the API currently holds
|
||||
// — this slice owns toggles only; window scheduling is a
|
||||
// separate slice and clobbering it on save would surprise the
|
||||
// operator.
|
||||
maintenanceStartAt: current?.maintenanceStartAt,
|
||||
maintenanceEndAt: current?.maintenanceEndAt,
|
||||
maintenanceReason: current?.maintenanceReason,
|
||||
criticality: current?.criticality,
|
||||
note: current?.note,
|
||||
};
|
||||
await setResourceOperatorState(props.resourceId, input);
|
||||
// Refresh from server so the section displays the persisted
|
||||
// attribution (setAt / setBy populated server-side).
|
||||
await query.refetch();
|
||||
notificationStore.success('Operator overrides saved');
|
||||
} catch (err) {
|
||||
notificationStore.error(
|
||||
err instanceof Error ? err.message : 'Failed to save operator overrides',
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
const current = persisted();
|
||||
setIntentionallyOffline(current?.intentionallyOffline ?? false);
|
||||
setNeverAutoRemediate(current?.neverAutoRemediate ?? false);
|
||||
setConfirmingLock(false);
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await clearResourceOperatorState(props.resourceId);
|
||||
// Refresh from server — DELETE leaves no entry, so refetch will
|
||||
// resolve to null and the section will return to the no-state
|
||||
// posture.
|
||||
await query.refetch();
|
||||
setIntentionallyOffline(false);
|
||||
setNeverAutoRemediate(false);
|
||||
notificationStore.success('Operator overrides cleared');
|
||||
} catch (err) {
|
||||
notificationStore.error(
|
||||
err instanceof Error ? err.message : 'Failed to clear operator overrides',
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Maintenance window is read-only this slice. Compute whether one is
|
||||
// currently active so the section can badge it.
|
||||
const activeMaintenanceWindow = createMemo(() => {
|
||||
const current = persisted();
|
||||
if (!current?.maintenanceStartAt || !current?.maintenanceEndAt) return null;
|
||||
const now = Date.now();
|
||||
const start = Date.parse(current.maintenanceStartAt);
|
||||
const end = Date.parse(current.maintenanceEndAt);
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
||||
if (now < start || now >= end) return null;
|
||||
return current;
|
||||
});
|
||||
|
||||
return (
|
||||
<section class="rounded-md border border-border bg-surface p-4 space-y-3" aria-label="Operator overrides">
|
||||
<header class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-base-content">Operator overrides</h3>
|
||||
<p class="text-xs text-muted">
|
||||
Tell Pulse how to treat this resource — suppress expected noise, or lock it against
|
||||
automated remediation.
|
||||
</p>
|
||||
</div>
|
||||
<Show when={persisted()?.setBy || persisted()?.setAt}>
|
||||
<span class="text-[11px] text-muted">
|
||||
<Show when={persisted()?.setBy}>
|
||||
<span>Set by {persisted()!.setBy} </span>
|
||||
</Show>
|
||||
<Show when={persisted()?.setAt}>
|
||||
<span>{formatRelativeTime(persisted()!.setAt, { compact: true })}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</header>
|
||||
|
||||
<Show when={activeMaintenanceWindow()}>
|
||||
<div class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
||||
<span class="font-semibold">Maintenance window active.</span>{' '}
|
||||
Findings raised on this resource are auto-acknowledged until{' '}
|
||||
{formatRelativeTime(activeMaintenanceWindow()!.maintenanceEndAt!, { compact: true })}.
|
||||
<Show when={activeMaintenanceWindow()!.maintenanceReason}>
|
||||
<span class="block mt-0.5">Reason: {activeMaintenanceWindow()!.maintenanceReason}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-sm font-medium text-base-content">Intentionally offline</label>
|
||||
<p class="text-[11px] text-muted mt-0.5 leading-tight">
|
||||
Suppress findings on this resource. Use when a workload is deprecated, a dev environment
|
||||
is shut down on purpose, or a host is archived.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={intentionallyOffline()}
|
||||
onChange={(e) => setIntentionallyOffline(e.currentTarget.checked)}
|
||||
disabled={saving()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-3 pt-2 border-t border-border-subtle">
|
||||
<div class="flex-1">
|
||||
<label class="text-sm font-medium text-red-700 dark:text-red-400">
|
||||
Never auto-remediate
|
||||
</label>
|
||||
<p class="text-[11px] text-muted mt-0.5 leading-tight">
|
||||
Refuse all automated remediation against this resource, even with a valid approval. The
|
||||
action broker logs every refused dispatch as a Failed audit record. Use for resources
|
||||
where Pulse must not act under any circumstance.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={neverAutoRemediate()}
|
||||
onChange={(e) => handleNeverAutoRemediateToggle(e.currentTarget.checked)}
|
||||
disabled={saving()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={confirmingLock()}>
|
||||
<div class="rounded border border-red-300 bg-red-50 px-3 py-2.5 text-xs text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-100">
|
||||
<p class="font-semibold">Lock this resource against all automated remediation?</p>
|
||||
<p class="mt-1 leading-relaxed">
|
||||
Pulse will refuse every dispatch targeting this resource, including approved actions
|
||||
from Patrol or the Assistant. Operators must clear the lock to allow remediation again.
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmLockToggle}
|
||||
class="rounded border border-red-400 bg-white px-2 py-1 text-xs font-medium text-red-900 hover:bg-red-100 dark:border-red-700 dark:bg-red-900 dark:text-red-100 dark:hover:bg-red-800"
|
||||
>
|
||||
Lock this resource
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelLockToggle}
|
||||
class="rounded border border-border bg-surface px-2 py-1 text-xs font-medium text-muted hover:bg-surface-hover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2 border-t border-border-subtle">
|
||||
<Show when={persisted() && !isDirty()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={saving()}
|
||||
class="px-2.5 py-1 text-xs font-medium text-muted hover:text-base-content hover:bg-surface-hover rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
Clear all overrides
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={isDirty()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscard}
|
||||
disabled={saving()}
|
||||
class="px-2.5 py-1 text-xs font-medium text-muted hover:bg-surface-hover rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving()}
|
||||
class="px-2.5 py-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded transition-colors"
|
||||
>
|
||||
{saving() ? 'Saving…' : 'Save overrides'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceOperatorStateSection;
|
||||
+10
@@ -100,6 +100,16 @@ vi.mock('@/api/actionAudit', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub the operator-state client so the drawer's
|
||||
// ResourceOperatorStateSection does not fan out a real network call
|
||||
// during this test. Returns no-state (null) by default — the resource
|
||||
// has no operator overrides, which is the default posture.
|
||||
vi.mock('@/api/resourceOperatorState', () => ({
|
||||
getResourceOperatorState: vi.fn().mockResolvedValue(null),
|
||||
setResourceOperatorState: vi.fn(),
|
||||
clearResourceOperatorState: vi.fn(),
|
||||
}));
|
||||
|
||||
class ResizeObserverMock {
|
||||
constructor(_callback: ResizeObserverCallback) {}
|
||||
observe() {}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const sectionSource = readFileSync(
|
||||
resolve(__dirname, '..', 'ResourceOperatorStateSection.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const overviewTabSource = readFileSync(
|
||||
resolve(__dirname, '..', 'ResourceDetailDrawerOverviewTab.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('ResourceOperatorStateSection', () => {
|
||||
it('exposes the two operator-set toggles bound to the canonical API client', () => {
|
||||
// The section is the operator's window into the per-resource state
|
||||
// feature. It must offer both toggles (intentionally offline + never
|
||||
// auto-remediate) and route them through the canonical
|
||||
// resourceOperatorState client — no parallel fetch path that could
|
||||
// drift from the API contract.
|
||||
expect(sectionSource).toContain("from '@/api/resourceOperatorState'");
|
||||
expect(sectionSource).toContain('getResourceOperatorState');
|
||||
expect(sectionSource).toContain('setResourceOperatorState');
|
||||
expect(sectionSource).toContain('clearResourceOperatorState');
|
||||
expect(sectionSource).toContain('Intentionally offline');
|
||||
expect(sectionSource).toContain('Never auto-remediate');
|
||||
});
|
||||
|
||||
it('keeps the section out of the parent Suspense fallback by using createNonSuspendingQuery', () => {
|
||||
// The drawer wraps its children in a page-level Suspense fallback;
|
||||
// a vanilla createResource here would flicker the fallback every
|
||||
// time the section's fetch resolves. Pin the helper choice so this
|
||||
// does not silently regress to createResource.
|
||||
expect(sectionSource).toContain('createNonSuspendingQuery');
|
||||
expect(sectionSource).not.toContain('createResource<');
|
||||
});
|
||||
|
||||
it('requires explicit confirmation before flipping never-auto-remediate to true', () => {
|
||||
// NeverAutoRemediate is a safety override — flipping it on must
|
||||
// require explicit confirmation so the operator does not lock a
|
||||
// resource by accident. The release path (true → false) is
|
||||
// permissive because clearing a lock is the recoverable action.
|
||||
expect(sectionSource).toContain('confirmingLock');
|
||||
expect(sectionSource).toContain('Lock this resource against all automated remediation?');
|
||||
expect(sectionSource).toContain('handleNeverAutoRemediateToggle');
|
||||
// The confirmation must not block the disable path — the inversion
|
||||
// gate only fires when next=true and the current value is false.
|
||||
expect(sectionSource).toContain('if (next && !neverAutoRemediate())');
|
||||
});
|
||||
|
||||
it('preserves persisted maintenance-window data on save so the toggle slice does not clobber the window slice', () => {
|
||||
// This slice owns toggles only; the maintenance-window scheduler
|
||||
// lands separately. If save sent only the toggle fields the server
|
||||
// would null out the window data on every save (PUT replaces). Pin
|
||||
// that the input passed to setResourceOperatorState carries the
|
||||
// current window fields through.
|
||||
expect(sectionSource).toContain('maintenanceStartAt: current?.maintenanceStartAt');
|
||||
expect(sectionSource).toContain('maintenanceEndAt: current?.maintenanceEndAt');
|
||||
expect(sectionSource).toContain('maintenanceReason: current?.maintenanceReason');
|
||||
expect(sectionSource).toContain('criticality: current?.criticality');
|
||||
expect(sectionSource).toContain('note: current?.note');
|
||||
});
|
||||
|
||||
it('renders a maintenance-window-active badge when the persisted window covers now', () => {
|
||||
// Read-only display of the active window so operators see "this is
|
||||
// why findings are quiet right now" without being able to schedule
|
||||
// one yet (scheduler is a follow-up slice). The section must gate
|
||||
// the badge on the now-falls-within-window check, not just on the
|
||||
// presence of a window — a future-scheduled window should not show
|
||||
// as active.
|
||||
expect(sectionSource).toContain('activeMaintenanceWindow');
|
||||
expect(sectionSource).toContain('Maintenance window active.');
|
||||
expect(sectionSource).toContain('if (now < start || now >= end) return null;');
|
||||
});
|
||||
|
||||
it('attributes the persisted state with set-by and set-at metadata', () => {
|
||||
// The audit-attribution comes from server-side population (setAt /
|
||||
// setBy populated from the authenticated identity). The section
|
||||
// must surface both so operators can see "I set this 3 days ago"
|
||||
// when revisiting a resource.
|
||||
expect(sectionSource).toContain('persisted()?.setBy');
|
||||
expect(sectionSource).toContain('persisted()?.setAt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResourceDetailDrawerOverviewTab integration', () => {
|
||||
it('renders ResourceOperatorStateSection alongside ResourceActionHistory', () => {
|
||||
// The operator-set state and the action audit history are
|
||||
// conceptually paired — what the operator decided to suppress, and
|
||||
// what Pulse actually did. They belong on the same drawer surface
|
||||
// so the operator can read both stories together.
|
||||
expect(overviewTabSource).toContain("from './ResourceOperatorStateSection'");
|
||||
expect(overviewTabSource).toContain('<ResourceOperatorStateSection resourceId={resource.id} />');
|
||||
// Section must precede the action-history block so the override
|
||||
// explains the actions that follow, not vice versa.
|
||||
const operatorIndex = overviewTabSource.indexOf('<ResourceOperatorStateSection');
|
||||
const historyIndex = overviewTabSource.indexOf('<ResourceActionHistory');
|
||||
expect(operatorIndex).toBeGreaterThan(0);
|
||||
expect(historyIndex).toBeGreaterThan(0);
|
||||
expect(operatorIndex).toBeLessThan(historyIndex);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user