mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Fold the action ledger into Patrol navigation
This commit is contained in:
@@ -3178,6 +3178,14 @@ query...`, and `Reading storage...` before streamed tool arguments are
|
||||
standalone, but `frontend-modern/src/AppLayout.tsx` must treat them as
|
||||
decorative inside tabs so the announced tab name comes from product chrome
|
||||
and meaningful badge text rather than icon title duplication. Scoped
|
||||
governed-operation history is subordinate to Patrol in that chrome:
|
||||
`/actions` remains a stable route and Assistant page context, but it must not
|
||||
render as a peer utility tab. While that route is open, the shell keeps
|
||||
Patrol selected so the user remains inside one operational workspace; this
|
||||
navigation relationship must not merge Activity history state into
|
||||
Assistant drawer state or allow the drawer to become an alternate action
|
||||
review client.
|
||||
Scoped
|
||||
approval handoffs sourced from Patrol, active alerts, or alert incident
|
||||
timelines must render as source-named investigation handoffs in the drawer
|
||||
instead of generic dashboard briefs. Source-owned handoff helpers may attach
|
||||
|
||||
@@ -551,12 +551,16 @@ and may label the default queue `Open work`, but those labels are chrome over
|
||||
this existing status/finding/approval contract; they are not new API fields,
|
||||
prompt payloads, or alternate current-work schemas.
|
||||
Shell navigation may derive a content-free `Patrol` open-work count from the
|
||||
existing Patrol findings endpoint plus live investigation-fix approvals,
|
||||
de-duplicated by finding/approval target id. That shell count is not a new API
|
||||
route or status field, must not include finding IDs, approval IDs, resource
|
||||
names, commands, prompt text, or model output in visible chrome, and must not
|
||||
replace the detail-owning Patrol findings, approval, run-history, or status
|
||||
routes.
|
||||
existing Patrol findings endpoint and durable action-inbox pending count. When
|
||||
there are pending governed actions, the shell badge prioritizes that universal
|
||||
approval count and label; otherwise it may show the active Patrol finding
|
||||
count. This is presentation over existing read models, not a new API route or
|
||||
status field. It must not include finding IDs, approval IDs, resource names,
|
||||
commands, prompt text, or model output in visible chrome, and must not replace
|
||||
the detail-owning Patrol findings, durable action inbox, approval, run-history,
|
||||
or status routes. The Patrol surface may expose a compact `Activity history`
|
||||
handoff with the same count, but the durable `/actions` route continues to own
|
||||
the complete record list and review controls.
|
||||
The authenticated `GET /api/agent/patrol-control/status` projection exposes the same content-free starter evidence as count-only fields
|
||||
(`operationsLoopStarterCount`, `assistantOperationsLoopStarterCount`,
|
||||
`patrolOperationsLoopStarterCount`,
|
||||
@@ -2506,6 +2510,11 @@ a new API state machine, queue contract, or verification-accounting field.
|
||||
instance-scoped style.
|
||||
8. Route unified-agent installer and binary download headers through `internal/api/unified_agent.go` and `internal/api/contract_test.go` together. Unified-agent BINARY downloads must keep the canonical `X-Checksum-Sha256` plus `X-Signature-Ed25519` contract for updater clients whether the binary is served locally or proxied from the matching GitHub release, instead of leaving callers to infer trust from source location alone. The served install-script endpoints (GET /install.sh and /install.ps1) are governed differently and have NO GitHub fallback at all: they serve the locally bundled AGENT installer or fail closed with 503. The agent installer is a per-build artifact bundled into every release tarball and Docker image, not a release asset, so the endpoint must never fetch the top-level GitHub install.sh release asset (the SERVER installer, which rejects the agent wizard's --url / --token-file, issue #1470). It attaches the base64-encoded `X-Signature-SSHSIG` header when the local detached signatures are present and omits it when they are not; a present-but-unsigned local agent installer is still served, because the agent install path (curl piped into bash) does not verify these headers, so correctness of the served script outranks signature presence.
|
||||
9. Route canonical AI intelligence summary and resource-intelligence reads through `frontend-modern/src/api/ai.ts`, `frontend-modern/src/stores/aiIntelligence.ts`, `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts`, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx`, the Patrol-owned section files under `frontend-modern/src/features/patrol/`, `frontend-modern/src/pages/AIIntelligence.tsx`, `internal/api/ai_handlers.go`, and `internal/api/contract_test.go` together so the store normalization owner, runtime hook, feature shell, Current work workspace, section owners, route shell, and backend payload stay aligned on one governed surface, including the canonical recent-changes slice
|
||||
while keeping the universal governed-operation ledger as one subordinate
|
||||
handoff rather than a second Patrol implementation: the surface may show
|
||||
the durable action inbox's content-free pending count and link to
|
||||
`/actions`, but action rows, policy decisions, mutations, and verified
|
||||
outcomes remain owned by the action inbox and review route
|
||||
and the Patrol work-group inputs, where the browser may group current
|
||||
approvals, failed actions, failed checks, recurring active issues, and
|
||||
overdue scheduled protection only from API-owned Patrol status, run history,
|
||||
|
||||
@@ -605,11 +605,14 @@ or other self-hosted uncapped continuity plans.
|
||||
|
||||
## Extension Points
|
||||
|
||||
The authenticated app shell exposes `/actions` as a global utility route on
|
||||
desktop and responsive navigation. `App.tsx` and `AppLayout.tsx` only provide
|
||||
route/navigation ownership; action authority and result truth remain in the
|
||||
unified-resources and api-contracts projections. Navigation proof is owned by
|
||||
the App/AppLayout, routing, and desktop Actions journey tests.
|
||||
The authenticated app shell keeps `/actions` as the stable global Activity
|
||||
history route, reached from Patrol rather than exposed as a peer desktop or
|
||||
responsive utility tab. `App.tsx` and `AppLayout.tsx` only provide route and
|
||||
navigation ownership: Patrol remains selected while Activity history is open,
|
||||
and action authority and result truth remain in the unified-resources and
|
||||
api-contracts projections. This information-architecture choice is independent
|
||||
of hosted entitlement, billing, and acquisition state. Navigation proof is
|
||||
owned by the App/AppLayout, routing, and desktop Actions journey tests.
|
||||
|
||||
1. Add or change limits through `pkg/licensing/`
|
||||
2. Add or change hosted entitlement issuance through `internal/cloudcp/entitlements/service.go`
|
||||
@@ -973,7 +976,10 @@ the App/AppLayout, routing, and desktop Actions journey tests.
|
||||
destination remains `Patrol` over the same `/patrol` route; it must not
|
||||
alter hosted org bootstrap, entitlement loading, or commercial posture
|
||||
loading. A Patrol open-work badge in that shell may consume only the
|
||||
existing Patrol findings and live-approval read model after authentication;
|
||||
existing Patrol findings and durable action-inbox pending count after
|
||||
authentication. The subordinate `/actions` Activity history route keeps
|
||||
Patrol selected in primary chrome and must not reappear as a commercial or
|
||||
peer utility destination;
|
||||
it must not read hosted billing state, trigger commercial-posture loading,
|
||||
affect organization visibility, or become an upgrade/acquisition cue.
|
||||
The same primary platform navigation must remember the last in-tab route
|
||||
|
||||
@@ -5710,6 +5710,15 @@ the matching Open or History subtab from server-authored lifecycle state, and
|
||||
removes the query when the dialog closes. Feature pages may summarize action
|
||||
context, but they must not recreate approve, reject, run, progress, or outcome
|
||||
controls outside the shared Actions review.
|
||||
The Actions ledger is no longer a peer top-level navigation destination.
|
||||
Patrol is the primary operations home and exposes the ledger as `Activity
|
||||
history`, including a pending-review count and a route-backed handoff. The
|
||||
canonical `/actions` route remains stable for exact action deep links and
|
||||
universal audit records originating from Patrol, Assistant, MCP, or manual
|
||||
controls. While that subordinate route is open, desktop and mobile navigation
|
||||
keep Patrol selected and the browser title identifies `Activity history`;
|
||||
removing the standalone navigation entry must not remove the shared review
|
||||
dialog, route, API client, or durable action identity.
|
||||
|
||||
### Protection posture presentation boundary
|
||||
|
||||
|
||||
@@ -596,6 +596,12 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
|
||||
verification needed, failed fix, or setup attention, but it must not expose
|
||||
generic `detected`, `review finding`, raw loop-state, investigation-status,
|
||||
investigation-outcome, or confidence badges on the default Patrol page.
|
||||
Patrol is also the daily entry point for governed operations that did not
|
||||
originate in Patrol. Its compact `Activity history` handoff exposes the
|
||||
universal pending-review count and opens the canonical action ledger without
|
||||
copying ledger rows or action controls into Patrol. The ledger remains an
|
||||
auditable subordinate route, not a second top-level inbox; `/actions` deep
|
||||
links stay stable and keep Patrol selected in shared navigation.
|
||||
Patrol approval and remediation actions own approval, denial, reapproval,
|
||||
review, and Assistant handoff semantics, but their visible action chrome must
|
||||
compose the shared `Button` primitive for success, warning-solid, primary,
|
||||
|
||||
@@ -785,15 +785,20 @@ container inventory table.
|
||||
activity must not infer related resources in the frontend.
|
||||
## Extension Points
|
||||
|
||||
The desktop Product Trust projection is owned at
|
||||
`frontend-modern/src/features/actions/` with the route shell in
|
||||
`frontend-modern/src/pages/Actions.tsx`. It must consume durable
|
||||
The global Product Trust projection is owned at
|
||||
`frontend-modern/src/features/actions/` with the stable `/actions` route shell
|
||||
in `frontend-modern/src/pages/Actions.tsx`, presented to users as `Activity
|
||||
history`. Patrol is the primary operational home and may expose a compact
|
||||
pending-count handoff into this route, but the ledger must not reappear as a
|
||||
peer top-level utility tab and Patrol must not duplicate its rows or controls.
|
||||
The route must consume durable
|
||||
`ActionAuditRecord.plan.policyDecision` and `result.actionResultV2` without
|
||||
deriving policy authority or collapsing execution, verification, and recovery
|
||||
into one outcome. Docker lifecycle controls may create a canonical plan and
|
||||
open this shared review, but may not auto-approve, auto-execute, or use a
|
||||
second-click local confirmation. Proof is owned by the colocated action tests,
|
||||
`DockerNativeTables.test.tsx`, and desktop journeys 81 and 83.
|
||||
the routing and app-shell tests, `DockerNativeTables.test.tsx`, and desktop
|
||||
journeys 81 and 83.
|
||||
Patrol findings may retain bounded action status and safety context, but exact
|
||||
typed action ids hand off to the route-backed Actions review through
|
||||
`frontend-modern/src/features/actions/actionRouting.ts`. The `action` query
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "772ff3087d60ac9ee9b69ddf42bcc76a8eae5be0",
|
||||
"verified_at": "2026-08-14T11:45:19Z",
|
||||
"base_sha": "48fac73ff020491c1731c281d0c69b874c27f71b",
|
||||
"verified_at": "2026-08-14T12:03:35Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/useDialogState.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
|
||||
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
|
||||
"frontend-modern/src/pages/Actions.tsx",
|
||||
"frontend-modern/src/utils/assistantPageContext.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/shared/useDialogState.ts": "3566700d8a8e72f3962204f12096912b84fb3bf089a4b181c1d9326ba85a1306",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "272c6cb9c975cb346aa96082e804ad2eb08419d7ab2157429b901ba8b2f67a94",
|
||||
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "ba86e9637aca3abd63de515d5e98a8881ddfc4ed7b308aaa651926fa5eb48db9",
|
||||
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "24ac4a67d8e97bc39867238c34b2cf4deede69ccf671eef1ffc47c739a25db4a"
|
||||
"frontend-modern/src/AppLayout.tsx": "91e6aa7723872ec61437da7d3851fe82be89e707f53c66a3c3ff74fa7851671a",
|
||||
"frontend-modern/src/components/shared/mobileNavBarModel.ts": "bccadf7522c9e7affa06ecebd816c8963295bd40ce2ac122a022d3662e1a5e7d",
|
||||
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "b6eaffbb9aa1e0b9e69a5245d0e2e4bce5b9c88df353d207db095189df6e82c1",
|
||||
"frontend-modern/src/pages/Actions.tsx": "878397bb4571fc714684e35474acc6a60220498b4ac256c4bd57f39e41533ba5",
|
||||
"frontend-modern/src/utils/assistantPageContext.ts": "7943a778ee4d4e1fb56c0a09778fffc8402d528da2e336713b1ff93e110acf6f"
|
||||
},
|
||||
"routes": [
|
||||
"/patrol"
|
||||
"/patrol",
|
||||
"/actions"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -29,21 +32,18 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Watch only with direct objective shown as Watching in background",
|
||||
"Watch only with Jellyfin and Frigate proxy objectives shown as Useful signal only",
|
||||
"objective creation dialog open with the outcome textarea focused",
|
||||
"Autopilot acknowledgement modal before and after acknowledgement checkbox",
|
||||
"Autopilot active with canonical server state and no year-one expiry",
|
||||
"Autopilot revoked and Patrol returned to Watch only",
|
||||
"narrow Patrol layout with no horizontal overflow"
|
||||
"Patrol desktop navigation with one pending universal action",
|
||||
"Patrol Activity history handoff with pending-review badge",
|
||||
"Activity history open queue with Patrol selected in desktop navigation",
|
||||
"Activity history open queue with Patrol selected in mobile navigation",
|
||||
"Patrol and Activity history at 390 pixels without horizontal overflow"
|
||||
],
|
||||
"interactions": [
|
||||
"opened Add objective and confirmed autofocus",
|
||||
"closed objective dialog with Escape and confirmed focus return",
|
||||
"opened Autopilot acknowledgement and enabled activation with the acknowledgement checkbox",
|
||||
"activated Autopilot through the real UI and confirmed the server-derived mode",
|
||||
"revoked Autopilot through the real UI",
|
||||
"switched Patrol back to Watch only",
|
||||
"resized to 390 by 844 and confirmed body and document scroll widths stayed at 390"
|
||||
"opened Activity history from the Patrol page",
|
||||
"confirmed the stable /actions route and Activity history browser title",
|
||||
"confirmed no Actions destination exists in desktop or mobile navigation",
|
||||
"confirmed Patrol remains selected while Activity history is open",
|
||||
"returned to Patrol through mobile navigation",
|
||||
"resized to 390 by 844 and confirmed document width stayed at 390"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Portal } from 'solid-js/web';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { useLocation, useNavigate } from '@solidjs/router';
|
||||
import BellIcon from 'lucide-solid/icons/bell';
|
||||
import ListChecksIcon from 'lucide-solid/icons/list-checks';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import Maximize2Icon from 'lucide-solid/icons/maximize-2';
|
||||
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
|
||||
@@ -298,7 +297,7 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
vmware: 'vSphere',
|
||||
standalone: 'Machines',
|
||||
alerts: 'Alerts',
|
||||
actions: 'Actions',
|
||||
actions: 'Activity history',
|
||||
ai: 'Patrol',
|
||||
settings: 'Settings',
|
||||
};
|
||||
@@ -440,8 +439,12 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
clearHeaderHideTimeout();
|
||||
});
|
||||
|
||||
const getActiveTabDesktop = () => getActiveTabForPath(location.pathname);
|
||||
const getActiveTabMobile = () => getActiveTabForPath(location.pathname);
|
||||
const getNavigationActiveTab = () => {
|
||||
const active = getActiveTabForPath(location.pathname);
|
||||
return active === 'actions' ? 'ai' : active;
|
||||
};
|
||||
const getActiveTabDesktop = getNavigationActiveTab;
|
||||
const getActiveTabMobile = getNavigationActiveTab;
|
||||
const assistantPageContext = createMemo(() => getAssistantPageContext(location.pathname));
|
||||
const openAssistantFromLauncher = () => {
|
||||
restoreAssistantLauncherFocus = true;
|
||||
@@ -482,6 +485,12 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
if (count <= 0) return undefined;
|
||||
return `${count} active attention ${count === 1 ? 'item' : 'items'}`;
|
||||
});
|
||||
const patrolNavigationCount = createMemo(
|
||||
() => (actionApprovalBadge()?.count ?? patrolAttentionCount()) || undefined,
|
||||
);
|
||||
const patrolNavigationCountLabel = createMemo(
|
||||
() => actionApprovalBadge()?.label ?? patrolAttentionCountLabel(),
|
||||
);
|
||||
|
||||
// Platform/runtime nav is resource-admitted. A platform or runtime lens only
|
||||
// appears when the support manifest says the surface is supported and the
|
||||
@@ -590,25 +599,14 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
breakdown,
|
||||
icon: BellIcon,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
route: '/actions',
|
||||
tooltip: 'Review proposed changes and verified outcomes',
|
||||
badge: null,
|
||||
count: actionApprovalBadge()?.count,
|
||||
countLabel: actionApprovalBadge()?.label,
|
||||
breakdown: undefined,
|
||||
icon: ListChecksIcon,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'Patrol',
|
||||
route: '/patrol',
|
||||
tooltip: 'Review active operational attention and recent Patrol checks',
|
||||
badge: null,
|
||||
count: patrolAttentionCount() > 0 ? patrolAttentionCount() : undefined,
|
||||
countLabel: patrolAttentionCountLabel(),
|
||||
count: patrolNavigationCount(),
|
||||
countLabel: patrolNavigationCountLabel(),
|
||||
breakdown: undefined,
|
||||
icon: PulsePatrolLogo,
|
||||
},
|
||||
|
||||
@@ -525,11 +525,14 @@ describe('App architecture', () => {
|
||||
expect(appLayoutSource).not.toContain("'/operations', '/patrol', '/ai'");
|
||||
expect(appLayoutSource).toContain("route: '/patrol',");
|
||||
expect(appLayoutSource).toContain("label: 'Patrol'");
|
||||
expect(appLayoutSource).not.toContain("id: 'actions',");
|
||||
expect(appLayoutSource).not.toContain("label: 'Actions',");
|
||||
expect(appLayoutSource).toContain("return active === 'actions' ? 'ai' : active;");
|
||||
expect(appLayoutSource).toContain(
|
||||
"tooltip: 'Review active operational attention and recent Patrol checks'",
|
||||
);
|
||||
expect(appLayoutSource).toContain('const patrolAttentionCount = createMemo(');
|
||||
expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()');
|
||||
expect(appLayoutSource).toContain('countLabel: patrolNavigationCountLabel()');
|
||||
expect(appLayoutSource).not.toContain("label: 'Needs Attention'");
|
||||
expect(appLayoutSource).not.toContain("route: '/operations',");
|
||||
expect(appLayoutSource).not.toContain('props.connected()');
|
||||
@@ -716,7 +719,7 @@ describe('App architecture', () => {
|
||||
expect(runtimeHomeSource).not.toContain('aiIntelligenceStore');
|
||||
expect(runtimeHomeSource).not.toContain('patrolOpenWork');
|
||||
expect(appLayoutSource).toContain("label: 'Patrol'");
|
||||
expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()');
|
||||
expect(appLayoutSource).toContain('countLabel: patrolNavigationCountLabel()');
|
||||
});
|
||||
|
||||
it('keeps licensed application branding inside the authenticated shell bootstrap', () => {
|
||||
|
||||
@@ -28,6 +28,9 @@ function setViewportWidth(width: number) {
|
||||
const patrolAttentionMockState = vi.hoisted(() => ({
|
||||
activeCount: 0,
|
||||
}));
|
||||
const actionInboxMockState = vi.hoisted(() => ({
|
||||
pendingActionCount: 0,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/patrolAttention', () => ({
|
||||
patrolAttentionStore: {
|
||||
@@ -37,6 +40,14 @@ vi.mock('@/stores/patrolAttention', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/actionInbox', () => ({
|
||||
actionInboxStore: {
|
||||
get pendingActionCount() {
|
||||
return actionInboxMockState.pendingActionCount;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/routing/routePreload', () => ({
|
||||
preloadRouteModule: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
@@ -118,6 +129,7 @@ const renderLayout = (
|
||||
<Route path="/proxmox/overview" component={LayoutRoute} />
|
||||
<Route path="/docker/overview" component={LayoutRoute} />
|
||||
<Route path="/alerts" component={LayoutRoute} />
|
||||
<Route path="/actions" component={LayoutRoute} />
|
||||
</Router>
|
||||
));
|
||||
};
|
||||
@@ -128,6 +140,7 @@ describe('AppLayout navigation icons', () => {
|
||||
window.history.replaceState({}, '', '/settings/infrastructure');
|
||||
resetPrimaryNavigationRouteMemory();
|
||||
patrolAttentionMockState.activeCount = 0;
|
||||
actionInboxMockState.pendingActionCount = 0;
|
||||
clearRuntimeBranding();
|
||||
aiChatStore.close();
|
||||
aiChatStore.setEnabled(true);
|
||||
@@ -160,7 +173,7 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(systemGroup).toBeTruthy();
|
||||
|
||||
const desktopTabs = within(systemGroup as HTMLElement).getAllByRole('tab');
|
||||
expect(desktopTabs).toHaveLength(4);
|
||||
expect(desktopTabs).toHaveLength(3);
|
||||
desktopTabs.forEach((tab) => {
|
||||
expect(tab.querySelector('svg')).toBeTruthy();
|
||||
});
|
||||
@@ -177,7 +190,7 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Patrol P' })).toBeNull();
|
||||
|
||||
const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
|
||||
['alerts', 'actions', 'ai'].forEach((tabId) => {
|
||||
['alerts', 'ai'].forEach((tabId) => {
|
||||
const button = mobileNav.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`);
|
||||
expect(button).toBeTruthy();
|
||||
expect(button?.querySelector('svg')).toBeTruthy();
|
||||
@@ -219,6 +232,26 @@ describe('AppLayout navigation icons', () => {
|
||||
expect(within(mobileNav).queryByText('Needs Attention')).toBeNull();
|
||||
});
|
||||
|
||||
it('routes universal pending operations and the subordinate activity route through Patrol', () => {
|
||||
actionInboxMockState.pendingActionCount = 3;
|
||||
renderLayout([], '/actions');
|
||||
|
||||
const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' });
|
||||
const systemGroup = desktopNav.querySelector('[aria-label="System"]');
|
||||
expect(systemGroup).toBeTruthy();
|
||||
const patrolTab = within(systemGroup as HTMLElement).getByRole('tab', {
|
||||
name: 'Patrol: 3 actions await approval',
|
||||
});
|
||||
expect(patrolTab.className).toContain('text-blue-600');
|
||||
expect(within(systemGroup as HTMLElement).queryByRole('tab', { name: 'Actions' })).toBeNull();
|
||||
|
||||
const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
|
||||
expect(
|
||||
within(mobileNav).getByRole('button', { name: 'Patrol: 3 actions await approval' }),
|
||||
).toHaveAttribute('aria-current', 'page');
|
||||
expect(document.title).toContain('Activity history');
|
||||
});
|
||||
|
||||
it('shows platform and runtime lens tabs with supported infrastructure evidence', () => {
|
||||
renderLayout([
|
||||
makeResource({ id: 'agent-1', type: 'agent', platformType: 'agent' }),
|
||||
|
||||
@@ -96,7 +96,6 @@ describe('MobileNavBar', () => {
|
||||
utilityTabs={() => [
|
||||
makeUtility('settings', 'Settings'),
|
||||
makeUtility('ai', 'Patrol'),
|
||||
makeUtility('actions', 'Actions'),
|
||||
makeUtility('alerts', 'Alerts'),
|
||||
]}
|
||||
onPrimaryClick={() => {}}
|
||||
@@ -110,7 +109,7 @@ describe('MobileNavBar', () => {
|
||||
Array.from(fixedRail?.querySelectorAll('button[data-tab-id]') ?? []).map((button) =>
|
||||
button.getAttribute('data-tab-id'),
|
||||
),
|
||||
).toEqual(['proxmox', 'alerts', 'actions', 'ai', 'more']);
|
||||
).toEqual(['proxmox', 'alerts', 'ai', 'more']);
|
||||
|
||||
const more = screen.getByRole('button', { name: 'More navigation' });
|
||||
expect(more).toHaveAttribute('aria-haspopup', 'menu');
|
||||
|
||||
+6
-8
@@ -114,22 +114,22 @@ describe('mobileNavBarModel.branchcov2', () => {
|
||||
|
||||
it('uses countLabel when count > 0 (countLabel ?? right-side fallback NOT taken)', () => {
|
||||
const tab = makeUtilityTab({
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
id: 'ai',
|
||||
label: 'Patrol',
|
||||
count: 12,
|
||||
countLabel: '12 pending',
|
||||
});
|
||||
expect(getMobileNavTabAriaLabel(tab)).toBe('Actions: 12 pending');
|
||||
expect(getMobileNavTabAriaLabel(tab)).toBe('Patrol: 12 pending');
|
||||
});
|
||||
|
||||
it('synthesizes "N items" when countLabel is absent (countLabel ?? right arm)', () => {
|
||||
const tab = makeUtilityTab({
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
id: 'ai',
|
||||
label: 'Patrol',
|
||||
count: 12,
|
||||
countLabel: undefined,
|
||||
});
|
||||
expect(getMobileNavTabAriaLabel(tab)).toBe('Actions: 12 items');
|
||||
expect(getMobileNavTabAriaLabel(tab)).toBe('Patrol: 12 items');
|
||||
});
|
||||
|
||||
it('returns the bare label for an alerts tab whose breakdown is all zeros (parts.length === 0 arm)', () => {
|
||||
@@ -225,7 +225,6 @@ describe('mobileNavBarModel.branchcov2', () => {
|
||||
[
|
||||
makeUtilityTab({ id: 'settings' }),
|
||||
makeUtilityTab({ id: 'ai' }),
|
||||
makeUtilityTab({ id: 'actions' }),
|
||||
makeUtilityTab({ id: 'alerts' }),
|
||||
],
|
||||
);
|
||||
@@ -233,7 +232,6 @@ describe('mobileNavBarModel.branchcov2', () => {
|
||||
expect(layout.fixedDestinations.map((destination) => destination.tab.id)).toEqual([
|
||||
'proxmox',
|
||||
'alerts',
|
||||
'actions',
|
||||
'ai',
|
||||
]);
|
||||
expect(layout.overflowDestinations.map((destination) => destination.tab.id)).toEqual([
|
||||
|
||||
@@ -16,7 +16,7 @@ export type MobileNavBarPrimaryTab = {
|
||||
};
|
||||
|
||||
export type MobileNavBarUtilityTab = {
|
||||
id: 'alerts' | 'actions' | 'ai' | 'settings';
|
||||
id: 'alerts' | 'ai' | 'settings';
|
||||
label: string;
|
||||
route: string;
|
||||
tooltip: string;
|
||||
@@ -53,12 +53,8 @@ const MOBILE_NAV_PRIMARY_PRIORITY = [
|
||||
'standalone',
|
||||
] as const;
|
||||
|
||||
const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'actions', 'ai', 'settings'] as const;
|
||||
const MOBILE_NAV_FIXED_UTILITY_IDS = new Set<MobileNavBarUtilityTab['id']>([
|
||||
'alerts',
|
||||
'actions',
|
||||
'ai',
|
||||
]);
|
||||
const MOBILE_NAV_UTILITY_PRIORITY = ['alerts', 'ai', 'settings'] as const;
|
||||
const MOBILE_NAV_FIXED_UTILITY_IDS = new Set<MobileNavBarUtilityTab['id']>(['alerts', 'ai']);
|
||||
|
||||
export function buildOrderedMobileNavTabs<T extends { id: string }>(
|
||||
tabs: T[],
|
||||
@@ -94,7 +90,7 @@ export function buildOrderedMobileNavUtilityTabs(
|
||||
|
||||
/**
|
||||
* Keep the bottom bar to five predictable targets at most: the primary
|
||||
* infrastructure destination, the three daily operations destinations, and
|
||||
* infrastructure destination, the two daily operations destinations, and
|
||||
* More. Every other admitted platform and utility remains available in More.
|
||||
*/
|
||||
export function buildMobileNavBarLayout(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
import ListChecksIcon from 'lucide-solid/icons/list-checks';
|
||||
import { ButtonLink } from '@/components/shared/Button';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
|
||||
import { actionInboxStore } from '@/stores/actionInbox';
|
||||
import { usePatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader';
|
||||
import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners';
|
||||
@@ -32,6 +35,49 @@ export function PatrolIntelligenceSurface() {
|
||||
autonomyLocked={state.autoFixLocked()}
|
||||
onOpenFindings={openFindings}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-labelledby="patrol-activity-history-title"
|
||||
class="flex flex-col gap-4 rounded-lg border border-border bg-surface px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-alt text-muted">
|
||||
<ListChecksIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2
|
||||
id="patrol-activity-history-title"
|
||||
class="text-base font-semibold text-base-content"
|
||||
>
|
||||
Activity history
|
||||
</h2>
|
||||
<Show when={actionInboxStore.pendingActionCount > 0}>
|
||||
<MetadataBadge
|
||||
tone="warning"
|
||||
size="sm"
|
||||
shape="rounded"
|
||||
aria-label={`${actionInboxStore.pendingActionCount} ${actionInboxStore.pendingActionCount === 1 ? 'operation needs' : 'operations need'} review`}
|
||||
>
|
||||
{actionInboxStore.pendingActionCount} to review
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
|
||||
Audit every governed operation, including work started elsewhere in Pulse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonLink
|
||||
href="/actions"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="w-full shrink-0 justify-center sm:w-auto"
|
||||
>
|
||||
Open activity history
|
||||
</ButtonLink>
|
||||
</section>
|
||||
|
||||
<PatrolRecentWorkPanel />
|
||||
|
||||
<details
|
||||
|
||||
@@ -139,8 +139,8 @@ export function Actions() {
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<PageHeader
|
||||
title="Actions"
|
||||
description="Review proposed infrastructure changes and track their outcomes."
|
||||
title="Activity history"
|
||||
description="Review every governed operation, approval, execution result, and verified outcome."
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
} from '@/utils/pricingHandoff';
|
||||
import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw';
|
||||
import patrolIntelligenceSurfaceSource from '@/features/patrol/PatrolIntelligenceSurface.tsx?raw';
|
||||
import patrolIntelligenceStateSource from '@/features/patrol/usePatrolIntelligenceState.ts?raw';
|
||||
import { AGENT_PATROL_CONTROL_STATUS_PATH } from '@/api/agentCapabilities';
|
||||
|
||||
@@ -547,6 +548,9 @@ describe('AIIntelligence entitlement gating', () => {
|
||||
expect(patrolIntelligenceHeaderSource).not.toContain('Sync page data');
|
||||
expect(patrolIntelligenceHeaderSource).not.toContain('animate-spin');
|
||||
expect(patrolIntelligenceHeaderSource).not.toContain('Refresh Patrol');
|
||||
expect(patrolIntelligenceSurfaceSource).toContain('Activity history');
|
||||
expect(patrolIntelligenceSurfaceSource).toContain('href="/actions"');
|
||||
expect(patrolIntelligenceSurfaceSource).toContain('Open activity history');
|
||||
});
|
||||
|
||||
it('keeps the advanced Patrol settings drawer out of the old save-spinner path', () => {
|
||||
|
||||
@@ -21,8 +21,6 @@ describe('navigation routing helpers', () => {
|
||||
expect(getActiveTabForPath('/ceph')).toBeNull();
|
||||
expect(getActiveTabForPath('/recovery')).toBeNull();
|
||||
expect(getActiveTabForPath('/alerts/open')).toBe('alerts');
|
||||
expect(getActiveTabForPath('/actions')).toBe('actions');
|
||||
expect(getActiveTabForPath('/actions/history')).toBe('actions');
|
||||
expect(getActiveTabForPath('/patrol')).toBe('ai');
|
||||
expect(getActiveTabForPath('/ai')).toBeNull();
|
||||
expect(getActiveTabForPath('/operations')).toBeNull();
|
||||
@@ -30,4 +28,11 @@ describe('navigation routing helpers', () => {
|
||||
expect(getActiveTabForPath('/operations/logs')).toBeNull();
|
||||
expect(getActiveTabForPath('/settings/security')).toBe('settings');
|
||||
});
|
||||
|
||||
it('keeps Activity history as a stable subordinate route identity', () => {
|
||||
// AppLayout presents this route beneath Patrol, while route identity stays
|
||||
// distinct for the page title, Assistant context, and durable action review.
|
||||
expect(getActiveTabForPath('/actions')).toBe('actions');
|
||||
expect(getActiveTabForPath('/actions/history')).toBe('actions');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface AssistantPageContextPresentation {
|
||||
|
||||
const VIEW_LABELS: Record<Exclude<ActiveAppTabId, null>, string> = {
|
||||
alerts: 'Alerts',
|
||||
actions: 'Actions',
|
||||
actions: 'Activity history',
|
||||
ai: 'Patrol',
|
||||
docker: 'Docker',
|
||||
kubernetes: 'Kubernetes',
|
||||
|
||||
Reference in New Issue
Block a user