mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
frontend: Patrol intelligence surface, shared primitives, and platform parity
Patrol operator UI (header, workspace, banners, findings, autonomy controls); convergence of shared primitives (buttons, spinners, notices, badges); settings information architecture (Pulse Intelligence, plans and billing, agent integrations); platform parity restoration across Proxmox, Kubernetes, Docker, storage, memory, and alerts; i18n foundation.
This commit is contained in:
@@ -14,9 +14,9 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces.
|
||||
|
||||
### Anonymous outbound telemetry
|
||||
|
||||
Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, and which features are in use.
|
||||
Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, which features are in use, and whether Patrol control and governed Pulse Intelligence operations are being adopted.
|
||||
|
||||
No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, or personally identifiable information is ever sent. See the full field list below.
|
||||
No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, command text, action output, token values, or personally identifiable information is ever sent. See the full field list below.
|
||||
|
||||
#### How to disable
|
||||
|
||||
@@ -80,11 +80,69 @@ Every field is listed below with the reason it exists — nothing else leaves yo
|
||||
| Multi-tenant | `true`/`false` | See whether multi-tenant/runtime-org features are being used |
|
||||
| Paid license | `true`/`false` | Distinguish free from paid posture without sending the exact commercial tier |
|
||||
| Has API tokens | `true`/`false` | See whether token-based automation/integration is being used without sending token counts |
|
||||
| Pulse Intelligence configured | `true`/`false` | See whether Assistant, Patrol, governed actions, or external-agent access is configured so adoption can be measured without sending configuration details |
|
||||
| Pulse Intelligence active 30d | `true`/`false` | See whether Assistant, Patrol, external-agent, or governed-action activity occurred in the current 30-day telemetry window |
|
||||
| Pulse Intelligence governed decision 30d | `true`/`false` | See whether Patrol issue activity reached an approved or rejected governed-action decision without sending prompts, findings, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence approved action attempt 30d | `true`/`false` | See whether Patrol issue activity reached an approved governed action attempt without sending action details |
|
||||
| Pulse Intelligence resolved issue 30d | `true`/`false` | See whether Patrol issue activity reached resolution with an approved action success without sending prompts, findings, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence Patrol mode decision 30d | `true`/`false` | See whether a Patrol mode starter, Patrol issue activity, contextual Assistant or external-agent collaboration, and either a rejected governed decision or an approved governed decision with a verified outcome occurred without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence Patrol mode resolved issue 30d | `true`/`false` | See whether a Patrol mode starter, Patrol issue activity, contextual Assistant or external-agent collaboration, an approved governed decision, and a verified outcome occurred without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence paid Patrol mode decision 30d | `true`/`false` | See whether the install currently has a paid license and Patrol mode reached a governed decision without sending the exact tier, checkout details, account links, prompts, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence paid Patrol mode resolved issue 30d | `true`/`false` | See whether the install currently has a paid license and Patrol mode reached a resolved issue without sending the exact tier, checkout details, account links, prompts, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence historical Patrol mode decision mirror 30d | `true`/`false` | Compatibility mirror of the Patrol mode decision field for historical aggregate reporting without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence historical Patrol mode resolved mirror 30d | `true`/`false` | Compatibility mirror of the Patrol mode resolved field for historical aggregate reporting without sending prompt text, checkout details, account links, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence paid historical Patrol mode decision mirror 30d | `true`/`false` | Compatibility mirror of the paid Patrol mode decision field for historical aggregate reporting without sending exact tier, checkout details, account links, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence paid historical Patrol mode resolved mirror 30d | `true`/`false` | Compatibility mirror of the paid Patrol mode resolved field for historical aggregate reporting without sending exact tier, checkout details, account links, token details, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence governed action active 30d | `true`/`false` | See whether Patrol or Assistant reached action planning, approval, approve/reject decision, or approved-action depth in the current 30-day telemetry window without sending action details |
|
||||
| Pulse Intelligence Assistant governed decision 30d | `true`/`false` | See whether Assistant collaboration specifically reached the Patrol issue plus approved/rejected governed-action path without sending prompts, chat messages, tool details, findings, command text, or action output |
|
||||
| Pulse Intelligence Assistant approved action attempt 30d | `true`/`false` | See whether Assistant collaboration specifically reached approved governed-action execution in the 30-day window without sending prompts, chat messages, command text, actors, or action output |
|
||||
| Pulse Intelligence Assistant approved action success 30d | `true`/`false` | See whether Assistant collaboration specifically reached approved action success in the 30-day window without sending prompts, chat messages, verification details, command text, or action output |
|
||||
| Pulse Intelligence Assistant resolved issue 30d | `true`/`false` | See whether Assistant collaboration specifically reached Patrol resolution plus approved action success in the 30-day window without sending findings, prompts, chat messages, verification details, command text, or action output |
|
||||
| Pulse Intelligence external-agent governed decision 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached the Patrol issue plus approved/rejected governed-action path without sending route parameters, prompts, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence external-agent approved action attempt 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached approved governed-action execution in the 30-day window without sending route parameters, command text, actors, or action output |
|
||||
| Pulse Intelligence external-agent approved action success 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached approved action success in the 30-day window without sending route parameters, command text, verification details, or action output |
|
||||
| Pulse Intelligence external-agent resolved issue 30d | `true`/`false` | See whether direct external-agent or MCP collaboration reached Patrol resolution plus approved action success in the 30-day window without sending findings, route parameters, command text, verification details, or action output |
|
||||
| Pulse Intelligence `pulse-mcp` governed decision 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached the Patrol issue plus approved/rejected governed-action path without sending prompts, tool inputs, route parameters, resource identifiers, command text, or action output |
|
||||
| Pulse Intelligence `pulse-mcp` approved action attempt 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached approved governed-action execution in the 30-day window without sending prompts, tool inputs, command text, actors, or action output |
|
||||
| Pulse Intelligence `pulse-mcp` approved action success 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached approved action success in the 30-day window without sending prompts, tool inputs, verification details, command text, or action output |
|
||||
| Pulse Intelligence `pulse-mcp` resolved issue 30d | `true`/`false` | See whether the `pulse-mcp` adapter specifically reached Patrol resolution plus approved action success in the 30-day window without sending findings, prompts, tool inputs, verification details, command text, or action output |
|
||||
| Pulse Intelligence starter requests 30d | `3` | Count shared Patrol-work starter requests in the current 30-day telemetry window without sending prompt text, chat messages, tool inputs, resource IDs, or request details |
|
||||
| Pulse Intelligence Assistant starter requests 30d | `2` | Count Assistant requests for shared Patrol work in the current 30-day telemetry window without sending prompt text, chat messages, resource IDs, or request details |
|
||||
| Pulse Intelligence Patrol starter requests 30d | `1` | Count Patrol work starter requests in the current 30-day telemetry window without sending prompt text, findings, resource IDs, or request details |
|
||||
| Pulse Intelligence Patrol mode starter requests 30d | `1` | Count Patrol work, Patrol-mode, and historical entry-point requests for shared Patrol work in the current 30-day telemetry window without sending prompt text, checkout details, account links, resource IDs, or request details |
|
||||
| Pulse Intelligence historical Patrol mode starter mirror 30d | `1` | Count historical entry-point requests for Patrol mode in the current 30-day telemetry window without sending prompt text, checkout details, account links, resource IDs, or request details |
|
||||
| Pulse Intelligence `pulse-mcp` starter requests 30d | `1` | Count `pulse-mcp` requests for shared Patrol work in the current 30-day telemetry window without sending prompt text, tool inputs, resource IDs, route parameters, or request details |
|
||||
| Pulse Intelligence Assistant AI calls 30d | `18` | Count Assistant model calls in the current 30-day telemetry window without sending prompts, responses, session IDs, or chat text |
|
||||
| Pulse Intelligence Assistant context AI calls 30d | `7` | Count Assistant model calls tied to a governed resource, finding, handoff, or action context in the current 30-day telemetry window without sending prompts, responses, session IDs, resource IDs, finding IDs, or chat text |
|
||||
| Pulse Intelligence Assistant tool calls 30d | `11` | Count Assistant tool calls in the current 30-day telemetry window without sending tool names, tool inputs, tool outputs, prompts, responses, session IDs, resource IDs, finding IDs, command text, or chat text |
|
||||
| Pulse Intelligence Patrol AI calls 30d | `6` | Count Patrol model calls in the current 30-day telemetry window without sending provider-bound context or findings text |
|
||||
| Pulse Intelligence Patrol runs 30d | `12` | Count Patrol investigations in the current 30-day telemetry window |
|
||||
| Pulse Intelligence Patrol new findings 30d | `5` | Count new findings produced by Patrol in the current 30-day telemetry window without sending finding IDs or details |
|
||||
| Pulse Intelligence Patrol investigations 30d | `3` | Count findings investigated by Patrol in the current 30-day telemetry window without sending finding IDs, resource IDs, or details |
|
||||
| Pulse Intelligence Patrol resolved findings 30d | `2` | Count findings resolved or fix-verified in the current 30-day telemetry window without sending finding IDs, resource IDs, fix details, or verification detail |
|
||||
| Pulse Intelligence Patrol autofixes 30d | `1` | Count Patrol autofix records in the current 30-day telemetry window without sending target resources or fix content |
|
||||
| Pulse Intelligence external agent enabled | `true`/`false` | See whether at least one token can use the external Pulse Intelligence agent/MCP surface without sending token counts, names, scopes, or values |
|
||||
| Pulse Intelligence external agent used 30d | `true`/`false` | See whether an external-agent-capable API token reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, or request details |
|
||||
| Pulse Intelligence MCP adapter used 30d | `true`/`false` | See whether the `pulse-mcp` adapter reached a Pulse Intelligence agent/MCP route in the current 30-day telemetry window without sending token identity, route parameters, resource IDs, prompts, or request details |
|
||||
| Pulse Intelligence external agent context requests 30d | `8` | Count external-agent/MCP resource-context and fleet-context requests in the current 30-day telemetry window without sending resource IDs, route parameters, or request details |
|
||||
| Pulse Intelligence external agent event stream requests 30d | `3` | Count external-agent/MCP event-stream requests in the current 30-day telemetry window without sending event content, route parameters, or request details |
|
||||
| Pulse Intelligence external agent provisioning requests 30d | `2` | Count external-agent/MCP provisioning requests in the current 30-day telemetry window without sending discovered resources, credentials, route parameters, or request details |
|
||||
| Pulse Intelligence external agent operator state requests 30d | `5` | Count external-agent/MCP operator-state requests in the current 30-day telemetry window without sending state payloads, route parameters, or request details |
|
||||
| Pulse Intelligence external agent finding requests 30d | `4` | Count external-agent/MCP finding-list and finding-decision requests in the current 30-day telemetry window without sending finding IDs, finding text, route parameters, or request details |
|
||||
| Pulse Intelligence external agent action requests 30d | `1` | Count external-agent/MCP action-plan, action-decision, and action-execution requests in the current 30-day telemetry window without sending command text, action output, route parameters, or request details |
|
||||
| Pulse Intelligence action plans 30d | `4` | Count governed action plans in the current 30-day telemetry window without sending command text, resource IDs, or plan details |
|
||||
| Pulse Intelligence approval requests 30d | `2` | Count approval-gated action requests in the current 30-day telemetry window without sending approvers, reasons, command text, or targets |
|
||||
| Pulse Intelligence rejected action decisions 30d | `1` | Count rejected governed action decisions in the current 30-day telemetry window without sending approvers, reasons, command text, targets, or action IDs |
|
||||
| Pulse Intelligence approved action decisions 30d | `1` | Count approved governed action decisions in the current 30-day telemetry window without sending approvers, reasons, command text, targets, or action IDs |
|
||||
| Pulse Intelligence approved action attempts 30d | `1` | Count approved governed action attempts in the current 30-day telemetry window without sending action output, command text, or verification detail |
|
||||
| Pulse Intelligence approved action successes 30d | `1` | Count approved governed actions that completed successfully in the current 30-day telemetry window without sending action output, command text, resource IDs, actors, reasons, or verification detail |
|
||||
|
||||
#### Server-side handling and retention
|
||||
|
||||
- Telemetry pings are stored on the Pulse license server only for aggregate install/use analysis.
|
||||
- The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers or exact API-token counts.
|
||||
- The license server stores only the same coarse telemetry fields listed above; it does not expand them into exact commercial tiers, exact API-token counts, prompts, chat messages, command text, action output, token values, or resource identifiers.
|
||||
- Pulse may derive aggregate Pulse Intelligence adoption reports from those same rows, including whether an install reached Patrol issue activity, Patrol resolution, Assistant, direct external-agent, or MCP collaboration, Patrol mode starter use, paid Patrol mode cohorts, governed-action activity, approved or rejected action decisions, approved action success, recent retention, and observed free-to-paid movement within the source window. Those reports do not add prompts, findings, resource identifiers, tool names, tool inputs, tool outputs, command payloads, action outputs, account links, or exact commercial tiers.
|
||||
- External-agent/MCP activity is stored only as a coarse adapter-origin flag plus capability-class counters: context, event stream, provisioning, operator state, findings, and action requests.
|
||||
- Telemetry rows older than **90 days** are purged automatically.
|
||||
- The license server uses client IP addresses transiently for abuse/rate limiting, but it does **not** store IP addresses in telemetry rows.
|
||||
|
||||
@@ -93,7 +151,7 @@ Every field is listed below with the reason it exists — nothing else leaves yo
|
||||
- No IP addresses are stored in telemetry rows
|
||||
- No hostnames, node names, VM names, or any infrastructure identifiers
|
||||
- No Proxmox credentials, API tokens, or passwords
|
||||
- No alert content, AI prompts, or chat messages
|
||||
- No alert content, AI prompts, chat messages, tool names, tool inputs, tool outputs, command text, action output, or token values
|
||||
- No personally identifiable information of any kind
|
||||
|
||||
#### Install ID rotation
|
||||
|
||||
@@ -96,6 +96,33 @@ export function stripInternalAnalyticsDiagnosticsFields(payload) {
|
||||
expect(collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot })).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports legacy MCP vocabulary on native Assistant diagnostics', () => {
|
||||
const { root, repoRoot } = makeFixture(({ root, repoRoot }) => ({
|
||||
[path.join(repoRoot, 'internal', 'api', 'diagnostics.go')]: `
|
||||
package api
|
||||
|
||||
type AIChatDiagnostic struct {
|
||||
MCPConnected bool
|
||||
}
|
||||
`,
|
||||
[path.join(root, 'src', 'components', 'Settings', 'DiagnosticsResultsPanel.tsx')]:
|
||||
"export const label = 'MCP Connection';\n",
|
||||
[path.join(root, 'src', 'components', 'Settings', 'diagnosticsModel.ts')]: `
|
||||
export interface AIChatDiagnostic {
|
||||
mcpConnected: boolean;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const findings = collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot });
|
||||
|
||||
expect(findings.map((finding) => finding.rule)).toEqual([
|
||||
'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-api',
|
||||
'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-ui',
|
||||
'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-types',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports commercial analytics routes, stores, and settings in product runtime files', () => {
|
||||
const { root, repoRoot } = makeFixture(({ root, repoRoot }) => ({
|
||||
[path.join(repoRoot, 'internal', 'api', 'router_routes_licensing.go')]: `
|
||||
|
||||
@@ -19,6 +19,12 @@ const INTERNAL_ANALYTICS_RULES = [
|
||||
message:
|
||||
'Do not expose maintainer/admin analytics from /api/diagnostics or customer product runtime.',
|
||||
},
|
||||
{
|
||||
rule: 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-api',
|
||||
regex: /\b(?:MCPConnected|MCPToolCount|mcpConnected|mcpToolCount)\b/g,
|
||||
message:
|
||||
'Do not expose native Pulse Assistant diagnostics through MCP transport fields.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -74,6 +80,12 @@ const INTERNAL_ANALYTICS_RULES = [
|
||||
message:
|
||||
'Do not render maintainer/admin analytics in the user-facing Settings diagnostics panel.',
|
||||
},
|
||||
{
|
||||
rule: 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-ui',
|
||||
regex: /\b(?:mcpConnected|mcpToolCount)\b|MCP Connection/g,
|
||||
message:
|
||||
'Render Pulse Assistant diagnostics as native Assistant runtime availability, not MCP transport state.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -87,6 +99,12 @@ const INTERNAL_ANALYTICS_RULES = [
|
||||
message:
|
||||
'Do not add commercial funnel or infrastructure onboarding fields to the customer diagnostics payload model.',
|
||||
},
|
||||
{
|
||||
rule: 'canonical-settings/no-mcp-vocabulary-in-assistant-diagnostics-types',
|
||||
regex: /\b(?:mcpConnected|mcpToolCount)\b/g,
|
||||
message:
|
||||
'Model Pulse Assistant diagnostics as native Assistant runtime availability, not MCP transport state.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -683,8 +683,7 @@
|
||||
{ "path": "src/components/patrol/ApprovalSection.tsx" },
|
||||
{ "path": "src/components/patrol/InvestigationMessages.tsx" },
|
||||
{ "path": "src/components/patrol/InvestigationSection.tsx" },
|
||||
{ "path": "src/components/patrol/RunToolCallTrace.tsx" },
|
||||
{ "path": "src/features/patrol/PatrolIntelligenceHeader.tsx" }
|
||||
{ "path": "src/components/patrol/RunToolCallTrace.tsx" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
@@ -796,12 +795,6 @@
|
||||
"patterns": [
|
||||
"h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/features/patrol/PatrolIntelligenceHeader.tsx",
|
||||
"patterns": [
|
||||
"animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
@@ -1576,8 +1569,7 @@
|
||||
{ "path": "src/components/shared/FilterToolbar.tsx" },
|
||||
{ "path": "src/features/alerts/AlertAppriseDestinationsSection.tsx" },
|
||||
{ "path": "src/features/alerts/AlertEscalationSection.tsx" },
|
||||
{ "path": "src/features/alerts/AlertQuietHoursSection.tsx" },
|
||||
{ "path": "src/features/patrol/PatrolIntelligenceHeader.tsx" }
|
||||
{ "path": "src/features/alerts/AlertQuietHoursSection.tsx" }
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
@@ -1661,28 +1653,12 @@
|
||||
"requiredConsumers": [
|
||||
{ "path": "src/components/Settings/AgentProfilesPanel.tsx" },
|
||||
{ "path": "src/components/Settings/DiagnosticsResultsPanel.tsx" },
|
||||
{ "path": "src/components/patrol/RunHistoryEntry.tsx" },
|
||||
{ "path": "src/components/patrol/PatrolStatusBar.tsx" },
|
||||
{ "path": "src/features/patrol/PatrolIntelligenceSummary.tsx" }
|
||||
{ "path": "src/components/patrol/RunHistoryEntry.tsx" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/patrol/RunHistoryEntry.tsx",
|
||||
"patterns": ["runStatus.badgeClass"]
|
||||
},
|
||||
{
|
||||
"path": "src/components/patrol/PatrolStatusBar.tsx",
|
||||
"patterns": [
|
||||
"lastRunStatus.badgeClass",
|
||||
"rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/features/patrol/PatrolIntelligenceSummary.tsx",
|
||||
"patterns": [
|
||||
"runtimeShellPresentation().badgeClass",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
@@ -1896,7 +1872,6 @@
|
||||
{ "path": "src/components/patrol/InvestigationSection.tsx" },
|
||||
{ "path": "src/components/patrol/RunHistoryEntry.tsx" },
|
||||
{ "path": "src/components/patrol/RunToolCallTrace.tsx" },
|
||||
{ "path": "src/components/patrol/PatrolStatusBar.tsx" },
|
||||
{ "path": "src/features/proxmox/proxmoxBackupsTableShared.tsx" },
|
||||
{ "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" }
|
||||
],
|
||||
@@ -1953,10 +1928,6 @@
|
||||
"getToolCallResultBadgeClass"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/components/patrol/PatrolStatusBar.tsx",
|
||||
"patterns": ["text-blue-600 dark:text-blue-400\">Findings snapshot unavailable"]
|
||||
},
|
||||
{
|
||||
"path": "src/features/proxmox/proxmoxBackupsTableShared.tsx",
|
||||
"patterns": [
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AGENT_CAPABILITIES_PATH,
|
||||
AGENT_OPERATIONS_LOOP_STATUS_PATH,
|
||||
AGENT_SURFACE_ID_PULSE_MCP,
|
||||
fetchAgentCapabilitiesManifest,
|
||||
fetchAgentOperationsLoopStatus,
|
||||
formatAgentMCPServersConfig,
|
||||
formatAgentOpenCodeMCPConfig,
|
||||
getAgentCapabilityErrorCodeSummaries,
|
||||
getAgentMCPOperationsLoopReadiness,
|
||||
getAgentManifestSurfaceToolContract,
|
||||
getAgentManifestSurfaceToolContracts,
|
||||
getAgentMCPClientExamples,
|
||||
getAgentMCPConfigFamilyByShape,
|
||||
getAgentSurfaceToolPosturePresentation,
|
||||
getAgentSurfaceContractEntries,
|
||||
getAgentWorkflowPrompts,
|
||||
groupAgentCapabilitiesByManifestCategories,
|
||||
normalizeAgentMCPAdapter,
|
||||
normalizeAgentSurfaceToolContract,
|
||||
type AgentCapability,
|
||||
type AgentCapabilitiesManifest,
|
||||
type AgentSurfaceToolContract,
|
||||
} from '../agentCapabilities';
|
||||
import generatedAgentCapabilitiesSource from '@/api/generated/agentCapabilities.ts?raw';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetchJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
const capability = (name: string, category: string): AgentCapability => ({
|
||||
name,
|
||||
category,
|
||||
description: `${name} description`,
|
||||
method: 'GET',
|
||||
path: `/api/${name}`,
|
||||
scope: 'monitoring:read',
|
||||
actionMode: 'read',
|
||||
approvalPolicy: 'scope_only',
|
||||
});
|
||||
|
||||
const surfaceContract = {
|
||||
core: {
|
||||
id: 'pulse_intelligence_core',
|
||||
label: 'Pulse Intelligence Core',
|
||||
description:
|
||||
'Canonical context, governed actions, safety gates, approval state, action audit, and verification.',
|
||||
},
|
||||
proactiveEngine: {
|
||||
id: 'pulse_patrol',
|
||||
label: 'Pulse Patrol',
|
||||
description:
|
||||
'Patrol is the first-party operations surface: it watches, investigates, acts within the chosen Patrol mode, verifies outcomes, and records what happened.',
|
||||
},
|
||||
operatorSurfaces: [
|
||||
{
|
||||
id: 'pulse_assistant',
|
||||
label: 'Pulse Assistant',
|
||||
description:
|
||||
'The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions.',
|
||||
native: true,
|
||||
externalAdapter: false,
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pulse_mcp',
|
||||
label: 'Pulse MCP',
|
||||
description: 'The external-agent adapter.',
|
||||
native: false,
|
||||
externalAdapter: true,
|
||||
affordances: {
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mcpAdapter = {
|
||||
serverName: 'pulse',
|
||||
command: 'pulse-mcp',
|
||||
baseUrlFlag: '--base-url',
|
||||
defaultBaseUrl: 'http://localhost:7655',
|
||||
tokenEnv: 'PULSE_API_TOKEN',
|
||||
configFamilies: [
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
shape: 'opencode_mcp',
|
||||
description: "Uses OpenCode's top-level mcp object.",
|
||||
fileHints: ['opencode.json', 'opencode.jsonc'],
|
||||
clientLabels: ['OpenCode'],
|
||||
},
|
||||
{
|
||||
id: 'claude-style',
|
||||
label: 'Claude-style clients',
|
||||
shape: 'mcp_servers',
|
||||
description: 'Uses the common mcpServers object.',
|
||||
fileHints: ['claude_desktop_config.json', '.mcp.json'],
|
||||
clientLabels: ['Claude Desktop', 'Claude Code'],
|
||||
},
|
||||
{
|
||||
id: 'custom-mcp',
|
||||
label: 'custom MCP clients',
|
||||
shape: 'custom',
|
||||
clientLabels: ['custom MCP clients'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('agent capabilities API client', () => {
|
||||
it('fetches the public manifest through the shared API client', async () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: ['monitoring:read'],
|
||||
categories: [{ id: 'context', label: 'Context (read-only)' }],
|
||||
workflowPrompts: [],
|
||||
capabilities: [capability('get_resource_context', 'context')],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce(manifest);
|
||||
|
||||
await expect(fetchAgentCapabilitiesManifest()).resolves.toBe(manifest);
|
||||
|
||||
expect(apiFetchJSON).toHaveBeenCalledWith(AGENT_CAPABILITIES_PATH, {
|
||||
skipAuth: true,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches the authenticated operations-loop status through the shared API client', async () => {
|
||||
const status = {
|
||||
nextAction: 'open_assistant',
|
||||
progressLabel: 'Open Assistant to explain the Patrol issue and safest next step.',
|
||||
steps: [
|
||||
{ id: 'patrol', label: 'Patrol', status: 'complete', count: 1 },
|
||||
{ id: 'assistant', label: 'Assistant', status: 'current' },
|
||||
{ id: 'governance', label: 'Governance', status: 'pending', count: 0 },
|
||||
{ id: 'verification', label: 'Verification', status: 'pending', count: 0 },
|
||||
],
|
||||
patrolEvidenceCount: 1,
|
||||
patrolIssueEvidenceCount: 1,
|
||||
activeFindingCount: 1,
|
||||
pendingApprovalCount: 0,
|
||||
governedActionCount: 0,
|
||||
approvedDecisionCount: 0,
|
||||
rejectedDecisionCount: 0,
|
||||
verifiedOutcomeCount: 0,
|
||||
operationsLoopStarterCount: 0,
|
||||
assistantOperationsLoopStarterCount: 0,
|
||||
patrolOperationsLoopStarterCount: 0,
|
||||
patrolControlOperationsLoopStarterCount: 0,
|
||||
patrolControlCompletedOperationsLoopCount: 0,
|
||||
patrolControlResolvedOperationsLoopCount: 0,
|
||||
patrolControlValueState: 'not_started',
|
||||
patrolAutonomyOperationsLoopStarterCount: 0,
|
||||
patrolAutonomyCompletedOperationsLoopCount: 0,
|
||||
patrolAutonomyResolvedOperationsLoopCount: 0,
|
||||
patrolAutonomyValueState: 'not_started',
|
||||
proActivationOperationsLoopStarterCount: 0,
|
||||
proActivationCompletedOperationsLoopCount: 0,
|
||||
proActivationResolvedOperationsLoopCount: 0,
|
||||
proActivationValueProofState: 'not_started',
|
||||
mcpOperationsLoopStarterCount: 0,
|
||||
externalAgentReady: true,
|
||||
windowStart: '2026-06-01T00:00:00Z',
|
||||
generatedAt: '2026-06-20T00:00:00Z',
|
||||
} satisfies Awaited<ReturnType<typeof fetchAgentOperationsLoopStatus>>;
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce(status);
|
||||
|
||||
await expect(fetchAgentOperationsLoopStatus()).resolves.toBe(status);
|
||||
|
||||
expect(apiFetchJSON).toHaveBeenCalledWith(AGENT_OPERATIONS_LOOP_STATUS_PATH, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('exports the generated surface tool contract type for Assistant and MCP consumers', () => {
|
||||
const contract: AgentSurfaceToolContract = {
|
||||
surfaceId: 'pulse_assistant',
|
||||
surfaceLabel: 'Pulse Assistant',
|
||||
toolSource: 'assistant_registry',
|
||||
toolNames: ['pulse_query', 'pulse_question'],
|
||||
registryToolNames: ['pulse_query'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
};
|
||||
|
||||
expect(contract.surfaceId).toBe('pulse_assistant');
|
||||
expect(contract.registryToolNames).toEqual(['pulse_query']);
|
||||
expect(contract.nativeToolNames).toEqual(['pulse_question']);
|
||||
});
|
||||
|
||||
it('normalizes runtime surface tool contracts without local tool catalog drift', () => {
|
||||
const normalized = normalizeAgentSurfaceToolContract({
|
||||
surfaceId: ' pulse_assistant ',
|
||||
surfaceLabel: ' Pulse Assistant ',
|
||||
toolSource: ' assistant_registry ',
|
||||
toolNames: ['pulse_query', 'pulse_question', 'pulse_query', ' '],
|
||||
registryToolNames: ['pulse_query', 'pulse_query'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
capabilityNames: [' '],
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
surfaceId: 'pulse_assistant',
|
||||
surfaceLabel: 'Pulse Assistant',
|
||||
toolSource: 'assistant_registry',
|
||||
toolNames: ['pulse_query', 'pulse_question'],
|
||||
registryToolNames: ['pulse_query'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
capabilityNames: undefined,
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('presents Assistant capability posture from the surface contract counts', () => {
|
||||
const presentation = getAgentSurfaceToolPosturePresentation({
|
||||
surfaceId: 'pulse_assistant',
|
||||
surfaceLabel: 'Pulse Assistant',
|
||||
toolSource: 'assistant_registry',
|
||||
toolNames: ['pulse_query', 'pulse_question'],
|
||||
registryToolNames: ['pulse_query'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
});
|
||||
|
||||
expect(presentation).toMatchObject({
|
||||
label: '2 capabilities',
|
||||
tone: 'ready',
|
||||
toolCount: 2,
|
||||
});
|
||||
expect(presentation?.title).toContain('Pulse Assistant capability availability');
|
||||
expect(presentation?.title).toContain('Source: Assistant registry');
|
||||
expect(presentation?.detail).toContain('1 registry capability');
|
||||
expect(presentation?.detail).toContain('1 native capability');
|
||||
expect(presentation?.detail).toContain('Interactive questions');
|
||||
});
|
||||
|
||||
it('uses manifest-owned external-adapter surface tool contracts before local fallback', () => {
|
||||
const surfaceContractWithCLI = {
|
||||
...surfaceContract,
|
||||
operatorSurfaces: [
|
||||
...surfaceContract.operatorSurfaces,
|
||||
{
|
||||
id: 'pulse_cli_agent',
|
||||
label: 'Pulse CLI Agent',
|
||||
description: 'Another external-agent adapter.',
|
||||
native: false,
|
||||
externalAdapter: true,
|
||||
affordances: {
|
||||
tools: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract: surfaceContractWithCLI,
|
||||
surfaceToolContracts: [
|
||||
{
|
||||
surfaceId: 'pulse_mcp',
|
||||
surfaceLabel: 'Pulse MCP',
|
||||
toolSource: 'capability_manifest',
|
||||
toolNames: ['get_fleet_context'],
|
||||
capabilityNames: ['get_fleet_context'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
surfaceId: 'pulse_cli_agent',
|
||||
surfaceLabel: 'Pulse CLI Agent',
|
||||
toolSource: 'capability_manifest',
|
||||
toolNames: ['plan_action'],
|
||||
capabilityNames: ['plan_action'],
|
||||
},
|
||||
],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [
|
||||
capability('get_fleet_context', 'context'),
|
||||
capability('plan_action', 'actions'),
|
||||
],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
const mcp = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP);
|
||||
expect(mcp?.toolNames).toEqual(['get_fleet_context']);
|
||||
expect(mcp?.capabilityNames).toEqual(['get_fleet_context']);
|
||||
|
||||
const contracts = getAgentManifestSurfaceToolContracts(manifest);
|
||||
expect(contracts.map((contract) => contract.surfaceId)).toEqual([
|
||||
'pulse_mcp',
|
||||
'pulse_cli_agent',
|
||||
]);
|
||||
expect(contracts[1]?.toolNames).toEqual(['plan_action']);
|
||||
});
|
||||
|
||||
it('does not infer external surface tools when stale payloads omit surface tool contracts', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [
|
||||
capability('get_fleet_context', 'context'),
|
||||
{
|
||||
...capability('subscribe_events', 'events'),
|
||||
description: 'Subscribe to Pulse Intelligence events.',
|
||||
},
|
||||
capability('plan_action', 'actions'),
|
||||
],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
const contract = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP);
|
||||
|
||||
expect(contract).toBeUndefined();
|
||||
expect(getAgentSurfaceToolPosturePresentation(contract)).toBeNull();
|
||||
});
|
||||
|
||||
it('gates published external surface tool posture by the manifest surface affordance', () => {
|
||||
const disabledToolSurfaceContract = {
|
||||
...surfaceContract,
|
||||
operatorSurfaces: surfaceContract.operatorSurfaces.map((surface) =>
|
||||
surface.id === 'pulse_mcp'
|
||||
? {
|
||||
...surface,
|
||||
affordances: {
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
}
|
||||
: surface,
|
||||
),
|
||||
};
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract: disabledToolSurfaceContract,
|
||||
surfaceToolContracts: [
|
||||
{
|
||||
surfaceId: 'pulse_mcp',
|
||||
surfaceLabel: 'Pulse MCP',
|
||||
toolSource: 'capability_manifest',
|
||||
toolNames: ['get_fleet_context'],
|
||||
capabilityNames: ['get_fleet_context'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [capability('get_fleet_context', 'context')],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
const contract = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP);
|
||||
|
||||
expect(contract).toMatchObject({
|
||||
surfaceId: 'pulse_mcp',
|
||||
surfaceLabel: 'Pulse MCP',
|
||||
toolSource: 'capability_manifest',
|
||||
toolNames: [],
|
||||
capabilityNames: undefined,
|
||||
affordances: {
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const presentation = getAgentSurfaceToolPosturePresentation(contract);
|
||||
expect(presentation).toMatchObject({
|
||||
label: '0 capabilities',
|
||||
tone: 'empty',
|
||||
toolCount: 0,
|
||||
});
|
||||
expect(presentation?.detail).toContain('Resources');
|
||||
expect(presentation?.detail).not.toContain('published capability');
|
||||
});
|
||||
|
||||
it('does not project native Assistant runtime tools from the static manifest', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [capability('get_fleet_context', 'context')],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
expect(getAgentManifestSurfaceToolContract(manifest, 'pulse_assistant')).toBeUndefined();
|
||||
expect(getAgentManifestSurfaceToolContract(manifest, 'missing_surface')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses manifest-owned category order and presentation copy', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: ['monitoring:read', 'ai:execute'],
|
||||
categories: [
|
||||
{
|
||||
id: 'context',
|
||||
label: 'Context (read-only)',
|
||||
description: 'Discovery and read-only situated reads. Agents start here.',
|
||||
},
|
||||
{
|
||||
id: 'action',
|
||||
label: 'Actions (governed plan/approval/execute)',
|
||||
description: 'Plan, approve, and execute capability invocations against a resource.',
|
||||
},
|
||||
],
|
||||
capabilities: [
|
||||
capability('plan_action', 'action'),
|
||||
{
|
||||
...capability('get_patrol_control_status', 'context'),
|
||||
description:
|
||||
'Return Patrol mode completed/resolved outcome status and compatibility aliases.',
|
||||
},
|
||||
],
|
||||
workflowPrompts: [],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
const sections = groupAgentCapabilitiesByManifestCategories(manifest);
|
||||
|
||||
expect(sections.map((section) => section.id)).toEqual(['context', 'action']);
|
||||
expect(sections.map((section) => section.label)).toEqual([
|
||||
'Context (read-only)',
|
||||
'Actions (governed plan/approval/execute)',
|
||||
]);
|
||||
expect(sections[0]?.description).toBe(
|
||||
'Discovery and read-only situated reads. Agents start here.',
|
||||
);
|
||||
expect(sections[0]?.entries.map((entry) => entry.name)).toEqual(['get_patrol_control_status']);
|
||||
expect(sections[0]?.entries[0]?.description).toBe(
|
||||
'Return Patrol mode completed/resolved outcome status and compatibility aliases.',
|
||||
);
|
||||
expect(sections[1]?.entries.map((entry) => entry.name)).toEqual(['plan_action']);
|
||||
});
|
||||
|
||||
it('keeps older or extended manifests visible without a local category table', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [
|
||||
capability('future_tool', 'future-category'),
|
||||
capability('uncategorized_tool', ' '),
|
||||
],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
const sections = groupAgentCapabilitiesByManifestCategories(manifest);
|
||||
|
||||
expect(sections.map((section) => [section.id, section.label])).toEqual([
|
||||
['future-category', 'future-category'],
|
||||
['uncategorized', 'Uncategorized'],
|
||||
]);
|
||||
expect(sections.flatMap((section) => section.entries.map((entry) => entry.name))).toEqual([
|
||||
'future_tool',
|
||||
'uncategorized_tool',
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects the manifest-owned Pulse Intelligence surface contract for settings UI', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
const entries = getAgentSurfaceContractEntries(manifest);
|
||||
|
||||
expect(entries.map((entry) => entry.label)).toEqual([
|
||||
'Pulse Intelligence Core',
|
||||
'Pulse Patrol',
|
||||
'Pulse Assistant',
|
||||
'Pulse MCP',
|
||||
]);
|
||||
expect(entries.map((entry) => entry.description)).toEqual([
|
||||
'Canonical context, governed actions, safety gates, approval state, action audit, and verification.',
|
||||
'Patrol is the first-party operations surface: it watches, investigates, acts within the chosen Patrol mode, verifies outcomes, and records what happened.',
|
||||
'The contextual explanation, approval, and handoff surface for Patrol findings, governed actions, verification, and operator questions.',
|
||||
'The external-agent adapter.',
|
||||
]);
|
||||
expect(entries[2]?.badges).toEqual(['Native surface', 'Actions', 'Interactive questions']);
|
||||
expect(entries[3]?.badges).toEqual([
|
||||
'External adapter',
|
||||
'Actions',
|
||||
'Resources',
|
||||
'Prompts',
|
||||
'Capability metadata',
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects manifest-owned workflow prompts for Assistant and MCP consumers', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [
|
||||
{
|
||||
name: ' pulse_triage_fleet ',
|
||||
label: ' Triage fleet ',
|
||||
presentationKind: ' fleet ',
|
||||
description: ' Triage the fleet. ',
|
||||
},
|
||||
{
|
||||
name: 'pulse_investigate_resource',
|
||||
description: ' Investigate a resource. ',
|
||||
arguments: [
|
||||
{
|
||||
name: ' resourceId ',
|
||||
description: ' Canonical resource id. ',
|
||||
required: true,
|
||||
},
|
||||
{ name: ' ', description: 'ignored' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: ' ',
|
||||
description: 'ignored',
|
||||
},
|
||||
],
|
||||
capabilities: [],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
expect(getAgentWorkflowPrompts(manifest)).toEqual([
|
||||
{
|
||||
name: 'pulse_triage_fleet',
|
||||
label: 'Triage fleet',
|
||||
presentationKind: 'fleet',
|
||||
description: 'Triage the fleet.',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'pulse_investigate_resource',
|
||||
description: 'Investigate a resource.',
|
||||
arguments: [
|
||||
{
|
||||
name: 'resourceId',
|
||||
description: 'Canonical resource id.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives Pulse MCP Patrol-control readiness from prompts and surface tools', () => {
|
||||
const patrolControlToolNames = [
|
||||
'get_patrol_control_status',
|
||||
'get_fleet_context',
|
||||
'get_resource_context',
|
||||
'list_findings',
|
||||
'plan_action',
|
||||
'decide_action',
|
||||
'execute_action',
|
||||
'resolve_finding',
|
||||
];
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [
|
||||
{
|
||||
surfaceId: 'pulse_mcp',
|
||||
surfaceLabel: 'Pulse MCP',
|
||||
toolSource: 'capability_manifest',
|
||||
toolNames: patrolControlToolNames,
|
||||
capabilityNames: patrolControlToolNames,
|
||||
affordances: {
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [{ name: 'pulse_operations_loop', label: 'Ask Patrol to handle an issue' }],
|
||||
capabilities: [],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
expect(getAgentMCPOperationsLoopReadiness(manifest)).toEqual({
|
||||
available: true,
|
||||
hasAdapter: true,
|
||||
hasPrompt: true,
|
||||
hasSurfaceTools: true,
|
||||
missingCapabilities: [],
|
||||
});
|
||||
|
||||
const baseMCPContract = manifest.surfaceToolContracts[0]!;
|
||||
const incomplete = {
|
||||
...manifest,
|
||||
surfaceToolContracts: [
|
||||
{
|
||||
...baseMCPContract,
|
||||
toolNames: patrolControlToolNames.filter((name) => name !== 'execute_action'),
|
||||
capabilityNames: patrolControlToolNames.filter((name) => name !== 'execute_action'),
|
||||
},
|
||||
],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
expect(getAgentMCPOperationsLoopReadiness(incomplete)).toMatchObject({
|
||||
available: false,
|
||||
hasAdapter: true,
|
||||
hasPrompt: true,
|
||||
hasSurfaceTools: false,
|
||||
missingCapabilities: ['execute_action'],
|
||||
});
|
||||
});
|
||||
|
||||
it('projects unique stable error codes with their declaring capabilities', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [
|
||||
{
|
||||
...capability('get_resource_context', 'context'),
|
||||
errorCodes: ['resource_not_found', ''],
|
||||
},
|
||||
{
|
||||
...capability('plan_action', 'actions'),
|
||||
errorCodes: [' resource_not_found ', 'action_execution_unavailable'],
|
||||
},
|
||||
{
|
||||
...capability('execute_action', 'actions'),
|
||||
errorCodes: ['action_execution_unavailable'],
|
||||
},
|
||||
],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
expect(getAgentCapabilityErrorCodeSummaries(manifest)).toEqual([
|
||||
{
|
||||
code: 'resource_not_found',
|
||||
capabilityNames: ['get_resource_context', 'plan_action'],
|
||||
},
|
||||
{
|
||||
code: 'action_execution_unavailable',
|
||||
capabilityNames: ['plan_action', 'execute_action'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits empty surface-contract entries from older manifests', () => {
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract: {
|
||||
core: { id: '', label: '', description: '' },
|
||||
proactiveEngine: { id: '', label: ' ', description: '' },
|
||||
operatorSurfaces: [
|
||||
{
|
||||
id: 'pulse_mcp',
|
||||
label: 'Pulse MCP',
|
||||
description: ' ',
|
||||
native: false,
|
||||
externalAdapter: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
expect(getAgentSurfaceContractEntries(manifest)).toEqual([
|
||||
{
|
||||
id: 'pulse_mcp',
|
||||
label: 'Pulse MCP',
|
||||
description: undefined,
|
||||
badges: ['External adapter', 'Actions', 'Resources', 'Prompts', 'Capability metadata'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects manifest-owned MCP adapter setup for settings UI snippets', () => {
|
||||
const customAdapter = {
|
||||
...mcpAdapter,
|
||||
serverName: 'pulse-prod',
|
||||
command: 'pulse-mcp-prod',
|
||||
baseUrlFlag: '--pulse-url',
|
||||
defaultBaseUrl: 'https://pulse.example.com',
|
||||
tokenEnv: 'PULSE_PROD_TOKEN',
|
||||
configFamilies: [
|
||||
{
|
||||
id: 'custom-opencode',
|
||||
label: 'Custom OpenCode',
|
||||
shape: 'opencode_mcp',
|
||||
clientLabels: ['OpenCode'],
|
||||
},
|
||||
{
|
||||
id: 'custom-mcp-servers',
|
||||
label: 'Custom MCP servers',
|
||||
shape: 'mcp_servers',
|
||||
clientLabels: ['Claude Desktop', 'Claude Code'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const manifest = {
|
||||
version: 'v1',
|
||||
surfaceContract,
|
||||
surfaceToolContracts: [],
|
||||
mcpAdapter: customAdapter,
|
||||
requiredScopes: [],
|
||||
categories: [],
|
||||
workflowPrompts: [],
|
||||
capabilities: [],
|
||||
} satisfies AgentCapabilitiesManifest;
|
||||
|
||||
expect(normalizeAgentMCPAdapter(manifest.mcpAdapter)).toMatchObject({
|
||||
serverName: 'pulse-prod',
|
||||
command: 'pulse-mcp-prod',
|
||||
baseUrlFlag: '--pulse-url',
|
||||
tokenEnv: 'PULSE_PROD_TOKEN',
|
||||
});
|
||||
expect(getAgentMCPClientExamples(manifest)).toEqual([
|
||||
'OpenCode',
|
||||
'Claude Desktop',
|
||||
'Claude Code',
|
||||
]);
|
||||
expect(getAgentMCPConfigFamilyByShape(manifest.mcpAdapter, 'mcp_servers')?.label).toBe(
|
||||
'Custom MCP servers',
|
||||
);
|
||||
|
||||
expect(formatAgentOpenCodeMCPConfig(manifest.mcpAdapter, 'https://pulse.example.com')).toBe(
|
||||
JSON.stringify(
|
||||
{
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
mcp: {
|
||||
'pulse-prod': {
|
||||
type: 'local',
|
||||
command: ['pulse-mcp-prod', '--pulse-url', 'https://pulse.example.com'],
|
||||
enabled: true,
|
||||
environment: {
|
||||
PULSE_PROD_TOKEN: '<your-api-token>',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
expect(formatAgentMCPServersConfig(manifest.mcpAdapter, 'https://pulse.example.com')).toBe(
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
'pulse-prod': {
|
||||
command: 'pulse-mcp-prod',
|
||||
args: ['--pulse-url', 'https://pulse.example.com'],
|
||||
env: {
|
||||
PULSE_PROD_TOKEN: '<your-api-token>',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses generated backend manifest types for the frontend contract', () => {
|
||||
expect(generatedAgentCapabilitiesSource).toContain(
|
||||
'// Source: internal/agentcapabilities manifest structs.',
|
||||
);
|
||||
expect(generatedAgentCapabilitiesSource).toContain('export interface Manifest');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('surfaceContract: SurfaceContract');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('mcpAdapter: MCPAdapterContract');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('workflowPrompts: PulseWorkflowPrompt[]');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('export interface MCPAdapterContract');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('export interface PulseWorkflowPrompt');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('label?: string');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('arguments?: PulseWorkflowPromptArgument[]');
|
||||
expect(generatedAgentCapabilitiesSource).toContain(
|
||||
'export interface SurfaceAffordanceContract',
|
||||
);
|
||||
expect(generatedAgentCapabilitiesSource).toContain('affordances?: SurfaceAffordanceContract');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('configFamilies: MCPAdapterConfigFamily[]');
|
||||
expect(generatedAgentCapabilitiesSource).toContain(
|
||||
'operatorSurfaces: OperatorSurfaceContract[]',
|
||||
);
|
||||
expect(generatedAgentCapabilitiesSource).toContain('actionMode: AgentCapabilityActionMode');
|
||||
expect(generatedAgentCapabilitiesSource).toContain(
|
||||
'approvalPolicy: AgentCapabilityApprovalPolicy',
|
||||
);
|
||||
expect(generatedAgentCapabilitiesSource).toContain('inputSchema?: Record<string, unknown>');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('export interface SurfaceToolContract');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('surfaceId: string');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('toolSource: string');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('toolNames: string[]');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('registryToolNames?: string[]');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('capabilityNames?: string[]');
|
||||
expect(generatedAgentCapabilitiesSource).toContain('nativeToolNames?: string[]');
|
||||
});
|
||||
});
|
||||
@@ -406,16 +406,16 @@ describe('AgentProfilesAPI', () => {
|
||||
const mockResponse = { ok: false, status: 503 } as unknown as Response;
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce(mockResponse);
|
||||
vi.mocked(assertAPIResponseOKOrThrowStatus).mockRejectedValueOnce(
|
||||
new Error('Pulse Assistant service is not available. Please check Pulse Assistant settings.'),
|
||||
new Error('Pulse Intelligence is not available. Please check Assistant & Patrol settings.'),
|
||||
);
|
||||
|
||||
await expect(AgentProfilesAPI.suggestProfile({ prompt: 'test' })).rejects.toThrow(
|
||||
'Pulse Assistant service is not available',
|
||||
'Pulse Intelligence is not available',
|
||||
);
|
||||
expect(assertAPIResponseOKOrThrowStatus).toHaveBeenCalledWith(
|
||||
mockResponse,
|
||||
503,
|
||||
'Pulse Assistant service is not available. Please check Pulse Assistant settings.',
|
||||
'Pulse Intelligence is not available. Please check Assistant & Patrol settings.',
|
||||
'Failed to get suggestion: 503',
|
||||
);
|
||||
});
|
||||
@@ -431,7 +431,7 @@ describe('AgentProfilesAPI', () => {
|
||||
expect(assertAPIResponseOKOrThrowStatus).toHaveBeenCalledWith(
|
||||
mockResponse,
|
||||
503,
|
||||
'Pulse Assistant service is not available. Please check Pulse Assistant settings.',
|
||||
'Pulse Intelligence is not available. Please check Assistant & Patrol settings.',
|
||||
'Failed to get suggestion: 500',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,12 @@ vi.mock('@/utils/logger', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { AIChatAPI, createAIChatStreamPaintCheckpointPredicate } from '@/api/aiChat';
|
||||
import {
|
||||
AIChatAPI,
|
||||
PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME,
|
||||
PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE,
|
||||
createAIChatStreamPaintCheckpointPredicate,
|
||||
} from '@/api/aiChat';
|
||||
import {
|
||||
AI_CHAT_DEV_STREAM_FIXTURE_ALIAS_NAMES,
|
||||
AI_CHAT_DEV_STREAM_FIXTURE_NAMES,
|
||||
@@ -104,6 +109,86 @@ describe('AIChatAPI', () => {
|
||||
expect(shouldYield({ type: 'thinking' })).toBe(false);
|
||||
});
|
||||
|
||||
it('renders native Assistant workflow prompts through the shared AI route', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
description: 'Pulse fleet triage',
|
||||
text: 'Use get_fleet_context...',
|
||||
});
|
||||
|
||||
await expect(
|
||||
AIChatAPI.renderWorkflowPrompt({
|
||||
name: 'pulse_triage_fleet',
|
||||
arguments: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
description: 'Pulse fleet triage',
|
||||
text: 'Use get_fleet_context...',
|
||||
});
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/workflow-prompts/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'pulse_triage_fleet', arguments: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
it('records first-party workflow prompt activity through the shared AI route', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
await expect(
|
||||
AIChatAPI.recordWorkflowPromptActivity({
|
||||
name: 'pulse_operations_loop',
|
||||
surface: 'pulse_patrol',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/workflow-prompts/activity', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'pulse_operations_loop', surface: 'pulse_patrol' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('records Patrol control workflow prompt activity through the shared AI route', async () => {
|
||||
apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
await expect(
|
||||
AIChatAPI.recordWorkflowPromptActivity({
|
||||
name: PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME,
|
||||
surface: PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/workflow-prompts/activity', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'pulse_operations_loop',
|
||||
surface: 'patrol_control',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches the live native Assistant surface tool contract through the shared AI route', async () => {
|
||||
const contract = {
|
||||
surfaceId: 'pulse_assistant',
|
||||
surfaceLabel: 'Pulse Assistant',
|
||||
toolSource: 'assistant_registry',
|
||||
toolNames: ['pulse_query', 'pulse_question'],
|
||||
registryToolNames: ['pulse_query'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
};
|
||||
apiFetchJSONMock.mockResolvedValueOnce(contract);
|
||||
|
||||
await expect(AIChatAPI.getAssistantSurfaceTools()).resolves.toEqual(contract);
|
||||
|
||||
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/assistant/surface-tools');
|
||||
});
|
||||
|
||||
it('runs the local dev stream fixture without opening a provider request', async () => {
|
||||
const onEvent = vi.fn();
|
||||
|
||||
@@ -396,12 +481,7 @@ describe('AIChatAPI', () => {
|
||||
it('runs the tool-chain dev stream fixture without opening a provider request', async () => {
|
||||
const onEvent = vi.fn();
|
||||
|
||||
await AIChatAPI.chat(
|
||||
'/fixture tool-chain',
|
||||
undefined,
|
||||
'openrouter:qwen/qwen3.7-plus',
|
||||
onEvent,
|
||||
);
|
||||
await AIChatAPI.chat('/fixture tool-chain', undefined, 'openrouter:qwen/qwen3.7-plus', onEvent);
|
||||
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([
|
||||
@@ -464,20 +544,10 @@ describe('AIChatAPI', () => {
|
||||
expect(eventTypes()).toEqual(['session']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(75);
|
||||
expect(eventTypes()).toEqual([
|
||||
'session',
|
||||
'workflow_state',
|
||||
'workflow_state',
|
||||
'tool_start',
|
||||
]);
|
||||
expect(eventTypes()).toEqual(['session', 'workflow_state', 'workflow_state', 'tool_start']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1399);
|
||||
expect(eventTypes()).toEqual([
|
||||
'session',
|
||||
'workflow_state',
|
||||
'workflow_state',
|
||||
'tool_start',
|
||||
]);
|
||||
expect(eventTypes()).toEqual(['session', 'workflow_state', 'workflow_state', 'tool_start']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(eventTypes()).toEqual([
|
||||
@@ -1414,9 +1484,7 @@ describe('AIChatAPI', () => {
|
||||
|
||||
it('rejects stalled chat stream reads instead of emitting synthetic completion', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
const read = vi.fn(
|
||||
() => new Promise<ReadableStreamReadResult<Uint8Array>>(() => undefined),
|
||||
);
|
||||
const read = vi.fn(() => new Promise<ReadableStreamReadResult<Uint8Array>>(() => undefined));
|
||||
const releaseLock = vi.fn();
|
||||
const onEvent = vi.fn();
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('AI chat stream event contract', () => {
|
||||
expect(event.data.id).toBe('sess-stream');
|
||||
expect(aiChatEventsSource).toContain('export interface SessionData');
|
||||
expect(aiChatEventsSource).toContain("type: 'session'");
|
||||
expect(aiChatEventsSource).toContain('context_limit_tokens?: number');
|
||||
});
|
||||
|
||||
it('does not expose automatic provider fallback metadata on workflow state events', () => {
|
||||
|
||||
@@ -87,6 +87,10 @@ describe('patrol utilities', () => {
|
||||
expect(investigationOutcomeLabels.fix_failed).toBe('Fix Failed');
|
||||
});
|
||||
|
||||
it('has correct label for fix_rejected', () => {
|
||||
expect(investigationOutcomeLabels.fix_rejected).toBe('Fix Rejected');
|
||||
});
|
||||
|
||||
it('has correct label for needs_attention', () => {
|
||||
expect(investigationOutcomeLabels.needs_attention).toBe('Needs Attention');
|
||||
});
|
||||
@@ -125,6 +129,10 @@ describe('patrol utilities', () => {
|
||||
expect(investigationOutcomeColors.fix_failed).toContain('red');
|
||||
});
|
||||
|
||||
it('has amber colors for fix_rejected', () => {
|
||||
expect(investigationOutcomeColors.fix_rejected).toContain('amber');
|
||||
});
|
||||
|
||||
it('has amber colors for needs_attention', () => {
|
||||
expect(investigationOutcomeColors.needs_attention).toContain('amber');
|
||||
});
|
||||
|
||||
@@ -1,11 +1,113 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { SettingsAPI, type SystemSettingsResponse } from '../settings';
|
||||
import { SettingsAPI, type SystemSettingsResponse, type TelemetryPingPreview } from '../settings';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetchJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTelemetryPreviewPayload = {
|
||||
install_id: 'preview-install-id',
|
||||
version: '6.0.0',
|
||||
version_channel: 'stable',
|
||||
version_is_development: false,
|
||||
version_is_published_release: true,
|
||||
platform: 'docker',
|
||||
os: 'linux',
|
||||
arch: 'amd64',
|
||||
event: 'heartbeat',
|
||||
pve_nodes: 1,
|
||||
pbs_instances: 0,
|
||||
pmg_instances: 0,
|
||||
vms: 2,
|
||||
containers: 3,
|
||||
agent_hosts: 1,
|
||||
docker_hosts: 0,
|
||||
docker_containers: 0,
|
||||
kubernetes_clusters: 0,
|
||||
kubernetes_nodes: 0,
|
||||
kubernetes_pods: 0,
|
||||
kubernetes_deployments: 0,
|
||||
storage_pools: 1,
|
||||
physical_disks: 2,
|
||||
ceph_clusters: 0,
|
||||
network_shares: 0,
|
||||
truenas_systems: 0,
|
||||
truenas_vms: 0,
|
||||
truenas_apps: 0,
|
||||
vmware_hosts: 0,
|
||||
vmware_vms: 0,
|
||||
vmware_datastores: 0,
|
||||
availability_targets: 1,
|
||||
ai_enabled: false,
|
||||
patrol_enabled: false,
|
||||
discovery_enabled: false,
|
||||
notifications_enabled: false,
|
||||
ai_actions_enabled: false,
|
||||
active_alerts: 0,
|
||||
relay_enabled: false,
|
||||
sso_enabled: false,
|
||||
multi_tenant: false,
|
||||
paid_license: false,
|
||||
has_api_tokens: true,
|
||||
pulse_intelligence_loop_configured: false,
|
||||
pulse_intelligence_loop_active_30d: false,
|
||||
pulse_intelligence_complete_operations_loop_30d: false,
|
||||
pulse_intelligence_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_paid_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_paid_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_paid_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_paid_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_governed_action_active_30d: false,
|
||||
pulse_intelligence_assistant_operations_loop_30d: false,
|
||||
pulse_intelligence_assistant_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_assistant_approved_action_success_loop_30d: false,
|
||||
pulse_intelligence_assistant_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_external_agent_operations_loop_30d: false,
|
||||
pulse_intelligence_external_agent_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_external_agent_approved_action_success_loop_30d: false,
|
||||
pulse_intelligence_external_agent_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_operations_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_approved_action_success_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_assistant_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_patrol_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_patrol_control_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_pro_activation_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_mcp_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_assistant_ai_calls_30d: 0,
|
||||
pulse_intelligence_assistant_context_ai_calls_30d: 0,
|
||||
pulse_intelligence_assistant_tool_calls_30d: 0,
|
||||
pulse_intelligence_patrol_ai_calls_30d: 0,
|
||||
pulse_intelligence_patrol_runs_30d: 0,
|
||||
pulse_intelligence_patrol_new_findings_30d: 0,
|
||||
pulse_intelligence_patrol_investigations_30d: 0,
|
||||
pulse_intelligence_patrol_resolved_findings_30d: 0,
|
||||
pulse_intelligence_patrol_autofixes_30d: 0,
|
||||
pulse_intelligence_external_agent_enabled: false,
|
||||
pulse_intelligence_external_agent_used_30d: false,
|
||||
pulse_intelligence_mcp_adapter_used_30d: false,
|
||||
pulse_intelligence_external_agent_context_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_event_stream_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_provisioning_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_operator_state_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_finding_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_action_requests_30d: 0,
|
||||
pulse_intelligence_action_plans_30d: 0,
|
||||
pulse_intelligence_approval_requests_30d: 0,
|
||||
pulse_intelligence_rejected_action_decisions_30d: 0,
|
||||
pulse_intelligence_approved_action_decisions_30d: 0,
|
||||
pulse_intelligence_approved_action_attempts_30d: 0,
|
||||
pulse_intelligence_approved_action_successes_30d: 0,
|
||||
} satisfies TelemetryPingPreview;
|
||||
|
||||
describe('SettingsAPI', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -46,51 +148,7 @@ describe('SettingsAPI', () => {
|
||||
it('fetches the telemetry preview payload', async () => {
|
||||
const mockPreview = {
|
||||
enabled: true,
|
||||
payload: {
|
||||
install_id: 'preview-install-id',
|
||||
version: '6.0.0',
|
||||
version_channel: 'stable',
|
||||
version_is_development: false,
|
||||
version_is_published_release: true,
|
||||
platform: 'docker',
|
||||
os: 'linux',
|
||||
arch: 'amd64',
|
||||
event: 'heartbeat',
|
||||
pve_nodes: 1,
|
||||
pbs_instances: 0,
|
||||
pmg_instances: 0,
|
||||
vms: 2,
|
||||
containers: 3,
|
||||
agent_hosts: 1,
|
||||
docker_hosts: 0,
|
||||
docker_containers: 0,
|
||||
kubernetes_clusters: 0,
|
||||
kubernetes_nodes: 0,
|
||||
kubernetes_pods: 0,
|
||||
kubernetes_deployments: 0,
|
||||
storage_pools: 1,
|
||||
physical_disks: 2,
|
||||
ceph_clusters: 0,
|
||||
network_shares: 0,
|
||||
truenas_systems: 0,
|
||||
truenas_vms: 0,
|
||||
truenas_apps: 0,
|
||||
vmware_hosts: 0,
|
||||
vmware_vms: 0,
|
||||
vmware_datastores: 0,
|
||||
availability_targets: 1,
|
||||
ai_enabled: false,
|
||||
patrol_enabled: false,
|
||||
discovery_enabled: false,
|
||||
notifications_enabled: false,
|
||||
ai_actions_enabled: false,
|
||||
active_alerts: 0,
|
||||
relay_enabled: false,
|
||||
sso_enabled: false,
|
||||
multi_tenant: false,
|
||||
paid_license: false,
|
||||
has_api_tokens: true,
|
||||
},
|
||||
payload: mockTelemetryPreviewPayload,
|
||||
};
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce(mockPreview);
|
||||
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import type {
|
||||
AgentCapabilityActionMode,
|
||||
AgentCapabilityApprovalPolicy,
|
||||
Capability,
|
||||
CapabilityCategory,
|
||||
Manifest,
|
||||
MCPAdapterConfigFamily,
|
||||
MCPAdapterContract,
|
||||
OperatorSurfaceContract,
|
||||
PulseWorkflowPrompt,
|
||||
PulseWorkflowPromptArgument,
|
||||
SurfaceAffordanceContract,
|
||||
SurfaceContractComponent,
|
||||
SurfaceToolContract,
|
||||
} from './generated/agentCapabilities';
|
||||
|
||||
export const AGENT_CAPABILITIES_PATH = '/api/agent/capabilities';
|
||||
export const AGENT_PATROL_CONTROL_STATUS_PATH = '/api/agent/patrol-control/status';
|
||||
export const AGENT_OPERATIONS_LOOP_STATUS_PATH = AGENT_PATROL_CONTROL_STATUS_PATH;
|
||||
|
||||
export type { AgentCapabilityActionMode, AgentCapabilityApprovalPolicy };
|
||||
export type AgentCapability = Capability;
|
||||
export type AgentCapabilityCategory = CapabilityCategory;
|
||||
export type AgentCapabilitiesManifest = Manifest;
|
||||
export type AgentMCPAdapterConfigFamily = MCPAdapterConfigFamily;
|
||||
export type AgentMCPAdapterContract = MCPAdapterContract;
|
||||
export type AgentOperatorSurfaceContract = OperatorSurfaceContract;
|
||||
export type AgentWorkflowPrompt = PulseWorkflowPrompt;
|
||||
export type AgentWorkflowPromptArgument = PulseWorkflowPromptArgument;
|
||||
export type AgentSurfaceAffordanceContract = SurfaceAffordanceContract;
|
||||
export type AgentSurfaceContractComponent = SurfaceContractComponent;
|
||||
export type AgentSurfaceToolContract = SurfaceToolContract;
|
||||
|
||||
export interface AgentCapabilitySection {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
entries: AgentCapability[];
|
||||
}
|
||||
|
||||
export interface AgentSurfaceContractEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
badges: string[];
|
||||
}
|
||||
|
||||
export interface AgentCapabilityErrorCodeSummary {
|
||||
code: string;
|
||||
capabilityNames: string[];
|
||||
}
|
||||
|
||||
export interface AgentSurfaceToolPosturePresentation {
|
||||
surfaceLabel: string;
|
||||
label: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
tone: 'ready' | 'empty';
|
||||
toolCount: number;
|
||||
}
|
||||
|
||||
export type AgentOperationsLoopNextAction =
|
||||
| 'run_patrol'
|
||||
| 'review_findings'
|
||||
| 'open_assistant'
|
||||
| 'review_approvals'
|
||||
| 'open_mcp'
|
||||
| 'complete';
|
||||
|
||||
export type AgentOperationsLoopStepStatus = 'complete' | 'current' | 'pending';
|
||||
|
||||
export type AgentOperationsLoopProActivationValueProofState =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'governed_decision_recorded'
|
||||
| 'verified_needs_mcp'
|
||||
| 'verified';
|
||||
|
||||
export type AgentOperationsLoopPatrolControlValueState =
|
||||
AgentOperationsLoopProActivationValueProofState;
|
||||
|
||||
export type AgentOperationsLoopPatrolAutonomyValueState =
|
||||
AgentOperationsLoopPatrolControlValueState;
|
||||
|
||||
export interface AgentOperationsLoopStep {
|
||||
id: 'patrol' | 'assistant' | 'governance' | 'verification';
|
||||
label: string;
|
||||
status: AgentOperationsLoopStepStatus;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface AgentOperationsLoopStatus {
|
||||
nextAction: AgentOperationsLoopNextAction;
|
||||
progressLabel: string;
|
||||
steps: AgentOperationsLoopStep[];
|
||||
patrolEvidenceCount: number;
|
||||
patrolIssueEvidenceCount: number;
|
||||
activeFindingCount: number;
|
||||
pendingApprovalCount: number;
|
||||
governedActionCount: number;
|
||||
approvedDecisionCount: number;
|
||||
rejectedDecisionCount: number;
|
||||
verifiedOutcomeCount: number;
|
||||
operationsLoopStarterCount: number;
|
||||
assistantOperationsLoopStarterCount: number;
|
||||
patrolOperationsLoopStarterCount: number;
|
||||
patrolControlOperationsLoopStarterCount?: number;
|
||||
patrolControlCompletedOperationsLoopCount?: number;
|
||||
patrolControlResolvedOperationsLoopCount?: number;
|
||||
patrolControlValueState?: AgentOperationsLoopPatrolControlValueState;
|
||||
patrolAutonomyOperationsLoopStarterCount?: number;
|
||||
patrolAutonomyCompletedOperationsLoopCount?: number;
|
||||
patrolAutonomyResolvedOperationsLoopCount?: number;
|
||||
patrolAutonomyValueState?: AgentOperationsLoopPatrolAutonomyValueState;
|
||||
proActivationOperationsLoopStarterCount?: number;
|
||||
proActivationCompletedOperationsLoopCount?: number;
|
||||
proActivationResolvedOperationsLoopCount?: number;
|
||||
proActivationValueProofState?: AgentOperationsLoopProActivationValueProofState;
|
||||
mcpOperationsLoopStarterCount: number;
|
||||
externalAgentReady: boolean;
|
||||
windowStart: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export const AGENT_MCP_TOKEN_PLACEHOLDER = '<your-api-token>';
|
||||
export const AGENT_SURFACE_ID_PULSE_MCP = 'pulse_mcp';
|
||||
export const AGENT_WORKFLOW_PROMPT_OPERATIONS_LOOP = 'pulse_operations_loop';
|
||||
|
||||
const AGENT_OPERATIONS_LOOP_CAPABILITY_NAMES = [
|
||||
'get_patrol_control_status',
|
||||
'get_fleet_context',
|
||||
'get_resource_context',
|
||||
'list_findings',
|
||||
'plan_action',
|
||||
'decide_action',
|
||||
'execute_action',
|
||||
'resolve_finding',
|
||||
] as const;
|
||||
|
||||
export interface AgentMCPOperationsLoopReadiness {
|
||||
available: boolean;
|
||||
hasAdapter: boolean;
|
||||
hasPrompt: boolean;
|
||||
hasSurfaceTools: boolean;
|
||||
missingCapabilities: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT_MCP_CONFIG_FAMILIES: AgentMCPAdapterConfigFamily[] = [
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
shape: 'opencode_mcp',
|
||||
description: "Uses OpenCode's top-level mcp object.",
|
||||
fileHints: ['opencode.json', 'opencode.jsonc', '~/.config/opencode/opencode.json'],
|
||||
clientLabels: ['OpenCode'],
|
||||
},
|
||||
{
|
||||
id: 'claude-style',
|
||||
label: 'Claude-style clients',
|
||||
shape: 'mcp_servers',
|
||||
description: 'Uses the common mcpServers object supported by Claude Desktop and Claude Code.',
|
||||
fileHints: ['~/Library/Application Support/Claude/claude_desktop_config.json', '.mcp.json'],
|
||||
clientLabels: ['Claude Desktop', 'Claude Code'],
|
||||
},
|
||||
{
|
||||
id: 'custom-mcp',
|
||||
label: 'custom clients',
|
||||
shape: 'custom',
|
||||
description:
|
||||
'Keeps the connector command, base URL flag, and token environment variable while adapting the outer client config shape.',
|
||||
clientLabels: ['custom clients'],
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_AGENT_MCP_ADAPTER: AgentMCPAdapterContract = {
|
||||
serverName: 'pulse',
|
||||
command: 'pulse-mcp',
|
||||
baseUrlFlag: '--base-url',
|
||||
defaultBaseUrl: 'http://localhost:7655',
|
||||
tokenEnv: 'PULSE_API_TOKEN',
|
||||
configFamilies: DEFAULT_AGENT_MCP_CONFIG_FAMILIES,
|
||||
};
|
||||
|
||||
const DEFAULT_AGENT_SURFACE_AFFORDANCES_BY_ID: Record<string, AgentSurfaceAffordanceContract> = {
|
||||
pulse_assistant: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
pulse_mcp: {
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
};
|
||||
|
||||
export async function fetchAgentCapabilitiesManifest(): Promise<AgentCapabilitiesManifest> {
|
||||
return apiFetchJSON<AgentCapabilitiesManifest>(AGENT_CAPABILITIES_PATH, {
|
||||
skipAuth: true,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAgentOperationsLoopStatus(): Promise<AgentOperationsLoopStatus> {
|
||||
return apiFetchJSON<AgentOperationsLoopStatus>(AGENT_OPERATIONS_LOOP_STATUS_PATH, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function trimOrDefault(value: string | undefined, fallback: string): string {
|
||||
return value?.trim() || fallback;
|
||||
}
|
||||
|
||||
function uniqueTrimmed(values: string[] | undefined): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const raw of values ?? []) {
|
||||
const value = raw.trim();
|
||||
if (!value || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
normalized.push(value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalNonEmptyList(values: string[]): string[] | undefined {
|
||||
return values.length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
function formatSurfaceToolSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'assistant_registry':
|
||||
return 'Assistant registry';
|
||||
case 'capability_manifest':
|
||||
return 'Capability manifest';
|
||||
default:
|
||||
return source
|
||||
.split(/[_\s-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
function findAgentOperatorSurface(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
surfaceId: string,
|
||||
): AgentOperatorSurfaceContract | undefined {
|
||||
const normalizedSurfaceId = surfaceId.trim();
|
||||
if (!normalizedSurfaceId) return undefined;
|
||||
return manifest?.surfaceContract?.operatorSurfaces?.find(
|
||||
(surface) => surface.id?.trim() === normalizedSurfaceId,
|
||||
);
|
||||
}
|
||||
|
||||
function findAgentSurfaceToolContract(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
surfaceId: string,
|
||||
): AgentSurfaceToolContract | undefined {
|
||||
const normalizedSurfaceId = surfaceId.trim();
|
||||
if (!normalizedSurfaceId) return undefined;
|
||||
return manifest?.surfaceToolContracts?.find(
|
||||
(contract) => contract.surfaceId?.trim() === normalizedSurfaceId,
|
||||
);
|
||||
}
|
||||
|
||||
function cloneConfigFamilies(
|
||||
families: AgentMCPAdapterConfigFamily[],
|
||||
): AgentMCPAdapterConfigFamily[] {
|
||||
return families.map((family) => ({
|
||||
...family,
|
||||
fileHints: family.fileHints ? [...family.fileHints] : undefined,
|
||||
clientLabels: family.clientLabels ? [...family.clientLabels] : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizedConfigFamilies(
|
||||
families: AgentMCPAdapterConfigFamily[] | undefined,
|
||||
): AgentMCPAdapterConfigFamily[] {
|
||||
if (!families || families.length === 0) {
|
||||
return cloneConfigFamilies(DEFAULT_AGENT_MCP_CONFIG_FAMILIES);
|
||||
}
|
||||
|
||||
const normalized: AgentMCPAdapterConfigFamily[] = [];
|
||||
for (const family of families) {
|
||||
const id = family.id?.trim() || family.label?.trim() || family.shape?.trim();
|
||||
const label = family.label?.trim() || id;
|
||||
const shape = family.shape?.trim() || id;
|
||||
if (!id || !label || !shape) continue;
|
||||
normalized.push({
|
||||
...family,
|
||||
id,
|
||||
label,
|
||||
shape,
|
||||
description: family.description?.trim() || undefined,
|
||||
fileHints: family.fileHints?.map((hint) => hint.trim()).filter(Boolean),
|
||||
clientLabels: family.clientLabels?.map((label) => label.trim()).filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: cloneConfigFamilies(DEFAULT_AGENT_MCP_CONFIG_FAMILIES);
|
||||
}
|
||||
|
||||
export function normalizeAgentMCPAdapter(
|
||||
adapter: AgentMCPAdapterContract | undefined,
|
||||
): AgentMCPAdapterContract {
|
||||
return {
|
||||
serverName: trimOrDefault(adapter?.serverName, DEFAULT_AGENT_MCP_ADAPTER.serverName),
|
||||
command: trimOrDefault(adapter?.command, DEFAULT_AGENT_MCP_ADAPTER.command),
|
||||
baseUrlFlag: trimOrDefault(adapter?.baseUrlFlag, DEFAULT_AGENT_MCP_ADAPTER.baseUrlFlag),
|
||||
defaultBaseUrl: trimOrDefault(
|
||||
adapter?.defaultBaseUrl,
|
||||
DEFAULT_AGENT_MCP_ADAPTER.defaultBaseUrl,
|
||||
),
|
||||
tokenEnv: trimOrDefault(adapter?.tokenEnv, DEFAULT_AGENT_MCP_ADAPTER.tokenEnv),
|
||||
configFamilies: normalizedConfigFamilies(adapter?.configFamilies),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentMCPAdapterConfigFamilies(
|
||||
adapter: AgentMCPAdapterContract | undefined,
|
||||
): AgentMCPAdapterConfigFamily[] {
|
||||
return normalizeAgentMCPAdapter(adapter).configFamilies;
|
||||
}
|
||||
|
||||
export function getAgentMCPConfigFamilyByShape(
|
||||
adapter: AgentMCPAdapterContract | undefined,
|
||||
shape: string,
|
||||
): AgentMCPAdapterConfigFamily | undefined {
|
||||
const normalizedShape = shape.trim();
|
||||
if (!normalizedShape) return undefined;
|
||||
return getAgentMCPAdapterConfigFamilies(adapter).find(
|
||||
(family) => family.shape === normalizedShape,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentMCPClientExamples(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
): string[] {
|
||||
const labels: string[] = [];
|
||||
for (const family of getAgentMCPAdapterConfigFamilies(manifest?.mcpAdapter)) {
|
||||
const clientLabels = family.clientLabels?.filter(Boolean);
|
||||
if (clientLabels && clientLabels.length > 0) {
|
||||
labels.push(...clientLabels);
|
||||
} else {
|
||||
labels.push(family.label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function formatAgentOpenCodeMCPConfig(
|
||||
adapter: AgentMCPAdapterContract | undefined,
|
||||
baseUrl: string,
|
||||
tokenPlaceholder = AGENT_MCP_TOKEN_PLACEHOLDER,
|
||||
): string {
|
||||
const normalized = normalizeAgentMCPAdapter(adapter);
|
||||
const config = {
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
mcp: {
|
||||
[normalized.serverName]: {
|
||||
type: 'local',
|
||||
command: [normalized.command, normalized.baseUrlFlag, baseUrl],
|
||||
enabled: true,
|
||||
environment: {
|
||||
[normalized.tokenEnv]: tokenPlaceholder,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
export function formatAgentMCPServersConfig(
|
||||
adapter: AgentMCPAdapterContract | undefined,
|
||||
baseUrl: string,
|
||||
tokenPlaceholder = AGENT_MCP_TOKEN_PLACEHOLDER,
|
||||
): string {
|
||||
const normalized = normalizeAgentMCPAdapter(adapter);
|
||||
const config = {
|
||||
mcpServers: {
|
||||
[normalized.serverName]: {
|
||||
command: normalized.command,
|
||||
args: [normalized.baseUrlFlag, baseUrl],
|
||||
env: {
|
||||
[normalized.tokenEnv]: tokenPlaceholder,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
function normalizedCapabilityCategoryID(category: string | undefined): string {
|
||||
return category?.trim() || 'uncategorized';
|
||||
}
|
||||
|
||||
function categoryFallbackLabel(category: string): string {
|
||||
if (category === 'uncategorized') return 'Uncategorized';
|
||||
return category || 'Uncategorized';
|
||||
}
|
||||
|
||||
function sanitizeAgentCapabilityDescription(description: string | undefined): string {
|
||||
const trimmed = description?.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed
|
||||
.replace(/legacy Pro activation aliases/g, 'compatibility aliases')
|
||||
.replace(
|
||||
/Pro activation completed\/resolved proof/g,
|
||||
'Patrol mode completed/resolved outcome status',
|
||||
)
|
||||
.replace(
|
||||
/Patrol autonomy completed\/resolved outcome status/g,
|
||||
'Patrol mode completed/resolved outcome status',
|
||||
)
|
||||
.replace(
|
||||
/Patrol autonomy completed\/resolved outcome evidence/g,
|
||||
'Patrol mode outcome evidence',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAgentCapabilityPresentation(capability: AgentCapability): AgentCapability {
|
||||
return {
|
||||
...capability,
|
||||
description: sanitizeAgentCapabilityDescription(capability.description),
|
||||
};
|
||||
}
|
||||
|
||||
export function groupAgentCapabilitiesByManifestCategories(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
): AgentCapabilitySection[] {
|
||||
if (!manifest) return [];
|
||||
|
||||
const byCategory = new Map<string, AgentCapability[]>();
|
||||
for (const cap of manifest.capabilities) {
|
||||
const category = normalizedCapabilityCategoryID(cap.category);
|
||||
const list = byCategory.get(category) ?? [];
|
||||
list.push(normalizeAgentCapabilityPresentation(cap));
|
||||
byCategory.set(category, list);
|
||||
}
|
||||
|
||||
const sections: AgentCapabilitySection[] = [];
|
||||
for (const category of manifest.categories ?? []) {
|
||||
const id = normalizedCapabilityCategoryID(category.id);
|
||||
const entries = byCategory.get(id);
|
||||
if (!entries || entries.length === 0) continue;
|
||||
const label = category.label?.trim() || categoryFallbackLabel(id);
|
||||
const description = category.description?.trim();
|
||||
sections.push({
|
||||
id,
|
||||
label,
|
||||
description: description || undefined,
|
||||
entries,
|
||||
});
|
||||
byCategory.delete(id);
|
||||
}
|
||||
|
||||
for (const [unknownCategory, entries] of byCategory) {
|
||||
sections.push({
|
||||
id: unknownCategory,
|
||||
label: categoryFallbackLabel(unknownCategory),
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function trimSurfaceContractComponent(
|
||||
component: AgentSurfaceContractComponent | undefined,
|
||||
): AgentSurfaceContractEntry | undefined {
|
||||
const label = component?.label?.trim();
|
||||
if (!label) return undefined;
|
||||
return {
|
||||
id: component?.id?.trim() || label,
|
||||
label,
|
||||
description: component?.description?.trim() || undefined,
|
||||
badges: [],
|
||||
};
|
||||
}
|
||||
|
||||
function surfaceBadges(surface: AgentOperatorSurfaceContract): string[] {
|
||||
const badges: string[] = [];
|
||||
if (surface.native) badges.push('Native surface');
|
||||
if (surface.externalAdapter) badges.push('External adapter');
|
||||
badges.push(...surfaceAffordanceLabels(normalizeSurfaceAffordances(surface)));
|
||||
return badges;
|
||||
}
|
||||
|
||||
function surfaceAffordancesDeclared(
|
||||
affordances: AgentSurfaceAffordanceContract | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
affordances?.tools ||
|
||||
affordances?.resources ||
|
||||
affordances?.prompts ||
|
||||
affordances?.capabilityMetadata ||
|
||||
affordances?.interactiveQuestions,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSurfaceAffordances(
|
||||
surface: AgentOperatorSurfaceContract,
|
||||
): AgentSurfaceAffordanceContract {
|
||||
if (surfaceAffordancesDeclared(surface.affordances)) {
|
||||
return surface.affordances!;
|
||||
}
|
||||
const id = surface.id?.trim();
|
||||
return id ? (DEFAULT_AGENT_SURFACE_AFFORDANCES_BY_ID[id] ?? {}) : {};
|
||||
}
|
||||
|
||||
function surfaceAffordanceLabels(affordances: AgentSurfaceAffordanceContract): string[] {
|
||||
const labels: string[] = [];
|
||||
if (affordances.tools) labels.push('Actions');
|
||||
if (affordances.resources) labels.push('Resources');
|
||||
if (affordances.prompts) labels.push('Prompts');
|
||||
if (affordances.capabilityMetadata) labels.push('Capability metadata');
|
||||
if (affordances.interactiveQuestions) labels.push('Interactive questions');
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function getAgentSurfaceContractEntries(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
): AgentSurfaceContractEntry[] {
|
||||
if (!manifest) return [];
|
||||
|
||||
const entries: AgentSurfaceContractEntry[] = [];
|
||||
const core = trimSurfaceContractComponent(manifest.surfaceContract?.core);
|
||||
if (core) entries.push(core);
|
||||
|
||||
const proactiveEngine = trimSurfaceContractComponent(manifest.surfaceContract?.proactiveEngine);
|
||||
if (proactiveEngine) entries.push(proactiveEngine);
|
||||
|
||||
for (const surface of manifest.surfaceContract?.operatorSurfaces ?? []) {
|
||||
const label = surface.label?.trim();
|
||||
if (!label) continue;
|
||||
entries.push({
|
||||
id: surface.id?.trim() || label,
|
||||
label,
|
||||
description: surface.description?.trim() || undefined,
|
||||
badges: surfaceBadges(surface),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function getAgentWorkflowPrompts(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
): AgentWorkflowPrompt[] {
|
||||
const prompts: AgentWorkflowPrompt[] = [];
|
||||
for (const prompt of manifest?.workflowPrompts ?? []) {
|
||||
const name = prompt.name?.trim();
|
||||
if (!name) continue;
|
||||
|
||||
const args: AgentWorkflowPromptArgument[] = [];
|
||||
for (const arg of prompt.arguments ?? []) {
|
||||
const argName = arg.name?.trim();
|
||||
if (!argName) continue;
|
||||
const normalizedArg: AgentWorkflowPromptArgument = {
|
||||
name: argName,
|
||||
required: Boolean(arg.required),
|
||||
};
|
||||
const argDescription = arg.description?.trim();
|
||||
if (argDescription) normalizedArg.description = argDescription;
|
||||
args.push(normalizedArg);
|
||||
}
|
||||
|
||||
const normalizedPrompt: AgentWorkflowPrompt = {
|
||||
name,
|
||||
arguments: args,
|
||||
};
|
||||
const label = prompt.label?.trim();
|
||||
if (label) normalizedPrompt.label = label;
|
||||
const presentationKind = prompt.presentationKind?.trim();
|
||||
if (presentationKind) normalizedPrompt.presentationKind = presentationKind;
|
||||
const description = prompt.description?.trim();
|
||||
if (description) normalizedPrompt.description = description;
|
||||
prompts.push(normalizedPrompt);
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
export function getAgentMCPOperationsLoopReadiness(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
): AgentMCPOperationsLoopReadiness {
|
||||
const adapter = normalizeAgentMCPAdapter(manifest?.mcpAdapter);
|
||||
const hasAdapter =
|
||||
Boolean(adapter.command) &&
|
||||
Boolean(adapter.serverName) &&
|
||||
Boolean(adapter.tokenEnv) &&
|
||||
adapter.configFamilies.length > 0;
|
||||
const hasPrompt = getAgentWorkflowPrompts(manifest).some(
|
||||
(prompt) => prompt.name === AGENT_WORKFLOW_PROMPT_OPERATIONS_LOOP,
|
||||
);
|
||||
const contract = getAgentManifestSurfaceToolContract(manifest, AGENT_SURFACE_ID_PULSE_MCP);
|
||||
const toolNames = new Set(contract?.toolNames ?? []);
|
||||
const missingCapabilities = AGENT_OPERATIONS_LOOP_CAPABILITY_NAMES.filter(
|
||||
(name) => !toolNames.has(name),
|
||||
);
|
||||
const hasSurfaceTools = Boolean(contract) && missingCapabilities.length === 0;
|
||||
|
||||
return {
|
||||
available: hasAdapter && hasPrompt && hasSurfaceTools,
|
||||
hasAdapter,
|
||||
hasPrompt,
|
||||
hasSurfaceTools,
|
||||
missingCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAgentSurfaceToolContract(
|
||||
contract: AgentSurfaceToolContract | undefined,
|
||||
): AgentSurfaceToolContract | undefined {
|
||||
const surfaceId = contract?.surfaceId?.trim();
|
||||
if (!contract || !surfaceId) return undefined;
|
||||
|
||||
const toolNames = uniqueTrimmed(contract.toolNames);
|
||||
const registryToolNames = uniqueTrimmed(contract.registryToolNames);
|
||||
const capabilityNames = uniqueTrimmed(contract.capabilityNames);
|
||||
const nativeToolNames = uniqueTrimmed(contract.nativeToolNames);
|
||||
const affordances = surfaceAffordancesDeclared(contract.affordances)
|
||||
? contract.affordances
|
||||
: (DEFAULT_AGENT_SURFACE_AFFORDANCES_BY_ID[surfaceId] ?? undefined);
|
||||
const toolsEnabled = Boolean(affordances?.tools);
|
||||
|
||||
return {
|
||||
surfaceId,
|
||||
surfaceLabel: contract.surfaceLabel?.trim() || undefined,
|
||||
toolSource: contract.toolSource?.trim() || 'unknown',
|
||||
toolNames: toolsEnabled ? toolNames : [],
|
||||
registryToolNames: optionalNonEmptyList(toolsEnabled ? registryToolNames : []),
|
||||
capabilityNames: optionalNonEmptyList(toolsEnabled ? capabilityNames : []),
|
||||
nativeToolNames: optionalNonEmptyList(toolsEnabled ? nativeToolNames : []),
|
||||
affordances,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentManifestSurfaceToolContract(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
surfaceId: string,
|
||||
): AgentSurfaceToolContract | undefined {
|
||||
if (!manifest) return undefined;
|
||||
|
||||
const normalizedSurfaceId = surfaceId.trim();
|
||||
if (!normalizedSurfaceId) return undefined;
|
||||
|
||||
const surface = findAgentOperatorSurface(manifest, normalizedSurfaceId);
|
||||
if (!surface?.externalAdapter) return undefined;
|
||||
|
||||
const manifestOwnedContract = findAgentSurfaceToolContract(manifest, normalizedSurfaceId);
|
||||
if (!manifestOwnedContract) return undefined;
|
||||
|
||||
return normalizeAgentSurfaceToolContract({
|
||||
...manifestOwnedContract,
|
||||
surfaceLabel: surface.label?.trim() || normalizedSurfaceId,
|
||||
affordances: normalizeSurfaceAffordances(surface),
|
||||
});
|
||||
}
|
||||
|
||||
export function getAgentManifestSurfaceToolContracts(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
): AgentSurfaceToolContract[] {
|
||||
const contracts: AgentSurfaceToolContract[] = [];
|
||||
for (const surface of manifest?.surfaceContract?.operatorSurfaces ?? []) {
|
||||
if (!surface.externalAdapter) continue;
|
||||
const contract = getAgentManifestSurfaceToolContract(manifest, surface.id);
|
||||
if (contract) contracts.push(contract);
|
||||
}
|
||||
return contracts;
|
||||
}
|
||||
|
||||
export function getAgentSurfaceToolPosturePresentation(
|
||||
contract: AgentSurfaceToolContract | undefined,
|
||||
): AgentSurfaceToolPosturePresentation | null {
|
||||
const normalized = normalizeAgentSurfaceToolContract(contract);
|
||||
if (!normalized) return null;
|
||||
|
||||
const toolCount = normalized.toolNames.length;
|
||||
const registryCount = normalized.registryToolNames?.length ?? 0;
|
||||
const capabilityCount = normalized.capabilityNames?.length ?? 0;
|
||||
const nativeCount = normalized.nativeToolNames?.length ?? 0;
|
||||
const source = formatSurfaceToolSource(normalized.toolSource);
|
||||
const surfaceLabel = normalized.surfaceLabel || normalized.surfaceId;
|
||||
const label = toolCount === 1 ? '1 capability' : `${toolCount} capabilities`;
|
||||
const breakdown = [
|
||||
registryCount > 0
|
||||
? `${registryCount} ${registryCount === 1 ? 'registry capability' : 'registry capabilities'}`
|
||||
: '',
|
||||
capabilityCount > 0
|
||||
? `${capabilityCount} published ${capabilityCount === 1 ? 'capability' : 'capabilities'}`
|
||||
: '',
|
||||
nativeCount > 0
|
||||
? `${nativeCount} ${nativeCount === 1 ? 'native capability' : 'native capabilities'}`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
const affordanceLabels = surfaceAffordanceLabels(normalized.affordances ?? {});
|
||||
const detail = [...breakdown, ...affordanceLabels].join(', ');
|
||||
|
||||
return {
|
||||
surfaceLabel,
|
||||
label,
|
||||
title: [`${surfaceLabel} capability availability`, `Source: ${source}`, detail || undefined]
|
||||
.filter(Boolean)
|
||||
.join('. '),
|
||||
detail: detail || undefined,
|
||||
tone: toolCount > 0 ? 'ready' : 'empty',
|
||||
toolCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAgentCapabilityErrorCodeSummaries(
|
||||
manifest: AgentCapabilitiesManifest | undefined,
|
||||
): AgentCapabilityErrorCodeSummary[] {
|
||||
if (!manifest) return [];
|
||||
|
||||
const summaries = new Map<string, Set<string>>();
|
||||
for (const capability of manifest.capabilities) {
|
||||
const capabilityName = capability.name.trim();
|
||||
if (!capabilityName) continue;
|
||||
|
||||
for (const rawCode of capability.errorCodes ?? []) {
|
||||
const code = rawCode.trim();
|
||||
if (!code) continue;
|
||||
const capabilityNames = summaries.get(code) ?? new Set<string>();
|
||||
capabilityNames.add(capabilityName);
|
||||
summaries.set(code, capabilityNames);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(summaries, ([code, capabilityNames]) => ({
|
||||
code,
|
||||
capabilityNames: Array.from(capabilityNames),
|
||||
}));
|
||||
}
|
||||
@@ -34,14 +34,14 @@ export interface AgentProfileAssignment {
|
||||
}
|
||||
|
||||
/**
|
||||
* Request for AI-assisted profile suggestion.
|
||||
* Request for Pulse Intelligence profile suggestion.
|
||||
*/
|
||||
export interface ProfileSuggestionRequest {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI-generated profile suggestion.
|
||||
* Pulse Intelligence profile suggestion.
|
||||
*/
|
||||
export interface ProfileSuggestion {
|
||||
name: string;
|
||||
@@ -365,7 +365,7 @@ export class AgentProfilesAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI-assisted profile suggestion.
|
||||
* Get Pulse Intelligence profile suggestion.
|
||||
* Requires AI to be enabled and running.
|
||||
*/
|
||||
static async suggestProfile(request: ProfileSuggestionRequest): Promise<ProfileSuggestion> {
|
||||
@@ -378,7 +378,7 @@ export class AgentProfilesAPI {
|
||||
await assertAPIResponseOKOrThrowStatus(
|
||||
response,
|
||||
503,
|
||||
'Pulse Assistant service is not available. Please check Pulse Assistant settings.',
|
||||
'Pulse Intelligence is not available. Please check Assistant & Patrol settings.',
|
||||
`Failed to get suggestion: ${response.status}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -681,6 +681,7 @@ export type InvestigationOutcome =
|
||||
| 'fix_queued'
|
||||
| 'fix_executed'
|
||||
| 'fix_failed'
|
||||
| 'fix_rejected'
|
||||
| 'needs_attention'
|
||||
| 'cannot_fix'
|
||||
| 'timed_out'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiFetchJSON, apiFetch } from '@/utils/apiClient';
|
||||
import { assertAPIResponseOK } from './responseUtils';
|
||||
import { logger } from '@/utils/logger';
|
||||
import type { AIChatStreamEvent } from './generated/aiChatEvents';
|
||||
import type { AgentSurfaceToolContract } from './agentCapabilities';
|
||||
import { consumeJSONEventStream } from './streaming';
|
||||
import { maybeRunAIChatDevStreamFixture } from './aiChatDevStreamFixture';
|
||||
|
||||
@@ -197,6 +198,34 @@ export interface AIStatus {
|
||||
engine: string;
|
||||
}
|
||||
|
||||
export interface AssistantWorkflowPromptRenderRequest {
|
||||
name: string;
|
||||
arguments?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME = 'pulse_operations_loop';
|
||||
export const PULSE_PATROL_WORKFLOW_PROMPT_SURFACE = 'pulse_patrol';
|
||||
export const PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE = 'patrol_control';
|
||||
export const PULSE_PATROL_AUTONOMY_WORKFLOW_PROMPT_SURFACE = 'patrol_autonomy';
|
||||
export const PULSE_PRO_ACTIVATION_WORKFLOW_PROMPT_SURFACE = 'pulse_pro_activation';
|
||||
|
||||
export type AssistantWorkflowPromptActivitySurface =
|
||||
| 'pulse_assistant'
|
||||
| 'pulse_patrol'
|
||||
| 'patrol_control'
|
||||
| 'patrol_autonomy'
|
||||
| 'pulse_pro_activation';
|
||||
|
||||
export interface AssistantWorkflowPromptActivityRequest {
|
||||
name: string;
|
||||
surface?: AssistantWorkflowPromptActivitySurface;
|
||||
}
|
||||
|
||||
export interface AssistantWorkflowPromptRenderResponse {
|
||||
description: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// AI Agent (build, code, etc.) with specific permissions and model
|
||||
export interface Agent {
|
||||
name: string;
|
||||
@@ -219,6 +248,35 @@ export class AIChatAPI {
|
||||
return apiFetchJSON(`${this.baseUrl}/status`) as Promise<AIStatus>;
|
||||
}
|
||||
|
||||
// Get the live native Assistant surface tool contract for the current runtime mode.
|
||||
static async getAssistantSurfaceTools(): Promise<AgentSurfaceToolContract> {
|
||||
return apiFetchJSON(
|
||||
`${this.baseUrl}/assistant/surface-tools`,
|
||||
) as Promise<AgentSurfaceToolContract>;
|
||||
}
|
||||
|
||||
// Render a shared Pulse Intelligence workflow prompt for the native Assistant surface.
|
||||
static async renderWorkflowPrompt(
|
||||
request: AssistantWorkflowPromptRenderRequest,
|
||||
): Promise<AssistantWorkflowPromptRenderResponse> {
|
||||
return apiFetchJSON(`${this.baseUrl}/workflow-prompts/render`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}) as Promise<AssistantWorkflowPromptRenderResponse>;
|
||||
}
|
||||
|
||||
// Record that a first-party Pulse surface started a shared workflow prompt.
|
||||
static async recordWorkflowPromptActivity(
|
||||
request: AssistantWorkflowPromptActivityRequest,
|
||||
): Promise<void> {
|
||||
await apiFetch(`${this.baseUrl}/workflow-prompts/activity`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
// List all chat sessions. Normalizes a null/non-array payload (e.g. a server
|
||||
// error body) to an empty array so callers can rely on array semantics and do
|
||||
// not crash on .length/.some()/.map() (#1149).
|
||||
@@ -310,9 +368,7 @@ export class AIChatAPI {
|
||||
}
|
||||
|
||||
// Summarize a session (compress context when nearing limits)
|
||||
static async summarizeSession(
|
||||
sessionId: string,
|
||||
): Promise<ChatSessionCompactionResult> {
|
||||
static async summarizeSession(sessionId: string): Promise<ChatSessionCompactionResult> {
|
||||
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/summarize`, {
|
||||
method: 'POST',
|
||||
}) as Promise<ChatSessionCompactionResult>;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// This file is generated from scripts/generate-types.go; DO NOT EDIT.
|
||||
// Source: internal/agentcapabilities manifest structs.
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
export type AgentCapabilityActionMode = 'read' | 'mixed' | 'write';
|
||||
export type AgentCapabilityApprovalPolicy = 'scope_only' | 'action_plan';
|
||||
|
||||
export interface Capability {
|
||||
name: string;
|
||||
title?: string;
|
||||
description: string;
|
||||
category: string;
|
||||
method: string;
|
||||
path: string;
|
||||
scope: string;
|
||||
actionMode: AgentCapabilityActionMode;
|
||||
approvalPolicy: AgentCapabilityApprovalPolicy;
|
||||
responseShape?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
errorCodes?: string[];
|
||||
requestBodyShape?: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CapabilityCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MCPAdapterConfigFamily {
|
||||
id: string;
|
||||
label: string;
|
||||
shape: string;
|
||||
description?: string;
|
||||
fileHints?: string[];
|
||||
clientLabels?: string[];
|
||||
}
|
||||
|
||||
export interface MCPAdapterContract {
|
||||
serverName: string;
|
||||
command: string;
|
||||
baseUrlFlag: string;
|
||||
defaultBaseUrl: string;
|
||||
tokenEnv: string;
|
||||
configFamilies: MCPAdapterConfigFamily[];
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
version: string;
|
||||
surfaceContract: SurfaceContract;
|
||||
surfaceToolContracts: SurfaceToolContract[];
|
||||
mcpAdapter: MCPAdapterContract;
|
||||
requiredScopes: string[];
|
||||
categories: CapabilityCategory[];
|
||||
workflowPrompts: PulseWorkflowPrompt[];
|
||||
capabilities: Capability[];
|
||||
}
|
||||
|
||||
export interface OperatorSurfaceContract {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
native: boolean;
|
||||
externalAdapter: boolean;
|
||||
affordances?: SurfaceAffordanceContract;
|
||||
}
|
||||
|
||||
export interface PulseWorkflowPrompt {
|
||||
name: string;
|
||||
label?: string;
|
||||
presentationKind?: string;
|
||||
description?: string;
|
||||
arguments?: PulseWorkflowPromptArgument[];
|
||||
}
|
||||
|
||||
export interface PulseWorkflowPromptArgument {
|
||||
name: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface SurfaceAffordanceContract {
|
||||
tools?: boolean;
|
||||
resources?: boolean;
|
||||
prompts?: boolean;
|
||||
capabilityMetadata?: boolean;
|
||||
interactiveQuestions?: boolean;
|
||||
}
|
||||
|
||||
export interface SurfaceContract {
|
||||
core: SurfaceContractComponent;
|
||||
proactiveEngine: SurfaceContractComponent;
|
||||
operatorSurfaces: OperatorSurfaceContract[];
|
||||
}
|
||||
|
||||
export interface SurfaceContractComponent {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SurfaceToolContract {
|
||||
surfaceId: string;
|
||||
surfaceLabel?: string;
|
||||
toolSource: string;
|
||||
toolNames: string[];
|
||||
registryToolNames?: string[];
|
||||
capabilityNames?: string[];
|
||||
nativeToolNames?: string[];
|
||||
affordances?: SurfaceAffordanceContract;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Code generated by scripts/generate-types.go; DO NOT EDIT.
|
||||
// This file is generated from scripts/generate-types.go; DO NOT EDIT.
|
||||
// Source: internal/ai/chat event payload structs.
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -58,6 +58,7 @@ export interface DoneData {
|
||||
model?: string;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
context_limit_tokens?: number;
|
||||
}
|
||||
|
||||
export interface ErrorData {
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface Finding {
|
||||
// quiet on re-detection until this timestamp passes, then the next
|
||||
// re-detection clears the dismissal and emits a "reminded" lifecycle event.
|
||||
remind_at?: string;
|
||||
// Investigation fields (Patrol Autonomy)
|
||||
// Investigation fields (Patrol mode)
|
||||
investigation_session_id?: string;
|
||||
investigation_status?: InvestigationStatus;
|
||||
investigation_outcome?: InvestigationOutcome;
|
||||
@@ -78,6 +78,7 @@ export type InvestigationOutcome =
|
||||
| 'fix_queued'
|
||||
| 'fix_executed'
|
||||
| 'fix_failed'
|
||||
| 'fix_rejected'
|
||||
| 'needs_attention'
|
||||
| 'cannot_fix'
|
||||
| 'timed_out'
|
||||
@@ -403,18 +404,18 @@ export async function setFindingNote(
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Patrol Autonomy APIs
|
||||
// Patrol Mode APIs
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get current patrol autonomy settings
|
||||
* Get current Patrol mode settings
|
||||
*/
|
||||
export async function getPatrolAutonomySettings(): Promise<PatrolAutonomySettings> {
|
||||
return apiFetchJSON<PatrolAutonomySettings>('/api/ai/patrol/autonomy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update patrol autonomy settings
|
||||
* Update Patrol mode settings
|
||||
*/
|
||||
export async function updatePatrolAutonomySettings(
|
||||
settings: PatrolAutonomySettings,
|
||||
@@ -505,6 +506,7 @@ export const investigationOutcomeLabels: Record<InvestigationOutcome, string> =
|
||||
fix_queued: 'Fix Queued',
|
||||
fix_executed: 'Fix Executed',
|
||||
fix_failed: 'Fix Failed',
|
||||
fix_rejected: 'Fix Rejected',
|
||||
needs_attention: 'Needs Attention',
|
||||
cannot_fix: 'Cannot Remediate',
|
||||
timed_out: 'Timed Out — Will Retry',
|
||||
@@ -525,6 +527,8 @@ export const investigationOutcomeColors: Record<InvestigationOutcome, string> =
|
||||
'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
fix_failed:
|
||||
'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
fix_rejected:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-300',
|
||||
needs_attention:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-300',
|
||||
cannot_fix: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
@@ -544,11 +548,7 @@ export const investigationOutcomeColors: Record<InvestigationOutcome, string> =
|
||||
|
||||
export type PatrolRunStatus = 'healthy' | 'issues_found' | 'critical' | 'error';
|
||||
|
||||
export type ToolCallVerificationStatus =
|
||||
| 'unknown'
|
||||
| 'verified'
|
||||
| 'unverified'
|
||||
| 'failed';
|
||||
export type ToolCallVerificationStatus = 'unknown' | 'verified' | 'unverified' | 'failed';
|
||||
|
||||
export interface ToolCallVerification {
|
||||
status: ToolCallVerificationStatus;
|
||||
|
||||
@@ -51,6 +51,62 @@ export interface TelemetryPingPreview {
|
||||
multi_tenant: boolean;
|
||||
paid_license: boolean;
|
||||
has_api_tokens: boolean;
|
||||
pulse_intelligence_loop_configured: boolean;
|
||||
pulse_intelligence_loop_active_30d: boolean;
|
||||
pulse_intelligence_complete_operations_loop_30d: boolean;
|
||||
pulse_intelligence_approved_execution_loop_30d: boolean;
|
||||
pulse_intelligence_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_patrol_control_completed_operations_loop_30d: boolean;
|
||||
pulse_intelligence_patrol_control_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_patrol_control_paid_completed_operations_loop_30d: boolean;
|
||||
pulse_intelligence_patrol_control_paid_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_pro_activation_completed_operations_loop_30d: boolean;
|
||||
pulse_intelligence_pro_activation_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_pro_activation_paid_completed_operations_loop_30d: boolean;
|
||||
pulse_intelligence_pro_activation_paid_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_governed_action_active_30d: boolean;
|
||||
pulse_intelligence_assistant_operations_loop_30d: boolean;
|
||||
pulse_intelligence_assistant_approved_execution_loop_30d: boolean;
|
||||
pulse_intelligence_assistant_approved_action_success_loop_30d: boolean;
|
||||
pulse_intelligence_assistant_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_external_agent_operations_loop_30d: boolean;
|
||||
pulse_intelligence_external_agent_approved_execution_loop_30d: boolean;
|
||||
pulse_intelligence_external_agent_approved_action_success_loop_30d: boolean;
|
||||
pulse_intelligence_external_agent_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_mcp_adapter_operations_loop_30d: boolean;
|
||||
pulse_intelligence_mcp_adapter_approved_execution_loop_30d: boolean;
|
||||
pulse_intelligence_mcp_adapter_approved_action_success_loop_30d: boolean;
|
||||
pulse_intelligence_mcp_adapter_resolved_operations_loop_30d: boolean;
|
||||
pulse_intelligence_operations_loop_starter_requests_30d: number;
|
||||
pulse_intelligence_assistant_operations_loop_starter_requests_30d: number;
|
||||
pulse_intelligence_patrol_operations_loop_starter_requests_30d: number;
|
||||
pulse_intelligence_patrol_control_operations_loop_starter_requests_30d: number;
|
||||
pulse_intelligence_pro_activation_operations_loop_starter_requests_30d: number;
|
||||
pulse_intelligence_mcp_operations_loop_starter_requests_30d: number;
|
||||
pulse_intelligence_assistant_ai_calls_30d: number;
|
||||
pulse_intelligence_assistant_context_ai_calls_30d: number;
|
||||
pulse_intelligence_assistant_tool_calls_30d: number;
|
||||
pulse_intelligence_patrol_ai_calls_30d: number;
|
||||
pulse_intelligence_patrol_runs_30d: number;
|
||||
pulse_intelligence_patrol_new_findings_30d: number;
|
||||
pulse_intelligence_patrol_investigations_30d: number;
|
||||
pulse_intelligence_patrol_resolved_findings_30d: number;
|
||||
pulse_intelligence_patrol_autofixes_30d: number;
|
||||
pulse_intelligence_external_agent_enabled: boolean;
|
||||
pulse_intelligence_external_agent_used_30d: boolean;
|
||||
pulse_intelligence_mcp_adapter_used_30d: boolean;
|
||||
pulse_intelligence_external_agent_context_requests_30d: number;
|
||||
pulse_intelligence_external_agent_event_stream_requests_30d: number;
|
||||
pulse_intelligence_external_agent_provisioning_requests_30d: number;
|
||||
pulse_intelligence_external_agent_operator_state_requests_30d: number;
|
||||
pulse_intelligence_external_agent_finding_requests_30d: number;
|
||||
pulse_intelligence_external_agent_action_requests_30d: number;
|
||||
pulse_intelligence_action_plans_30d: number;
|
||||
pulse_intelligence_approval_requests_30d: number;
|
||||
pulse_intelligence_rejected_action_decisions_30d: number;
|
||||
pulse_intelligence_approved_action_decisions_30d: number;
|
||||
pulse_intelligence_approved_action_attempts_30d: number;
|
||||
pulse_intelligence_approved_action_successes_30d: number;
|
||||
}
|
||||
|
||||
export interface TelemetryPreviewResponse {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
AI_COST_PANEL_TITLE,
|
||||
AI_COST_PATROL_USE_CASE_LABEL,
|
||||
AI_COST_PROVIDER_MODEL_PAIR_LABEL,
|
||||
AI_COST_TARGET_TABLE_LABEL,
|
||||
AI_COST_RESET_HISTORY_LABEL,
|
||||
buildAICostExportFilename,
|
||||
getAICostBudgetNote,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
getAICostResetHistoryConfirmationMessage,
|
||||
getAICostResetHistoryErrorMessage,
|
||||
getAICostResetHistorySuccessMessage,
|
||||
getAICostTargetPresentation,
|
||||
} from '@/utils/aiCostPresentation';
|
||||
|
||||
const usdFormatter = new Intl.NumberFormat(undefined, {
|
||||
@@ -548,7 +550,9 @@ export const AICostDashboard: Component = () => {
|
||||
<Table class="min-w-full text-sm">
|
||||
<TableHeader class="text-xs text-muted uppercase tracking-wide">
|
||||
<TableRow class="border-b border-border">
|
||||
<TableHead class="text-left py-2 pr-4">Top targets</TableHead>
|
||||
<TableHead class="text-left py-2 pr-4">
|
||||
{AI_COST_TARGET_TABLE_LABEL}
|
||||
</TableHead>
|
||||
<TableHead class="text-right py-2 px-2">Est. USD</TableHead>
|
||||
<TableHead class="text-right py-2 px-2">Calls</TableHead>
|
||||
<TableHead class="text-right py-2 px-2">Tokens</TableHead>
|
||||
@@ -556,27 +560,37 @@ export const AICostDashboard: Component = () => {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={data().targets}>
|
||||
{(t) => (
|
||||
<TableRow class="border-b border-border-subtle">
|
||||
<TableCell class="py-2 pr-4 text-base-content font-mono text-xs">
|
||||
{t.target_type}:{t.target_id}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 px-2 text-right text-base-content">
|
||||
<Show
|
||||
when={t.pricing_known}
|
||||
fallback={<span class="text-muted" aria-hidden="true">—</span>}
|
||||
>
|
||||
{formatUSD(t.estimated_usd ?? 0)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell class="py-2 px-2 text-right text-base-content">
|
||||
{formatNumber(t.calls)}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 px-2 text-right text-base-content">
|
||||
{formatNumber(t.total_tokens)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{(t) => {
|
||||
const target = getAICostTargetPresentation(t);
|
||||
return (
|
||||
<TableRow class="border-b border-border-subtle">
|
||||
<TableCell class="py-2 pr-4 text-base-content">
|
||||
<span class="font-medium">{target.label}</span>
|
||||
<Show when={target.detail}>
|
||||
<span class="block text-xs text-muted">{target.detail}</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell class="py-2 px-2 text-right text-base-content">
|
||||
<Show
|
||||
when={t.pricing_known}
|
||||
fallback={
|
||||
<span class="text-muted" aria-hidden="true">
|
||||
—
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{formatUSD(t.estimated_usd ?? 0)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell class="py-2 px-2 text-right text-base-content">
|
||||
{formatNumber(t.calls)}
|
||||
</TableCell>
|
||||
<TableCell class="py-2 px-2 text-right text-base-content">
|
||||
{formatNumber(t.total_tokens)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
mockChat,
|
||||
mockAIAPI,
|
||||
mockAIChatAPI,
|
||||
mockFetchAgentCapabilitiesManifest,
|
||||
mockNotificationStore,
|
||||
mockAiChatStore,
|
||||
mockByType,
|
||||
@@ -91,6 +92,18 @@ const {
|
||||
|
||||
const mockAIChatAPI = {
|
||||
getStatus: vi.fn().mockResolvedValue({ running: true }),
|
||||
getAssistantSurfaceTools: vi.fn().mockResolvedValue({
|
||||
surfaceId: 'pulse_assistant',
|
||||
surfaceLabel: 'Pulse Assistant',
|
||||
toolSource: 'assistant_registry',
|
||||
toolNames: ['pulse_query', 'pulse_question'],
|
||||
registryToolNames: ['pulse_query'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
}),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
id: 'new',
|
||||
@@ -124,8 +137,25 @@ const {
|
||||
}),
|
||||
approveCommand: vi.fn().mockResolvedValue({ approved: true, message: 'ok' }),
|
||||
denyCommand: vi.fn().mockResolvedValue({ denied: true, message: 'ok' }),
|
||||
renderWorkflowPrompt: vi.fn().mockResolvedValue({
|
||||
description: 'Pulse fleet triage',
|
||||
text: 'Use get_fleet_context to triage the Pulse fleet.',
|
||||
}),
|
||||
};
|
||||
|
||||
const mockFetchAgentCapabilitiesManifest = vi.fn().mockResolvedValue({
|
||||
version: 'v1',
|
||||
workflowPrompts: [],
|
||||
capabilities: [],
|
||||
categories: [],
|
||||
requiredScopes: [],
|
||||
surfaceContract: {
|
||||
core: { id: 'pulse_intelligence_core', label: 'Pulse Intelligence Core', description: '' },
|
||||
proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' },
|
||||
operatorSurfaces: [],
|
||||
},
|
||||
});
|
||||
|
||||
const mockNotificationStore = {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
@@ -144,6 +174,7 @@ const {
|
||||
handoffResources: undefined,
|
||||
handoffActions: undefined,
|
||||
handoffMetadata: undefined,
|
||||
preferredWorkflowPromptName: undefined,
|
||||
});
|
||||
|
||||
const mockAiChatStore = {
|
||||
@@ -189,6 +220,7 @@ const {
|
||||
runStatus?: string;
|
||||
runtimeFailure?: boolean;
|
||||
};
|
||||
preferredWorkflowPromptName?: string;
|
||||
briefing?: {
|
||||
sourceLabel: string;
|
||||
title: string;
|
||||
@@ -219,6 +251,7 @@ const {
|
||||
handoffResources: _handoffResources,
|
||||
handoffActions: _handoffActions,
|
||||
handoffMetadata: _handoffMetadata,
|
||||
preferredWorkflowPromptName: _preferredWorkflowPromptName,
|
||||
...rest
|
||||
} = mockAiChatStore.context;
|
||||
mockAiChatStore.context = rest;
|
||||
@@ -236,6 +269,7 @@ const {
|
||||
mockChat,
|
||||
mockAIAPI,
|
||||
mockAIChatAPI,
|
||||
mockFetchAgentCapabilitiesManifest,
|
||||
mockNotificationStore,
|
||||
mockAiChatStore,
|
||||
mockByType,
|
||||
@@ -398,6 +432,36 @@ vi.mock('../MentionAutocomplete', () => ({
|
||||
|
||||
vi.mock('@/api/ai', () => ({ AIAPI: mockAIAPI }));
|
||||
vi.mock('@/api/aiChat', () => ({ AIChatAPI: mockAIChatAPI }));
|
||||
vi.mock('@/api/agentCapabilities', () => ({
|
||||
fetchAgentCapabilitiesManifest: mockFetchAgentCapabilitiesManifest,
|
||||
getAgentSurfaceToolPosturePresentation: (
|
||||
contract:
|
||||
| {
|
||||
surfaceLabel?: string;
|
||||
toolSource?: string;
|
||||
toolNames?: string[];
|
||||
registryToolNames?: string[];
|
||||
nativeToolNames?: string[];
|
||||
}
|
||||
| undefined,
|
||||
) => {
|
||||
if (!contract) return null;
|
||||
const toolCount = contract.toolNames?.length ?? 0;
|
||||
const label = toolCount === 1 ? '1 capability' : `${toolCount} capabilities`;
|
||||
return {
|
||||
label,
|
||||
title: `${contract.surfaceLabel || 'Pulse Assistant'} capability availability. Source: ${
|
||||
contract.toolSource === 'assistant_registry' ? 'Assistant registry' : contract.toolSource
|
||||
}. ${(contract.registryToolNames?.length ?? 0).toString()} registry capabilities. ${(
|
||||
contract.nativeToolNames?.length ?? 0
|
||||
).toString()} native capabilities.`,
|
||||
tone: toolCount > 0 ? 'ready' : 'empty',
|
||||
toolCount,
|
||||
};
|
||||
},
|
||||
getAgentWorkflowPrompts: (manifest: { workflowPrompts?: unknown[] } | undefined) =>
|
||||
manifest?.workflowPrompts ?? [],
|
||||
}));
|
||||
vi.mock('@/stores/notifications', () => ({ notificationStore: mockNotificationStore }));
|
||||
vi.mock('@/stores/aiChat', () => ({ aiChatStore: mockAiChatStore }));
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
@@ -511,7 +575,35 @@ beforeEach(() => {
|
||||
model: 'openai:gpt-4',
|
||||
});
|
||||
mockAIChatAPI.getStatus.mockResolvedValue({ running: true });
|
||||
mockAIChatAPI.getAssistantSurfaceTools.mockResolvedValue({
|
||||
surfaceId: 'pulse_assistant',
|
||||
surfaceLabel: 'Pulse Assistant',
|
||||
toolSource: 'assistant_registry',
|
||||
toolNames: ['pulse_query', 'pulse_question'],
|
||||
registryToolNames: ['pulse_query'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
});
|
||||
mockAIChatAPI.listSessions.mockResolvedValue([]);
|
||||
mockAIChatAPI.renderWorkflowPrompt.mockResolvedValue({
|
||||
description: 'Pulse fleet triage',
|
||||
text: 'Use get_fleet_context to triage the Pulse fleet.',
|
||||
});
|
||||
mockFetchAgentCapabilitiesManifest.mockResolvedValue({
|
||||
version: 'v1',
|
||||
workflowPrompts: [],
|
||||
capabilities: [],
|
||||
categories: [],
|
||||
requiredScopes: [],
|
||||
surfaceContract: {
|
||||
core: { id: 'pulse_intelligence_core', label: 'Pulse Intelligence Core', description: '' },
|
||||
proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' },
|
||||
operatorSurfaces: [],
|
||||
},
|
||||
});
|
||||
mockAIChatAPI.renameSession.mockResolvedValue({
|
||||
id: 'renamed-session',
|
||||
title: 'Renamed session',
|
||||
@@ -813,6 +905,151 @@ describe('AIChat', () => {
|
||||
expect(screen.getByPlaceholderText('Ask about your infrastructure...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders shared workflow starters and inserts rendered prompt text into the composer', async () => {
|
||||
mockAiChatStore.context = {
|
||||
targetType: 'vm',
|
||||
targetId: 'vm:101',
|
||||
findingId: undefined,
|
||||
autonomousMode: undefined,
|
||||
briefing: undefined,
|
||||
};
|
||||
mockFetchAgentCapabilitiesManifest.mockResolvedValueOnce({
|
||||
version: 'v1',
|
||||
workflowPrompts: [
|
||||
{
|
||||
name: 'pulse_triage_fleet',
|
||||
label: 'Review fleet posture',
|
||||
presentationKind: 'fleet',
|
||||
description: 'Triage the Pulse fleet.',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'pulse_operations_loop',
|
||||
label: 'Ask Patrol to handle an issue',
|
||||
presentationKind: 'workflow',
|
||||
description:
|
||||
'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'pulse_investigate_resource',
|
||||
label: 'Inspect this resource',
|
||||
presentationKind: 'resource',
|
||||
description: 'Investigate one Pulse resource.',
|
||||
arguments: [{ name: 'resourceId', required: true }],
|
||||
},
|
||||
{
|
||||
name: 'pulse_review_finding',
|
||||
presentationKind: 'finding',
|
||||
description: 'Review one Patrol finding.',
|
||||
arguments: [{ name: 'finding_id', required: true }],
|
||||
},
|
||||
],
|
||||
capabilities: [],
|
||||
categories: [],
|
||||
requiredScopes: [],
|
||||
surfaceContract: {
|
||||
core: {
|
||||
id: 'pulse_intelligence_core',
|
||||
label: 'Pulse Intelligence Core',
|
||||
description: '',
|
||||
},
|
||||
proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' },
|
||||
operatorSurfaces: [],
|
||||
},
|
||||
});
|
||||
mockAIChatAPI.renderWorkflowPrompt.mockResolvedValueOnce({
|
||||
description: 'Pulse resource investigation',
|
||||
text: 'Investigate Pulse resource "vm:101".',
|
||||
});
|
||||
|
||||
renderChat();
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('assistant-workflow-starter-pulse_triage_fleet'),
|
||||
).toHaveTextContent('Review fleet posture');
|
||||
expect(
|
||||
await screen.findByTestId('assistant-workflow-starter-pulse_operations_loop'),
|
||||
).toHaveTextContent('Ask Patrol to handle an issue');
|
||||
const resourceStarter = await screen.findByTestId(
|
||||
'assistant-workflow-starter-pulse_investigate_resource',
|
||||
);
|
||||
expect(screen.queryByTestId('assistant-workflow-starter-pulse_review_finding')).toBeNull();
|
||||
|
||||
fireEvent.click(resourceStarter);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAIChatAPI.renderWorkflowPrompt).toHaveBeenCalledWith({
|
||||
name: 'pulse_investigate_resource',
|
||||
arguments: { resourceId: 'vm:101' },
|
||||
});
|
||||
});
|
||||
expect(screen.getByPlaceholderText('Ask about your infrastructure...')).toHaveValue(
|
||||
'Investigate Pulse resource "vm:101".',
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-renders a preferred operations-loop starter for scoped Patrol handoffs', async () => {
|
||||
mockAiChatStore.context = {
|
||||
targetType: 'vm',
|
||||
targetId: 'vm:101',
|
||||
findingId: 'finding-1',
|
||||
autonomousMode: false,
|
||||
preferredWorkflowPromptName: 'pulse_operations_loop',
|
||||
};
|
||||
mockFetchAgentCapabilitiesManifest.mockResolvedValueOnce({
|
||||
version: 'v1',
|
||||
workflowPrompts: [
|
||||
{
|
||||
name: 'pulse_operations_loop',
|
||||
label: 'Ask Patrol to handle an issue',
|
||||
presentationKind: 'workflow',
|
||||
description:
|
||||
'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'pulse_review_finding',
|
||||
label: 'Review selected finding',
|
||||
presentationKind: 'finding',
|
||||
description: 'Review one Patrol finding.',
|
||||
arguments: [{ name: 'finding_id', required: true }],
|
||||
},
|
||||
],
|
||||
capabilities: [],
|
||||
categories: [],
|
||||
requiredScopes: [],
|
||||
surfaceContract: {
|
||||
core: {
|
||||
id: 'pulse_intelligence_core',
|
||||
label: 'Pulse Intelligence Core',
|
||||
description: '',
|
||||
},
|
||||
proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' },
|
||||
operatorSurfaces: [],
|
||||
},
|
||||
});
|
||||
mockAIChatAPI.renderWorkflowPrompt.mockResolvedValueOnce({
|
||||
description: 'Patrol issue handling',
|
||||
text: 'Handle this Patrol finding through explanation, governed action, verification, and history.',
|
||||
});
|
||||
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAIChatAPI.renderWorkflowPrompt).toHaveBeenCalledWith({
|
||||
name: 'pulse_operations_loop',
|
||||
arguments: {},
|
||||
});
|
||||
});
|
||||
expect(screen.getByPlaceholderText('Ask about your infrastructure...')).toHaveValue(
|
||||
'Handle this Patrol finding through explanation, governed action, verification, and history.',
|
||||
);
|
||||
expect(
|
||||
await screen.findByTestId('assistant-workflow-starter-pulse_review_finding'),
|
||||
).toHaveTextContent('Review selected finding');
|
||||
});
|
||||
|
||||
it('registers and focuses the composer when opened', async () => {
|
||||
renderChat();
|
||||
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
|
||||
@@ -833,6 +1070,30 @@ describe('AIChat', () => {
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders native Assistant capability availability from the runtime surface contract', async () => {
|
||||
mockAIChatAPI.getAssistantSurfaceTools.mockResolvedValueOnce({
|
||||
surfaceId: 'pulse_assistant',
|
||||
surfaceLabel: 'Pulse Assistant',
|
||||
toolSource: 'assistant_registry',
|
||||
toolNames: ['pulse_query', 'pulse_question', 'pulse_storage'],
|
||||
registryToolNames: ['pulse_query', 'pulse_storage'],
|
||||
nativeToolNames: ['pulse_question'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderChat();
|
||||
|
||||
const toolHealth = await screen.findByTestId('assistant-surface-tools-health');
|
||||
expect(mockAIChatAPI.getAssistantSurfaceTools).toHaveBeenCalled();
|
||||
expect(toolHealth).toHaveTextContent('3 capabilities');
|
||||
const title = toolHealth.getAttribute('title') || '';
|
||||
expect(title).toContain('Pulse Assistant capability availability');
|
||||
expect(title).toContain('Assistant registry');
|
||||
});
|
||||
|
||||
it('opens the model selector from failed-turn recovery', async () => {
|
||||
renderChat();
|
||||
|
||||
@@ -1094,7 +1355,7 @@ describe('AIChat', () => {
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /Open settings/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/system-ai',
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1507,39 +1768,39 @@ describe('AIChat', () => {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Patrol finding attached',
|
||||
subject: 'Provider connection issue on Patrol runtime',
|
||||
actionLabel: 'Open Patrol provider settings',
|
||||
actionHref: '/settings/system-ai',
|
||||
actionLabel: 'Open Provider & Models',
|
||||
actionHref: '/settings/pulse-intelligence/provider',
|
||||
},
|
||||
};
|
||||
|
||||
renderChat();
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Open Patrol provider settings' })).toHaveAttribute(
|
||||
expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/system-ai',
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Patrol configuration handoff details without replacing the attached headline', () => {
|
||||
it('renders Patrol mode handoff details without replacing the attached headline', () => {
|
||||
mockAiChatStore.context = {
|
||||
targetType: 'patrol-configuration',
|
||||
autonomousMode: false,
|
||||
briefing: {
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Patrol configuration failure attached',
|
||||
title: 'Patrol mode save failure attached',
|
||||
subject: 'patrol_autonomy_pro_required: Investigation and auto-fix require Pulse Pro.',
|
||||
statusLabel: 'HTTP 402 · model_unsupported_tools',
|
||||
detailLines: ['Provider: openrouter'],
|
||||
evidence: ['Command: sensitive or command detail withheld'],
|
||||
safetyNote:
|
||||
'Assistant can explain the configuration state; provider changes remain operator-controlled.',
|
||||
'Assistant can explain the Patrol mode state; provider changes remain operator-controlled.',
|
||||
},
|
||||
};
|
||||
|
||||
renderChat();
|
||||
|
||||
const context = screen.getByLabelText('Assistant context');
|
||||
expect(context).toHaveTextContent('Patrol configuration failure attached');
|
||||
expect(context).toHaveTextContent('Patrol mode save failure attached');
|
||||
expect(context).toHaveTextContent('patrol_autonomy_pro_required');
|
||||
expect(context).toHaveTextContent('Provider: openrouter');
|
||||
expect(context).toHaveTextContent('Command: sensitive or command detail withheld');
|
||||
@@ -1748,7 +2009,7 @@ describe('AIChat', () => {
|
||||
const routeControls = screen.getByTestId('assistant-composer-route-controls');
|
||||
const modelSelector = screen.getByTestId('model-selector');
|
||||
const controlButton = screen.getByRole('button', {
|
||||
name: 'Assistant control mode: Read-only',
|
||||
name: 'Assistant chat action mode: Read-only',
|
||||
});
|
||||
|
||||
expect(closeButton).toHaveClass('order-2');
|
||||
@@ -2004,7 +2265,7 @@ describe('AIChat', () => {
|
||||
fireEvent.input(textarea, { target: { value: '/connect' } });
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/system-ai');
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/provider');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockChat.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2018,7 +2279,7 @@ describe('AIChat', () => {
|
||||
|
||||
selectorProps.onManageProviders?.();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/system-ai');
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/provider');
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -3141,7 +3402,7 @@ describe('AIChat', () => {
|
||||
it('opens control menu on click and focuses the current mode', async () => {
|
||||
renderChat();
|
||||
const controlButton = screen.getByRole('button', {
|
||||
name: 'Assistant control mode: Read-only',
|
||||
name: 'Assistant chat action mode: Read-only',
|
||||
});
|
||||
expect(controlButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
@@ -3149,20 +3410,20 @@ describe('AIChat', () => {
|
||||
|
||||
expect(controlButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(
|
||||
screen.getByRole('menu', { name: 'Assistant control mode options' }),
|
||||
screen.getByRole('menu', { name: 'Assistant chat action options' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitemradio', { name: /Read-only/ })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('menuitemradio', { name: /Approval/ })).toHaveAttribute(
|
||||
expect(screen.getByRole('menuitemradio', { name: /Ask first/ })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByText('Default control mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('No commands or control actions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ask before running commands')).toBeInTheDocument();
|
||||
expect(screen.getByText('Executes without approval')).toBeInTheDocument();
|
||||
expect(screen.getByText('Observes only')).toBeInTheDocument();
|
||||
expect(screen.getByText('Asks before chat-only actions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Eligible chat-only actions')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('menuitemradio', { name: /Read-only/ }),
|
||||
@@ -3173,14 +3434,14 @@ describe('AIChat', () => {
|
||||
it('opens the control menu from the keyboard', async () => {
|
||||
renderChat();
|
||||
const controlButton = screen.getByRole('button', {
|
||||
name: 'Assistant control mode: Read-only',
|
||||
name: 'Assistant chat action mode: Read-only',
|
||||
});
|
||||
|
||||
fireEvent.keyDown(controlButton, { key: 'ArrowDown' });
|
||||
|
||||
expect(controlButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(
|
||||
screen.getByRole('menu', { name: 'Assistant control mode options' }),
|
||||
screen.getByRole('menu', { name: 'Assistant chat action options' }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(
|
||||
@@ -3198,34 +3459,34 @@ describe('AIChat', () => {
|
||||
</div>
|
||||
));
|
||||
const controlButton = screen.getByRole('button', {
|
||||
name: 'Assistant control mode: Read-only',
|
||||
name: 'Assistant chat action mode: Read-only',
|
||||
});
|
||||
|
||||
fireEvent.click(controlButton);
|
||||
const readOnlyOption = screen.getByRole('menuitemradio', { name: /Read-only/ });
|
||||
const approvalOption = screen.getByRole('menuitemradio', { name: /Approval/ });
|
||||
const autonomousOption = screen.getByRole('menuitemradio', { name: /Autonomous/ });
|
||||
const askFirstOption = screen.getByRole('menuitemradio', { name: /Ask first/ });
|
||||
const chatActionsOption = screen.getByRole('menuitemradio', { name: /Chat actions/ });
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(readOnlyOption);
|
||||
});
|
||||
|
||||
fireEvent.keyDown(readOnlyOption, { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(approvalOption);
|
||||
expect(document.activeElement).toBe(askFirstOption);
|
||||
|
||||
fireEvent.keyDown(approvalOption, { key: 'End' });
|
||||
expect(document.activeElement).toBe(autonomousOption);
|
||||
fireEvent.keyDown(askFirstOption, { key: 'End' });
|
||||
expect(document.activeElement).toBe(chatActionsOption);
|
||||
|
||||
fireEvent.keyDown(autonomousOption, { key: 'Home' });
|
||||
fireEvent.keyDown(chatActionsOption, { key: 'Home' });
|
||||
expect(document.activeElement).toBe(readOnlyOption);
|
||||
|
||||
fireEvent.keyDown(readOnlyOption, { key: 'ArrowUp' });
|
||||
expect(document.activeElement).toBe(autonomousOption);
|
||||
expect(document.activeElement).toBe(chatActionsOption);
|
||||
|
||||
fireEvent.keyDown(autonomousOption, { key: 'Escape' });
|
||||
fireEvent.keyDown(chatActionsOption, { key: 'Escape' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('menu', { name: 'Assistant control mode options' }),
|
||||
screen.queryByRole('menu', { name: 'Assistant chat action options' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(document.activeElement).toBe(controlButton);
|
||||
});
|
||||
@@ -4249,7 +4510,7 @@ describe('AIChat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores Patrol configuration failure handoff state as a runtime issue', async () => {
|
||||
it('restores Patrol mode save failure handoff state as a runtime issue', async () => {
|
||||
mockAIChatAPI.listSessions.mockResolvedValue([
|
||||
{
|
||||
id: 's-patrol-config',
|
||||
@@ -4293,9 +4554,9 @@ describe('AIChat', () => {
|
||||
}),
|
||||
briefing: expect.objectContaining({
|
||||
sourceLabel: 'Pulse Patrol',
|
||||
title: 'Patrol configuration failure',
|
||||
subject: 'Patrol configuration',
|
||||
actionLabel: 'Review Patrol configuration issue',
|
||||
title: 'Patrol mode save failure',
|
||||
subject: 'Patrol mode',
|
||||
actionLabel: 'Review Patrol mode issue',
|
||||
statusLabel: 'runtime issue',
|
||||
}),
|
||||
}),
|
||||
@@ -4405,7 +4666,7 @@ describe('AIChat', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
|
||||
expect(screen.getByText('Context attached')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Open Patrol provider settings')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Open Provider & Models')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Context-only Patrol follow-up'));
|
||||
@@ -5274,14 +5535,14 @@ describe('AIChat', () => {
|
||||
renderChat();
|
||||
await waitFor(() => {
|
||||
const warning = screen.getByRole('status', {
|
||||
name: 'Assistant autonomous control warning',
|
||||
name: 'Assistant chat actions warning',
|
||||
});
|
||||
expect(screen.getByTestId('assistant-activity-dock')).toContainElement(warning);
|
||||
expect(warning).toHaveTextContent('Autonomous: commands execute without approval.');
|
||||
expect(warning).toHaveTextContent('Chat-only actions are allowed.');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Switch to Approval button in autonomous warning row', async () => {
|
||||
it('shows Switch to Ask first button in autonomous warning row', async () => {
|
||||
mockAIAPI.getSettings.mockResolvedValue({
|
||||
model: 'gpt-4',
|
||||
chat_model: '',
|
||||
@@ -5292,13 +5553,13 @@ describe('AIChat', () => {
|
||||
renderChat();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('status', { name: 'Assistant autonomous control warning' }),
|
||||
screen.getByRole('status', { name: 'Assistant chat actions warning' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Switch Assistant control mode to Approval' }),
|
||||
screen.getByRole('button', { name: 'Switch Assistant chat actions to Ask first' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Dismiss autonomous control warning' }),
|
||||
screen.getByRole('button', { name: 'Dismiss chat actions warning' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5320,13 +5581,13 @@ describe('AIChat', () => {
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Approval')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ask first')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByText(/Approval required for this dashboard brief/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('status', { name: 'Assistant autonomous control warning' }),
|
||||
screen.queryByRole('status', { name: 'Assistant chat actions warning' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
|
||||
@@ -5361,7 +5622,7 @@ describe('AIChat', () => {
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Approval')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ask first')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Approval required before any action.')).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -5394,7 +5655,7 @@ describe('AIChat', () => {
|
||||
renderChat();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Approval')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ask first')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Approval required before any action.')).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -5551,7 +5812,7 @@ describe('AIChat', () => {
|
||||
});
|
||||
renderChat();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Workload Discovery is off.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Discovery is off.')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText(
|
||||
@@ -5565,7 +5826,7 @@ describe('AIChat', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockAIAPI.getSettings).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText('Workload Discovery is off.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Discovery is off.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6077,7 +6338,7 @@ describe('AIChat', () => {
|
||||
screen.getByLabelText('Assistant active turn status'),
|
||||
);
|
||||
expect(activityDock).toContainElement(
|
||||
screen.getByRole('status', { name: 'Assistant autonomous control warning' }),
|
||||
screen.getByRole('status', { name: 'Assistant chat actions warning' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -842,7 +842,7 @@ describe('getAssistantActiveTurnStatus', () => {
|
||||
),
|
||||
).toEqual({
|
||||
type: 'tool',
|
||||
text: 'Preparing governed tools · command',
|
||||
text: 'Preparing governed actions · command',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -861,7 +861,7 @@ describe('getAssistantActiveTurnStatus', () => {
|
||||
message: 'Preparing governed tools',
|
||||
tool: 'pulse_exec',
|
||||
}),
|
||||
).toBe('Preparing governed tools · command');
|
||||
).toBe('Preparing governed actions · command');
|
||||
});
|
||||
|
||||
it('lets a newer provider switch replace stale waiting progress', () => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AgentWorkflowPrompt } from '@/api/agentCapabilities';
|
||||
import { getAssistantWorkflowStarters } from '../workflowStarters';
|
||||
|
||||
const prompts = [
|
||||
{
|
||||
name: 'pulse_triage_fleet',
|
||||
label: 'Review the whole fleet',
|
||||
presentationKind: 'fleet',
|
||||
description: 'Triage the Pulse fleet.',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'pulse_operations_loop',
|
||||
label: 'Ask Patrol to handle an issue',
|
||||
presentationKind: 'workflow',
|
||||
description:
|
||||
'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'pulse_investigate_resource',
|
||||
label: 'Inspect selected resource',
|
||||
presentationKind: 'resource',
|
||||
description: 'Investigate one Pulse resource.',
|
||||
arguments: [{ name: 'resourceId', required: true }],
|
||||
},
|
||||
{
|
||||
name: 'pulse_review_finding',
|
||||
label: 'Review selected finding',
|
||||
presentationKind: 'finding',
|
||||
description: 'Review one Patrol finding.',
|
||||
arguments: [{ name: 'finding_id', required: true }],
|
||||
},
|
||||
] satisfies AgentWorkflowPrompt[];
|
||||
|
||||
describe('Assistant workflow starters', () => {
|
||||
it('keeps global starters available without scoped context', () => {
|
||||
expect(getAssistantWorkflowStarters(prompts, {})).toEqual([
|
||||
{
|
||||
id: 'pulse_triage_fleet',
|
||||
name: 'pulse_triage_fleet',
|
||||
label: 'Review the whole fleet',
|
||||
description: 'Triage the Pulse fleet.',
|
||||
kind: 'fleet',
|
||||
arguments: {},
|
||||
},
|
||||
{
|
||||
id: 'pulse_operations_loop',
|
||||
name: 'pulse_operations_loop',
|
||||
label: 'Ask Patrol to handle an issue',
|
||||
description:
|
||||
'Have Patrol investigate active findings, follow the configured Patrol policy, take approved actions, verify the outcome, and record what happened.',
|
||||
kind: 'workflow',
|
||||
arguments: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves resource workflow arguments from attached handoff resources first', () => {
|
||||
const starters = getAssistantWorkflowStarters(prompts, {
|
||||
targetType: 'vm',
|
||||
targetId: 'vm:old',
|
||||
handoffResources: [{ id: 'vm:101', name: 'web-101' }],
|
||||
});
|
||||
|
||||
expect(starters.find((starter) => starter.name === 'pulse_investigate_resource')).toMatchObject(
|
||||
{
|
||||
label: 'Inspect selected resource',
|
||||
kind: 'resource',
|
||||
arguments: { resourceId: 'vm:101' },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat Patrol run ids as resource workflow arguments', () => {
|
||||
const starters = getAssistantWorkflowStarters(prompts, {
|
||||
targetType: 'patrol-run',
|
||||
targetId: 'run-123',
|
||||
findingId: 'finding-1',
|
||||
});
|
||||
|
||||
expect(starters.map((starter) => starter.name)).toEqual([
|
||||
'pulse_triage_fleet',
|
||||
'pulse_operations_loop',
|
||||
'pulse_review_finding',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves finding workflow arguments from direct and action handoff context', () => {
|
||||
const direct = getAssistantWorkflowStarters(prompts, {
|
||||
findingId: 'finding-direct',
|
||||
});
|
||||
expect(direct.find((starter) => starter.name === 'pulse_review_finding')?.arguments).toEqual({
|
||||
finding_id: 'finding-direct',
|
||||
});
|
||||
|
||||
const action = getAssistantWorkflowStarters(prompts, {
|
||||
handoffActions: [{ findingId: 'finding-action' }],
|
||||
});
|
||||
expect(action.find((starter) => starter.name === 'pulse_review_finding')?.arguments).toEqual({
|
||||
finding_id: 'finding-action',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to workflow presentation for undeclared kinds instead of inferring names', () => {
|
||||
const [starter] = getAssistantWorkflowStarters(
|
||||
[
|
||||
{
|
||||
name: 'pulse_triage_fleet',
|
||||
label: 'Legacy fleet prompt',
|
||||
presentationKind: 'legacy',
|
||||
arguments: [],
|
||||
},
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(starter).toMatchObject({
|
||||
name: 'pulse_triage_fleet',
|
||||
kind: 'workflow',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,7 @@ const sanitizeWorkflowStatusMessage = (message: string, tool?: string) => {
|
||||
}
|
||||
|
||||
return sanitized
|
||||
.replace(/\bgoverned tools\b/gi, 'governed actions')
|
||||
.replace(/\s+\./g, '.')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
|
||||
@@ -2395,7 +2395,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
updateQuestion(messageId, questionId, { isAnswering: true });
|
||||
|
||||
try {
|
||||
// Send answer to Pulse AI via API
|
||||
// Send answer to Pulse Assistant via API
|
||||
await AIChatAPI.answerQuestion(questionId, answers);
|
||||
|
||||
// Remove the question card - it's been handled
|
||||
|
||||
@@ -23,6 +23,8 @@ import Redo2Icon from 'lucide-solid/icons/redo-2';
|
||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import RotateCwIcon from 'lucide-solid/icons/rotate-cw';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
|
||||
import SparklesIcon from 'lucide-solid/icons/sparkles';
|
||||
import Undo2Icon from 'lucide-solid/icons/undo-2';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import BellIcon from 'lucide-solid/icons/bell';
|
||||
@@ -34,6 +36,7 @@ import LoaderCircleIcon from 'lucide-solid/icons/loader-circle';
|
||||
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
|
||||
import PlusIcon from 'lucide-solid/icons/plus';
|
||||
import Trash2Icon from 'lucide-solid/icons/trash-2';
|
||||
import WrenchIcon from 'lucide-solid/icons/wrench';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import {
|
||||
AIChatAPI,
|
||||
@@ -41,6 +44,13 @@ import {
|
||||
type ChatSession,
|
||||
type ChatSessionHandoffSummary,
|
||||
} from '@/api/aiChat';
|
||||
import {
|
||||
fetchAgentCapabilitiesManifest,
|
||||
getAgentSurfaceToolPosturePresentation,
|
||||
getAgentWorkflowPrompts,
|
||||
type AgentSurfaceToolContract,
|
||||
type AgentWorkflowPrompt,
|
||||
} from '@/api/agentCapabilities';
|
||||
import { ActionIconButton } from '@/components/shared/Button';
|
||||
import { SearchField } from '@/components/shared/SearchField';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
@@ -173,6 +183,7 @@ import {
|
||||
type AssistantSlashCommand,
|
||||
type AssistantSlashCommandAction,
|
||||
} from './assistantSlashCommands';
|
||||
import { getAssistantWorkflowStarters, type AssistantWorkflowStarter } from './workflowStarters';
|
||||
import {
|
||||
assistantNotificationsEnabled,
|
||||
assistantNotificationsSupported,
|
||||
@@ -246,6 +257,22 @@ interface AssistantActiveRoutePresentation {
|
||||
title: string;
|
||||
}
|
||||
|
||||
type AssistantSurfaceToolsStatus = 'idle' | 'loading' | 'ready' | 'unavailable';
|
||||
|
||||
interface AssistantSurfaceToolsState {
|
||||
status: AssistantSurfaceToolsStatus;
|
||||
contract?: AgentSurfaceToolContract;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface AssistantSurfaceToolHealthPresentation {
|
||||
label: string;
|
||||
title: string;
|
||||
className: string;
|
||||
dotClassName: string;
|
||||
iconClassName: string;
|
||||
}
|
||||
|
||||
interface PromptHistoryEntry {
|
||||
prompt: string;
|
||||
mentions: MentionResource[];
|
||||
@@ -520,7 +547,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
|
||||
: isPatrolAssessment
|
||||
? 'Patrol assessment handoff'
|
||||
: isPatrolConfigurationFailure
|
||||
? 'Patrol configuration failure'
|
||||
? 'Patrol mode save failure'
|
||||
: isPatrolFinding
|
||||
? resourceLabel
|
||||
? `Patrol finding on ${resourceLabel}`
|
||||
@@ -574,7 +601,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
|
||||
: isPatrolAssessment
|
||||
? 'Current Patrol assessment'
|
||||
: isPatrolConfigurationFailure
|
||||
? 'Patrol configuration'
|
||||
? 'Patrol mode'
|
||||
: findingId
|
||||
? `Finding ${findingId}`
|
||||
: undefined,
|
||||
@@ -596,7 +623,7 @@ const buildSessionHandoffContext = (session?: ChatSession): AIChatContext | unde
|
||||
actionLabel: summary.requires_approval
|
||||
? 'Approval required'
|
||||
: isPatrolConfigurationFailure
|
||||
? 'Review Patrol configuration issue'
|
||||
? 'Review Patrol mode issue'
|
||||
: isPatrolAssessment
|
||||
? 'Review Patrol assessment'
|
||||
: isPatrolRun && summary.runtime_failure
|
||||
@@ -679,6 +706,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
});
|
||||
const [providerReadinessVisible, setProviderReadinessVisible] = createSignal(false);
|
||||
const [providerReadinessRetryNonce, setProviderReadinessRetryNonce] = createSignal(0);
|
||||
const [assistantSurfaceTools, setAssistantSurfaceTools] =
|
||||
createSignal<AssistantSurfaceToolsState>({
|
||||
status: 'idle',
|
||||
});
|
||||
const [controlLevel, setControlLevel] = createSignal<AIControlLevel>('read_only');
|
||||
const [showControlMenu, setShowControlMenu] = createSignal(false);
|
||||
const [controlSaving, setControlSaving] = createSignal(false);
|
||||
@@ -687,6 +718,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const [discoveryEnabled, setDiscoveryEnabled] = createSignal<boolean | null>(null); // null = loading
|
||||
const [discoveryHintDismissed, setDiscoveryHintDismissed] = createSignal(false);
|
||||
const [autonomousBannerDismissed, setAutonomousBannerDismissed] = createSignal(false);
|
||||
const [workflowPrompts, setWorkflowPrompts] = createSignal<AgentWorkflowPrompt[]>([]);
|
||||
const [renderingWorkflowStarterId, setRenderingWorkflowStarterId] = createSignal('');
|
||||
const wsStore = getGlobalWebSocketStore();
|
||||
const allResources = createMemo<Resource[]>(() => {
|
||||
const liveResources = wsStore.state.resources ?? [];
|
||||
@@ -2027,8 +2060,61 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
};
|
||||
});
|
||||
|
||||
const assistantSurfaceToolHealth = createMemo<AssistantSurfaceToolHealthPresentation | null>(
|
||||
() => {
|
||||
const state = assistantSurfaceTools();
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return {
|
||||
label: 'Checking capabilities',
|
||||
title: 'Checking Assistant capability availability',
|
||||
className:
|
||||
'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200',
|
||||
dotClassName: 'bg-blue-500 animate-pulse',
|
||||
iconClassName: 'text-blue-500 dark:text-blue-300',
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'unavailable') {
|
||||
return {
|
||||
label: 'Capabilities unavailable',
|
||||
title:
|
||||
state.message ||
|
||||
'Pulse could not load the Assistant capability contract for this session.',
|
||||
className:
|
||||
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100',
|
||||
dotClassName: 'bg-amber-500',
|
||||
iconClassName: 'text-amber-500 dark:text-amber-300',
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status !== 'ready') return null;
|
||||
const posture = getAgentSurfaceToolPosturePresentation(state.contract);
|
||||
if (!posture) return null;
|
||||
|
||||
const readyClassName =
|
||||
posture.tone === 'ready'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100';
|
||||
const readyDotClassName = posture.tone === 'ready' ? 'bg-emerald-500' : 'bg-amber-500';
|
||||
const readyIconClassName =
|
||||
posture.tone === 'ready'
|
||||
? 'text-emerald-500 dark:text-emerald-300'
|
||||
: 'text-amber-500 dark:text-amber-300';
|
||||
|
||||
return {
|
||||
label: posture.label,
|
||||
title: posture.title,
|
||||
className: readyClassName,
|
||||
dotClassName: readyDotClassName,
|
||||
iconClassName: readyIconClassName,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let providerReadinessRequestId = 0;
|
||||
let lastProviderReadinessKey = '';
|
||||
let assistantSurfaceToolsRequestId = 0;
|
||||
|
||||
const refreshSelectedProviderReadiness = async (provider: string, model: string) => {
|
||||
const requestId = ++providerReadinessRequestId;
|
||||
@@ -2146,6 +2232,41 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadWorkflowPrompts = async () => {
|
||||
try {
|
||||
const manifest = await fetchAgentCapabilitiesManifest();
|
||||
setWorkflowPrompts(getAgentWorkflowPrompts(manifest));
|
||||
} catch (error) {
|
||||
logger.debug('[AIChat] Workflow prompt manifest unavailable during initialization:', error);
|
||||
setWorkflowPrompts([]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssistantSurfaceTools = async () => {
|
||||
const requestId = ++assistantSurfaceToolsRequestId;
|
||||
setAssistantSurfaceTools({ status: 'loading' });
|
||||
|
||||
try {
|
||||
const contract = await AIChatAPI.getAssistantSurfaceTools();
|
||||
if (requestId !== assistantSurfaceToolsRequestId) return;
|
||||
setAssistantSurfaceTools({ status: 'ready', contract });
|
||||
} catch (error) {
|
||||
if (requestId !== assistantSurfaceToolsRequestId) return;
|
||||
logger.debug(
|
||||
'[AIChat] Assistant surface tool contract unavailable during initialization:',
|
||||
error,
|
||||
);
|
||||
setAssistantSurfaceTools({
|
||||
status: 'unavailable',
|
||||
message: 'Assistant capabilities could not be loaded.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const assistantWorkflowStarters = createMemo(() =>
|
||||
getAssistantWorkflowStarters(workflowPrompts(), aiChatStore.context),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const settings = aiRuntimeSettings();
|
||||
const chatOverride = (settings?.chat_model || '').trim();
|
||||
@@ -2170,7 +2291,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
setControlLevel(resolved);
|
||||
if (resolved === 'autonomous') setAutonomousBannerDismissed(false);
|
||||
notificationStore.success(
|
||||
`Control mode set to ${getAIChatControlLevelPresentation(resolved).label}`,
|
||||
`Assistant chat action mode set to ${getAIChatControlLevelPresentation(resolved).label}`,
|
||||
2000,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -2351,12 +2472,23 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
setChatOverrideModel('');
|
||||
setDiscoveryEnabled(false);
|
||||
setControlLevel('read_only');
|
||||
setWorkflowPrompts([]);
|
||||
assistantSurfaceToolsRequestId += 1;
|
||||
setAssistantSurfaceTools({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
const [sessionsResult, settingsResult, modelsResult] = await Promise.allSettled([
|
||||
const [
|
||||
sessionsResult,
|
||||
settingsResult,
|
||||
modelsResult,
|
||||
workflowPromptsResult,
|
||||
assistantSurfaceToolsResult,
|
||||
] = await Promise.allSettled([
|
||||
refreshSessions(),
|
||||
loadAIRuntimeSettings(),
|
||||
loadAIRuntimeModels(),
|
||||
loadWorkflowPrompts(),
|
||||
loadAssistantSurfaceTools(),
|
||||
]);
|
||||
if (sessionsResult.status === 'rejected') {
|
||||
logger.error('[AIChat] Failed to load sessions:', sessionsResult.reason);
|
||||
@@ -2370,6 +2502,18 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
modelsResult.reason,
|
||||
);
|
||||
}
|
||||
if (workflowPromptsResult.status === 'rejected') {
|
||||
logger.debug(
|
||||
'[AIChat] Workflow prompt manifest unavailable during initialization:',
|
||||
workflowPromptsResult.reason,
|
||||
);
|
||||
}
|
||||
if (assistantSurfaceToolsResult.status === 'rejected') {
|
||||
logger.debug(
|
||||
'[AIChat] Assistant surface tool contract unavailable during initialization:',
|
||||
assistantSurfaceToolsResult.reason,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AIChat] Failed to initialize:', error);
|
||||
}
|
||||
@@ -3263,7 +3407,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
Boolean(ctx.handoffContext?.trim()) ||
|
||||
Boolean(ctx.handoffResources?.length) ||
|
||||
Boolean(ctx.handoffActions?.length) ||
|
||||
Boolean(ctx.handoffMetadata);
|
||||
Boolean(ctx.handoffMetadata) ||
|
||||
Boolean(ctx.preferredWorkflowPromptName?.trim());
|
||||
sendPromise
|
||||
.then((ok) => {
|
||||
if (!ok) {
|
||||
@@ -3294,6 +3439,78 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
focusComposer();
|
||||
};
|
||||
|
||||
const renderWorkflowStarterIntoComposer = async (
|
||||
starter: AssistantWorkflowStarter,
|
||||
options: { onlyWhenComposerEmpty?: boolean } = {},
|
||||
) => {
|
||||
if (renderingWorkflowStarterId()) return;
|
||||
if (options.onlyWhenComposerEmpty && input().trim()) return;
|
||||
|
||||
setRenderingWorkflowStarterId(starter.id);
|
||||
try {
|
||||
const rendered = await AIChatAPI.renderWorkflowPrompt({
|
||||
name: starter.name,
|
||||
arguments: starter.arguments,
|
||||
});
|
||||
const starterText = rendered.text.trim();
|
||||
if (!starterText) {
|
||||
notificationStore.warning('Workflow starter returned an empty prompt.', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
setInput((current) => {
|
||||
const draft = current.trim();
|
||||
if (options.onlyWhenComposerEmpty && draft) return current;
|
||||
return draft ? `${draft}\n\n${starterText}` : starterText;
|
||||
});
|
||||
setSlashCommandActive(false);
|
||||
setSlashCommandQuery('');
|
||||
setMentionActive(false);
|
||||
resetPromptHistoryNavigation();
|
||||
focusComposer();
|
||||
} catch (error) {
|
||||
logger.error('[AIChat] Failed to render workflow starter:', error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to prepare workflow starter.';
|
||||
notificationStore.error(message);
|
||||
} finally {
|
||||
setRenderingWorkflowStarterId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkflowStarterSelect = async (starter: AssistantWorkflowStarter) => {
|
||||
await renderWorkflowStarterIntoComposer(starter);
|
||||
};
|
||||
|
||||
let autoRenderedWorkflowStarterKey = '';
|
||||
createEffect(() => {
|
||||
const context = aiChatStore.context;
|
||||
const preferredWorkflowPromptName = (context.preferredWorkflowPromptName ?? '').trim();
|
||||
if (!preferredWorkflowPromptName) {
|
||||
autoRenderedWorkflowStarterKey = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const starter = assistantWorkflowStarters().find(
|
||||
(candidate) => candidate.name === preferredWorkflowPromptName,
|
||||
);
|
||||
if (!starter || renderingWorkflowStarterId()) return;
|
||||
|
||||
const starterKey = [
|
||||
starter.name,
|
||||
context.findingId ?? '',
|
||||
context.targetType ?? '',
|
||||
context.targetId ?? '',
|
||||
JSON.stringify(starter.arguments),
|
||||
].join('|');
|
||||
if (autoRenderedWorkflowStarterKey === starterKey) return;
|
||||
|
||||
autoRenderedWorkflowStarterKey = starterKey;
|
||||
if (input().trim()) return;
|
||||
|
||||
void renderWorkflowStarterIntoComposer(starter, { onlyWhenComposerEmpty: true });
|
||||
});
|
||||
|
||||
const updateSlashCommandAutocomplete = (value: string, cursorPos: number) => {
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
const textAfterCursor = value.slice(cursorPos);
|
||||
@@ -3890,6 +4107,19 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
chat.updateQuestion(messageId, questionId, { removed: true });
|
||||
};
|
||||
|
||||
const workflowStarterIcon = (starter: AssistantWorkflowStarter) => {
|
||||
switch (starter.kind) {
|
||||
case 'resource':
|
||||
return <CpuIcon class="h-3.5 w-3.5" aria-hidden="true" />;
|
||||
case 'finding':
|
||||
return <ShieldAlertIcon class="h-3.5 w-3.5" aria-hidden="true" />;
|
||||
case 'fleet':
|
||||
case 'workflow':
|
||||
default:
|
||||
return <SparklesIcon class="h-3.5 w-3.5" aria-hidden="true" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={isOpen() && isOverlayLayout()}>
|
||||
@@ -4709,7 +4939,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
currentStatus() ? 'border-t border-border/70' : ''
|
||||
}`}
|
||||
role="status"
|
||||
aria-label="Assistant autonomous control warning"
|
||||
aria-label="Assistant chat actions warning"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
@@ -4717,7 +4947,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 font-medium leading-4 sm:truncate">
|
||||
Autonomous: commands execute without approval.
|
||||
Chat-only actions are allowed.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -4725,7 +4955,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
class="inline-flex shrink-0 items-center rounded-md border border-red-200 bg-surface px-2 py-1 text-[10px] font-medium text-red-700 transition-colors hover:bg-red-50 hover:text-red-900 dark:border-red-800 dark:bg-surface dark:text-red-200 dark:hover:bg-red-950/40"
|
||||
aria-label={AI_CHAT_SWITCH_TO_APPROVAL_LABEL}
|
||||
>
|
||||
Switch to Approval
|
||||
Switch to Ask first
|
||||
</button>
|
||||
<ActionIconButton
|
||||
onClick={() => setAutonomousBannerDismissed(true)}
|
||||
@@ -4867,6 +5097,46 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={assistantWorkflowStarters().length > 0}>
|
||||
<div
|
||||
class="mb-2 flex min-h-7 min-w-0 flex-wrap items-center gap-1.5"
|
||||
aria-label="Assistant workflow starters"
|
||||
data-testid="assistant-workflow-starters"
|
||||
>
|
||||
<For each={assistantWorkflowStarters()}>
|
||||
{(starter) => {
|
||||
const isRendering = () => renderingWorkflowStarterId() === starter.id;
|
||||
const label = () =>
|
||||
starter.description
|
||||
? `${starter.label}: ${starter.description}`
|
||||
: starter.label;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleWorkflowStarterSelect(starter);
|
||||
}}
|
||||
disabled={Boolean(renderingWorkflowStarterId())}
|
||||
class="inline-flex h-7 max-w-full items-center gap-1.5 rounded-md border border-border bg-surface-alt px-2 text-[11px] font-medium text-base-content transition-colors hover:border-blue-300 hover:bg-blue-50 disabled:cursor-wait disabled:opacity-60 dark:hover:border-blue-800 dark:hover:bg-blue-950/40"
|
||||
title={label()}
|
||||
aria-label={label()}
|
||||
data-testid={`assistant-workflow-starter-${starter.name}`}
|
||||
>
|
||||
<Show
|
||||
when={isRendering()}
|
||||
fallback={
|
||||
<span class="shrink-0 text-muted">{workflowStarterIcon(starter)}</span>
|
||||
}
|
||||
>
|
||||
<LoaderCircleIcon class="h-3.5 w-3.5 shrink-0 animate-spin text-blue-600 dark:text-blue-300" />
|
||||
</Show>
|
||||
<span class="min-w-0 truncate">{starter.label}</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<form
|
||||
data-assistant-composer
|
||||
onSubmit={(e) => {
|
||||
@@ -4965,6 +5235,28 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={assistantSurfaceToolHealth()}>
|
||||
{(health) => (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Assistant capability availability"
|
||||
aria-live="polite"
|
||||
title={health().title}
|
||||
class={`inline-flex h-7 max-w-[9rem] shrink-0 items-center gap-1.5 rounded-md border px-2 text-[10px] font-medium ${health().className}`}
|
||||
data-testid="assistant-surface-tools-health"
|
||||
>
|
||||
<WrenchIcon
|
||||
class={`h-3 w-3 shrink-0 ${health().iconClassName}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class={`h-1.5 w-1.5 shrink-0 rounded-full ${health().dotClassName}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{health().label}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ActionIconButton
|
||||
onClick={openAssistantCommandHelp}
|
||||
tone="outline"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { AgentWorkflowPrompt } from '@/api/agentCapabilities';
|
||||
import type { AIChatContext } from '@/stores/aiChat';
|
||||
|
||||
export type AssistantWorkflowStarterKind = 'fleet' | 'resource' | 'finding' | 'workflow';
|
||||
|
||||
export interface AssistantWorkflowStarter {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
kind: AssistantWorkflowStarterKind;
|
||||
arguments: Record<string, string>;
|
||||
}
|
||||
|
||||
const WORKFLOW_STARTER_KINDS = new Set<AssistantWorkflowStarterKind>([
|
||||
'fleet',
|
||||
'resource',
|
||||
'finding',
|
||||
'workflow',
|
||||
]);
|
||||
|
||||
const PATROL_TARGET_TYPES = new Set(['patrol-run', 'patrol-assessment', 'patrol-configuration']);
|
||||
|
||||
const normalizeIdentifier = (value: unknown): string => {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const workflowStarterLabel = (prompt: AgentWorkflowPrompt): string => {
|
||||
const label = normalizeIdentifier(prompt.label);
|
||||
if (label) return label;
|
||||
|
||||
const name = normalizeIdentifier(prompt.name);
|
||||
const description = normalizeIdentifier(prompt.description);
|
||||
if (description) return description;
|
||||
|
||||
return name
|
||||
.replace(/^pulse_/, '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const workflowStarterKind = (prompt: AgentWorkflowPrompt): AssistantWorkflowStarterKind => {
|
||||
const kind = normalizeIdentifier(prompt.presentationKind);
|
||||
if (WORKFLOW_STARTER_KINDS.has(kind as AssistantWorkflowStarterKind)) {
|
||||
return kind as AssistantWorkflowStarterKind;
|
||||
}
|
||||
return 'workflow';
|
||||
};
|
||||
|
||||
export const getAssistantWorkflowStarterResourceId = (context: AIChatContext): string => {
|
||||
for (const resource of context.handoffResources ?? []) {
|
||||
const id = normalizeIdentifier(resource.id);
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
for (const action of context.handoffActions ?? []) {
|
||||
const id = normalizeIdentifier(action.targetResourceId);
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
const targetId = normalizeIdentifier(context.targetId);
|
||||
const targetType = normalizeIdentifier(context.targetType);
|
||||
if (targetId && !PATROL_TARGET_TYPES.has(targetType)) {
|
||||
return targetId;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getAssistantWorkflowStarterFindingId = (context: AIChatContext): string => {
|
||||
const direct = normalizeIdentifier(context.findingId);
|
||||
if (direct) return direct;
|
||||
|
||||
for (const action of context.handoffActions ?? []) {
|
||||
const id = normalizeIdentifier(action.findingId);
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
return normalizeIdentifier(context.context?.findingId);
|
||||
};
|
||||
|
||||
const resolveWorkflowStarterArgument = (
|
||||
name: string,
|
||||
context: AIChatContext,
|
||||
): string | undefined => {
|
||||
switch (name) {
|
||||
case 'resourceId':
|
||||
return getAssistantWorkflowStarterResourceId(context);
|
||||
case 'finding_id':
|
||||
return getAssistantWorkflowStarterFindingId(context);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const buildWorkflowStarterArguments = (
|
||||
prompt: AgentWorkflowPrompt,
|
||||
context: AIChatContext,
|
||||
): Record<string, string> | null => {
|
||||
const args: Record<string, string> = {};
|
||||
for (const argument of prompt.arguments ?? []) {
|
||||
const name = normalizeIdentifier(argument.name);
|
||||
if (!name) continue;
|
||||
|
||||
const value = resolveWorkflowStarterArgument(name, context);
|
||||
if (value) {
|
||||
args[name] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argument.required) return null;
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
export const getAssistantWorkflowStarters = (
|
||||
prompts: AgentWorkflowPrompt[],
|
||||
context: AIChatContext,
|
||||
): AssistantWorkflowStarter[] => {
|
||||
const starters: AssistantWorkflowStarter[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const name = normalizeIdentifier(prompt.name);
|
||||
if (!name || seen.has(name)) continue;
|
||||
|
||||
const args = buildWorkflowStarterArguments(prompt, context);
|
||||
if (!args) continue;
|
||||
|
||||
seen.add(name);
|
||||
const description = normalizeIdentifier(prompt.description);
|
||||
starters.push({
|
||||
id: name,
|
||||
name,
|
||||
label: workflowStarterLabel(prompt),
|
||||
description: description || undefined,
|
||||
kind: workflowStarterKind(prompt),
|
||||
arguments: args,
|
||||
});
|
||||
}
|
||||
|
||||
return starters;
|
||||
};
|
||||
@@ -21,8 +21,9 @@ import { notificationStore } from '@/stores/notifications';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import {
|
||||
buildPatrolAssistantApprovalBriefingInput,
|
||||
buildPatrolAssistantFindingHandoff,
|
||||
buildPatrolAssistantFindingHandoffFromUnifiedFinding,
|
||||
buildPatrolAssistantProposedFixBriefingInput,
|
||||
buildPatrolAssistantProposedFixBriefingInputFromApproval,
|
||||
buildPatrolRemediationPlanAssistantBriefing,
|
||||
buildPatrolRemediationPlanAssistantModelContext,
|
||||
patrolAssistantFindingHandoffRequiresApprovalMode,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
type PatrolAssistantProposedFixBriefingInput,
|
||||
} from '@/features/patrol/patrolInvestigationContextModel';
|
||||
import { InvestigationSection, ApprovalSection } from '@/components/patrol';
|
||||
import { AIAPI, type ApprovalRequest, type RemediationPlan } from '@/api/ai';
|
||||
import { AIAPI, type RemediationPlan } from '@/api/ai';
|
||||
import { createSuppressionRuleFromFinding } from '@/api/patrol';
|
||||
import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
@@ -43,6 +44,8 @@ import { getPatrolFindingsEmptyState } from '@/utils/patrolEmptyStatePresentatio
|
||||
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
|
||||
import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
|
||||
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
|
||||
import InfoIcon from 'lucide-solid/icons/info';
|
||||
import ChevronDownIcon from 'lucide-solid/icons/chevron-down';
|
||||
import {
|
||||
buildFindingFilterOptions,
|
||||
formatFindingForClipboard,
|
||||
@@ -52,6 +55,9 @@ import {
|
||||
formatFindingLoopState,
|
||||
getFindingActiveRuntimeSortOrder,
|
||||
getFindingSeverityPresentation,
|
||||
buildPatrolFindingDisplayGroups,
|
||||
getPatrolFindingIssueCountLabel,
|
||||
getFindingPatrolWorkflowPresentation,
|
||||
getFindingSeveritySortOrder,
|
||||
getFindingResolutionReason,
|
||||
getFindingLoopStateBadgeTone,
|
||||
@@ -95,7 +101,10 @@ interface FindingsPanelProps {
|
||||
runtimeState?: PatrolRuntimeState;
|
||||
blockedReason?: string;
|
||||
overallHealth?: IntelligenceHealthScore;
|
||||
historicalRegressionCount?: number;
|
||||
patrolRuns?: PatrolRunRecord[];
|
||||
findingsSource?: 'unified' | 'patrol';
|
||||
onAssistantHandoff?: (finding: UnifiedFinding) => void;
|
||||
runSnapshot?: Pick<
|
||||
PatrolRunRecord,
|
||||
| 'resources_checked'
|
||||
@@ -147,6 +156,33 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
|
||||
const [dismissedPlanIds, setDismissedPlanIds] = createSignal<string[]>([]);
|
||||
const isPatrolFindingsSource = createMemo(() => props.findingsSource === 'patrol');
|
||||
const hasUnknownRunSnapshot = createMemo(
|
||||
() => props.runSnapshot !== undefined && props.filterFindingIds === undefined,
|
||||
);
|
||||
const hasEmptyRunSnapshot = createMemo(
|
||||
() =>
|
||||
props.runSnapshot !== undefined &&
|
||||
Array.isArray(props.filterFindingIds) &&
|
||||
props.filterFindingIds.length === 0,
|
||||
);
|
||||
const canRenderRunSnapshotWithoutSourceFindings = createMemo(
|
||||
() => hasUnknownRunSnapshot() || hasEmptyRunSnapshot(),
|
||||
);
|
||||
const shouldShowFindingLifecycle = (finding: UnifiedFinding) =>
|
||||
Boolean(finding.lifecycle && finding.lifecycle.length > 0) &&
|
||||
(!isPatrolFindingsSource() ||
|
||||
finding.status !== 'active' ||
|
||||
props.runSnapshot !== undefined ||
|
||||
filter() === 'all' ||
|
||||
filter() === 'resolved');
|
||||
const shouldShowCollapsedPrimaryAction = (finding: UnifiedFinding) =>
|
||||
isPatrolFindingsSource() &&
|
||||
finding.status === 'active' &&
|
||||
props.runSnapshot === undefined &&
|
||||
expandedId() !== finding.id &&
|
||||
Boolean(getFindingPrimaryActionPresentation(finding));
|
||||
const shouldShowInlineManualControls = (finding: UnifiedFinding) =>
|
||||
finding.status === 'active' && !isPatrolFindingsSource();
|
||||
const sourceFindings = createMemo(() =>
|
||||
isPatrolFindingsSource() ? aiIntelligenceStore.patrolFindings : aiIntelligenceStore.findings,
|
||||
);
|
||||
@@ -156,7 +192,12 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
? aiIntelligenceStore.patrolFindingsLoading
|
||||
: aiIntelligenceStore.findingsLoading,
|
||||
);
|
||||
const shouldShowLoadingState = createMemo(() => sourceFindingsLoading() && !sourceHasFindings());
|
||||
const shouldShowLoadingState = createMemo(
|
||||
() =>
|
||||
sourceFindingsLoading() &&
|
||||
!sourceHasFindings() &&
|
||||
!canRenderRunSnapshotWithoutSourceFindings(),
|
||||
);
|
||||
const sourceFindingsError = createMemo(() =>
|
||||
isPatrolFindingsSource()
|
||||
? aiIntelligenceStore.patrolFindingsError
|
||||
@@ -230,9 +271,6 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
});
|
||||
|
||||
// Filter and sort findings
|
||||
const hasUnknownRunSnapshot = createMemo(
|
||||
() => props.runSnapshot !== undefined && props.filterFindingIds === undefined,
|
||||
);
|
||||
const filteredFindings = createMemo(() => {
|
||||
if (hasUnknownRunSnapshot()) {
|
||||
return [];
|
||||
@@ -381,6 +419,28 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
(f) => f.source !== 'threshold' && !f.isThreshold && !hasTriggeringAlert(f),
|
||||
);
|
||||
});
|
||||
const shouldGroupPatrolFindingsByResource = createMemo(
|
||||
() =>
|
||||
isPatrolFindingsSource() &&
|
||||
filter() === 'active' &&
|
||||
props.runSnapshot === undefined &&
|
||||
props.resourceId === undefined,
|
||||
);
|
||||
const patrolFindingDisplayGroups = createMemo(() => {
|
||||
const findings = patrolFindings();
|
||||
if (!shouldGroupPatrolFindingsByResource()) {
|
||||
return findings.map((finding) => ({
|
||||
id: `finding:${finding.id}`,
|
||||
resourceKey: `finding:${finding.id}`,
|
||||
resourceLabel: getFindingSubjectPresentation(finding).label,
|
||||
primaryFinding: finding,
|
||||
relatedFindings: [],
|
||||
findings: [finding],
|
||||
}));
|
||||
}
|
||||
|
||||
return buildPatrolFindingDisplayGroups(findings);
|
||||
});
|
||||
const filterOptions = createMemo(() => buildFindingFilterOptions(filterCounts()));
|
||||
const emptyStateCopy = createMemo(() => {
|
||||
// 'overdue' is a FindingsPanel-local extension to the shared
|
||||
@@ -395,10 +455,15 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
overallHealth: props.overallHealth,
|
||||
runtimeState: props.runtimeState,
|
||||
blockedReason: props.blockedReason,
|
||||
historicalRegressionCount: props.historicalRegressionCount,
|
||||
runs: props.patrolRuns,
|
||||
runSnapshot: props.runSnapshot,
|
||||
});
|
||||
});
|
||||
const emptyStateTone = createMemo(() => getSemanticTonePresentation(emptyStateCopy().tone));
|
||||
const shouldShowRichEmptyState = createMemo(
|
||||
() => filter() === 'active' || props.runSnapshot !== undefined,
|
||||
);
|
||||
// Auto-reset filter when the conditional filter buttons disappear
|
||||
createEffect(() => {
|
||||
if (filter() === 'attention' && filterCounts().needsAttentionCount === 0) {
|
||||
@@ -563,20 +628,6 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
setEditingNoteId(null);
|
||||
};
|
||||
|
||||
const buildLiveApprovalProposedFixBriefing = (
|
||||
approval: ApprovalRequest | undefined,
|
||||
): PatrolAssistantProposedFixBriefingInput | undefined =>
|
||||
buildPatrolAssistantProposedFixBriefingInput(
|
||||
approval
|
||||
? {
|
||||
description: approval.context,
|
||||
riskLevel: approval.riskLevel,
|
||||
targetHost: approval.targetName,
|
||||
commandCount: approval.command ? 1 : 0,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const loadLatestInvestigationProposedFixBriefing = async (
|
||||
finding: UnifiedFinding,
|
||||
pendingApprovalBriefing: PatrolAssistantApprovalBriefingInput | undefined,
|
||||
@@ -610,8 +661,6 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
|
||||
const openFindingInAssistant = async (finding: UnifiedFinding) => {
|
||||
await aiIntelligenceStore.loadPendingApprovals();
|
||||
const subject = getFindingSubjectPresentation(finding).label;
|
||||
const title = getFindingTitlePresentation(finding).label;
|
||||
const pendingApproval = aiIntelligenceStore.patrolPendingApprovals.find(
|
||||
(approval) => approval.toolId === 'investigation_fix' && approval.targetId === finding.id,
|
||||
);
|
||||
@@ -621,31 +670,14 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
pendingApprovalBriefing,
|
||||
);
|
||||
const proposedFix =
|
||||
latestInvestigationProposedFix || buildLiveApprovalProposedFixBriefing(pendingApproval);
|
||||
const handoff = buildPatrolAssistantFindingHandoff({
|
||||
id: finding.id,
|
||||
title,
|
||||
subject,
|
||||
description: finding.description,
|
||||
severity: finding.severity,
|
||||
findingStatus: finding.status,
|
||||
investigationStatus: finding.investigationStatus,
|
||||
investigationOutcome: finding.investigationOutcome,
|
||||
loopState: finding.loopState,
|
||||
timesRaised: finding.timesRaised,
|
||||
regressionCount: finding.regressionCount,
|
||||
lastRegressionAt: finding.lastRegressionAt,
|
||||
remediationId: finding.remediationPlanId,
|
||||
resourceId: finding.resourceId,
|
||||
resourceName: finding.resourceName,
|
||||
resourceType: finding.resourceType,
|
||||
detectedAt: finding.detectedAt,
|
||||
lastSeenAt: finding.lastSeenAt,
|
||||
latestInvestigationProposedFix ||
|
||||
buildPatrolAssistantProposedFixBriefingInputFromApproval(pendingApproval);
|
||||
const handoff = buildPatrolAssistantFindingHandoffFromUnifiedFinding(finding, {
|
||||
pendingApproval: pendingApprovalBriefing,
|
||||
proposedFix,
|
||||
investigationRecord: finding.investigationRecord,
|
||||
});
|
||||
aiChatStore.open(handoff.context);
|
||||
props.onAssistantHandoff?.(finding);
|
||||
};
|
||||
|
||||
// Create rule from this is the promotion path: take the operator's
|
||||
@@ -780,12 +812,46 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
};
|
||||
|
||||
// Render a single finding item
|
||||
const renderFindingItem = (finding: UnifiedFinding, showSourceBadge: boolean = false) => {
|
||||
const renderFindingItem = (
|
||||
finding: UnifiedFinding,
|
||||
showSourceBadge: boolean = false,
|
||||
options: { hideSubject?: boolean; relatedFindings?: readonly UnifiedFinding[] } = {},
|
||||
) => {
|
||||
const recency = getFindingRecencyPresentation(finding);
|
||||
const subject = getFindingSubjectPresentation(finding);
|
||||
const title = getFindingTitlePresentation(finding);
|
||||
const manualControls = getFindingManualControlsPresentation(finding);
|
||||
const severityPresentation = getFindingSeverityPresentation(finding);
|
||||
const rowPrimaryAction = getFindingPrimaryActionPresentation(finding);
|
||||
const patrolWorkflow = () =>
|
||||
getFindingPatrolWorkflowPresentation(finding, aiIntelligenceStore.patrolPendingApprovals);
|
||||
const collapsedApprovalAction = () => {
|
||||
const workflow = patrolWorkflow();
|
||||
return workflow?.stage === 'approval' ? workflow : undefined;
|
||||
};
|
||||
const shouldShowLoopStateBadge = () =>
|
||||
!isPatrolFindingsSource() &&
|
||||
finding.status === 'active' &&
|
||||
finding.source !== 'ai-patrol' &&
|
||||
Boolean(finding.loopState) &&
|
||||
finding.loopState !== 'detected' &&
|
||||
!finding.investigationStatus &&
|
||||
!finding.investigationOutcome;
|
||||
const shouldShowInvestigationStatusBadge = () =>
|
||||
!isPatrolFindingsSource() &&
|
||||
Boolean(finding.investigationStatus) &&
|
||||
!(
|
||||
finding.investigationOutcome &&
|
||||
finding.investigationStatus !== 'running' &&
|
||||
finding.investigationStatus !== 'pending'
|
||||
);
|
||||
const shouldShowInvestigationOutcomeBadge = () =>
|
||||
!isPatrolFindingsSource() &&
|
||||
Boolean(finding.investigationOutcome) &&
|
||||
finding.investigationStatus !== 'running' &&
|
||||
finding.investigationStatus !== 'pending';
|
||||
const shouldShowInvestigationConfidenceBadge = () =>
|
||||
!isPatrolFindingsSource() && Boolean(finding.investigationRecord?.confidence);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
if (expandedId() === finding.id) {
|
||||
@@ -809,12 +875,20 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
>
|
||||
{/* Finding header */}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${expandedId() === finding.id ? 'Close issue details' : 'Open issue details'} for ${title.label}`}
|
||||
aria-expanded={expandedId() === finding.id}
|
||||
aria-controls={`finding-${finding.id}-details`}
|
||||
class="min-w-0 flex-1 cursor-pointer text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
onClick={toggleExpanded}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpanded();
|
||||
}
|
||||
}}
|
||||
class="min-w-0 flex-1 cursor-pointer rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{/* Status badge for non-active findings */}
|
||||
@@ -862,19 +936,11 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
Acknowledged
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
finding.status === 'active' &&
|
||||
finding.loopState &&
|
||||
!(finding.acknowledgedAt && finding.loopState === 'detected') &&
|
||||
!finding.investigationStatus &&
|
||||
!finding.investigationOutcome
|
||||
}
|
||||
>
|
||||
<Show when={shouldShowLoopStateBadge()}>
|
||||
<MetadataBadge
|
||||
{...FINDING_ROW_BADGE_PROPS}
|
||||
tone={getFindingLoopStateBadgeTone(finding.loopState!)}
|
||||
title={`Patrol loop: ${formatFindingLoopState(finding.loopState!)}`}
|
||||
title={`Patrol status: ${formatFindingLoopState(finding.loopState!)}`}
|
||||
>
|
||||
{formatFindingLoopState(finding.loopState!)}
|
||||
</MetadataBadge>
|
||||
@@ -888,17 +954,8 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
Out of scope
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
{/* Investigation status badge — only when no outcome badge will show */}
|
||||
<Show
|
||||
when={
|
||||
finding.investigationStatus &&
|
||||
!(
|
||||
finding.investigationOutcome &&
|
||||
finding.investigationStatus !== 'running' &&
|
||||
finding.investigationStatus !== 'pending'
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Non-Patrol investigation status badge — only when no outcome badge will show. */}
|
||||
<Show when={shouldShowInvestigationStatusBadge()}>
|
||||
<MetadataBadge
|
||||
{...FINDING_ROW_BADGE_PROPS}
|
||||
tone={getInvestigationStatusBadgeTone(finding.investigationStatus!)}
|
||||
@@ -920,14 +977,8 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
{getInvestigationStatusLabel(finding.investigationStatus!)}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
{/* Investigation outcome badge — replaces status badge when outcome is known */}
|
||||
<Show
|
||||
when={
|
||||
finding.investigationOutcome &&
|
||||
finding.investigationStatus !== 'running' &&
|
||||
finding.investigationStatus !== 'pending'
|
||||
}
|
||||
>
|
||||
{/* Non-Patrol investigation outcome badge — replaces status badge when known. */}
|
||||
<Show when={shouldShowInvestigationOutcomeBadge()}>
|
||||
<MetadataBadge
|
||||
{...FINDING_ROW_BADGE_PROPS}
|
||||
tone={getInvestigationOutcomeBadgeTone(finding.investigationOutcome!)}
|
||||
@@ -935,13 +986,14 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
{getInvestigationOutcomeLabel(finding.investigationOutcome!)}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
{/* Investigation confidence badge — surfaces the seven-question
|
||||
schema's confidence answer in the collapsed row so operators
|
||||
can scan trust without expanding the card. */}
|
||||
<Show when={finding.investigationRecord?.confidence}>
|
||||
{/* Non-Patrol confidence badge. Patrol keeps investigation detail
|
||||
in the expanded issue so the default row stays simple. */}
|
||||
<Show when={shouldShowInvestigationConfidenceBadge()}>
|
||||
<MetadataBadge
|
||||
{...FINDING_ROW_BADGE_PROPS}
|
||||
tone={getInvestigationConfidenceBadgeTone(finding.investigationRecord!.confidence!)}
|
||||
tone={getInvestigationConfidenceBadgeTone(
|
||||
finding.investigationRecord!.confidence!,
|
||||
)}
|
||||
title={`Investigation confidence: ${finding.investigationRecord!.confidence!}`}
|
||||
>
|
||||
{finding.investigationRecord!.confidence!} confidence
|
||||
@@ -972,7 +1024,16 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
</div>
|
||||
{/* Resource info */}
|
||||
<div class="text-xs text-muted mt-1">
|
||||
{subject.label} - {recency.label} {formatTime(recency.timestamp)}
|
||||
<Show
|
||||
when={options.hideSubject}
|
||||
fallback={
|
||||
<>
|
||||
{subject.label} - {recency.label} {formatTime(recency.timestamp)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{recency.label} {formatTime(recency.timestamp)}
|
||||
</Show>
|
||||
<Show when={finding.status === 'resolved' && finding.resolvedAt}>
|
||||
<span class="ml-2 text-green-600 dark:text-green-400">
|
||||
{' · '}
|
||||
@@ -1029,10 +1090,33 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Show when={finding.status === 'active'}>
|
||||
<Show when={expandedId() !== finding.id ? collapsedApprovalAction() : undefined}>
|
||||
{(action) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpanded}
|
||||
class="rounded bg-amber-600 px-2 py-1 text-xs font-semibold text-white transition-colors hover:bg-amber-700"
|
||||
title={action().detail}
|
||||
>
|
||||
{action().label}
|
||||
</button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={shouldShowCollapsedPrimaryAction(finding) && rowPrimaryAction}>
|
||||
{(action) => (
|
||||
<a
|
||||
href={action().href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center rounded bg-blue-600 px-2 py-1 text-xs font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{action().label}
|
||||
</a>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={shouldShowInlineManualControls(finding)}>
|
||||
<Show when={manualControls.acknowledge && !finding.acknowledgedAt}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1088,51 +1172,47 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
{/* Expand indicator */}
|
||||
<svg
|
||||
class={`w-4 h-4 text-slate-400 transition-transform ${expandedId() === finding.id ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${expandedId() === finding.id ? 'Hide' : 'View'} details for ${title.label}`}
|
||||
aria-expanded={expandedId() === finding.id}
|
||||
aria-controls={`finding-${finding.id}-details`}
|
||||
onClick={toggleExpanded}
|
||||
class="inline-flex items-center gap-1 rounded border border-border bg-surface px-2 py-1 text-xs font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
title={expandedId() === finding.id ? 'Hide details' : 'View details'}
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
<span>{expandedId() === finding.id ? 'Hide' : 'Details'}</span>
|
||||
<ChevronDownIcon
|
||||
class={`h-3.5 w-3.5 transition-transform ${expandedId() === finding.id ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
<Show when={expandedId() === finding.id}>
|
||||
{renderExpandedContent(finding)}
|
||||
</Show>
|
||||
<Show when={expandedId() === finding.id}>{renderExpandedContent(finding, options)}</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render expanded content for a finding
|
||||
const renderExpandedContent = (finding: UnifiedFinding) => {
|
||||
const renderExpandedContent = (
|
||||
finding: UnifiedFinding,
|
||||
options: { relatedFindings?: readonly UnifiedFinding[] } = {},
|
||||
) => {
|
||||
const primaryAction = getFindingPrimaryActionPresentation(finding);
|
||||
const manualControls = getFindingManualControlsPresentation(finding);
|
||||
const relatedFindings = options.relatedFindings ?? [];
|
||||
const shouldShowAssistantPrimaryAction = !isPatrolFindingsSource();
|
||||
const shouldShowAssistantManageAction = isPatrolFindingsSource() && !primaryAction;
|
||||
const shouldShowExpandedManageMenu =
|
||||
!primaryAction ||
|
||||
manualControls.acknowledge ||
|
||||
manualControls.snooze ||
|
||||
manualControls.dismiss;
|
||||
|
||||
return (
|
||||
<div id={`finding-${finding.id}-details`} class="mt-3 pt-3 border-t border-border-subtle">
|
||||
<Show when={primaryAction}>
|
||||
{(action) => (
|
||||
<div class="mb-3">
|
||||
<a
|
||||
href={action().href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center rounded border border-border bg-surface px-2.5 py-1.5 text-xs font-semibold text-base-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{action().label}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={hasTriggeringAlert(finding)}>
|
||||
<div class="text-xs text-amber-700 dark:text-amber-300 mb-2">
|
||||
Triggered by alert{finding.alertType ? ` (${finding.alertType})` : ''} • Identifier{' '}
|
||||
@@ -1163,7 +1243,37 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={finding.lifecycle && finding.lifecycle.length > 0}>
|
||||
<Show when={relatedFindings.length > 0}>
|
||||
<div class="mt-3 rounded border border-border bg-surface-alt p-2">
|
||||
<div class="text-xs font-medium text-base-content">Related issues</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<For each={relatedFindings}>
|
||||
{(relatedFinding) => {
|
||||
const relatedSeverity = getFindingSeverityPresentation(relatedFinding);
|
||||
const relatedTitle = getFindingTitlePresentation(relatedFinding);
|
||||
const relatedRecency = getFindingRecencyPresentation(relatedFinding);
|
||||
return (
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<MetadataBadge
|
||||
{...FINDING_ROW_BADGE_PROPS}
|
||||
tone={relatedSeverity.badgeTone}
|
||||
uppercase={relatedSeverity.uppercase}
|
||||
>
|
||||
{relatedSeverity.label}
|
||||
</MetadataBadge>
|
||||
<span class="font-medium text-base-content">{relatedTitle.label}</span>
|
||||
<span>
|
||||
{relatedRecency.label} {formatTime(relatedRecency.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={shouldShowFindingLifecycle(finding)}>
|
||||
<div class="mt-3 p-2 rounded border border-border bg-surface-alt">
|
||||
<div class="text-xs font-medium text-base-content mb-2">Lifecycle</div>
|
||||
<div class="space-y-1">
|
||||
@@ -1277,139 +1387,167 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
class="mt-3 flex flex-wrap items-start gap-2 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void openFindingInAssistant(finding);
|
||||
}}
|
||||
class="inline-flex items-center gap-1 rounded bg-blue-600 px-3 py-1.5 font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
title={getPrimaryAssistantFindingAction(finding).title}
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{getPrimaryAssistantFindingAction(finding).label}
|
||||
</button>
|
||||
<Show when={primaryAction}>
|
||||
{(action) => (
|
||||
<a
|
||||
href={action().href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-1 rounded bg-blue-600 px-3 py-1.5 font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{action().label}
|
||||
</a>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!primaryAction && shouldShowAssistantPrimaryAction}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void openFindingInAssistant(finding);
|
||||
}}
|
||||
class="inline-flex items-center gap-1 rounded bg-blue-600 px-3 py-1.5 font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
title={getPrimaryAssistantFindingAction(finding).title}
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{getPrimaryAssistantFindingAction(finding).label}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<details onClick={(e) => e.stopPropagation()}>
|
||||
<summary class="list-none cursor-pointer rounded border border-border bg-surface px-3 py-1.5 font-medium text-base-content hover:bg-surface-hover">
|
||||
Manage
|
||||
</summary>
|
||||
<div class="mt-1 flex min-w-48 flex-col gap-1 rounded border border-border bg-surface p-1 shadow-sm">
|
||||
<Show when={editingNoteId() !== finding.id && !finding.userNote}>
|
||||
<Show when={shouldShowExpandedManageMenu}>
|
||||
<details onClick={(e) => e.stopPropagation()}>
|
||||
<summary class="list-none cursor-pointer rounded border border-border bg-surface px-3 py-1.5 font-medium text-base-content hover:bg-surface-hover">
|
||||
Manage
|
||||
</summary>
|
||||
<div class="mt-1 flex min-w-48 flex-col gap-1 rounded border border-border bg-surface p-1 shadow-sm">
|
||||
<Show when={shouldShowAssistantManageAction}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void openFindingInAssistant(finding);
|
||||
}}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
|
||||
title={getPrimaryAssistantFindingAction(finding).title}
|
||||
>
|
||||
{getPrimaryAssistantFindingAction(finding).label}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={editingNoteId() !== finding.id && !finding.userNote}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartEditNote(finding, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
|
||||
>
|
||||
Add Note
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartEditNote(finding, e)}
|
||||
onClick={(e) => handleCopyFindingSummary(finding, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
|
||||
>
|
||||
Add Note
|
||||
Copy summary
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleCopyFindingSummary(finding, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover"
|
||||
>
|
||||
Copy summary
|
||||
</button>
|
||||
<Show when={finding.status === 'active'}>
|
||||
<Show when={manualControls.acknowledge && !finding.acknowledgedAt}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleAcknowledge(finding, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
<Show when={finding.status === 'active'}>
|
||||
<Show when={manualControls.acknowledge && !finding.acknowledgedAt}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleAcknowledge(finding, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={manualControls.acknowledge}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleResolve(finding, e)}
|
||||
class="rounded px-2 py-1 text-left text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-300 dark:hover:bg-emerald-900"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Mark resolved
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={manualControls.snooze}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSnooze(finding, 1, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Snooze 1h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSnooze(finding, 24, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Snooze 24h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSnooze(finding, 168, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Snooze 7d
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={manualControls.dismiss}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartDismiss(finding, 'not_an_issue', e)}
|
||||
class="rounded px-2 py-1 text-left text-red-600 hover:bg-red-50 disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-900"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Dismiss: Not an issue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartDismiss(finding, 'expected_behavior', e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Remember as expected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartDismiss(finding, 'will_fix_later', e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Dismiss: Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartCreateRule(finding, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={
|
||||
actionLoading() === finding.id ||
|
||||
!getFindingSuppressionRuleScope(finding).canCreate
|
||||
}
|
||||
title={
|
||||
getFindingSuppressionRuleScope(finding).canCreate
|
||||
? 'Promote this dismissal into a permanent rule for this resource and category'
|
||||
: 'This finding is missing the resource or category needed for a scoped rule'
|
||||
}
|
||||
>
|
||||
Create rule from this
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={manualControls.acknowledge}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleResolve(finding, e)}
|
||||
class="rounded px-2 py-1 text-left text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-300 dark:hover:bg-emerald-900"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Mark resolved
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={manualControls.snooze}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSnooze(finding, 1, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Snooze 1h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSnooze(finding, 24, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Snooze 24h
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleSnooze(finding, 168, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Snooze 7d
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={manualControls.dismiss}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartDismiss(finding, 'not_an_issue', e)}
|
||||
class="rounded px-2 py-1 text-left text-red-600 hover:bg-red-50 disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-900"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Dismiss: Not an issue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartDismiss(finding, 'expected_behavior', e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Remember as expected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartDismiss(finding, 'will_fix_later', e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={actionLoading() === finding.id}
|
||||
>
|
||||
Dismiss: Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartCreateRule(finding, e)}
|
||||
class="rounded px-2 py-1 text-left hover:bg-surface-hover disabled:opacity-50"
|
||||
disabled={
|
||||
actionLoading() === finding.id ||
|
||||
!getFindingSuppressionRuleScope(finding).canCreate
|
||||
}
|
||||
title={
|
||||
getFindingSuppressionRuleScope(finding).canCreate
|
||||
? 'Promote this dismissal into a permanent rule for this resource and category'
|
||||
: 'This finding is missing the resource or category needed for a scoped rule'
|
||||
}
|
||||
>
|
||||
Create rule from this
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Inline create-rule confirmation. Confirms scope (resource +
|
||||
category) and requires a reason so the persisted rule has
|
||||
@@ -1560,6 +1698,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
(finding.investigationOutcome === 'fix_queued' ||
|
||||
finding.investigationOutcome === 'fix_executed' ||
|
||||
finding.investigationOutcome === 'fix_failed' ||
|
||||
finding.investigationOutcome === 'fix_rejected' ||
|
||||
finding.investigationOutcome === 'fix_verified' ||
|
||||
finding.investigationOutcome === 'fix_verification_failed' ||
|
||||
finding.investigationOutcome === 'fix_verification_unknown')
|
||||
@@ -1646,7 +1785,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={patrolFindings().length > 1}>
|
||||
<Show when={patrolFindingDisplayGroups().length > 1}>
|
||||
<FormSelect
|
||||
label="Sort findings"
|
||||
labelClass="sr-only"
|
||||
@@ -1678,9 +1817,9 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
<Card padding="none" class="overflow-hidden">
|
||||
{/* Content */}
|
||||
<div class="divide-y divide-border-subtle">
|
||||
<Show when={patrolFindings().length === 0}>
|
||||
<Show when={patrolFindingDisplayGroups().length === 0}>
|
||||
<div class="p-6 text-sm text-muted text-center">
|
||||
<Show when={filter() === 'active'}>
|
||||
<Show when={shouldShowRichEmptyState()}>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Show
|
||||
when={emptyStateCopy().tone === 'success'}
|
||||
@@ -1691,9 +1830,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
<Show
|
||||
when={emptyStateCopy().tone === 'warning'}
|
||||
fallback={
|
||||
<AlertCircleIcon
|
||||
class={`w-10 h-10 ${emptyStateTone().iconClass}`}
|
||||
/>
|
||||
<InfoIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />
|
||||
}
|
||||
>
|
||||
<AlertTriangleIcon
|
||||
@@ -1724,14 +1861,38 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
||||
filter() !== 'active' &&
|
||||
filter() !== 'attention' &&
|
||||
filter() !== 'approvals' &&
|
||||
filter() !== 'overdue'
|
||||
filter() !== 'overdue' &&
|
||||
props.runSnapshot === undefined
|
||||
}
|
||||
>
|
||||
{emptyStateCopy().title}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={patrolFindings()}>{(finding) => renderFindingItem(finding, false)}</For>
|
||||
<For each={patrolFindingDisplayGroups()}>
|
||||
{(group) => (
|
||||
<Show
|
||||
when={group.findings.length > 1}
|
||||
fallback={renderFindingItem(group.primaryFinding, false)}
|
||||
>
|
||||
<section
|
||||
class="divide-y divide-border-subtle"
|
||||
aria-label={`${group.resourceLabel}: ${getPatrolFindingIssueCountLabel(group.findings.length)}`}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 bg-surface-alt/60 px-3 py-2 text-xs">
|
||||
<span class="font-semibold text-base-content">{group.resourceLabel}</span>
|
||||
<span class="text-muted">
|
||||
{getPatrolFindingIssueCountLabel(group.findings.length)}
|
||||
</span>
|
||||
</div>
|
||||
{renderFindingItem(group.primaryFinding, false, {
|
||||
hideSubject: true,
|
||||
relatedFindings: group.relatedFindings,
|
||||
})}
|
||||
</section>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
@@ -432,8 +432,8 @@ describe('AICostDashboard', () => {
|
||||
baseSummary({
|
||||
targets: [
|
||||
{
|
||||
target_type: 'vm',
|
||||
target_id: '100',
|
||||
target_type: 'assistant_session_title',
|
||||
target_id: '7f5941d9-a503-416d-b84e-5a46c9e1e11f',
|
||||
calls: 5,
|
||||
input_tokens: 10000,
|
||||
output_tokens: 2000,
|
||||
@@ -446,9 +446,11 @@ describe('AICostDashboard', () => {
|
||||
);
|
||||
renderDashboard();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Top targets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Usage by task')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('vm:100')).toBeInTheDocument();
|
||||
expect(screen.getByText('Assistant sessions')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/assistant_session_title/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/7f5941d9/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('12,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -457,7 +459,7 @@ describe('AICostDashboard', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Estimated spend')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Top targets')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Usage by task')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---- error states ----
|
||||
|
||||
@@ -46,7 +46,7 @@ const mockState = vi.hoisted(() => {
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
const patrolFindings = [
|
||||
const patrolFindings: Array<Record<string, unknown>> = [
|
||||
{
|
||||
id: 'finding-provider-issue',
|
||||
source: 'ai-patrol',
|
||||
@@ -58,6 +58,7 @@ const mockState = vi.hoisted(() => {
|
||||
severity: 'warning',
|
||||
title: 'Provider connection issue',
|
||||
description: 'Pulse Patrol could not complete provider analysis.',
|
||||
impact: 'Patrol cannot analyze infrastructure until provider setup is fixed.',
|
||||
detectedAt: '2026-03-30T11:00:00Z',
|
||||
lastSeenAt: '2026-03-30T11:05:00Z',
|
||||
status: 'active',
|
||||
@@ -65,12 +66,20 @@ const mockState = vi.hoisted(() => {
|
||||
];
|
||||
|
||||
return {
|
||||
initialFindings: findings,
|
||||
initialPatrolFindings: patrolFindings,
|
||||
findings,
|
||||
findingsError: null as string | null,
|
||||
findingsLoading: false,
|
||||
patrolFindings,
|
||||
patrolFindingsError: null as string | null,
|
||||
patrolFindingsLoading: false,
|
||||
patrolPendingApprovals: [] as Array<{
|
||||
status: string;
|
||||
toolId: string;
|
||||
targetId: string;
|
||||
expiresAt?: string;
|
||||
}>,
|
||||
getResource,
|
||||
getRemediationPlans,
|
||||
loadFindings,
|
||||
@@ -165,6 +174,9 @@ vi.mock('@/stores/aiIntelligence', () => ({
|
||||
get patrolPendingApprovalCount() {
|
||||
return 0;
|
||||
},
|
||||
get patrolPendingApprovals() {
|
||||
return mockState.patrolPendingApprovals;
|
||||
},
|
||||
get remediationPlans() {
|
||||
return [];
|
||||
},
|
||||
@@ -178,6 +190,8 @@ vi.mock('@/stores/aiIntelligence', () => ({
|
||||
|
||||
describe('FindingsPanel resource links', () => {
|
||||
beforeEach(() => {
|
||||
mockState.findings = [...mockState.initialFindings];
|
||||
mockState.patrolFindings = [...mockState.initialPatrolFindings];
|
||||
mockState.loadFindings.mockClear();
|
||||
mockState.loadPatrolFindings.mockClear();
|
||||
mockState.getRemediationPlans.mockClear();
|
||||
@@ -186,6 +200,7 @@ describe('FindingsPanel resource links', () => {
|
||||
mockState.findingsError = null;
|
||||
mockState.patrolFindingsLoading = false;
|
||||
mockState.patrolFindingsError = null;
|
||||
mockState.patrolPendingApprovals = [];
|
||||
|
||||
if (typeof window.requestAnimationFrame !== 'function') {
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
|
||||
@@ -201,7 +216,11 @@ describe('FindingsPanel resource links', () => {
|
||||
|
||||
await waitFor(() => expect(mockState.loadFindings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText('Nextcloud failed readiness checks'));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'View details for Nextcloud failed readiness checks',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Nextcloud is unreachable for users until readiness recovers.'),
|
||||
@@ -212,9 +231,50 @@ describe('FindingsPanel resource links', () => {
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'Open related infrastructure for Nextcloud' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'Open related workloads for Nextcloud' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: 'Open related workloads for Nextcloud' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps Patrol runtime setup findings primary-action-only when expanded', async () => {
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
id: 'finding-provider-runtime',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'ai-service',
|
||||
resourceName: 'Pulse Patrol Service',
|
||||
resourceType: 'service',
|
||||
alertIdentifier: 'ai-service::patrol/runtime',
|
||||
category: 'runtime',
|
||||
severity: 'warning',
|
||||
title: 'Pulse Patrol: Provider billing or quota issue',
|
||||
description: 'Pulse Patrol could not maintain a healthy provider connection.',
|
||||
impact: 'Patrol cannot analyze infrastructure until provider setup is fixed.',
|
||||
detectedAt: '2026-03-30T11:00:00Z',
|
||||
lastSeenAt: '2026-03-30T11:05:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
render(() => <FindingsPanel findingsSource="patrol" />);
|
||||
|
||||
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'View details for Provider billing or quota issue',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Open in Assistant' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Manage')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps finding disclosure separate from inline manual controls', async () => {
|
||||
@@ -223,7 +283,7 @@ describe('FindingsPanel resource links', () => {
|
||||
await waitFor(() => expect(mockState.loadFindings).toHaveBeenCalled());
|
||||
|
||||
const disclosure = screen.getByRole('button', {
|
||||
name: /Nextcloud failed readiness checks/,
|
||||
name: 'View details for Nextcloud failed readiness checks',
|
||||
});
|
||||
const acknowledge = screen.getByRole('button', { name: 'Acknowledge' });
|
||||
|
||||
@@ -236,6 +296,243 @@ describe('FindingsPanel resource links', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Patrol queue rows free of inline triage controls until expanded', async () => {
|
||||
render(() => <FindingsPanel findingsSource="patrol" />);
|
||||
|
||||
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
|
||||
|
||||
const disclosure = screen.getByRole('button', {
|
||||
name: 'View details for Provider connection issue',
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByRole('button', {
|
||||
name: 'View details for Provider connection issue',
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
expect(disclosure).toHaveTextContent('Details');
|
||||
expect(screen.queryByRole('button', { name: 'Acknowledge' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Snooze 24h' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(disclosure);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Hide details for Provider connection issue',
|
||||
}),
|
||||
).toHaveTextContent('Hide');
|
||||
expect(screen.getByText('Manage')).toBeInTheDocument();
|
||||
const assistantHandoff = screen.getByRole('button', { name: 'Open in Assistant' });
|
||||
expect(assistantHandoff.closest('details')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Acknowledge' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Snooze 24h' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Dismiss: Later' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps default Patrol workflow states out of collapsed issue badges', async () => {
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
id: 'finding-default-workflow',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'vm-101',
|
||||
resourceName: 'database-01',
|
||||
resourceType: 'vm',
|
||||
category: 'reliability',
|
||||
severity: 'warning',
|
||||
title: 'Database latency spike',
|
||||
description: 'Patrol saw the database latency threshold trip.',
|
||||
impact: 'Database-backed apps may respond slowly.',
|
||||
detectedAt: '2026-03-30T11:00:00Z',
|
||||
lastSeenAt: '2026-03-30T11:05:00Z',
|
||||
status: 'active',
|
||||
loopState: 'detected',
|
||||
},
|
||||
];
|
||||
|
||||
render(() => <FindingsPanel findingsSource="patrol" />);
|
||||
|
||||
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText('warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Database latency spike')).toBeInTheDocument();
|
||||
expect(screen.queryByText('detected')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Review finding')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Patrol process states out of collapsed issue badges', async () => {
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
id: 'finding-investigating',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'vm-101',
|
||||
resourceName: 'database-01',
|
||||
resourceType: 'vm',
|
||||
category: 'reliability',
|
||||
severity: 'warning',
|
||||
title: 'Database latency spike',
|
||||
description: 'Patrol saw the database latency threshold trip.',
|
||||
impact: 'Database-backed apps may respond slowly.',
|
||||
detectedAt: '2026-03-30T11:00:00Z',
|
||||
lastSeenAt: '2026-03-30T11:05:00Z',
|
||||
status: 'active',
|
||||
loopState: 'investigating',
|
||||
investigationStatus: 'running',
|
||||
},
|
||||
{
|
||||
id: 'finding-verifying',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'vm-102',
|
||||
resourceName: 'web-01',
|
||||
resourceType: 'vm',
|
||||
category: 'availability',
|
||||
severity: 'warning',
|
||||
title: 'Web service restart loop',
|
||||
description: 'Patrol saw repeated restarts.',
|
||||
detectedAt: '2026-03-30T11:01:00Z',
|
||||
lastSeenAt: '2026-03-30T11:06:00Z',
|
||||
status: 'active',
|
||||
investigationOutcome: 'fix_executed',
|
||||
},
|
||||
];
|
||||
|
||||
render(() => <FindingsPanel findingsSource="patrol" />);
|
||||
|
||||
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText('Database latency spike')).toBeInTheDocument();
|
||||
expect(screen.getByText('Web service restart loop')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol investigating')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Verify outcome')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Review finding')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pending Patrol approval as the user decision, not as a process badge', async () => {
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
id: 'finding-approval',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'vm-103',
|
||||
resourceName: 'worker-01',
|
||||
resourceType: 'vm',
|
||||
category: 'availability',
|
||||
severity: 'critical',
|
||||
title: 'Worker disk is full',
|
||||
description: 'Patrol found the worker disk is full.',
|
||||
detectedAt: '2026-03-30T11:02:00Z',
|
||||
lastSeenAt: '2026-03-30T11:07:00Z',
|
||||
status: 'active',
|
||||
investigationOutcome: 'fix_queued',
|
||||
},
|
||||
];
|
||||
mockState.patrolPendingApprovals = [
|
||||
{
|
||||
status: 'pending',
|
||||
toolId: 'investigation_fix',
|
||||
targetId: 'finding-approval',
|
||||
expiresAt: '2999-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
render(() => <FindingsPanel findingsSource="patrol" />);
|
||||
|
||||
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
|
||||
|
||||
const approvalAction = screen.getByRole('button', { name: 'Approve or reject' });
|
||||
expect(approvalAction).toBeInTheDocument();
|
||||
expect(screen.queryByText('Review finding')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(approvalAction);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Hide details for Worker disk is full',
|
||||
}),
|
||||
).toHaveTextContent('Hide');
|
||||
});
|
||||
|
||||
it('keeps selected Patrol run issue rows simple and expandable', async () => {
|
||||
mockState.patrolFindings = [
|
||||
{
|
||||
id: 'finding-array-parity',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'storage:tower-array',
|
||||
resourceName: 'Tower Array',
|
||||
resourceType: 'storage',
|
||||
category: 'availability',
|
||||
severity: 'critical',
|
||||
title: 'Unraid array running without parity protection while at 86% capacity',
|
||||
description:
|
||||
'The Unraid array on Tower is running without parity protection while capacity is high.',
|
||||
impact: 'A disk failure before parity is rebuilt could cause unrecoverable data loss.',
|
||||
detectedAt: '2026-03-30T11:00:00Z',
|
||||
lastSeenAt: '2026-03-30T11:05:00Z',
|
||||
status: 'active',
|
||||
loopState: 'detected',
|
||||
},
|
||||
{
|
||||
id: 'finding-array-usage',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'storage:tower-array',
|
||||
resourceName: 'Tower Array',
|
||||
resourceType: 'storage',
|
||||
category: 'capacity',
|
||||
severity: 'warning',
|
||||
title: 'Storage pool Tower Array at 85.9% usage',
|
||||
description: 'Tower Array is nearing the configured storage capacity threshold.',
|
||||
detectedAt: '2026-03-30T11:01:00Z',
|
||||
lastSeenAt: '2026-03-30T11:05:00Z',
|
||||
status: 'active',
|
||||
loopState: 'detected',
|
||||
},
|
||||
];
|
||||
|
||||
render(() => (
|
||||
<FindingsPanel
|
||||
findingsSource="patrol"
|
||||
filterOverride="all"
|
||||
filterFindingIds={['finding-array-parity', 'finding-array-usage']}
|
||||
runSnapshot={{
|
||||
resources_checked: 72,
|
||||
scope_resource_ids: [],
|
||||
effective_scope_resource_ids: [],
|
||||
finding_ids: ['finding-array-parity', 'finding-array-usage'],
|
||||
status: 'critical',
|
||||
error_count: 0,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.getByText('critical')).toBeInTheDocument();
|
||||
expect(screen.getByText('warning')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Unraid array running without parity protection while at 86% capacity'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Storage pool Tower Array at 85.9% usage')).toBeInTheDocument();
|
||||
expect(screen.queryByText('detected')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Review finding')).not.toBeInTheDocument();
|
||||
|
||||
const disclosure = screen.getByRole('button', {
|
||||
name: 'View details for Unraid array running without parity protection while at 86% capacity',
|
||||
});
|
||||
expect(disclosure).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(disclosure);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Hide details for Unraid array running without parity protection while at 86% capacity',
|
||||
}),
|
||||
).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The Unraid array on Tower is running without parity protection while capacity is high.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can render Patrol findings from the direct Patrol source', async () => {
|
||||
render(() => <FindingsPanel findingsSource="patrol" />);
|
||||
|
||||
@@ -267,4 +564,35 @@ describe('FindingsPanel resource links', () => {
|
||||
expect(screen.queryByText('Loading findings...')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Provider connection issue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a selected Patrol run empty snapshot while Patrol findings are loading', async () => {
|
||||
mockState.patrolFindings = [];
|
||||
mockState.patrolFindingsLoading = true;
|
||||
|
||||
render(() => (
|
||||
<FindingsPanel
|
||||
findingsSource="patrol"
|
||||
filterOverride="all"
|
||||
filterFindingIds={[]}
|
||||
runSnapshot={{
|
||||
resources_checked: 67,
|
||||
scope_resource_ids: ['node-a'],
|
||||
effective_scope_resource_ids: ['node-a'],
|
||||
finding_ids: [],
|
||||
status: 'error',
|
||||
error_count: 1,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => expect(mockState.loadPatrolFindings).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByText('Loading findings...')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No findings recorded for this run')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Checked 67 scoped resources. This run recorded no Patrol findings, but it ended with issues requiring review.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPatrolFindingDisplayGroups,
|
||||
buildFindingFilterOptions,
|
||||
formatFindingForClipboard,
|
||||
formatFindingLifecycleType,
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
getFindingPrimaryActionPresentation,
|
||||
getFindingSubjectPresentation,
|
||||
getFindingTitlePresentation,
|
||||
getFindingPatrolWorkflowPresentation,
|
||||
getPatrolFindingIssueCountLabel,
|
||||
getPatrolFindingClassification,
|
||||
getFindingSeverityCompactLabel,
|
||||
getFindingSeveritySortOrder,
|
||||
@@ -51,6 +54,14 @@ const patrolWorkspaceSource = readFileSync(
|
||||
resolve(__dirname, '..', '..', '..', 'features', 'patrol', 'PatrolIntelligenceWorkspace.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const patrolInvestigationContextModelSource = readFileSync(
|
||||
resolve(__dirname, '..', '..', '..', 'features', 'patrol', 'patrolInvestigationContextModel.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
const aiFindingPresentationSource = readFileSync(
|
||||
resolve(__dirname, '..', '..', '..', 'utils', 'aiFindingPresentation.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('FindingsPanel assistant handoff', () => {
|
||||
it('routes finding loading indicators through the shared LoadingSpinner primitive', () => {
|
||||
@@ -60,19 +71,29 @@ describe('FindingsPanel assistant handoff', () => {
|
||||
});
|
||||
|
||||
it('routes Patrol investigation records into the Assistant briefing context', () => {
|
||||
expect(findingsPanelSource).toContain('buildPatrolAssistantFindingHandoff');
|
||||
expect(findingsPanelSource).toContain('buildPatrolAssistantFindingHandoffFromUnifiedFinding');
|
||||
expect(findingsPanelSource).toContain('buildPatrolAssistantApprovalBriefingInput');
|
||||
expect(findingsPanelSource).toContain('buildPatrolAssistantProposedFixBriefingInput');
|
||||
expect(findingsPanelSource).toContain(
|
||||
'buildPatrolAssistantProposedFixBriefingInputFromApproval',
|
||||
);
|
||||
expect(findingsPanelSource).toContain('aiChatStore.open(handoff.context)');
|
||||
expect(findingsPanelSource).not.toContain('autoSendInitialPrompt');
|
||||
expect(findingsPanelSource).not.toContain('openWithPrompt');
|
||||
expect(findingsPanelSource).toContain('investigationOutcome: finding.investigationOutcome');
|
||||
expect(findingsPanelSource).toContain('investigationStatus: finding.investigationStatus');
|
||||
expect(findingsPanelSource).toContain('remediationId: finding.remediationPlanId');
|
||||
expect(findingsPanelSource).toContain('resourceId: finding.resourceId');
|
||||
expect(findingsPanelSource).toContain('detectedAt: finding.detectedAt');
|
||||
expect(findingsPanelSource).toContain('lastSeenAt: finding.lastSeenAt');
|
||||
expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord');
|
||||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
'investigationOutcome: finding.investigationOutcome',
|
||||
);
|
||||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
'investigationStatus: finding.investigationStatus',
|
||||
);
|
||||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
'remediationId: finding.remediationPlanId',
|
||||
);
|
||||
expect(patrolInvestigationContextModelSource).toContain('resourceId: finding.resourceId');
|
||||
expect(patrolInvestigationContextModelSource).toContain('detectedAt: finding.detectedAt');
|
||||
expect(patrolInvestigationContextModelSource).toContain('lastSeenAt: finding.lastSeenAt');
|
||||
expect(patrolInvestigationContextModelSource).toContain(
|
||||
'investigationRecord: finding.investigationRecord',
|
||||
);
|
||||
expect(findingsPanelSource).toContain('pendingApproval: pendingApprovalBriefing');
|
||||
expect(findingsPanelSource).toContain('proposedFix,');
|
||||
expect(findingsPanelSource).not.toContain('nextStepAction,');
|
||||
@@ -120,12 +141,20 @@ describe('FindingsPanel assistant handoff', () => {
|
||||
|
||||
it('keeps collapsed finding disclosure separate from inline manual controls', () => {
|
||||
// The collapsed finding row contains acknowledge/snooze/dismiss controls.
|
||||
// Those controls must remain siblings of the disclosure button so the row
|
||||
// does not expose one interactive control nested inside another.
|
||||
expect(findingsPanelSource).not.toContain('role="button"');
|
||||
// Those controls must remain siblings of the disclosure area, while the
|
||||
// row body itself opens the same details panel for normal scanning.
|
||||
expect(findingsPanelSource).toContain('role="button"');
|
||||
expect(findingsPanelSource).toContain('tabIndex={0}');
|
||||
expect(findingsPanelSource).toContain('class="min-w-0 flex-1 cursor-pointer rounded text-left');
|
||||
expect(findingsPanelSource).toContain('aria-expanded={expandedId() === finding.id}');
|
||||
expect(findingsPanelSource).toContain(
|
||||
"aria-label={`${expandedId() === finding.id ? 'Close issue details' : 'Open issue details'} for ${title.label}`}",
|
||||
);
|
||||
expect(findingsPanelSource).toContain(
|
||||
"aria-label={`${expandedId() === finding.id ? 'Hide' : 'View'} details for ${title.label}`}",
|
||||
);
|
||||
expect(findingsPanelSource).toContain("e.key === 'Enter' || e.key === ' '");
|
||||
expect(findingsPanelSource).toContain('onClick={toggleExpanded}');
|
||||
expect(findingsPanelSource).toContain('</button>\n {/* Actions */}');
|
||||
});
|
||||
|
||||
it('previews the will_fix_later remind-at deadline before the operator confirms dismiss', () => {
|
||||
@@ -160,13 +189,50 @@ describe('FindingsPanel assistant handoff', () => {
|
||||
expect(findingsPanelSource).toMatch(/>\s*Copy summary\s*</);
|
||||
});
|
||||
|
||||
it('keeps expanded finding actions behind a primary Assistant action and compact Manage menu', () => {
|
||||
it('keeps expanded finding actions behind a canonical primary action and compact Manage menu', () => {
|
||||
expect(findingsPanelSource).toContain('getFindingPrimaryActionPresentation(finding)');
|
||||
expect(findingsPanelSource).toContain('getPrimaryAssistantFindingAction');
|
||||
expect(findingsPanelSource).toContain('const shouldShowAssistantPrimaryAction =');
|
||||
expect(findingsPanelSource).toContain('const shouldShowAssistantManageAction =');
|
||||
expect(findingsPanelSource).toContain(
|
||||
'<Show when={!primaryAction && shouldShowAssistantPrimaryAction}>',
|
||||
);
|
||||
expect(findingsPanelSource).toContain('<Show when={shouldShowAssistantManageAction}>');
|
||||
expect(findingsPanelSource).not.toContain(
|
||||
'fallback={\n <button\n type="button"\n onClick={(e) => {\n e.stopPropagation();\n void openFindingInAssistant(finding);',
|
||||
);
|
||||
expect(findingsPanelSource).toContain('Manage');
|
||||
expect(findingsPanelSource).toContain('min-w-48');
|
||||
expect(findingsPanelSource).not.toContain('min-w-40');
|
||||
});
|
||||
|
||||
it('keeps primary-action Patrol runtime findings out of secondary action clutter', () => {
|
||||
// Runtime/setup findings already have one canonical recovery path:
|
||||
// provider settings. The expanded card must not add the Assistant
|
||||
// secondary button or generic Manage menu beside that primary action.
|
||||
expect(findingsPanelSource).toContain('const shouldShowExpandedManageMenu =');
|
||||
expect(findingsPanelSource).toContain('!primaryAction ||');
|
||||
expect(findingsPanelSource).toContain('<Show when={shouldShowExpandedManageMenu}>');
|
||||
expect(findingsPanelSource).toContain('when={primaryAction}');
|
||||
expect(findingsPanelSource).not.toContain(
|
||||
'getPrimaryAssistantFindingAction(finding).label}\n </button>\n </>',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces primary Patrol issue actions before expansion', () => {
|
||||
expect(findingsPanelSource).toContain('shouldShowCollapsedPrimaryAction');
|
||||
expect(findingsPanelSource).toContain('isPatrolFindingsSource()');
|
||||
expect(findingsPanelSource).toContain("finding.status === 'active'");
|
||||
expect(findingsPanelSource).toContain('props.runSnapshot === undefined');
|
||||
expect(findingsPanelSource).toContain('expandedId() !== finding.id');
|
||||
expect(findingsPanelSource).toContain(
|
||||
'const rowPrimaryAction = getFindingPrimaryActionPresentation(finding)',
|
||||
);
|
||||
expect(findingsPanelSource).toContain(
|
||||
'shouldShowCollapsedPrimaryAction(finding) && rowPrimaryAction',
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes a manual Mark resolved action that goes through the patrol resolve store action', () => {
|
||||
// The /api/ai/patrol/resolve endpoint exists server-side but had no
|
||||
// operator path on the canonical Patrol surface. An operator fixing
|
||||
@@ -214,6 +280,32 @@ describe('FindingsPanel assistant handoff', () => {
|
||||
expect(regressionIndex).toBeGreaterThan(confidenceIndex);
|
||||
});
|
||||
|
||||
it('surfaces only actionable Patrol workflow states from the shared presentation model', () => {
|
||||
// Collapsed Patrol rows should stay issue-focused. Workflow state can
|
||||
// drive a direct approval action, but it must not add process badges such
|
||||
// as "detected", "investigating", or "verify outcome" to the scan view.
|
||||
expect(findingsPanelSource).toContain('getFindingPatrolWorkflowPresentation');
|
||||
expect(findingsPanelSource).toContain(
|
||||
'getFindingPatrolWorkflowPresentation(finding, aiIntelligenceStore.patrolPendingApprovals)',
|
||||
);
|
||||
expect(findingsPanelSource).toContain("workflow?.stage === 'approval'");
|
||||
expect(findingsPanelSource).not.toContain("workflow.stage === 'investigating'");
|
||||
expect(findingsPanelSource).not.toContain("workflow.stage === 'verification'");
|
||||
expect(findingsPanelSource).not.toContain("workflow.stage === 'attention'");
|
||||
expect(findingsPanelSource).not.toContain("workflow.stage === 'recorded'");
|
||||
expect(findingsPanelSource).not.toContain("workflow.stage === 'paused'");
|
||||
});
|
||||
|
||||
it('turns approval-required Patrol findings into a direct collapsed action', () => {
|
||||
expect(findingsPanelSource).toContain('const collapsedApprovalAction = () => {');
|
||||
expect(findingsPanelSource).toContain("workflow?.stage === 'approval'");
|
||||
expect(findingsPanelSource).toContain(
|
||||
'<Show when={expandedId() !== finding.id ? collapsedApprovalAction() : undefined}>',
|
||||
);
|
||||
expect(findingsPanelSource).toContain('title={action().detail}');
|
||||
expect(findingsPanelSource).toContain('{action().label}');
|
||||
});
|
||||
|
||||
it('warns the operator before dismissing a recurrent finding as not_an_issue or expected_behavior', () => {
|
||||
// not_an_issue and expected_behavior both stay quiet forever after dismiss.
|
||||
// If the finding has already regressed before, the operator may be
|
||||
@@ -266,6 +358,20 @@ describe('FindingsPanel assistant handoff', () => {
|
||||
expect(findingsPanelSource).toContain('>Impact:</span> {finding.impact}');
|
||||
expect(findingsPanelSource).not.toContain('>Recommendation:</span>');
|
||||
});
|
||||
|
||||
it('keeps active Patrol lifecycle history out of the default issue expansion', () => {
|
||||
// Current issues should answer "what needs doing" first. Raw lifecycle
|
||||
// history is still available when the operator chooses all/resolved history
|
||||
// or a run record, but it should not be dumped into the default active
|
||||
// Patrol issue expansion.
|
||||
expect(findingsPanelSource).toContain('shouldShowFindingLifecycle');
|
||||
expect(findingsPanelSource).toContain('!isPatrolFindingsSource()');
|
||||
expect(findingsPanelSource).toContain("finding.status !== 'active'");
|
||||
expect(findingsPanelSource).toContain('props.runSnapshot !== undefined');
|
||||
expect(findingsPanelSource).toContain("filter() === 'all'");
|
||||
expect(findingsPanelSource).toContain("filter() === 'resolved'");
|
||||
expect(findingsPanelSource).toContain('Show when={shouldShowFindingLifecycle(finding)}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aiFindingPresentation', () => {
|
||||
@@ -478,6 +584,15 @@ describe('aiFindingPresentation', () => {
|
||||
});
|
||||
|
||||
describe('patrolFindingClassification', () => {
|
||||
it('describes grouped Patrol findings as issues instead of internal signals', () => {
|
||||
expect(getPatrolFindingIssueCountLabel(0)).toBe('0 issues');
|
||||
expect(getPatrolFindingIssueCountLabel(1)).toBe('1 issue');
|
||||
expect(getPatrolFindingIssueCountLabel(2)).toBe('2 issues');
|
||||
expect(getPatrolFindingIssueCountLabel(2.9)).toBe('2 issues');
|
||||
expect(aiFindingPresentationSource).toContain('getPatrolFindingIssueCountLabel');
|
||||
expect(aiFindingPresentationSource).not.toContain('getPatrolFindingSignalCountLabel');
|
||||
});
|
||||
|
||||
it('classifies ai-service findings as patrol runtime issues', () => {
|
||||
expect(
|
||||
getPatrolFindingClassification({
|
||||
@@ -677,6 +792,7 @@ describe('aiFindingPresentation', () => {
|
||||
expect(findingsPanelSource).toContain('getFindingPrimaryActionPresentation');
|
||||
expect(findingsPanelSource).toContain('{action().label}');
|
||||
expect(findingsPanelSource).toContain('href={action().href}');
|
||||
expect(findingsPanelSource).toContain('shouldShowAssistantManageAction');
|
||||
});
|
||||
|
||||
it('does not cross-jump expanded findings to broad Infrastructure or aggregate workspace routes', () => {
|
||||
@@ -696,6 +812,16 @@ describe('aiFindingPresentation', () => {
|
||||
expect(findingsPanelSource).toContain('manualControls.dismiss');
|
||||
});
|
||||
|
||||
it('keeps inline manual controls out of the default Patrol queue rows', () => {
|
||||
expect(findingsPanelSource).toContain('const shouldShowInlineManualControls =');
|
||||
expect(findingsPanelSource).toContain(
|
||||
"finding.status === 'active' && !isPatrolFindingsSource()",
|
||||
);
|
||||
expect(findingsPanelSource).toContain(
|
||||
'<Show when={shouldShowInlineManualControls(finding)}>',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads remediation artifacts through the shared Patrol store instead of probing the API directly', () => {
|
||||
expect(findingsPanelSource).toContain('aiIntelligenceStore.loadRemediationPlans()');
|
||||
expect(findingsPanelSource).toContain('aiIntelligenceStore.remediationPlans');
|
||||
@@ -718,13 +844,31 @@ describe('aiFindingPresentation', () => {
|
||||
expect(patrolWorkspaceSource).toContain('findingsSource="patrol"');
|
||||
});
|
||||
|
||||
it('passes Patrol history regression evidence into the findings empty-state helper', () => {
|
||||
expect(findingsPanelSource).toContain('historicalRegressionCount?: number');
|
||||
expect(findingsPanelSource).toContain(
|
||||
'historicalRegressionCount: props.historicalRegressionCount',
|
||||
);
|
||||
expect(patrolWorkspaceSource).toContain(
|
||||
'historicalRegressionCount={state.historicalRegressionCount()}',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses an informational icon for non-warning Patrol empty-state context', () => {
|
||||
expect(findingsPanelSource).toContain("import InfoIcon from 'lucide-solid/icons/info';");
|
||||
expect(findingsPanelSource).toContain("when={emptyStateCopy().tone === 'warning'}");
|
||||
expect(findingsPanelSource).toContain(
|
||||
'<InfoIcon class={`w-10 h-10 ${emptyStateTone().iconClass}`} />',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes same-severity ordering through the shared patrol runtime sort helper', () => {
|
||||
expect(findingsPanelSource).toContain('getFindingActiveRuntimeSortOrder(a)');
|
||||
expect(findingsPanelSource).toContain('getFindingActiveRuntimeSortOrder(b)');
|
||||
});
|
||||
|
||||
it('only shows the sort control when there are multiple Patrol findings to sort', () => {
|
||||
expect(findingsPanelSource).toContain('<Show when={patrolFindings().length > 1}>');
|
||||
expect(findingsPanelSource).toContain('patrolFindingDisplayGroups().length > 1');
|
||||
expect(findingsPanelSource).toContain('FormSelect');
|
||||
expect(findingsPanelSource).toContain('label="Sort findings"');
|
||||
expect(findingsPanelSource).toContain('<option value="severity">By Severity</option>');
|
||||
@@ -761,17 +905,29 @@ describe('aiFindingPresentation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let global Patrol loading mask a selected run empty snapshot', () => {
|
||||
expect(findingsPanelSource).toContain('const hasEmptyRunSnapshot = createMemo(');
|
||||
expect(findingsPanelSource).toContain(
|
||||
'const canRenderRunSnapshotWithoutSourceFindings = createMemo(',
|
||||
);
|
||||
expect(findingsPanelSource).toContain('!canRenderRunSnapshotWithoutSourceFindings()');
|
||||
expect(findingsPanelSource).toContain('const shouldShowRichEmptyState = createMemo(');
|
||||
expect(findingsPanelSource).toContain("filter() === 'active' || props.runSnapshot");
|
||||
});
|
||||
|
||||
it('uses explicit textual separators in finding metadata instead of relying on visual spacing', () => {
|
||||
expect(findingsPanelSource).toContain("{' · '}acknowledged");
|
||||
expect(findingsPanelSource).toContain("{' · '}last investigated");
|
||||
expect(findingsPanelSource).toContain("{' · '}snoozed until");
|
||||
});
|
||||
|
||||
it('uses explicit textual separators for patrol tab badges instead of css-only spacing', () => {
|
||||
it('uses shared metadata badges for the current Patrol queue resource count only', () => {
|
||||
expect(patrolWorkspaceSource).toContain('aria-hidden="true"');
|
||||
expect(patrolWorkspaceSource).toContain("{' '}");
|
||||
expect(patrolWorkspaceSource).toContain('{state.findingsTabBadgeCount()}');
|
||||
expect(patrolWorkspaceSource).toContain('{state.displayRunHistory().length}');
|
||||
expect(patrolWorkspaceSource).toContain('<MetadataBadge');
|
||||
expect(patrolWorkspaceSource).toContain('getPatrolQueueBadgeLabel');
|
||||
expect(patrolWorkspaceSource).toContain('queueAffectedResourceCount');
|
||||
expect(patrolWorkspaceSource).toContain('{queueBadgeLabel()}');
|
||||
expect(patrolWorkspaceSource).not.toContain('{runHistoryCount()}');
|
||||
});
|
||||
|
||||
it('routes the findings tab badge tone through the shared patrol findings badge helper', () => {
|
||||
@@ -782,10 +938,45 @@ describe('aiFindingPresentation', () => {
|
||||
expect(patrolWorkspaceSource).not.toContain('findingsBadgePresentation().toneClasses');
|
||||
});
|
||||
|
||||
it('does not stack a detected loop-state badge on top of acknowledged active findings', () => {
|
||||
expect(findingsPanelSource).toContain(
|
||||
"!(finding.acknowledgedAt && finding.loopState === 'detected')",
|
||||
it('does not stack a default detected loop-state badge on active findings', () => {
|
||||
expect(findingsPanelSource).toContain('const shouldShowLoopStateBadge = () =>');
|
||||
expect(findingsPanelSource).toContain('!isPatrolFindingsSource()');
|
||||
expect(findingsPanelSource).toContain("finding.status === 'active'");
|
||||
expect(findingsPanelSource).toContain("finding.source !== 'ai-patrol'");
|
||||
expect(findingsPanelSource).toContain("finding.loopState !== 'detected'");
|
||||
expect(findingsPanelSource).toContain('when={shouldShowLoopStateBadge()}');
|
||||
expect(findingsPanelSource).toContain('Patrol status:');
|
||||
expect(findingsPanelSource).not.toContain('Patrol loop:');
|
||||
});
|
||||
|
||||
it('keeps raw Patrol process badges out of the collapsed Patrol issue row', () => {
|
||||
expect(findingsPanelSource).toContain('const shouldShowInvestigationStatusBadge = () =>');
|
||||
expect(findingsPanelSource).toContain('const shouldShowInvestigationOutcomeBadge = () =>');
|
||||
expect(findingsPanelSource).toContain('const shouldShowInvestigationConfidenceBadge = () =>');
|
||||
expect(findingsPanelSource).toContain('!isPatrolFindingsSource() &&');
|
||||
expect(findingsPanelSource).not.toContain('const collapsedPatrolStatusBadge = () => {');
|
||||
expect(findingsPanelSource).not.toContain(
|
||||
"workflow?.stage === 'review' ? undefined : workflow",
|
||||
);
|
||||
expect(findingsPanelSource).not.toContain('<Show when={collapsedPatrolStatusBadge()}>');
|
||||
});
|
||||
|
||||
it('groups active Patrol queue findings by resource without changing run records', () => {
|
||||
expect(findingsPanelSource).toContain('buildPatrolFindingDisplayGroups');
|
||||
expect(findingsPanelSource).toContain('getPatrolFindingIssueCountLabel');
|
||||
expect(findingsPanelSource).toContain('const shouldGroupPatrolFindingsByResource');
|
||||
expect(findingsPanelSource).toContain("filter() === 'active'");
|
||||
expect(findingsPanelSource).toContain('props.runSnapshot === undefined');
|
||||
expect(findingsPanelSource).toContain(
|
||||
'aria-label={`${group.resourceLabel}: ${getPatrolFindingIssueCountLabel(',
|
||||
);
|
||||
expect(findingsPanelSource).toContain(
|
||||
'{getPatrolFindingIssueCountLabel(group.findings.length)}',
|
||||
);
|
||||
expect(findingsPanelSource).toContain('renderFindingItem(group.primaryFinding, false, {');
|
||||
expect(findingsPanelSource).toContain('relatedFindings: group.relatedFindings');
|
||||
expect(findingsPanelSource).not.toContain('<For each={group.findings}>');
|
||||
expect(findingsPanelSource).toContain('hideSubject: true');
|
||||
});
|
||||
|
||||
it('uses canonical finding recency presentation instead of raw detected timestamps for active rows', () => {
|
||||
@@ -878,6 +1069,8 @@ describe('aiFindingPresentation', () => {
|
||||
expect(getInvestigationOutcomeLabel('fix_verified')).toBe('Fix verified');
|
||||
expect(getInvestigationOutcomeBadgeClasses('fix_failed')).toContain('red');
|
||||
expect(getInvestigationOutcomeBadgeTone('fix_failed')).toBe('danger');
|
||||
expect(getInvestigationOutcomeLabel('fix_rejected')).toBe('Fix rejected');
|
||||
expect(getInvestigationOutcomeBadgeTone('fix_rejected')).toBe('warning');
|
||||
expect(getInvestigationOutcomeBadgeClasses('cannot_fix')).toContain('bg-surface-alt');
|
||||
expect(getInvestigationOutcomeBadgeTone('cannot_fix')).toBe('muted');
|
||||
expect(getInvestigationOutcomeSortOrder('fix_failed')).toBe(0);
|
||||
@@ -1024,6 +1217,220 @@ describe('aiFindingPresentation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Patrol workflow presentation', () => {
|
||||
const baseFinding = {
|
||||
id: 'finding-1',
|
||||
source: 'ai-patrol',
|
||||
status: 'active',
|
||||
resourceId: 'vm-101',
|
||||
resourceName: 'database-01',
|
||||
title: 'Disk pressure',
|
||||
} as const;
|
||||
|
||||
it('does not invent a workflow badge for plain active Patrol findings', () => {
|
||||
expect(getFindingPatrolWorkflowPresentation(baseFinding)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps Patrol runtime setup copy neutral to watch-only and paid control modes', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation({
|
||||
...baseFinding,
|
||||
resourceId: 'ai-service',
|
||||
resourceName: 'Pulse Patrol service',
|
||||
title: 'Pulse Patrol: Provider connection issue',
|
||||
}),
|
||||
).toMatchObject({
|
||||
stage: 'attention',
|
||||
label: 'Fix Patrol setup',
|
||||
detail:
|
||||
'Patrol needs runtime or provider setup before it can check infrastructure reliably.',
|
||||
tone: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes live governed approvals above generic review context', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation(
|
||||
{
|
||||
...baseFinding,
|
||||
investigationOutcome: 'fix_queued',
|
||||
},
|
||||
[
|
||||
{
|
||||
status: 'pending',
|
||||
toolId: 'investigation_fix',
|
||||
targetId: 'finding-1',
|
||||
expiresAt: '2026-06-20T18:15:00Z',
|
||||
},
|
||||
],
|
||||
Date.parse('2026-06-20T18:00:00Z'),
|
||||
),
|
||||
).toMatchObject({
|
||||
stage: 'approval',
|
||||
label: 'Approve or reject',
|
||||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('asks the operator to recover a queued fix when no live approval exists', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation(
|
||||
{
|
||||
...baseFinding,
|
||||
investigationOutcome: 'fix_queued',
|
||||
},
|
||||
[
|
||||
{
|
||||
status: 'pending',
|
||||
toolId: 'investigation_fix',
|
||||
targetId: 'finding-1',
|
||||
expiresAt: '2026-06-20T17:59:00Z',
|
||||
},
|
||||
],
|
||||
Date.parse('2026-06-20T18:00:00Z'),
|
||||
),
|
||||
).toMatchObject({
|
||||
stage: 'approval',
|
||||
label: 'Review fix',
|
||||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('separates rejected, executed, and verified outcomes into distinct operator next steps', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation({
|
||||
...baseFinding,
|
||||
investigationOutcome: 'fix_rejected',
|
||||
}),
|
||||
).toMatchObject({
|
||||
stage: 'attention',
|
||||
label: 'Decide follow-up',
|
||||
});
|
||||
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation({
|
||||
...baseFinding,
|
||||
investigationOutcome: 'fix_executed',
|
||||
}),
|
||||
).toMatchObject({
|
||||
stage: 'verification',
|
||||
label: 'Verify outcome',
|
||||
});
|
||||
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation({
|
||||
...baseFinding,
|
||||
investigationOutcome: 'fix_verified',
|
||||
}),
|
||||
).toMatchObject({
|
||||
stage: 'recorded',
|
||||
label: 'Outcome recorded',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses concrete labels for failed or input-needed Patrol outcomes', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation({
|
||||
...baseFinding,
|
||||
investigationOutcome: 'fix_failed',
|
||||
}),
|
||||
).toMatchObject({
|
||||
stage: 'attention',
|
||||
label: 'Fix failed',
|
||||
});
|
||||
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation({
|
||||
...baseFinding,
|
||||
investigationOutcome: 'needs_attention',
|
||||
}),
|
||||
).toMatchObject({
|
||||
stage: 'attention',
|
||||
label: 'Needs input',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not attach the Patrol operations loop to threshold alerts', () => {
|
||||
expect(
|
||||
getFindingPatrolWorkflowPresentation({
|
||||
...baseFinding,
|
||||
source: 'threshold',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Patrol finding display groups', () => {
|
||||
it('groups findings with the same resource identity while preserving sorted order', () => {
|
||||
const groups = buildPatrolFindingDisplayGroups([
|
||||
{
|
||||
id: 'critical',
|
||||
resourceId: 'storage:tower-array',
|
||||
resourceName: 'Tower Array',
|
||||
resourceType: 'storage',
|
||||
title: 'No parity protection',
|
||||
},
|
||||
{
|
||||
id: 'warning',
|
||||
resourceId: 'storage:tower-array',
|
||||
resourceName: 'Tower Array',
|
||||
resourceType: 'storage',
|
||||
title: 'High usage',
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
resourceId: 'ai-service',
|
||||
resourceName: 'Pulse Patrol service',
|
||||
resourceType: 'service',
|
||||
title: 'Provider connection issue',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toMatchObject({
|
||||
resourceKey: 'subject:tower array (storage)',
|
||||
resourceLabel: 'Tower Array (storage)',
|
||||
});
|
||||
expect(groups[0].primaryFinding.id).toBe('critical');
|
||||
expect(groups[0].findings.map((finding) => finding.id)).toEqual(['critical', 'warning']);
|
||||
expect(groups[0].relatedFindings.map((finding) => finding.id)).toEqual(['warning']);
|
||||
expect(groups[1].findings.map((finding) => finding.id)).toEqual(['provider']);
|
||||
expect(groups[1].relatedFindings).toEqual([]);
|
||||
});
|
||||
|
||||
it('groups display-identical resources even when backend finding ids differ', () => {
|
||||
const groups = buildPatrolFindingDisplayGroups([
|
||||
{
|
||||
id: 'array-risk',
|
||||
resourceId: 'unraid:array:tower',
|
||||
resourceName: 'Tower Array',
|
||||
resourceType: 'storage',
|
||||
title: 'No parity protection',
|
||||
},
|
||||
{
|
||||
id: 'pool-usage',
|
||||
resourceId: 'unraid:pool:tower-array',
|
||||
resourceName: 'Tower Array',
|
||||
resourceType: 'storage',
|
||||
title: 'High usage',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].findings.map((finding) => finding.id)).toEqual(['array-risk', 'pool-usage']);
|
||||
});
|
||||
|
||||
it('keeps findings without resource identity separate instead of inventing duplicates', () => {
|
||||
const groups = buildPatrolFindingDisplayGroups([
|
||||
{ id: 'a', resourceId: '', resourceName: '', resourceType: '', title: 'First' },
|
||||
{ id: 'b', resourceId: '', resourceName: '', resourceType: '', title: 'Second' },
|
||||
]);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.map((group) => group.resourceKey)).toEqual(['finding:a', 'finding:b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycleLabels', () => {
|
||||
it('has detected label', () => {
|
||||
expect(formatFindingLifecycleType('detected')).toBe('Detected');
|
||||
|
||||
@@ -146,9 +146,10 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
<div class="flex items-start gap-2.5">
|
||||
<TriangleAlertIcon class="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div class="text-xs text-amber-800 dark:text-amber-200">
|
||||
<p class="mb-1 font-medium">AI Discovery Disabled</p>
|
||||
<p class="mb-1 font-medium">Service Context Disabled</p>
|
||||
<p class="text-amber-700 dark:text-amber-300">
|
||||
Enable infrastructure discovery in Settings -> AI before using this tab.
|
||||
Enable service context in Settings -> Pulse Intelligence -> Service Context
|
||||
before using this tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export type DiscoveryReadinessStatus =
|
||||
| 'ready';
|
||||
|
||||
export interface DiscoveryReadinessInputs {
|
||||
/** The discovery feature toggle (Settings → AI → Workload Discovery). */
|
||||
/** The service context toggle (Settings -> Pulse Intelligence -> Service Context). */
|
||||
discoveryEnabled: boolean;
|
||||
/** Whether at least one AI provider has credentials configured. */
|
||||
aiProviderConfigured: boolean;
|
||||
|
||||
@@ -198,7 +198,9 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
|
||||
|
||||
const handleTriggerDiscovery = async (force = false) => {
|
||||
if (!discoveryFeatureEnabled()) {
|
||||
setScanError('AI discovery is disabled in Settings -> AI.');
|
||||
setScanError(
|
||||
'Service context is disabled in Settings -> Pulse Intelligence -> Service Context.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ResourceChangeSummaryProps {
|
||||
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
|
||||
maxChanges?: number;
|
||||
compact?: boolean;
|
||||
showMetadataBadges?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ export const ResourceChangeSummary: Component<ResourceChangeSummaryProps> = (pro
|
||||
const headlineText = () => (compact() ? 'text-[11px]' : 'text-sm');
|
||||
const badgeText = () => (compact() ? 'text-[10px]' : 'text-[11px]');
|
||||
const gapSize = () => (compact() ? 'gap-2' : 'gap-3');
|
||||
const showMetadataBadges = () => props.showMetadataBadges ?? true;
|
||||
|
||||
const renderRelatedResource = (resourceId: string) => {
|
||||
const label = resolveResourceLabel?.(resourceId)?.trim() || resourceId;
|
||||
@@ -77,6 +79,8 @@ export const ResourceChangeSummary: Component<ResourceChangeSummaryProps> = (pro
|
||||
<For each={visibleChanges()}>
|
||||
{(change) => {
|
||||
const headline = formatResourceChangeHeadline(change);
|
||||
const reason = change.reason?.trim() ?? '';
|
||||
const shouldShowReason = reason.length > 0 && reason !== headline;
|
||||
const resourceHref = buildResourceHref(change.resourceId);
|
||||
const relatedResources = (change.relatedResources ?? []).slice(0, 3);
|
||||
const kindPresentation = getResourceChangeKindPresentation(change.kind);
|
||||
@@ -91,9 +95,7 @@ export const ResourceChangeSummary: Component<ResourceChangeSummaryProps> = (pro
|
||||
<li class={`rounded-md border border-border-subtle bg-base ${itemPadding()}`}>
|
||||
<div class={`flex flex-wrap items-start justify-between ${gapSize()}`}>
|
||||
<div class="min-w-0">
|
||||
<p class={headlineText()}>
|
||||
{headline}
|
||||
</p>
|
||||
<p class={headlineText()}>{headline}</p>
|
||||
<p class={`mt-1 ${itemText()} text-muted`}>
|
||||
{resourceHref ? (
|
||||
<a
|
||||
@@ -115,40 +117,44 @@ export const ResourceChangeSummary: Component<ResourceChangeSummaryProps> = (pro
|
||||
<Show when={change.actor}>
|
||||
<p class={`mt-1 ${itemText()} text-muted`}>By {change.actor}</p>
|
||||
</Show>
|
||||
<Show when={change.reason}>
|
||||
<p class={`mt-1 ${itemText()} text-muted`}>{change.reason}</p>
|
||||
<Show when={shouldShowReason}>
|
||||
<p class={`mt-1 ${itemText()} text-muted`}>{reason}</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class={`rounded-full border px-2 py-0.5 ${badgeText()} font-medium ${
|
||||
kindPresentation.className
|
||||
}`}
|
||||
>
|
||||
{kindPresentation.label}
|
||||
</span>
|
||||
<span
|
||||
class={`rounded-full border px-2 py-0.5 ${badgeText()} font-medium ${
|
||||
sourceTypePresentation.className
|
||||
}`}
|
||||
>
|
||||
{sourceTypePresentation.label}
|
||||
</span>
|
||||
<Show when={sourceAdapterPresentation}>
|
||||
<Show when={showMetadataBadges()}>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class={`rounded-full border px-2 py-0.5 ${badgeText()} font-medium ${
|
||||
sourceAdapterPresentation!.className
|
||||
kindPresentation.className
|
||||
}`}
|
||||
>
|
||||
{sourceAdapterPresentation!.label}
|
||||
{kindPresentation.label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span
|
||||
class={`rounded-full border px-2 py-0.5 ${badgeText()} font-medium ${
|
||||
sourceTypePresentation.className
|
||||
}`}
|
||||
>
|
||||
{sourceTypePresentation.label}
|
||||
</span>
|
||||
<Show when={sourceAdapterPresentation}>
|
||||
<span
|
||||
class={`rounded-full border px-2 py-0.5 ${badgeText()} font-medium ${
|
||||
sourceAdapterPresentation!.className
|
||||
}`}
|
||||
>
|
||||
{sourceAdapterPresentation!.label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={(change.relatedResources ?? []).length > 0}>
|
||||
<div class={`mt-2 flex flex-wrap items-center gap-1.5 ${itemText()} text-muted`}>
|
||||
<div
|
||||
class={`mt-2 flex flex-wrap items-center gap-1.5 ${itemText()} text-muted`}
|
||||
>
|
||||
<span>Related:</span>
|
||||
<For each={relatedResources}>{renderRelatedResource}</For>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export const ResourceCorrelationSummary: Component<ResourceCorrelationSummaryPro
|
||||
{targetLabel}
|
||||
</span>
|
||||
)}
|
||||
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-muted">
|
||||
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[9px] text-muted">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
@@ -247,7 +247,7 @@ export const ResourceCorrelationSummary: Component<ResourceCorrelationSummaryPro
|
||||
{targetLabel}
|
||||
</span>
|
||||
)}
|
||||
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[9px] uppercase tracking-wide text-muted">
|
||||
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 text-[9px] text-muted">
|
||||
{patternLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+28
@@ -72,4 +72,32 @@ describe('ResourceChangeSummary', () => {
|
||||
expect(screen.getByText('Latest canonical change')).toBeInTheDocument();
|
||||
expect(screen.getByText('No canonical changes were recorded.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can suppress metadata badges for compact operator context', () => {
|
||||
render(() => (
|
||||
<ResourceChangeSummary
|
||||
title="Nearby activity"
|
||||
changes={[
|
||||
{
|
||||
id: 'change-1',
|
||||
resourceId: 'storage-1',
|
||||
kind: 'alert_resolved',
|
||||
reason: 'Alert resolved: ZFS pool recovered',
|
||||
observedAt: '2026-03-18T12:00:00Z',
|
||||
sourceType: 'platform_event',
|
||||
sourceAdapter: 'proxmox_adapter',
|
||||
confidence: 'medium',
|
||||
},
|
||||
]}
|
||||
showMetadataBadges={false}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Nearby activity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert resolved: ZFS pool recovered')).toBeInTheDocument();
|
||||
expect(screen.getByText('storage-1')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Platform event')).toBeNull();
|
||||
expect(screen.queryByText('Proxmox adapter')).toBeNull();
|
||||
expect(screen.queryByText('Heuristic')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+20
-1
@@ -23,6 +23,20 @@ describe('ResourceCorrelationSummary', () => {
|
||||
last_seen: '2026-03-01T00:15:00Z',
|
||||
description: 'Disk pressure often precedes restarts',
|
||||
},
|
||||
{
|
||||
source_id: 'storage-2',
|
||||
source_name: 'Storage 2',
|
||||
source_type: 'storage',
|
||||
target_id: 'host-2',
|
||||
target_name: 'Host 2',
|
||||
target_type: 'vm',
|
||||
event_pattern: 'ALERT → ALERT',
|
||||
occurrences: 1,
|
||||
avg_delay: 125000000000,
|
||||
confidence: 0.5,
|
||||
last_seen: '2026-03-01T00:10:00Z',
|
||||
description: 'Alerts often cluster together',
|
||||
},
|
||||
]}
|
||||
summaryText="5 total"
|
||||
/>
|
||||
@@ -36,6 +50,9 @@ describe('ResourceCorrelationSummary', () => {
|
||||
expect(screen.getByText('Storage 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Host 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument();
|
||||
const alertPattern = screen.getByText('Alert → Alert');
|
||||
expect(alertPattern).toBeInTheDocument();
|
||||
expect(alertPattern.className).not.toContain('uppercase');
|
||||
expect(screen.getByText(/2 occurrences · avg delay 2m · 88% confidence/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Disk pressure often precedes restarts')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/last seen/i)).toBeNull();
|
||||
@@ -102,7 +119,9 @@ describe('ResourceCorrelationSummary', () => {
|
||||
expect(screen.getByText('PVE 1')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('VM Child').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Storage 1 alias')).toBeInTheDocument();
|
||||
expect(screen.getByText('Runs On')).toBeInTheDocument();
|
||||
const relationshipType = screen.getByText('Runs On');
|
||||
expect(relationshipType).toBeInTheDocument();
|
||||
expect(relationshipType.className).not.toContain('uppercase');
|
||||
expect(screen.getByText(/100% confidence · Proxmox Adapter · last seen/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/last seen/i).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
+16
@@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { ResourceDiscovery } from '@/types/discovery';
|
||||
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
|
||||
import discoveryReadinessSource from '@/components/Discovery/discoveryReadiness.ts?raw';
|
||||
import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw';
|
||||
import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
|
||||
import { hasMeaningfulDiscoveryContext } from '@/utils/discoveryPresentation';
|
||||
|
||||
@@ -355,6 +358,19 @@ describe('toDiscoveryConfig', () => {
|
||||
});
|
||||
|
||||
describe('resource drawer discovery promotion', () => {
|
||||
it('points disabled discovery readiness at Pulse Intelligence service context settings', () => {
|
||||
expect(discoveryTabSource).toContain('Service Context Disabled');
|
||||
expect(discoveryTabSource).toContain(
|
||||
'Settings -> Pulse Intelligence -> Service Context',
|
||||
);
|
||||
expect(discoveryTabStateSource).toContain(
|
||||
'Service context is disabled in Settings -> Pulse Intelligence -> Service Context.',
|
||||
);
|
||||
expect(discoveryReadinessSource).toContain(
|
||||
'Settings -> Pulse Intelligence -> Service Context',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat command-only diagnostic records as meaningful resource context', () => {
|
||||
const discovery: ResourceDiscovery = {
|
||||
id: 'system-container:pve4:152',
|
||||
|
||||
@@ -4,7 +4,6 @@ import { isModelProviderConfigured } from '@/components/Settings/aiSettingsModel
|
||||
import type { AISettingsState } from '@/components/Settings/useAISettingsState';
|
||||
import { AIModelPicker } from '@/components/shared/AIModelPicker';
|
||||
import { formField, labelClass, controlClass } from '@/components/shared/Form';
|
||||
import { AI_SETTINGS_MODEL_OVERRIDES_TITLE } from '@/utils/aiSettingsPresentation';
|
||||
import {
|
||||
formatAIModelRouteLabel,
|
||||
getAIProviderDisplayName,
|
||||
@@ -15,21 +14,56 @@ interface AIModelSelectionSectionProps {
|
||||
state: AISettingsState;
|
||||
}
|
||||
|
||||
type AIModelOverrideKind = 'assistant' | 'patrol' | 'discovery';
|
||||
|
||||
const MODEL_OVERRIDE_CONFIG: Record<
|
||||
AIModelOverrideKind,
|
||||
{
|
||||
formKey: 'chatModel' | 'patrolModel' | 'discoveryModel';
|
||||
label: string;
|
||||
description: string;
|
||||
ariaLabel: string;
|
||||
title: string;
|
||||
}
|
||||
> = {
|
||||
assistant: {
|
||||
formKey: 'chatModel',
|
||||
label: 'Pulse Assistant model',
|
||||
description: 'Used for chat, explanations, and review. Patrol handles infrastructure work.',
|
||||
ariaLabel: 'Pulse Assistant model identifier',
|
||||
title: 'Select Pulse Assistant model',
|
||||
},
|
||||
patrol: {
|
||||
formKey: 'patrolModel',
|
||||
label: 'Patrol model',
|
||||
description: 'Used when Patrol watches, investigates, and verifies work.',
|
||||
ariaLabel: 'Patrol model identifier',
|
||||
title: 'Select Patrol model',
|
||||
},
|
||||
discovery: {
|
||||
formKey: 'discoveryModel',
|
||||
label: 'Service context model',
|
||||
description: 'Used for model-backed service identification and scheduled context refreshes.',
|
||||
ariaLabel: 'Service context model identifier',
|
||||
title: 'Select service context model',
|
||||
},
|
||||
};
|
||||
|
||||
const stripModelProvider = (modelId: string) => {
|
||||
const trimmed = modelId.trim();
|
||||
const colon = trimmed.indexOf(':');
|
||||
return colon === -1 ? trimmed : trimmed.slice(colon + 1);
|
||||
};
|
||||
|
||||
const PatrolPreflightControl: Component<{ state: AISettingsState }> = (controlProps) => {
|
||||
export const PatrolPreflightControl: Component<{ state: AISettingsState }> = (controlProps) => {
|
||||
const { state } = controlProps;
|
||||
const result = state.patrolPreflightResult;
|
||||
|
||||
// The cached result may be for a model the operator already moved away
|
||||
// from in the form (e.g. they changed the dropdown but haven't clicked
|
||||
// Verify Patrol yet). When that happens, surface a hint so the green
|
||||
// Check Patrol model yet). When that happens, surface a hint so the green
|
||||
// "verified" badge doesn't silently mislead. The backend reads the
|
||||
// form's pending patrolModel on Verify Patrol click, so refreshing
|
||||
// form's pending patrolModel on Check Patrol model click, so refreshing
|
||||
// resolves the staleness.
|
||||
const pendingFormModel = () => stripModelProvider(state.form.patrolModel || '');
|
||||
const cachedResultModel = () => result()?.model?.trim() || '';
|
||||
@@ -68,19 +102,25 @@ const PatrolPreflightControl: Component<{ state: AISettingsState }> = (controlPr
|
||||
return `Verified result is for ${cachedResultModel()}, your current selection is ${pendingFormModel()}`;
|
||||
}
|
||||
if (r.success) {
|
||||
return 'Tool calling verified';
|
||||
return 'Patrol model ready';
|
||||
}
|
||||
if (r.cause === 'model_tool_support_unverified') {
|
||||
return 'Provider accepted the request but the model did not call the tool';
|
||||
return 'Patrol model needs a real run to confirm';
|
||||
}
|
||||
return r.message || 'Patrol preflight failed';
|
||||
return r.message || 'Patrol model check failed';
|
||||
};
|
||||
|
||||
const detail = () => {
|
||||
const r = result();
|
||||
if (!r) return '';
|
||||
if (isStaleAgainstFormSelection()) {
|
||||
return 'Click Verify Patrol to test the pending selection.';
|
||||
return 'Click Check Patrol model to test the pending selection.';
|
||||
}
|
||||
if (r.success) {
|
||||
return 'The selected model can run Patrol work on this install.';
|
||||
}
|
||||
if (r.cause === 'model_tool_support_unverified') {
|
||||
return 'Run Patrol once to confirm this model works correctly in practice.';
|
||||
}
|
||||
return r.summary || r.message || '';
|
||||
};
|
||||
@@ -107,9 +147,7 @@ const PatrolPreflightControl: Component<{ state: AISettingsState }> = (controlPr
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-[11px] text-muted">
|
||||
Verify that this Patrol model can actually call tools — different from{' '}
|
||||
<span class="font-medium">Run Preflight</span>, which only checks each provider's
|
||||
connection.
|
||||
Check that this model can run Patrol work, not just connect to the provider.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -117,7 +155,7 @@ const PatrolPreflightControl: Component<{ state: AISettingsState }> = (controlPr
|
||||
disabled={state.patrolPreflightRunning() || state.saving()}
|
||||
class="inline-flex min-h-9 items-center rounded-md px-3 py-1.5 text-sm bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{state.patrolPreflightRunning() ? 'Verifying...' : 'Verify Patrol'}
|
||||
{state.patrolPreflightRunning() ? 'Checking...' : 'Check Patrol model'}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={result()}>
|
||||
@@ -154,8 +192,15 @@ const PatrolPreflightControl: Component<{ state: AISettingsState }> = (controlPr
|
||||
);
|
||||
};
|
||||
|
||||
export const AIModelSelectionSection: Component<AIModelSelectionSectionProps> = (props) => {
|
||||
export const AIModelOverrideField: Component<{
|
||||
state: AISettingsState;
|
||||
kind: AIModelOverrideKind;
|
||||
includePatrolPreflight?: boolean;
|
||||
}> = (props) => {
|
||||
const { state } = props;
|
||||
const config = () => MODEL_OVERRIDE_CONFIG[props.kind];
|
||||
const selectedModel = () => state.form[config().formKey];
|
||||
const setSelectedModel = (modelId: string) => state.setForm(config().formKey, modelId);
|
||||
const modelLabel = (modelId: string) => {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed) {
|
||||
@@ -164,6 +209,86 @@ export const AIModelSelectionSection: Component<AIModelSelectionSectionProps> =
|
||||
const match = state.availableModels().find((model) => model.id === trimmed);
|
||||
return formatAIModelRouteLabel(match || trimmed);
|
||||
};
|
||||
const selectableModels = () => {
|
||||
const selected = selectedModel().trim();
|
||||
return state
|
||||
.availableModels()
|
||||
.filter(
|
||||
(model) => isModelProviderConfigured(model.id, state.settings()) || model.id === selected,
|
||||
);
|
||||
};
|
||||
const pickerButtonClass = () =>
|
||||
`${controlClass()} flex items-center gap-2 justify-between text-left disabled:cursor-not-allowed disabled:opacity-60`;
|
||||
const sharedDefaultDescription = () =>
|
||||
state.form.model ? `Currently ${modelLabel(state.form.model)}` : 'No shared default model set';
|
||||
|
||||
return (
|
||||
<div class={formField}>
|
||||
<label class="block text-xs font-medium text-muted mb-0.5">{config().label}</label>
|
||||
<p class="text-[11px] text-muted mb-1">{config().description}</p>
|
||||
<Show
|
||||
when={selectableModels().length > 0}
|
||||
fallback={
|
||||
<input
|
||||
type="text"
|
||||
value={selectedModel()}
|
||||
onInput={(e) => setSelectedModel(e.currentTarget.value)}
|
||||
placeholder="Use shared default model"
|
||||
aria-label={config().ariaLabel}
|
||||
class={controlClass()}
|
||||
disabled={state.saving()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AIModelPicker
|
||||
models={selectableModels()}
|
||||
selectedModel={selectedModel()}
|
||||
onModelSelect={setSelectedModel}
|
||||
defaultOption={{
|
||||
label: 'Use shared default',
|
||||
description: sharedDefaultDescription(),
|
||||
}}
|
||||
emptySelectionLabel="Use shared default"
|
||||
title={config().title}
|
||||
searchPlaceholder="Search configured provider models"
|
||||
customModelDescription="Custom provider:model ID"
|
||||
disabled={state.saving()}
|
||||
align="left"
|
||||
buttonClass={pickerButtonClass()}
|
||||
buttonLabelClass="min-w-0 flex-1 truncate text-left font-normal"
|
||||
dropdownClass="w-[calc(100vw-2rem)] max-w-xl"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={selectedModel() && !isModelProviderConfigured(selectedModel(), state.settings())}>
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1 flex items-center gap-1">
|
||||
<svg
|
||||
class="w-3.5 h-3.5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
This model requires{' '}
|
||||
{getAIProviderDisplayName(getProviderFromModelId(selectedModel())) ||
|
||||
getProviderFromModelId(selectedModel())}{' '}
|
||||
to be configured. Add an API key on Provider & Models or select a different model.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={props.includePatrolPreflight}>
|
||||
<PatrolPreflightControl state={state} />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIModelSelectionSection: Component<AIModelSelectionSectionProps> = (props) => {
|
||||
const { state } = props;
|
||||
const selectableModels = (selectedModel: string) => {
|
||||
const selected = selectedModel.trim();
|
||||
return state
|
||||
@@ -173,15 +298,10 @@ export const AIModelSelectionSection: Component<AIModelSelectionSectionProps> =
|
||||
);
|
||||
};
|
||||
const sharedModelOptions = () => selectableModels(state.form.model);
|
||||
const chatModelOptions = () => selectableModels(state.form.chatModel);
|
||||
const patrolModelOptions = () => selectableModels(state.form.patrolModel);
|
||||
const discoveryModelOptions = () => selectableModels(state.form.discoveryModel);
|
||||
const pickerButtonClass = () =>
|
||||
`${controlClass()} flex items-center gap-2 justify-between text-left disabled:cursor-not-allowed disabled:opacity-60`;
|
||||
const pickerLabelClass = 'min-w-0 flex-1 truncate text-left font-normal';
|
||||
const pickerDropdownClass = 'w-[calc(100vw-2rem)] max-w-xl';
|
||||
const sharedDefaultDescription = () =>
|
||||
state.form.model ? `Currently ${modelLabel(state.form.model)}` : 'No shared default model set';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -294,185 +414,6 @@ export const AIModelSelectionSection: Component<AIModelSelectionSectionProps> =
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border border-border rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full min-h-10 sm:min-h-9 px-3 py-2.5 flex items-center justify-between bg-surface-alt hover:bg-surface-hover transition-colors text-left"
|
||||
onClick={() => state.setShowAdvancedModels(!state.showAdvancedModels())}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-4 h-4 text-slate-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-base-content">
|
||||
{AI_SETTINGS_MODEL_OVERRIDES_TITLE}
|
||||
</span>
|
||||
<Show
|
||||
when={state.form.chatModel || state.form.patrolModel || state.form.discoveryModel}
|
||||
>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
|
||||
Customized
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<svg
|
||||
class={`w-4 h-4 text-slate-500 transition-transform ${state.showAdvancedModels() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={state.showAdvancedModels()}>
|
||||
<div class="px-3 py-3 bg-surface border-t border-border space-y-3">
|
||||
<p class="text-xs text-muted">
|
||||
Override the shared default for Pulse Assistant, Patrol, or Discovery. Leave empty to
|
||||
use the shared default model.
|
||||
</p>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted mb-0.5">
|
||||
Pulse Assistant Model
|
||||
</label>
|
||||
<p class="text-[11px] text-muted mb-1">
|
||||
Used for live chat and approved fix execution — a more capable model is recommended.
|
||||
</p>
|
||||
<Show
|
||||
when={chatModelOptions().length > 0}
|
||||
fallback={
|
||||
<input
|
||||
type="text"
|
||||
value={state.form.chatModel}
|
||||
onInput={(e) => state.setForm('chatModel', e.currentTarget.value)}
|
||||
placeholder="Use shared default model"
|
||||
aria-label="Pulse Assistant model identifier"
|
||||
class={controlClass()}
|
||||
disabled={state.saving()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AIModelPicker
|
||||
models={chatModelOptions()}
|
||||
selectedModel={state.form.chatModel}
|
||||
onModelSelect={(modelId) => state.setForm('chatModel', modelId)}
|
||||
defaultOption={{
|
||||
label: 'Use shared default',
|
||||
description: sharedDefaultDescription(),
|
||||
}}
|
||||
emptySelectionLabel="Use shared default"
|
||||
title="Select Pulse Assistant model"
|
||||
searchPlaceholder="Search configured provider models"
|
||||
customModelDescription="Custom provider:model ID"
|
||||
disabled={state.saving()}
|
||||
align="left"
|
||||
buttonClass={pickerButtonClass()}
|
||||
buttonLabelClass={pickerLabelClass}
|
||||
dropdownClass={pickerDropdownClass}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted mb-0.5">
|
||||
Patrol Verification Model
|
||||
</label>
|
||||
<p class="text-[11px] text-muted mb-1">
|
||||
Used for recurring verification and finding generation — a smaller, cheaper model
|
||||
keeps costs low.
|
||||
</p>
|
||||
<Show
|
||||
when={patrolModelOptions().length > 0}
|
||||
fallback={
|
||||
<input
|
||||
type="text"
|
||||
value={state.form.patrolModel}
|
||||
onInput={(e) => state.setForm('patrolModel', e.currentTarget.value)}
|
||||
placeholder="Use shared default model"
|
||||
aria-label="Patrol Verification model identifier"
|
||||
class={controlClass()}
|
||||
disabled={state.saving()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AIModelPicker
|
||||
models={patrolModelOptions()}
|
||||
selectedModel={state.form.patrolModel}
|
||||
onModelSelect={(modelId) => state.setForm('patrolModel', modelId)}
|
||||
defaultOption={{
|
||||
label: 'Use shared default',
|
||||
description: sharedDefaultDescription(),
|
||||
}}
|
||||
emptySelectionLabel="Use shared default"
|
||||
title="Select Patrol verification model"
|
||||
searchPlaceholder="Search configured provider models"
|
||||
customModelDescription="Custom provider:model ID"
|
||||
disabled={state.saving()}
|
||||
align="left"
|
||||
buttonClass={pickerButtonClass()}
|
||||
buttonLabelClass={pickerLabelClass}
|
||||
dropdownClass={pickerDropdownClass}
|
||||
/>
|
||||
</Show>
|
||||
<PatrolPreflightControl state={state} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted mb-0.5">Discovery Model</label>
|
||||
<p class="text-[11px] text-muted mb-1">
|
||||
Used for one-shot service identification on a single resource — a cheaper model like
|
||||
Haiku is usually sufficient.
|
||||
</p>
|
||||
<Show
|
||||
when={discoveryModelOptions().length > 0}
|
||||
fallback={
|
||||
<input
|
||||
type="text"
|
||||
value={state.form.discoveryModel}
|
||||
onInput={(e) => state.setForm('discoveryModel', e.currentTarget.value)}
|
||||
placeholder="Use shared default model"
|
||||
aria-label="Discovery model identifier"
|
||||
class={controlClass()}
|
||||
disabled={state.saving()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AIModelPicker
|
||||
models={discoveryModelOptions()}
|
||||
selectedModel={state.form.discoveryModel}
|
||||
onModelSelect={(modelId) => state.setForm('discoveryModel', modelId)}
|
||||
defaultOption={{
|
||||
label: 'Use shared default',
|
||||
description: sharedDefaultDescription(),
|
||||
}}
|
||||
emptySelectionLabel="Use shared default"
|
||||
title="Select Discovery model"
|
||||
searchPlaceholder="Search configured provider models"
|
||||
customModelDescription="Custom provider:model ID"
|
||||
disabled={state.saving()}
|
||||
align="left"
|
||||
buttonClass={pickerButtonClass()}
|
||||
buttonLabelClass={pickerLabelClass}
|
||||
dropdownClass={pickerDropdownClass}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class={formField}>
|
||||
<AIProviderConfigurationSection
|
||||
settings={state.settings}
|
||||
|
||||
@@ -26,49 +26,28 @@ interface AIRuntimeControlsSectionProps {
|
||||
|
||||
export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps> = (props) => {
|
||||
const { state } = props;
|
||||
const showAutonomousControlOption = () =>
|
||||
!state.autoFixLocked() ||
|
||||
state.showUpgradePrompts() ||
|
||||
state.form.controlLevel === 'autonomous';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="rounded-md border border-blue-200 dark:border-blue-800 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full min-h-10 sm:min-h-9 px-3 py-2.5 flex items-center justify-between bg-blue-50 dark:bg-blue-900 hover:bg-blue-100 dark:hover:bg-blue-900 transition-colors text-left"
|
||||
onClick={() => state.setShowDiscoverySettings(!state.showDiscoverySettings())}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-4 h-4 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-base-content">Workload Discovery</span>
|
||||
<Show when={state.form.discoveryEnabled}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 rounded">
|
||||
{state.form.discoveryIntervalHours > 0
|
||||
? `Auto ${state.form.discoveryIntervalHours}h`
|
||||
: 'Manual only'}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!state.form.discoveryEnabled}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-surface-hover text-muted rounded">
|
||||
Off
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<AIDiscoveryControlsSection state={state} />
|
||||
<AIProviderRuntimeControlsSection state={state} />
|
||||
<AIAssistantCommandAccessSection state={state} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIDiscoveryControlsSection: Component<AIRuntimeControlsSectionProps> = (props) => {
|
||||
const { state } = props;
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-blue-200 dark:border-blue-800 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full min-h-10 sm:min-h-9 px-3 py-2.5 flex items-center justify-between bg-blue-50 dark:bg-blue-900 hover:bg-blue-100 dark:hover:bg-blue-900 transition-colors text-left"
|
||||
onClick={() => state.setShowDiscoverySettings(!state.showDiscoverySettings())}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class={`w-4 h-4 transition-transform ${state.showDiscoverySettings() ? 'rotate-180' : ''}`}
|
||||
class="w-4 h-4 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -77,83 +56,119 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={state.showDiscoverySettings()}>
|
||||
<div class="px-3 py-3 bg-surface border-t border-border space-y-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="text-xs font-medium text-muted flex items-center gap-1.5">
|
||||
Enable workload discovery
|
||||
<HelpIcon inline={getAISettingsWorkloadDiscoveryHelpContent()} size="xs" />
|
||||
</label>
|
||||
<Toggle
|
||||
checked={state.form.discoveryEnabled}
|
||||
onChange={(event) => state.setForm('discoveryEnabled', event.currentTarget.checked)}
|
||||
disabled={state.saving()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={state.form.discoveryEnabled}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<FormSelect
|
||||
id="ai-workload-discovery-scan-interval"
|
||||
label="Scan Interval"
|
||||
value={String(state.form.discoveryIntervalHours)}
|
||||
onChange={(e) =>
|
||||
state.setForm('discoveryIntervalHours', parseInt(e.currentTarget.value, 10))
|
||||
}
|
||||
disabled={state.saving()}
|
||||
fieldBaseClass="flex"
|
||||
fieldClass="items-center gap-3"
|
||||
labelClass="text-xs font-medium text-muted w-32 flex-shrink-0"
|
||||
selectBaseClass="flex-1 px-2 py-1 text-sm border border-border rounded bg-surface"
|
||||
>
|
||||
<option value="0">Manual only</option>
|
||||
<option value="6">Every 6 hours</option>
|
||||
<option value="12">Every 12 hours</option>
|
||||
<option value="24">Every 24 hours</option>
|
||||
<option value="48">Every 2 days</option>
|
||||
<option value="168">Every 7 days</option>
|
||||
</FormSelect>
|
||||
<p class="text-[10px] text-muted ml-32 pl-3">
|
||||
{state.form.discoveryIntervalHours === 0
|
||||
? 'Automatic workload scans are off. Only manual refreshes will run.'
|
||||
: 'Automatic workload scans will run at this interval.'}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded border border-border bg-surface-alt px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p class="text-[10px] text-muted sm:flex-1">
|
||||
{state.form.discoveryEnabled && state.form.discoveryIntervalHours > 0
|
||||
? 'Runs the pending workload sweep used by the schedule.'
|
||||
: state.form.discoveryEnabled
|
||||
? 'Manual-only mode: runs the pending workload sweep once without enabling recurring scans.'
|
||||
: 'Runs a one-time workload discovery refresh without changing the schedule.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-10 items-center gap-1.5 self-start rounded-md border border-border px-3 py-1.5 text-xs font-medium text-base-content hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:self-auto sm:flex-shrink-0"
|
||||
onClick={() => void state.handleRunDiscoveryRefresh()}
|
||||
disabled={state.saving() || state.discoveryRunRunning()}
|
||||
>
|
||||
<Show
|
||||
when={state.discoveryRunRunning()}
|
||||
fallback={<RefreshCwIcon class="h-3.5 w-3.5" />}
|
||||
>
|
||||
<RefreshCwIcon class="h-3.5 w-3.5 animate-spin" />
|
||||
</Show>
|
||||
{state.discoveryRunRunning() ? 'Running...' : 'Run discovery now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] text-muted">{getAISettingsWorkloadDiscoverySummary().text}</p>
|
||||
<span class="text-sm font-medium text-base-content">Service Context</span>
|
||||
<Show when={state.form.discoveryEnabled}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-300 rounded">
|
||||
{state.form.discoveryIntervalHours > 0
|
||||
? `Auto ${state.form.discoveryIntervalHours}h`
|
||||
: 'Manual only'}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!state.form.discoveryEnabled}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-medium bg-surface-hover text-muted rounded">
|
||||
Off
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<svg
|
||||
class={`w-4 h-4 transition-transform ${state.showDiscoverySettings() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={state.showDiscoverySettings()}>
|
||||
<div class="px-3 py-3 bg-surface border-t border-border space-y-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="text-xs font-medium text-muted flex items-center gap-1.5">
|
||||
Enable service context scans
|
||||
<HelpIcon inline={getAISettingsWorkloadDiscoveryHelpContent()} size="xs" />
|
||||
</label>
|
||||
<Toggle
|
||||
checked={state.form.discoveryEnabled}
|
||||
onChange={(event) => state.setForm('discoveryEnabled', event.currentTarget.checked)}
|
||||
disabled={state.saving()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={state.form.discoveryEnabled}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<FormSelect
|
||||
id="ai-workload-discovery-scan-interval"
|
||||
label="Scan Interval"
|
||||
value={String(state.form.discoveryIntervalHours)}
|
||||
onChange={(e) =>
|
||||
state.setForm('discoveryIntervalHours', parseInt(e.currentTarget.value, 10))
|
||||
}
|
||||
disabled={state.saving()}
|
||||
fieldBaseClass="flex"
|
||||
fieldClass="items-center gap-3"
|
||||
labelClass="text-xs font-medium text-muted w-32 flex-shrink-0"
|
||||
selectBaseClass="flex-1 px-2 py-1 text-sm border border-border rounded bg-surface"
|
||||
>
|
||||
<option value="0">Manual only</option>
|
||||
<option value="6">Every 6 hours</option>
|
||||
<option value="12">Every 12 hours</option>
|
||||
<option value="24">Every 24 hours</option>
|
||||
<option value="48">Every 2 days</option>
|
||||
<option value="168">Every 7 days</option>
|
||||
</FormSelect>
|
||||
<p class="text-[10px] text-muted ml-32 pl-3">
|
||||
{state.form.discoveryIntervalHours === 0
|
||||
? 'Recurring service context scans are off. Only manual refreshes will run.'
|
||||
: 'Recurring service context scans will run at this interval.'}
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded border border-border bg-surface-alt px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<p class="text-[10px] text-muted sm:flex-1">
|
||||
{state.form.discoveryEnabled && state.form.discoveryIntervalHours > 0
|
||||
? 'Runs the same scan used by the schedule.'
|
||||
: state.form.discoveryEnabled
|
||||
? 'Manual-only mode: runs one scan without enabling recurring scans.'
|
||||
: 'Runs one service context scan without changing the schedule.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-10 items-center gap-1.5 self-start rounded-md border border-border px-3 py-1.5 text-xs font-medium text-base-content hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:self-auto sm:flex-shrink-0"
|
||||
onClick={() => void state.handleRunDiscoveryRefresh()}
|
||||
disabled={state.saving() || state.discoveryRunRunning()}
|
||||
>
|
||||
<Show
|
||||
when={state.discoveryRunRunning()}
|
||||
fallback={<RefreshCwIcon class="h-3.5 w-3.5" />}
|
||||
>
|
||||
<RefreshCwIcon class="h-3.5 w-3.5 animate-spin" />
|
||||
</Show>
|
||||
{state.discoveryRunRunning() ? 'Running...' : 'Run context scan'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] text-muted">{getAISettingsWorkloadDiscoverySummary().text}</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIProviderRuntimeControlsSection: Component<AIRuntimeControlsSectionProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { state } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex items-center gap-3 p-3 rounded-md border border-border bg-surface-alt">
|
||||
<svg
|
||||
class="w-4 h-4 text-muted flex-shrink-0"
|
||||
@@ -233,7 +248,23 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
<p class="text-[10px] text-muted -mt-4 ml-1">
|
||||
Increase for slower Ollama hardware (default: 300s / 5 min)
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIAssistantCommandAccessSection: Component<AIRuntimeControlsSectionProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { state } = props;
|
||||
const showAutonomousControlOption = () =>
|
||||
!state.autoFixLocked() ||
|
||||
state.showUpgradePrompts() ||
|
||||
state.form.controlLevel === 'autonomous';
|
||||
const assistantCommandAccessBadgeLabel = () =>
|
||||
state.form.controlLevel === 'autonomous' ? 'Allowed' : 'Ask first';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class={`space-y-3 p-4 rounded-md border ${getAIControlLevelPanelClass(state.form.controlLevel)}`}
|
||||
>
|
||||
@@ -258,14 +289,18 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
<span
|
||||
class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${getAIControlLevelBadgeClass(state.form.controlLevel)}`}
|
||||
>
|
||||
{state.form.controlLevel}
|
||||
{assistantCommandAccessBadgeLabel()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted">
|
||||
This controls actions started from Assistant chat only. Patrol handles infrastructure
|
||||
work from the Patrol page.
|
||||
</p>
|
||||
|
||||
<FormSelect
|
||||
id="ai-control-mode-select"
|
||||
label="Control mode"
|
||||
label="Chat action mode"
|
||||
value={state.form.controlLevel}
|
||||
onChange={(e) => state.setForm('controlLevel', e.currentTarget.value as AIControlLevel)}
|
||||
disabled={state.saving()}
|
||||
@@ -274,11 +309,11 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
labelClass="text-xs font-medium text-muted w-28 flex-shrink-0"
|
||||
selectBaseClass="flex-1 min-h-10 sm:min-h-9 px-2 py-2 text-sm border border-border rounded bg-surface"
|
||||
>
|
||||
<option value="read_only">Read-only - Pulse Assistant can observe only</option>
|
||||
<option value="controlled">Controlled - Pulse Assistant asks before actions</option>
|
||||
<option value="read_only">Observe only - Assistant cannot take chat actions</option>
|
||||
<option value="controlled">Ask first - Assistant asks before chat-only actions</option>
|
||||
<Show when={showAutonomousControlOption()}>
|
||||
<option value="autonomous">
|
||||
Autonomous - Pulse Assistant can run eligible actions
|
||||
Allow chat-only actions - Assistant may take eligible chat actions
|
||||
</option>
|
||||
</Show>
|
||||
</FormSelect>
|
||||
@@ -287,8 +322,9 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
</p>
|
||||
<Show when={state.form.controlLevel === 'autonomous'}>
|
||||
<div class="p-2 bg-amber-100 dark:bg-amber-900 rounded border border-amber-200 dark:border-amber-800 text-[10px] text-amber-800 dark:text-amber-200">
|
||||
<strong>Legal Disclaimer:</strong> Model-driven systems can hallucinate. You are
|
||||
responsible for any damage caused by autonomous actions. See{' '}
|
||||
<strong>Important:</strong> Assistant may take eligible chat-only actions from this
|
||||
mode. Infrastructure changes stay with Patrol mode. Keep protected guests set for
|
||||
anything Assistant must not touch. See{' '}
|
||||
<ExternalTextLink href={TERMS_DOC_URL} variant="compactInherit">
|
||||
Terms of Service
|
||||
</ExternalTextLink>
|
||||
@@ -309,7 +345,7 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
>
|
||||
{UPGRADE_ACTION_LABEL}
|
||||
</UpgradeLink>{' '}
|
||||
to review autonomous-mode options.
|
||||
to review eligible chat action options.
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
@@ -328,8 +364,7 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
|
||||
disabled={state.saving()}
|
||||
/>
|
||||
<p class="text-[10px] text-muted mt-1">
|
||||
Comma-separated VMIDs or names that Pulse Assistant cannot control, even when
|
||||
command execution is enabled.
|
||||
Comma-separated VMIDs or names Assistant cannot touch when chat actions are allowed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,19 @@ import { Component, Show } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { AIChatMaintenanceSection } from '@/components/Settings/AIChatMaintenanceSection';
|
||||
import { AISettingsDialogs } from '@/components/Settings/AISettingsDialogs';
|
||||
import { AIModelSelectionSection } from '@/components/Settings/AIModelSelectionSection';
|
||||
import { AIRuntimeControlsSection } from '@/components/Settings/AIRuntimeControlsSection';
|
||||
import AgentIntegrationsPanel from '@/components/Settings/AgentIntegrationsPanel';
|
||||
import {
|
||||
AIModelOverrideField,
|
||||
AIModelSelectionSection,
|
||||
} from '@/components/Settings/AIModelSelectionSection';
|
||||
import {
|
||||
AIAssistantCommandAccessSection,
|
||||
AIDiscoveryControlsSection,
|
||||
AIProviderRuntimeControlsSection,
|
||||
} from '@/components/Settings/AIRuntimeControlsSection';
|
||||
import { AISettingsStatusAndActions } from '@/components/Settings/AISettingsStatusAndActions';
|
||||
import { useAISettingsState } from '@/components/Settings/useAISettingsState';
|
||||
import { FormSelect } from '@/components/shared/FormSelect';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import SettingsPanel from '@/components/shared/SettingsPanel';
|
||||
import { Toggle } from '@/components/shared/Toggle';
|
||||
@@ -17,119 +26,367 @@ import {
|
||||
getAISettingsRetryLabel,
|
||||
} from '@/utils/aiSettingsPresentation';
|
||||
|
||||
export const AISettings: Component = () => {
|
||||
type AISettingsPage = 'provider' | 'patrol' | 'assistant' | 'discovery';
|
||||
|
||||
const AI_SETTINGS_PAGE_META: Record<
|
||||
AISettingsPage,
|
||||
{
|
||||
resetLabel: string;
|
||||
saveErrorFallback: string;
|
||||
savedLabel: string;
|
||||
saveLabel: string;
|
||||
savingLabel: string;
|
||||
title: string;
|
||||
}
|
||||
> = {
|
||||
provider: {
|
||||
resetLabel: 'Reset provider settings',
|
||||
saveErrorFallback: 'Unable to save Provider & Models settings.',
|
||||
savedLabel: 'Provider & Models settings saved',
|
||||
saveLabel: 'Save provider settings',
|
||||
savingLabel: 'Saving provider settings...',
|
||||
title: AI_SETTINGS_PANEL_TITLE,
|
||||
},
|
||||
patrol: {
|
||||
resetLabel: 'Reset Patrol settings',
|
||||
saveErrorFallback: 'Unable to save Patrol settings.',
|
||||
savedLabel: 'Patrol settings saved',
|
||||
saveLabel: 'Save Patrol settings',
|
||||
savingLabel: 'Saving Patrol settings...',
|
||||
title: 'Patrol',
|
||||
},
|
||||
assistant: {
|
||||
resetLabel: 'Reset Assistant settings',
|
||||
saveErrorFallback: 'Unable to save Assistant settings.',
|
||||
savedLabel: 'Assistant settings saved',
|
||||
saveLabel: 'Save Assistant settings',
|
||||
savingLabel: 'Saving Assistant settings...',
|
||||
title: 'Assistant',
|
||||
},
|
||||
discovery: {
|
||||
resetLabel: 'Reset service context settings',
|
||||
saveErrorFallback: 'Unable to save service context settings.',
|
||||
savedLabel: 'Service context settings saved',
|
||||
saveLabel: 'Save service context settings',
|
||||
savingLabel: 'Saving service context settings...',
|
||||
title: 'Service Context',
|
||||
},
|
||||
};
|
||||
|
||||
const formatPatrolInterval = (minutes: number): string => {
|
||||
if (minutes <= 0) return 'Manual only';
|
||||
if (minutes < 60) return `Every ${minutes} minute${minutes === 1 ? '' : 's'}`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `Every ${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
const ProviderSettingsContent: Component<{ state: ReturnType<typeof useAISettingsState> }> = (
|
||||
props,
|
||||
) => (
|
||||
<div class="space-y-6 p-4 sm:p-6">
|
||||
<AIModelSelectionSection state={props.state} />
|
||||
<AIProviderRuntimeControlsSection state={props.state} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const PatrolSettingsContent: Component<{ state: ReturnType<typeof useAISettingsState> }> = (
|
||||
props,
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
const state = useAISettingsState();
|
||||
const intervalOptions = [
|
||||
{ value: 0, label: 'Manual only' },
|
||||
{ value: 60, label: 'Every hour' },
|
||||
{ value: 180, label: 'Every 3 hours' },
|
||||
{ value: 360, label: 'Every 6 hours' },
|
||||
{ value: 720, label: 'Every 12 hours' },
|
||||
{ value: 1440, label: 'Daily' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-4 sm:p-6">
|
||||
<div class="rounded-md border border-border bg-surface-alt p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-base-content">Patrol mode</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Choose a Patrol mode on the Patrol page. Keep schedule, triggers, and model readiness
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-10 items-center justify-center rounded-md border border-blue-300 px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:hover:bg-blue-900 sm:min-h-9"
|
||||
onClick={() => navigate(PATROL_PATH)}
|
||||
>
|
||||
Open Patrol
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<FormSelect
|
||||
id="ai-patrol-schedule"
|
||||
label="Schedule"
|
||||
value={String(props.state.form.patrolIntervalMinutes)}
|
||||
onChange={(event) =>
|
||||
props.state.setForm('patrolIntervalMinutes', parseInt(event.currentTarget.value, 10))
|
||||
}
|
||||
disabled={props.state.saving()}
|
||||
fieldClass="gap-2"
|
||||
labelClass="text-xs font-medium text-muted"
|
||||
selectBaseClass="w-full min-h-10 rounded-md border border-border bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
{intervalOptions.map((option) => (
|
||||
<option value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
|
||||
<div class="rounded-md border border-border bg-surface-alt p-3">
|
||||
<p class="text-xs font-medium text-base-content">Current schedule</p>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{formatPatrolInterval(props.state.form.patrolIntervalMinutes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-base-content">Triggers</h3>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div class="rounded-md border border-border bg-surface-alt p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-base-content">Alert-triggered Patrols</p>
|
||||
<p class="mt-1 text-xs text-muted">Start Patrol when alerts fire or clear.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={props.state.form.patrolAlertTriggers}
|
||||
onChange={(event) =>
|
||||
props.state.setForm('patrolAlertTriggers', event.currentTarget.checked)
|
||||
}
|
||||
disabled={props.state.saving()}
|
||||
ariaLabel="Enable alert-triggered Patrols"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={props.state.form.patrolAlertTriggers}>
|
||||
<FormSelect
|
||||
id="ai-patrol-alert-min-severity"
|
||||
label="Investigate alerts at or above"
|
||||
value={props.state.form.patrolAlertTriggerMinSeverity}
|
||||
onChange={(event) =>
|
||||
props.state.setForm(
|
||||
'patrolAlertTriggerMinSeverity',
|
||||
event.currentTarget.value === 'warning' ? 'warning' : 'critical',
|
||||
)
|
||||
}
|
||||
disabled={props.state.saving()}
|
||||
fieldClass="mt-3 gap-2"
|
||||
labelClass="text-xs font-medium text-muted"
|
||||
selectBaseClass="w-full min-h-10 rounded-md border border-border bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="critical">Critical only</option>
|
||||
<option value="warning">Warning and critical</option>
|
||||
</FormSelect>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border bg-surface-alt p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-base-content">Anomaly-triggered Patrols</p>
|
||||
<p class="mt-1 text-xs text-muted">Start Patrol from learned baseline changes.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={props.state.form.patrolAnomalyTriggers}
|
||||
onChange={(event) =>
|
||||
props.state.setForm('patrolAnomalyTriggers', event.currentTarget.checked)
|
||||
}
|
||||
disabled={props.state.saving()}
|
||||
ariaLabel="Enable anomaly-triggered Patrols"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-border bg-surface-alt p-3 lg:col-span-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-base-content">Container update risk</p>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Assess risk when container-update alerts fire.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={props.state.form.alertTriggeredAnalysis}
|
||||
onChange={(event) =>
|
||||
props.state.setForm('alertTriggeredAnalysis', event.currentTarget.checked)
|
||||
}
|
||||
disabled={props.state.saving() || props.state.alertAnalysisLocked()}
|
||||
ariaLabel="Enable container update risk analysis"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-base-content">Model readiness</h3>
|
||||
<AIModelOverrideField state={props.state} kind="patrol" includePatrolPreflight />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantSettingsContent: Component<{ state: ReturnType<typeof useAISettingsState> }> = (
|
||||
props,
|
||||
) => (
|
||||
<div class="space-y-6 p-4 sm:p-6">
|
||||
<AIModelOverrideField state={props.state} kind="assistant" />
|
||||
<AIAssistantCommandAccessSection state={props.state} />
|
||||
<AIChatMaintenanceSection state={props.state} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DiscoverySettingsContent: Component<{ state: ReturnType<typeof useAISettingsState> }> = (
|
||||
props,
|
||||
) => (
|
||||
<div class="space-y-6 p-4 sm:p-6">
|
||||
<div class="rounded-md border border-border bg-surface-alt p-4">
|
||||
<h3 class="text-sm font-semibold text-base-content">Service context</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
These settings control the service facts Assistant and Patrol can use. Normal infrastructure
|
||||
discovery and onboarding stay under Infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<AIModelOverrideField state={props.state} kind="discovery" />
|
||||
<AIDiscoveryControlsSection state={props.state} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const AISettingsPageContent: Component<{
|
||||
page: AISettingsPage;
|
||||
state: ReturnType<typeof useAISettingsState>;
|
||||
}> = (props) => (
|
||||
<Show
|
||||
when={props.page === 'provider'}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.page === 'patrol'}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.page === 'assistant'}
|
||||
fallback={<DiscoverySettingsContent state={props.state} />}
|
||||
>
|
||||
<AssistantSettingsContent state={props.state} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<PatrolSettingsContent state={props.state} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ProviderSettingsContent state={props.state} />
|
||||
</Show>
|
||||
);
|
||||
|
||||
export const AISettings: Component<{ page?: AISettingsPage }> = (props) => {
|
||||
const page = () => props.page ?? 'provider';
|
||||
const pageMeta = () => AI_SETTINGS_PAGE_META[page()];
|
||||
const state = useAISettingsState({
|
||||
get saveErrorFallback() {
|
||||
return pageMeta().saveErrorFallback;
|
||||
},
|
||||
get savedLabel() {
|
||||
return pageMeta().savedLabel;
|
||||
},
|
||||
});
|
||||
const isProviderPage = () => page() === 'provider';
|
||||
const showExternalAgentAccess = () => page() === 'assistant';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
title={AI_SETTINGS_PANEL_TITLE}
|
||||
action={(() => {
|
||||
return (
|
||||
<Toggle
|
||||
checked={state.form.enabled}
|
||||
onChange={async (event) => {
|
||||
const newValue = event.currentTarget.checked;
|
||||
await state.handleEnableRequest(newValue);
|
||||
}}
|
||||
disabled={state.loading() || state.saving() || state.loadError()}
|
||||
containerClass="items-center gap-2"
|
||||
ariaLabel="Enable Assistant and Patrol"
|
||||
label={
|
||||
<span class="text-xs font-medium text-muted">
|
||||
{state.form.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
noPadding
|
||||
>
|
||||
<form class="divide-y divide-border" onSubmit={state.handleSave}>
|
||||
<Show when={state.loading()}>
|
||||
<div class="flex items-center gap-3 text-sm text-muted p-4 sm:p-6">
|
||||
<LoadingSpinner size="md" tone="current" />
|
||||
{getAISettingsLoadingState().text}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!state.loading() && state.loadError()}>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="flex items-center justify-between gap-3 p-4 sm:p-6 bg-red-50 dark:bg-red-900/30 border-b border-red-200 dark:border-red-800"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
||||
<svg
|
||||
class="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{getAISettingsLoadErrorMessage()}</span>
|
||||
<div class="space-y-6">
|
||||
<SettingsPanel
|
||||
title={pageMeta().title}
|
||||
action={
|
||||
isProviderPage() ? (
|
||||
<Toggle
|
||||
checked={state.form.enabled}
|
||||
onChange={async (event) => {
|
||||
const newValue = event.currentTarget.checked;
|
||||
await state.handleEnableRequest(newValue);
|
||||
}}
|
||||
disabled={state.loading() || state.saving() || state.loadError()}
|
||||
containerClass="items-center gap-2"
|
||||
ariaLabel="Enable Pulse Intelligence"
|
||||
label={
|
||||
<span class="text-xs font-medium text-muted">
|
||||
{state.form.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
noPadding
|
||||
>
|
||||
<form class="divide-y divide-border" onSubmit={state.handleSave}>
|
||||
<Show when={state.loading()}>
|
||||
<div class="flex items-center gap-3 text-sm text-muted p-4 sm:p-6">
|
||||
<LoadingSpinner size="md" tone="current" />
|
||||
{getAISettingsLoadingState().text}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 px-3 py-1.5 text-sm font-medium text-red-700 dark:text-red-300 border border-red-300 dark:border-red-700 rounded-md hover:bg-red-100 dark:hover:bg-red-900/50"
|
||||
onClick={() => state.loadSettings()}
|
||||
>
|
||||
{getAISettingsRetryLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={!state.loading() && !state.loadError()}>
|
||||
<Show when={state.form.enabled}>
|
||||
<div class="p-4 sm:p-6">
|
||||
<div class="flex items-start gap-2 text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded-md p-3">
|
||||
<Show when={!state.loading() && state.loadError()}>
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="flex items-center justify-between gap-3 p-4 sm:p-6 bg-red-50 dark:bg-red-900/30 border-b border-red-200 dark:border-red-800"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-red-700 dark:text-red-300">
|
||||
<svg
|
||||
class="w-4 h-4 mt-0.5 shrink-0"
|
||||
class="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Patrol runs automatically every{' '}
|
||||
{state.form.patrolIntervalMinutes >= 60
|
||||
? `${Math.round(state.form.patrolIntervalMinutes / 60)} hour${Math.round(state.form.patrolIntervalMinutes / 60) === 1 ? '' : 's'}`
|
||||
: `${state.form.patrolIntervalMinutes} minute${state.form.patrolIntervalMinutes === 1 ? '' : 's'}`}{' '}
|
||||
to verify your infrastructure continuously.{' '}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-1 py-1 text-sm underline hover:text-blue-800 dark:hover:text-blue-300"
|
||||
onClick={() => navigate(PATROL_PATH)}
|
||||
>
|
||||
Configure schedule & autonomy
|
||||
</button>
|
||||
</span>
|
||||
<span>{getAISettingsLoadErrorMessage()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-shrink-0 px-3 py-1.5 text-sm font-medium text-red-700 dark:text-red-300 border border-red-300 dark:border-red-700 rounded-md hover:bg-red-100 dark:hover:bg-red-900/50"
|
||||
onClick={() => state.loadSettings()}
|
||||
>
|
||||
{getAISettingsRetryLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="space-y-6 p-4 sm:p-6">
|
||||
<AIModelSelectionSection state={state} />
|
||||
<AIRuntimeControlsSection state={state} />
|
||||
<AIChatMaintenanceSection state={state} />
|
||||
</div>
|
||||
|
||||
<AISettingsStatusAndActions state={state} />
|
||||
</Show>
|
||||
</form>
|
||||
</SettingsPanel>
|
||||
<Show when={!state.loading() && !state.loadError()}>
|
||||
<AISettingsPageContent page={page()} state={state} />
|
||||
|
||||
<AISettingsStatusAndActions
|
||||
resetLabel={pageMeta().resetLabel}
|
||||
saveLabel={pageMeta().saveLabel}
|
||||
savingLabel={pageMeta().savingLabel}
|
||||
state={state}
|
||||
showConnectionControls={isProviderPage()}
|
||||
/>
|
||||
</Show>
|
||||
</form>
|
||||
</SettingsPanel>
|
||||
|
||||
<Show when={showExternalAgentAccess()}>
|
||||
<AgentIntegrationsPanel />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<AISettingsDialogs
|
||||
showSetupModal={state.showSetupModal}
|
||||
@@ -147,4 +404,8 @@ export const AISettings: Component = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const AIPatrolSettings: Component = () => <AISettings page="patrol" />;
|
||||
export const AIAssistantSettings: Component = () => <AISettings page="assistant" />;
|
||||
export const AIDiscoverySettings: Component = () => <AISettings page="discovery" />;
|
||||
|
||||
export default AISettings;
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { AISettingsState } from '@/components/Settings/useAISettingsState';
|
||||
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
|
||||
|
||||
interface AISettingsStatusAndActionsProps {
|
||||
resetLabel?: string;
|
||||
saveLabel?: string;
|
||||
savingLabel?: string;
|
||||
state: AISettingsState;
|
||||
showConnectionControls?: boolean;
|
||||
}
|
||||
|
||||
export const AISettingsStatusAndActions: Component<AISettingsStatusAndActionsProps> = (props) => {
|
||||
@@ -19,7 +23,7 @@ export const AISettingsStatusAndActions: Component<AISettingsStatusAndActionsPro
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={state.settings()}>
|
||||
<Show when={props.showConnectionControls && state.settings()}>
|
||||
<div class="p-4 sm:p-6">
|
||||
<div
|
||||
class={`flex items-center gap-2 p-3 rounded-md ${state.settingsReadiness().containerClassName}`}
|
||||
@@ -38,7 +42,7 @@ export const AISettingsStatusAndActions: Component<AISettingsStatusAndActionsPro
|
||||
</Show>
|
||||
|
||||
<div class="sticky bottom-0 bg-surface px-4 sm:px-6 py-4 flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3">
|
||||
<Show when={state.settings()?.configured}>
|
||||
<Show when={props.showConnectionControls && state.settings()?.configured}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full sm:w-auto min-h-10 sm:min-h-9 px-4 py-2.5 text-sm border border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -55,14 +59,16 @@ export const AISettingsStatusAndActions: Component<AISettingsStatusAndActionsPro
|
||||
onClick={() => state.resetForm(state.settings())}
|
||||
disabled={state.saving() || state.loading()}
|
||||
>
|
||||
Reset
|
||||
{props.resetLabel ?? 'Reset'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full sm:w-auto min-h-10 sm:min-h-9 px-4 py-2.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={state.saving() || state.loading()}
|
||||
>
|
||||
{state.saving() ? 'Saving...' : 'Save changes'}
|
||||
{state.saving()
|
||||
? (props.savingLabel ?? 'Saving...')
|
||||
: (props.saveLabel ?? 'Save changes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ButtonLink } from '@/components/shared/Button';
|
||||
import { API_TOKEN_ACCESS_PANEL_DESCRIPTION } from '@/utils/apiTokenPresentation';
|
||||
import { API_TOKEN_SCOPES_DOC_URL } from '@/utils/docsLinks';
|
||||
import APITokenManager from './APITokenManager';
|
||||
import AgentIntegrationsPanel from './AgentIntegrationsPanel';
|
||||
|
||||
interface APIAccessPanelProps {
|
||||
currentTokenHint?: string;
|
||||
@@ -31,14 +30,14 @@ export const APIAccessPanel: Component<APIAccessPanelProps> = (props) => {
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
|
||||
<APITokenManager
|
||||
currentTokenHint={props.currentTokenHint}
|
||||
onTokensChanged={props.onTokensChanged}
|
||||
refreshing={props.refreshing}
|
||||
canManage={props.canManage}
|
||||
/>
|
||||
|
||||
<AgentIntegrationsPanel />
|
||||
<div data-testid="api-access-token-section">
|
||||
<APITokenManager
|
||||
currentTokenHint={props.currentTokenHint}
|
||||
onTokensChanged={props.onTokensChanged}
|
||||
refreshing={props.refreshing}
|
||||
canManage={props.canManage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getAPITokenDockerPodmanUsageSummary,
|
||||
getAPITokenDockerPodmanUsageTitle,
|
||||
} from '@/utils/apiTokenPresentation';
|
||||
import { API_TOKEN_CREATE_ANCHOR } from '@/routing/resourceLinks';
|
||||
import { useAPITokenManagerState } from './useAPITokenManagerState';
|
||||
import type { APITokenRecord } from '@/types/api';
|
||||
|
||||
@@ -29,8 +30,10 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
API_SCOPE_LABELS,
|
||||
API_TOKEN_SCOPES_DOC_URL,
|
||||
agentTokenUsage,
|
||||
applyFullAccessPreset,
|
||||
applyScopePreset,
|
||||
canManage,
|
||||
canGenerateToken,
|
||||
clearScopes,
|
||||
copyNewMonitoringKioskLink,
|
||||
createHighlight,
|
||||
@@ -41,6 +44,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
handleDelete,
|
||||
handleGenerate,
|
||||
hasWildcardTokens,
|
||||
hasScopeSelection,
|
||||
isFullAccessSelected,
|
||||
isGenerating,
|
||||
isRevealActiveForCurrentToken,
|
||||
@@ -406,6 +410,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
<Card
|
||||
id={API_TOKEN_CREATE_ANCHOR}
|
||||
padding="none"
|
||||
class={`border border-border transition-shadow ${
|
||||
createHighlight() ? 'ring-2 ring-blue-500 shadow-sm' : ''
|
||||
@@ -422,7 +427,12 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!canManage() || isGenerating()}
|
||||
disabled={!canGenerateToken()}
|
||||
title={
|
||||
hasScopeSelection()
|
||||
? undefined
|
||||
: 'Choose a scope preset or custom scope before generating a token.'
|
||||
}
|
||||
class="inline-flex min-h-10 sm:min-h-10 items-center justify-center rounded-md bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||
>
|
||||
{isGenerating() ? 'Generating…' : 'Generate'}
|
||||
@@ -463,14 +473,14 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<SelectablePillButton
|
||||
active={isFullAccessSelected()}
|
||||
onClick={clearScopes}
|
||||
onClick={applyFullAccessPreset}
|
||||
disabled={!canManage()}
|
||||
title="Legacy wildcard – all permissions"
|
||||
>
|
||||
Full access
|
||||
</SelectablePillButton>
|
||||
|
||||
<For each={scopePresets}>
|
||||
<For each={scopePresets()}>
|
||||
{(preset) => (
|
||||
<SelectablePillButton
|
||||
active={presetMatchesSelection(preset.scopes)}
|
||||
@@ -483,6 +493,11 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={!hasScopeSelection()}>
|
||||
<p class="text-xs text-muted">
|
||||
Choose a scoped preset for least privilege, or deliberately choose Full access.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<details class="group rounded-md border border-border bg-surface-hover p-4 text-sm transition">
|
||||
|
||||
@@ -1,87 +1,45 @@
|
||||
import { Component, createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
} from 'solid-js';
|
||||
import {
|
||||
AGENT_MCP_TOKEN_PLACEHOLDER,
|
||||
AGENT_SURFACE_ID_PULSE_MCP,
|
||||
AGENT_WORKFLOW_PROMPT_OPERATIONS_LOOP,
|
||||
fetchAgentCapabilitiesManifest,
|
||||
formatAgentMCPServersConfig,
|
||||
formatAgentOpenCodeMCPConfig,
|
||||
getAgentCapabilityErrorCodeSummaries,
|
||||
getAgentWorkflowPrompts,
|
||||
getAgentManifestSurfaceToolContract,
|
||||
getAgentMCPConfigFamilyByShape,
|
||||
getAgentSurfaceToolPosturePresentation,
|
||||
getAgentSurfaceContractEntries,
|
||||
groupAgentCapabilitiesByManifestCategories,
|
||||
normalizeAgentMCPAdapter,
|
||||
} from '@/api/agentCapabilities';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
|
||||
import SettingsPanel from '@/components/shared/SettingsPanel';
|
||||
import {
|
||||
EXTERNAL_AGENT_SETUP_ANCHOR,
|
||||
PATROL_CONTROL_PATH,
|
||||
isExternalAgentSetupHash,
|
||||
PULSE_MCP_SETUP_ANCHOR,
|
||||
PULSE_MCP_TOKEN_SETUP_PATH,
|
||||
} from '@/routing/resourceLinks';
|
||||
import { AGENT_SUBSTRATE_DOC_URL } from '@/utils/docsLinks';
|
||||
import KeyRoundIcon from 'lucide-solid/icons/key-round';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import CopyCommandBlock from './CopyCommandBlock';
|
||||
|
||||
// AgentCapability mirrors the wire shape of one entry in
|
||||
// /api/agent/capabilities. Defined inline so this component does
|
||||
// not depend on a shared API client; the manifest endpoint is
|
||||
// public and stable.
|
||||
interface AgentCapability {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
method: string;
|
||||
path: string;
|
||||
scope: string;
|
||||
responseShape?: string;
|
||||
errorCodes?: string[];
|
||||
requestBodyShape?: string;
|
||||
}
|
||||
|
||||
interface AgentCapabilitiesManifest {
|
||||
version: string;
|
||||
capabilities: AgentCapability[];
|
||||
}
|
||||
|
||||
// Category presentation order on screen. Matches the closed set
|
||||
// the backend pin enforces (see TestAgentCapabilitiesManifest_
|
||||
// CategoriesAreClosed). Capabilities with an unknown category are
|
||||
// rendered last under a generic "other" heading so a future
|
||||
// addition that the panel does not yet know about still renders.
|
||||
const CATEGORY_ORDER: ReadonlyArray<{ id: string; label: string; description: string }> = [
|
||||
{
|
||||
id: 'context',
|
||||
label: 'Context',
|
||||
description: 'Discovery and read-only situated reads. Agents start here.',
|
||||
},
|
||||
{
|
||||
id: 'operator-state',
|
||||
label: 'Operator state',
|
||||
description:
|
||||
'Per-resource intent: intentionally-offline, never-auto-remediate, maintenance windows.',
|
||||
},
|
||||
{
|
||||
id: 'finding',
|
||||
label: 'Patrol findings',
|
||||
description: 'Acknowledge, snooze, dismiss, or resolve findings the patrol runtime raised.',
|
||||
},
|
||||
{
|
||||
id: 'action',
|
||||
label: 'Action governance',
|
||||
description: 'Plan, approve, and execute capability invocations against a resource.',
|
||||
},
|
||||
];
|
||||
|
||||
async function fetchManifest(): Promise<AgentCapabilitiesManifest> {
|
||||
const response = await fetch('/api/agent/capabilities', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`manifest fetch failed: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as AgentCapabilitiesManifest;
|
||||
}
|
||||
|
||||
// formatMcpConfig builds the JSON snippet an integrator drops into
|
||||
// claude_desktop_config.json (or .mcp.json for Claude Code). The
|
||||
// host is the deployment's own origin so the snippet is correct
|
||||
// for whatever URL the operator is reading the panel from.
|
||||
function formatMcpConfig(origin: string): string {
|
||||
const config = {
|
||||
mcpServers: {
|
||||
pulse: {
|
||||
command: 'pulse-mcp',
|
||||
args: ['--base-url', origin],
|
||||
env: {
|
||||
PULSE_API_TOKEN: '<your-api-token>',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
import { API_TOKEN_PATROL_EXTERNAL_AGENT_PRESET_LABEL } from './apiTokenManagerModel';
|
||||
|
||||
// All three external resources (the MCP adapter README, the agent-probe HTTP
|
||||
// example, the substrate design notes) are consolidated under the shipped
|
||||
@@ -89,113 +47,600 @@ function formatMcpConfig(origin: string): string {
|
||||
// frontend-runtime links pointing at unpinned GitHub main paths.
|
||||
|
||||
export const AgentIntegrationsPanel: Component = () => {
|
||||
const [manifest] = createResource(fetchManifest);
|
||||
const [manifest] = createResource(fetchAgentCapabilitiesManifest);
|
||||
const [copied, setCopied] = createSignal<string | null>(null);
|
||||
const [routeHash, setRouteHash] = createSignal('');
|
||||
const [panelHighlight, setPanelHighlight] = createSignal(false);
|
||||
const [installerCommandsOpen, setInstallerCommandsOpen] = createSignal(false);
|
||||
const [clientConfigOpen, setClientConfigOpen] = createSignal(false);
|
||||
const [advancedClientDetailsOpen, setAdvancedClientDetailsOpen] = createSignal(false);
|
||||
const [setupOpen, setSetupOpen] = createSignal(false);
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:7655';
|
||||
const mcpConfig = formatMcpConfig(origin);
|
||||
const mcpInstallShellCommand =
|
||||
'curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.sh | bash';
|
||||
const mcpInstallPowerShellCommand =
|
||||
'irm https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.ps1 | iex';
|
||||
let highlightTimer: number | undefined;
|
||||
let routeFocusTimer: number | undefined;
|
||||
const mcpAdapter = createMemo(() => normalizeAgentMCPAdapter(manifest()?.mcpAdapter));
|
||||
const openCodeConfigFamily = createMemo(() =>
|
||||
getAgentMCPConfigFamilyByShape(mcpAdapter(), 'opencode_mcp'),
|
||||
);
|
||||
const mcpServersConfigFamily = createMemo(() =>
|
||||
getAgentMCPConfigFamilyByShape(mcpAdapter(), 'mcp_servers'),
|
||||
);
|
||||
const claudeMcpConfig = createMemo(() => formatAgentMCPServersConfig(mcpAdapter(), origin));
|
||||
const openCodeMcpConfig = createMemo(() => formatAgentOpenCodeMCPConfig(mcpAdapter(), origin));
|
||||
const requiredScopes = createMemo(() =>
|
||||
(manifest()?.requiredScopes ?? []).map((scope) => scope.trim()).filter(Boolean),
|
||||
);
|
||||
const surfaceContractEntries = createMemo(() => getAgentSurfaceContractEntries(manifest()));
|
||||
const workflowPrompts = createMemo(() => getAgentWorkflowPrompts(manifest()));
|
||||
const mcpSurfaceToolPosture = createMemo(() =>
|
||||
getAgentSurfaceToolPosturePresentation(
|
||||
getAgentManifestSurfaceToolContract(manifest(), AGENT_SURFACE_ID_PULSE_MCP),
|
||||
),
|
||||
);
|
||||
const errorCodeSummaries = createMemo(() => getAgentCapabilityErrorCodeSummaries(manifest()));
|
||||
const grouped = createMemo(() => groupAgentCapabilitiesByManifestCategories(manifest()));
|
||||
const hasManifestInventory = createMemo(
|
||||
() =>
|
||||
workflowPrompts().length > 0 ||
|
||||
requiredScopes().length > 0 ||
|
||||
errorCodeSummaries().length > 0 ||
|
||||
Boolean(manifest() && grouped().length > 0),
|
||||
);
|
||||
const hasClientBuilderDetails = createMemo(
|
||||
() => surfaceContractEntries().length > 0 || hasManifestInventory(),
|
||||
);
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const m = manifest();
|
||||
if (!m) return [];
|
||||
// Index capabilities by category, then return them in
|
||||
// CATEGORY_ORDER. Unknown categories spill into a generic
|
||||
// "other" bucket so the panel never silently drops a
|
||||
// capability the operator should be able to see.
|
||||
const byCategory = new Map<string, AgentCapability[]>();
|
||||
for (const cap of m.capabilities) {
|
||||
const list = byCategory.get(cap.category) ?? [];
|
||||
list.push(cap);
|
||||
byCategory.set(cap.category, list);
|
||||
}
|
||||
const sections: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
entries: AgentCapability[];
|
||||
}> = [];
|
||||
for (const known of CATEGORY_ORDER) {
|
||||
const entries = byCategory.get(known.id);
|
||||
if (entries && entries.length > 0) {
|
||||
sections.push({
|
||||
id: known.id,
|
||||
label: known.label,
|
||||
description: known.description,
|
||||
entries,
|
||||
});
|
||||
byCategory.delete(known.id);
|
||||
}
|
||||
}
|
||||
for (const [unknownCategory, entries] of byCategory) {
|
||||
sections.push({ id: unknownCategory, label: unknownCategory, entries });
|
||||
}
|
||||
return sections;
|
||||
});
|
||||
|
||||
const handleCopySnippet = async (snippet: string) => {
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
const handleCopySnippet = (snippet: string) => {
|
||||
setCopied(snippet);
|
||||
window.setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const readRouteHash = () => (typeof window === 'undefined' ? '' : window.location.hash);
|
||||
|
||||
const getPanelElement = () =>
|
||||
typeof document === 'undefined'
|
||||
? null
|
||||
: (document.getElementById(EXTERNAL_AGENT_SETUP_ANCHOR) as HTMLElement | null);
|
||||
|
||||
const findScrollableAncestor = (element: HTMLElement): HTMLElement | null => {
|
||||
let current = element.parentElement;
|
||||
while (current) {
|
||||
const style = window.getComputedStyle(current);
|
||||
const canScroll =
|
||||
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||
current.scrollHeight > current.clientHeight;
|
||||
if (canScroll) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const focusPanel = (): boolean => {
|
||||
const panelElement = getPanelElement();
|
||||
if (!panelElement) return false;
|
||||
if (typeof panelElement.scrollIntoView === 'function') {
|
||||
panelElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
const scrollParent = findScrollableAncestor(panelElement);
|
||||
if (scrollParent && typeof scrollParent.scrollTo === 'function') {
|
||||
const parentRect = scrollParent.getBoundingClientRect();
|
||||
const panelRect = panelElement.getBoundingClientRect();
|
||||
scrollParent.scrollTo({
|
||||
top: Math.max(0, scrollParent.scrollTop + panelRect.top - parentRect.top - 16),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
setPanelHighlight(true);
|
||||
window.clearTimeout(highlightTimer);
|
||||
highlightTimer = window.setTimeout(() => setPanelHighlight(false), 1600);
|
||||
const panelRect = panelElement.getBoundingClientRect();
|
||||
return panelRect.top >= 0 && panelRect.top < 220;
|
||||
};
|
||||
|
||||
const focusPanelUntilLayoutSettles = (hash: string, attempt = 0) => {
|
||||
if (readRouteHash() !== hash) return;
|
||||
const panelReached = focusPanel();
|
||||
if (panelReached || attempt >= 8) return;
|
||||
window.clearTimeout(routeFocusTimer);
|
||||
routeFocusTimer = window.setTimeout(
|
||||
() => focusPanelUntilLayoutSettles(hash, attempt + 1),
|
||||
attempt < 2 ? 100 : 250,
|
||||
);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
const updateRouteHash = () => setRouteHash(readRouteHash());
|
||||
updateRouteHash();
|
||||
window.addEventListener('hashchange', updateRouteHash);
|
||||
window.addEventListener('popstate', updateRouteHash);
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('hashchange', updateRouteHash);
|
||||
window.removeEventListener('popstate', updateRouteHash);
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (highlightTimer) window.clearTimeout(highlightTimer);
|
||||
if (routeFocusTimer) window.clearTimeout(routeFocusTimer);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const hash = routeHash();
|
||||
if (!isExternalAgentSetupHash(hash) || !getPanelElement()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSetupOpen(true);
|
||||
window.clearTimeout(routeFocusTimer);
|
||||
const canonicalHash = `#${EXTERNAL_AGENT_SETUP_ANCHOR}`;
|
||||
if (hash === `#${PULSE_MCP_SETUP_ANCHOR}` && window.location.hash === hash) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}${canonicalHash}`,
|
||||
);
|
||||
setRouteHash(canonicalHash);
|
||||
focusPanelUntilLayoutSettles(canonicalHash);
|
||||
return;
|
||||
}
|
||||
|
||||
focusPanelUntilLayoutSettles(hash);
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsPanel title="Agent integrations" noPadding>
|
||||
<SettingsPanel
|
||||
id={EXTERNAL_AGENT_SETUP_ANCHOR}
|
||||
title="External agents"
|
||||
noPadding
|
||||
class={`scroll-mt-20 transition-shadow ${
|
||||
panelHighlight() ? 'ring-2 ring-blue-500 shadow-sm' : ''
|
||||
}`}
|
||||
>
|
||||
<span id={PULSE_MCP_SETUP_ANCHOR} class="sr-only" aria-hidden="true" />
|
||||
<div class="space-y-5 p-4 sm:p-6">
|
||||
<div class="space-y-2 text-sm text-muted">
|
||||
<p>
|
||||
Pulse exposes a manifest-driven agent surface for MCP and HTTP clients. An external
|
||||
agent (Claude Desktop, Claude Code, or a custom integration) discovers what it can do
|
||||
via <code class="font-mono text-xs">/api/agent/capabilities</code>, then calls the
|
||||
declared endpoints with an API token.
|
||||
</p>
|
||||
<p>
|
||||
The capabilities below are read live from this Pulse instance. Adding a capability on
|
||||
the backend adds it here automatically; nothing in this panel is hardcoded.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 text-sm text-muted lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="max-w-3xl space-y-2">
|
||||
<p>Connect external tools to read Pulse context and request Patrol work.</p>
|
||||
<p>Patrol mode and scoped tokens control what connected agents can do.</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<Show when={!setupOpen()}>
|
||||
<ButtonLink
|
||||
href={PATROL_CONTROL_PATH}
|
||||
variant="secondary"
|
||||
size="settingsAction"
|
||||
class="w-fit gap-2 font-semibold"
|
||||
>
|
||||
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Choose Patrol mode
|
||||
</ButtonLink>
|
||||
</Show>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="settingsAction"
|
||||
class="w-fit font-semibold"
|
||||
aria-controls="external-agent-setup-details"
|
||||
aria-expanded={setupOpen()}
|
||||
onClick={() => setSetupOpen((open) => !open)}
|
||||
>
|
||||
{setupOpen() ? 'Hide connector setup' : 'Show connector setup'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-sm font-semibold text-base-content">
|
||||
Claude Desktop / Claude Code config
|
||||
</h3>
|
||||
<Show when={copied() === mcpConfig}>
|
||||
<span class="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
Copied to clipboard
|
||||
</span>
|
||||
<Show when={setupOpen()}>
|
||||
<div id="external-agent-setup-details" class="space-y-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-sm font-semibold text-base-content">Connector setup</h3>
|
||||
</div>
|
||||
<ol class="divide-y divide-border rounded-md border border-border text-sm">
|
||||
<li class="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-muted">Step 1</p>
|
||||
<p class="font-semibold text-base-content">Choose Patrol mode</p>
|
||||
<p class="text-xs text-muted">Required before agents can request Patrol work.</p>
|
||||
</div>
|
||||
<ButtonLink
|
||||
href={PATROL_CONTROL_PATH}
|
||||
variant="secondary"
|
||||
size="settingsAction"
|
||||
class="w-fit shrink-0 gap-2 font-semibold"
|
||||
>
|
||||
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Choose Patrol mode
|
||||
</ButtonLink>
|
||||
</li>
|
||||
<li class="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-muted">Step 2</p>
|
||||
<p class="font-semibold text-base-content">Create a scoped token</p>
|
||||
<p class="text-xs text-muted">
|
||||
Create a token with the{' '}
|
||||
<span class="font-medium text-base-content">
|
||||
{API_TOKEN_PATROL_EXTERNAL_AGENT_PRESET_LABEL}
|
||||
</span>{' '}
|
||||
preset.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonLink
|
||||
href={PULSE_MCP_TOKEN_SETUP_PATH}
|
||||
hardNavigation
|
||||
variant="secondary"
|
||||
size="settingsAction"
|
||||
class="w-fit shrink-0 gap-2 font-semibold"
|
||||
>
|
||||
<KeyRoundIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Create token
|
||||
</ButtonLink>
|
||||
</li>
|
||||
<li class="space-y-1 p-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-muted">Step 3</p>
|
||||
<p class="font-semibold text-base-content">Connect the agent</p>
|
||||
<p class="text-xs text-muted">
|
||||
Install the connector, paste the client config, and replace{' '}
|
||||
<code class="font-mono">{AGENT_MCP_TOKEN_PLACEHOLDER}</code>.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
<details
|
||||
class="rounded-md border border-border bg-surface-alt/40 p-3"
|
||||
onToggle={(event) => setInstallerCommandsOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Installer commands
|
||||
</summary>
|
||||
<Show when={installerCommandsOpen()}>
|
||||
<div class="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
macOS / Linux
|
||||
</h4>
|
||||
<CopyCommandBlock
|
||||
command={mcpInstallShellCommand}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Windows
|
||||
</h4>
|
||||
<CopyCommandBlock
|
||||
command={mcpInstallPowerShellCommand}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</details>
|
||||
<details
|
||||
class="rounded-md border border-border bg-surface-alt/40 p-3"
|
||||
onToggle={(event) => setClientConfigOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Client config
|
||||
</summary>
|
||||
<Show when={clientConfigOpen()}>
|
||||
<div class="mt-3 space-y-4">
|
||||
<p class="text-xs text-muted">
|
||||
Both snippets point at this Pulse instance and use the same token placeholder:{' '}
|
||||
<code class="font-mono">{AGENT_MCP_TOKEN_PLACEHOLDER}</code>.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{openCodeConfigFamily()?.label ?? 'OpenCode'}{' '}
|
||||
<code class="font-mono normal-case">opencode.json</code>
|
||||
</h4>
|
||||
<Show when={copied() === openCodeMcpConfig()}>
|
||||
<span class="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
OpenCode config copied
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<CopyCommandBlock
|
||||
command={openCodeMcpConfig()}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{mcpServersConfigFamily()?.label ?? 'Claude-style clients'}{' '}
|
||||
<code class="font-mono normal-case">mcpServers</code>
|
||||
</h4>
|
||||
<Show when={copied() === claudeMcpConfig()}>
|
||||
<span class="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
mcpServers config copied
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<CopyCommandBlock
|
||||
command={claudeMcpConfig()}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</details>
|
||||
<Show when={hasClientBuilderDetails()}>
|
||||
<details
|
||||
class="rounded-md border border-border bg-surface-alt/40 p-3"
|
||||
onToggle={(event) => setAdvancedClientDetailsOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Developer details
|
||||
</summary>
|
||||
<Show when={advancedClientDetailsOpen()}>
|
||||
<div class="mt-3 space-y-3">
|
||||
<p class="text-xs text-muted">
|
||||
Only open this when you are building or debugging a client against this Pulse
|
||||
instance. Normal setup only needs Patrol mode, a scoped token, the connector,
|
||||
and the client config above.
|
||||
</p>
|
||||
<Show when={mcpSurfaceToolPosture()}>
|
||||
{(posture) => (
|
||||
<div
|
||||
class="flex w-fit flex-wrap items-center gap-1.5 rounded-md border border-border bg-base px-2 py-1 text-[11px] font-medium text-muted"
|
||||
title={posture().title}
|
||||
data-testid="agent-mcp-tool-posture"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full bg-emerald-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
External agents expose {posture().label}{' '}
|
||||
<span class="text-muted/80">through Patrol mode</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={surfaceContractEntries().length > 0}>
|
||||
<details class="rounded-md border border-border bg-base p-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Patrol access model
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2">
|
||||
<p class="text-xs text-muted">
|
||||
Built-in Pulse views and connected clients all sit behind the same
|
||||
Patrol policy. Connected agents do not get separate powers.
|
||||
</p>
|
||||
<ul class="divide-y divide-border rounded-md border border-border bg-surface-alt/40">
|
||||
<For each={surfaceContractEntries()}>
|
||||
{(entry) => (
|
||||
<li class="space-y-1 p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold text-base-content">
|
||||
{entry.id === AGENT_SURFACE_ID_PULSE_MCP
|
||||
? 'External agents'
|
||||
: entry.label}
|
||||
</span>
|
||||
<For each={entry.badges}>
|
||||
{(badge) => (
|
||||
<span class="rounded-full border border-border bg-base px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={entry.description}>
|
||||
<p class="text-xs text-muted">{entry.description}</p>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
<Show when={hasManifestInventory()}>
|
||||
<details class="rounded-md border border-border bg-base p-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Live manifest details
|
||||
</summary>
|
||||
<div class="mt-3 space-y-4">
|
||||
<p class="text-xs text-muted">
|
||||
These values come from{' '}
|
||||
<code class="font-mono">/api/agent/capabilities</code>. Use them to
|
||||
build clients against this instance; they do not change Patrol mode.
|
||||
</p>
|
||||
<Show when={workflowPrompts().length > 0}>
|
||||
<details class="rounded-md border border-border bg-surface-alt/40 p-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
<span>Agent starting points</span>{' '}
|
||||
<span class="ml-2 text-xs font-normal text-muted">
|
||||
({workflowPrompts().length} from manifest)
|
||||
</span>
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2">
|
||||
<ul class="divide-y divide-border rounded-md border border-border bg-base">
|
||||
<For each={workflowPrompts()}>
|
||||
{(prompt) => (
|
||||
<li class="space-y-1 p-3">
|
||||
<div class="flex flex-wrap items-baseline gap-2">
|
||||
<span class="text-sm font-semibold text-base-content">
|
||||
{prompt.label ?? prompt.name}
|
||||
</span>
|
||||
<Show
|
||||
when={
|
||||
prompt.name === AGENT_WORKFLOW_PROMPT_OPERATIONS_LOOP
|
||||
}
|
||||
>
|
||||
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
Patrol
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={prompt.description}>
|
||||
<p class="text-xs text-muted">{prompt.description}</p>
|
||||
</Show>
|
||||
<p class="text-[11px] text-muted">
|
||||
<span class="font-semibold">Wire name:</span>{' '}
|
||||
<code class="font-mono">{prompt.name}</code>
|
||||
</p>
|
||||
<Show when={(prompt.arguments?.length ?? 0) > 0}>
|
||||
<p class="text-[11px] text-muted">
|
||||
<span class="font-semibold">Arguments:</span>{' '}
|
||||
<For each={prompt.arguments}>
|
||||
{(arg, index) => (
|
||||
<>
|
||||
<code class="font-mono">{arg.name}</code>
|
||||
{arg.required ? ' required' : ' optional'}
|
||||
{index() < (prompt.arguments?.length ?? 0) - 1
|
||||
? ', '
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</p>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
<Show when={requiredScopes().length > 0}>
|
||||
<p class="text-xs text-muted">
|
||||
Manifest scopes:{' '}
|
||||
<For each={requiredScopes()}>
|
||||
{(scope, index) => (
|
||||
<>
|
||||
<code class="font-mono">{scope}</code>
|
||||
{index() < requiredScopes().length - 1 ? ', ' : ''}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
. Use narrower tokens when the client should only perform part of the
|
||||
allowed work; each capability below shows its required scope.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={errorCodeSummaries().length > 0}>
|
||||
<details class="rounded-md border border-border bg-surface-alt/40 p-3 text-xs text-muted">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Failure codes{' '}
|
||||
<span class="ml-2 text-xs font-normal text-muted">
|
||||
({errorCodeSummaries().length} from the live manifest)
|
||||
</span>
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2">
|
||||
<p>
|
||||
Connected agents should branch on these codes instead of scraping
|
||||
response text.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={errorCodeSummaries()}>
|
||||
{(summary) => (
|
||||
<span
|
||||
class="rounded-md border border-border bg-base px-2 py-1"
|
||||
title={`Declared by ${summary.capabilityNames.join(', ')}`}
|
||||
>
|
||||
<code class="font-mono">{summary.code}</code>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
<p class="text-xs text-muted">
|
||||
See{' '}
|
||||
<ExternalTextLink href={AGENT_SUBSTRATE_DOC_URL} variant="inlineSubtle">
|
||||
docs/AGENT_SUBSTRATE.md
|
||||
</ExternalTextLink>{' '}
|
||||
for build instructions on <code class="font-mono">cmd/pulse-mcp</code>{' '}
|
||||
(including the <code class="font-mono">--emit-notifications</code> flag
|
||||
and known limitations), the companion HTTP example at{' '}
|
||||
<code class="font-mono">cmd/agent-probe</code>, and the substrate's
|
||||
design notes.
|
||||
</p>
|
||||
<Show when={manifest() && grouped().length > 0}>
|
||||
<details class="rounded-md border border-border bg-surface-alt/40 p-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Agent capabilities{' '}
|
||||
<span class="ml-2 text-xs font-normal text-muted">
|
||||
(manifest {manifest()?.version} -{' '}
|
||||
{manifest()?.capabilities.length ?? 0} total)
|
||||
</span>
|
||||
</summary>
|
||||
<div class="mt-3 space-y-4">
|
||||
<For each={grouped()}>
|
||||
{(section) => (
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{section.label}
|
||||
</h4>
|
||||
<Show when={section.description}>
|
||||
<p class="text-xs text-muted">{section.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<ul class="divide-y divide-border rounded-md border border-border bg-base">
|
||||
<For each={section.entries}>
|
||||
{(cap) => (
|
||||
<li class="space-y-1 p-3">
|
||||
<div class="flex flex-wrap items-baseline gap-2">
|
||||
<code class="font-mono text-sm font-semibold text-base-content">
|
||||
{cap.name}
|
||||
</code>
|
||||
<span class="text-xs text-muted">
|
||||
<code class="font-mono">{cap.method}</code>{' '}
|
||||
{cap.path}
|
||||
</span>
|
||||
<span class="ml-auto rounded-full border border-border bg-surface-alt px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted">
|
||||
{cap.scope}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">{cap.description}</p>
|
||||
<Show
|
||||
when={cap.errorCodes && cap.errorCodes.length > 0}
|
||||
>
|
||||
<p class="text-[11px] text-muted">
|
||||
<span class="font-semibold">
|
||||
Stable error codes:
|
||||
</span>{' '}
|
||||
<For each={cap.errorCodes}>
|
||||
{(code, idx) => (
|
||||
<>
|
||||
<code class="font-mono">{code}</code>
|
||||
{idx() < cap.errorCodes!.length - 1
|
||||
? ', '
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</p>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</details>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
First, install <code class="font-mono">pulse-mcp</code>. The fastest path:{' '}
|
||||
<code class="font-mono">
|
||||
curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.sh
|
||||
| bash
|
||||
</code>{' '}
|
||||
(or <code class="font-mono">irm .../install-mcp.ps1 | iex</code> on Windows). Then drop
|
||||
this block into{' '}
|
||||
<code class="font-mono">
|
||||
~/Library/Application Support/Claude/claude_desktop_config.json
|
||||
</code>{' '}
|
||||
(Claude Desktop) or your project's <code class="font-mono">.mcp.json</code> (Claude
|
||||
Code). Mint a token below with <code class="font-mono">monitoring:read</code> (and{' '}
|
||||
<code class="font-mono">monitoring:write</code> for the operator-state write tools),
|
||||
then replace <code class="font-mono"><your-api-token></code>.
|
||||
</p>
|
||||
<CopyCommandBlock
|
||||
command={mcpConfig}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
<p class="text-xs text-muted">
|
||||
See{' '}
|
||||
<ExternalTextLink href={AGENT_SUBSTRATE_DOC_URL} variant="inlineSubtle">
|
||||
docs/AGENT_SUBSTRATE.md
|
||||
</ExternalTextLink>{' '}
|
||||
for build instructions on <code class="font-mono">cmd/pulse-mcp</code> (including the{' '}
|
||||
<code class="font-mono">--emit-notifications</code> flag and known limitations), the
|
||||
companion HTTP example at <code class="font-mono">cmd/agent-probe</code>, and the
|
||||
substrate's design notes.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={manifest.loading}>
|
||||
<p class="text-sm text-muted">Loading capabilities…</p>
|
||||
@@ -207,64 +652,6 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
{String(manifest.error)}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={manifest() && grouped().length > 0}>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-base-content">
|
||||
Declared capabilities (manifest {manifest()?.version})
|
||||
</h3>
|
||||
<span class="text-xs text-muted">{manifest()?.capabilities.length ?? 0} total</span>
|
||||
</div>
|
||||
<For each={grouped()}>
|
||||
{(section) => (
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{section.label}
|
||||
</h4>
|
||||
<Show when={section.description}>
|
||||
<p class="text-xs text-muted">{section.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<ul class="divide-y divide-border rounded-md border border-border">
|
||||
<For each={section.entries}>
|
||||
{(cap) => (
|
||||
<li class="space-y-1 p-3">
|
||||
<div class="flex flex-wrap items-baseline gap-2">
|
||||
<code class="font-mono text-sm font-semibold text-base-content">
|
||||
{cap.name}
|
||||
</code>
|
||||
<span class="text-xs text-muted">
|
||||
<code class="font-mono">{cap.method}</code> {cap.path}
|
||||
</span>
|
||||
<span class="ml-auto rounded-full border border-border bg-surface-alt px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted">
|
||||
{cap.scope}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">{cap.description}</p>
|
||||
<Show when={cap.errorCodes && cap.errorCodes.length > 0}>
|
||||
<p class="text-[11px] text-muted">
|
||||
<span class="font-semibold">Stable error codes:</span>{' '}
|
||||
<For each={cap.errorCodes}>
|
||||
{(code, idx) => (
|
||||
<>
|
||||
<code class="font-mono">{code}</code>
|
||||
{idx() < cap.errorCodes!.length - 1 ? ', ' : ''}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</p>
|
||||
</Show>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</SettingsPanel>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CommandCopyButton } from '@/components/shared/Button';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
|
||||
interface CopyCommandBlockProps {
|
||||
command: string;
|
||||
@@ -14,11 +15,11 @@ const DEFAULT_CODE_CLASS =
|
||||
|
||||
export function CopyCommandBlock(props: CopyCommandBlockProps) {
|
||||
const handleCopy = () => {
|
||||
if (props.onCopy) {
|
||||
void props.onCopy(props.command);
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(props.command);
|
||||
void (async () => {
|
||||
const copied = await copyToClipboard(props.command);
|
||||
if (!copied) return;
|
||||
await props.onCopy?.(props.command);
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -470,22 +470,24 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between border-t border-border-subtle pt-3 text-xs">
|
||||
<span class="text-muted">MCP Connection</span>
|
||||
<span class="text-muted">Assistant runtime</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Show
|
||||
when={props.diagnosticsData?.aiChat?.mcpConnected}
|
||||
when={props.diagnosticsData?.aiChat?.assistantRuntimeConnected}
|
||||
fallback={<XCircle class="h-3.5 w-3.5 text-rose-400" />}
|
||||
>
|
||||
<CheckCircle class="h-3.5 w-3.5 text-emerald-400" />
|
||||
</Show>
|
||||
<span
|
||||
class={
|
||||
props.diagnosticsData?.aiChat?.mcpConnected
|
||||
props.diagnosticsData?.aiChat?.assistantRuntimeConnected
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-slate-500'
|
||||
}
|
||||
>
|
||||
{props.diagnosticsData?.aiChat?.mcpConnected ? 'Connected' : 'Disconnected'}
|
||||
{props.diagnosticsData?.aiChat?.assistantRuntimeConnected
|
||||
? 'Connected'
|
||||
: 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,8 +302,9 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2 mt-0.5 text-emerald-500">✓</span>
|
||||
<span>
|
||||
Read-only by default. Assistant control and host shell stay off until you opt in
|
||||
per host from <span class="font-medium">Settings → Infrastructure</span>.
|
||||
Read-only by default. Assistant chat actions and host shell stay off until you
|
||||
opt in per host from{' '}
|
||||
<span class="font-medium">Settings → Infrastructure</span>.
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
|
||||
@@ -89,7 +89,6 @@ const ProLicensePanelContent: Component = () => {
|
||||
<div class="space-y-6">
|
||||
<ProLicensePlanSection
|
||||
activationSuccessSummary={state.activationSuccessSummary()}
|
||||
activationProof={state.activationProof()}
|
||||
commercialMigrationNotice={state.commercialMigrationNotice()}
|
||||
commercialPlanModel={state.commercialPlanModel()}
|
||||
currentPlanSummary={state.currentPlanSummary()}
|
||||
@@ -98,8 +97,9 @@ const ProLicensePanelContent: Component = () => {
|
||||
hasLicenseDetails={state.hasLicenseDetails()}
|
||||
loading={state.loading()}
|
||||
onReload={() => void state.loadPanelData()}
|
||||
planSelectionPrompt={state.planSelectionPrompt()}
|
||||
planComparisonSummary={state.planComparisonSummary()}
|
||||
planSelectionPrompt={state.planSelectionPrompt()}
|
||||
planStatus={state.planStatus()}
|
||||
purchaseActivationAction={state.purchaseActivationAction()}
|
||||
purchaseActivationNotice={state.purchaseActivationNotice()}
|
||||
/>
|
||||
|
||||
@@ -6,9 +6,10 @@ import { licenseEntitlementsLoadError } from '@/stores/licenseEntitlements';
|
||||
import {
|
||||
getLicenseStatusLoadingState,
|
||||
getNoActiveSelfHostedActivationState,
|
||||
type SelfHostedPatrolControlActionIntent,
|
||||
} from '@/utils/licensePresentation';
|
||||
import { isExternalUpgradeHref, type UpgradeDestination } from '@/utils/upgradeNavigation';
|
||||
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation';
|
||||
import type { UpgradeDestination } from '@/utils/upgradeNavigation';
|
||||
import { CommercialStatGrid } from './CommercialBillingSections';
|
||||
|
||||
interface Notice {
|
||||
@@ -31,16 +32,7 @@ interface ProLicensePlanSectionProps {
|
||||
highlights: string[];
|
||||
actionLabel?: string;
|
||||
actionUrl?: string;
|
||||
} | null;
|
||||
activationProof: {
|
||||
title: string;
|
||||
body: string;
|
||||
items: Array<{
|
||||
label: string;
|
||||
statusLabel: string;
|
||||
state: 'active' | 'partial' | 'missing';
|
||||
detail: string;
|
||||
}>;
|
||||
actionIntent?: SelfHostedPatrolControlActionIntent;
|
||||
} | null;
|
||||
commercialMigrationNotice: Notice | null;
|
||||
commercialPlanModel: {
|
||||
@@ -62,6 +54,11 @@ interface ProLicensePlanSectionProps {
|
||||
actionLabel: string;
|
||||
actionUrl: string;
|
||||
};
|
||||
patrolControlAction?: {
|
||||
actionLabel: string;
|
||||
actionUrl: string;
|
||||
actionIntent: SelfHostedPatrolControlActionIntent;
|
||||
};
|
||||
};
|
||||
entitlements: {
|
||||
in_grace_period?: boolean;
|
||||
@@ -83,6 +80,16 @@ interface ProLicensePlanSectionProps {
|
||||
} | null;
|
||||
};
|
||||
planSelectionPrompt: ActionNotice | null;
|
||||
planStatus: {
|
||||
title: string;
|
||||
body: string;
|
||||
items: Array<{
|
||||
label: string;
|
||||
statusLabel: string;
|
||||
state: 'active' | 'partial' | 'missing';
|
||||
detail: string;
|
||||
}>;
|
||||
} | null;
|
||||
purchaseActivationNotice: Notice | null;
|
||||
purchaseActivationAction: {
|
||||
label: string;
|
||||
@@ -97,7 +104,7 @@ const formatDate = (value?: string | null) => {
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const proofStateClass = (state: 'active' | 'partial' | 'missing') => {
|
||||
const statusStateClass = (state: 'active' | 'partial' | 'missing') => {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-200';
|
||||
@@ -109,6 +116,30 @@ const proofStateClass = (state: 'active' | 'partial' | 'missing') => {
|
||||
};
|
||||
|
||||
export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (props) => {
|
||||
const getCurrentPlanPatrolControlAction = () => {
|
||||
const action = props.currentPlanSummary.patrolControlAction;
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
if (props.activationSuccessSummary?.actionIntent === action.actionIntent) {
|
||||
return null;
|
||||
}
|
||||
return action;
|
||||
};
|
||||
const getPlanStatusSummary = () => {
|
||||
const status = props.planStatus;
|
||||
if (!status) {
|
||||
return '';
|
||||
}
|
||||
const attentionCount = status.items.filter((item) => item.state !== 'active').length;
|
||||
if (attentionCount > 0) {
|
||||
return attentionCount === 1
|
||||
? '1 item needs attention'
|
||||
: `${attentionCount} items need attention`;
|
||||
}
|
||||
return status.items.length === 1 ? '1 item ready' : `${status.items.length} items ready`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.activationSuccessSummary}>
|
||||
@@ -130,7 +161,7 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
|
||||
{(action) => (
|
||||
<ButtonLink
|
||||
href={action().href}
|
||||
target="_blank"
|
||||
target={isExternalUpgradeHref(action().href) ? '_blank' : undefined}
|
||||
variant="outline"
|
||||
size="settingsActionXs"
|
||||
class="mt-3 gap-1"
|
||||
@@ -233,6 +264,19 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
|
||||
</ButtonLink>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={getCurrentPlanPatrolControlAction()}>
|
||||
{(action) => (
|
||||
<ButtonLink
|
||||
href={action().actionUrl}
|
||||
target={isExternalUpgradeHref(action().actionUrl) ? '_blank' : undefined}
|
||||
variant="outline"
|
||||
size="settingsActionXs"
|
||||
class="mt-3 gap-1"
|
||||
>
|
||||
{action().actionLabel}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.currentPlanSummary.unlockedFeatures.length > 0}>
|
||||
<div class="mt-4">
|
||||
<p class="text-xs uppercase tracking-wide text-muted mb-2">
|
||||
@@ -268,31 +312,36 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.activationProof}>
|
||||
{(proof) => (
|
||||
<div class="mb-4 rounded-md border border-border bg-surface p-4">
|
||||
<p class="text-sm font-medium text-base-content">{proof().title}</p>
|
||||
<p class="mt-1 text-xs text-muted">{proof().body}</p>
|
||||
<ul class="mt-4 grid gap-3">
|
||||
<For each={proof().items}>
|
||||
{(item) => (
|
||||
<li class="border-t border-border pt-3 first:border-t-0 first:pt-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-base-content">{item.label}</p>
|
||||
<span
|
||||
class={`rounded-full px-2 py-1 text-[11px] font-medium ${proofStateClass(
|
||||
item.state,
|
||||
)}`}
|
||||
>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">{item.detail}</p>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
<Show when={props.planStatus}>
|
||||
{(status) => (
|
||||
<details class="mb-4 rounded-md border border-border bg-surface p-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-base-content">
|
||||
<span>{status().title}</span>{' '}
|
||||
<span class="text-xs font-normal text-muted">({getPlanStatusSummary()})</span>
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-muted">{status().body}</p>
|
||||
<ul class="mt-4 grid gap-3">
|
||||
<For each={status().items}>
|
||||
{(item) => (
|
||||
<li class="border-t border-border pt-3 first:border-t-0 first:pt-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-medium text-base-content">{item.label}</p>
|
||||
<span
|
||||
class={`rounded-full px-2 py-1 text-[11px] font-medium ${statusStateClass(
|
||||
item.state,
|
||||
)}`}
|
||||
>
|
||||
{item.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">{item.detail}</p>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.planComparisonSummary.cards.length > 0}>
|
||||
|
||||
@@ -2,9 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
|
||||
import { PULSE_MCP_TOKEN_SETUP_PATH } from '@/routing/resourceLinks';
|
||||
import { resetAIRuntimeState } from '@/stores/aiRuntimeState';
|
||||
import type { AISettings as AISettingsType } from '@/types/ai';
|
||||
import { AISettings } from '../AISettings';
|
||||
import {
|
||||
AIAssistantSettings,
|
||||
AIDiscoverySettings,
|
||||
AISettings,
|
||||
AIPatrolSettings,
|
||||
} from '../AISettings';
|
||||
|
||||
const getSettingsMock = vi.fn();
|
||||
const updateSettingsMock = vi.fn();
|
||||
@@ -12,6 +18,7 @@ const getModelsMock = vi.fn();
|
||||
const testProviderMock = vi.fn();
|
||||
const testConnectionMock = vi.fn();
|
||||
const runDiscoveryRefreshMock = vi.fn();
|
||||
const fetchAgentCapabilitiesManifestMock = vi.fn();
|
||||
const listSessionsMock = vi.fn();
|
||||
const summarizeSessionMock = vi.fn();
|
||||
const notificationSuccessMock = vi.fn();
|
||||
@@ -48,6 +55,15 @@ vi.mock('@/api/discovery', () => ({
|
||||
runDiscoveryRefresh: (...args: unknown[]) => runDiscoveryRefreshMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/agentCapabilities', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/api/agentCapabilities')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAgentCapabilitiesManifest: (...args: unknown[]) =>
|
||||
fetchAgentCapabilitiesManifestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: (...args: unknown[]) => notificationSuccessMock(...args),
|
||||
@@ -100,12 +116,29 @@ const baseSettings = (): AISettingsType => ({
|
||||
configured_providers: [],
|
||||
});
|
||||
|
||||
const renderComponent = () =>
|
||||
render(() => (
|
||||
type AISettingsTestPage = 'provider' | 'patrol' | 'assistant' | 'discovery';
|
||||
|
||||
const getTestComponent = (page: AISettingsTestPage) => {
|
||||
switch (page) {
|
||||
case 'patrol':
|
||||
return AIPatrolSettings;
|
||||
case 'assistant':
|
||||
return AIAssistantSettings;
|
||||
case 'discovery':
|
||||
return AIDiscoverySettings;
|
||||
default:
|
||||
return AISettings;
|
||||
}
|
||||
};
|
||||
|
||||
const renderComponent = (page: AISettingsTestPage = 'provider') => {
|
||||
const Component = getTestComponent(page);
|
||||
return render(() => (
|
||||
<Router>
|
||||
<Route path="/" component={() => <AISettings />} />
|
||||
<Route path="/" component={() => <Component />} />
|
||||
</Router>
|
||||
));
|
||||
};
|
||||
|
||||
const resetAllMocks = () => {
|
||||
getSettingsMock.mockReset();
|
||||
@@ -114,6 +147,7 @@ const resetAllMocks = () => {
|
||||
testProviderMock.mockReset();
|
||||
testConnectionMock.mockReset();
|
||||
runDiscoveryRefreshMock.mockReset();
|
||||
fetchAgentCapabilitiesManifestMock.mockReset();
|
||||
listSessionsMock.mockReset();
|
||||
summarizeSessionMock.mockReset();
|
||||
notificationSuccessMock.mockReset();
|
||||
@@ -153,6 +187,7 @@ const setupDefaultMocks = () => {
|
||||
failed_count: 0,
|
||||
last_run: '2026-05-15T12:00:00Z',
|
||||
});
|
||||
fetchAgentCapabilitiesManifestMock.mockResolvedValue(null);
|
||||
listSessionsMock.mockResolvedValue([]);
|
||||
summarizeSessionMock.mockResolvedValue(undefined);
|
||||
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false);
|
||||
@@ -169,6 +204,138 @@ describe('AISettings model loading error states', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('separates Patrol mode from Assistant chat actions on the settings page', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
enabled: true,
|
||||
configured: true,
|
||||
patrol_interval_minutes: 180,
|
||||
control_level: 'controlled',
|
||||
});
|
||||
|
||||
renderComponent('assistant');
|
||||
|
||||
expect(await screen.findByText('Assistant chat actions')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('This controls actions started from Assistant chat only', { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Chat action mode')).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /Ask first/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Assistant settings/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'External agents' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Optional connector access for Claude Desktop', { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Show connector setup' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'Connector setup' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Create token' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show connector setup' }));
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Create token' })).toHaveAttribute(
|
||||
'href',
|
||||
PULSE_MCP_TOKEN_SETUP_PATH,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Provider & Models focused on provider setup and runtime cost controls', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
enabled: true,
|
||||
configured: true,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText('Provider Configuration')).toBeInTheDocument();
|
||||
expect(screen.getByText('30-day Budget')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Enable Pulse Intelligence' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Test Connection/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save provider settings/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Assistant chat actions')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('External agents')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Service Context/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol mode')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Open Patrol/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Set how much Patrol can do/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Patrol scheduling and readiness without rendering the operator loop', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
enabled: true,
|
||||
configured: true,
|
||||
patrol_interval_minutes: 180,
|
||||
alert_triggered_analysis: true,
|
||||
patrol_alert_triggers_enabled: true,
|
||||
patrol_anomaly_triggers_enabled: false,
|
||||
patrol_alert_trigger_min_severity: 'warning',
|
||||
});
|
||||
|
||||
renderComponent('patrol');
|
||||
|
||||
expect(await screen.findByText('Patrol mode')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Schedule')).toHaveValue('180');
|
||||
expect(screen.getByLabelText('Enable alert-triggered Patrols')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Enable anomaly-triggered Patrols')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Enable container update risk analysis')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Investigate alerts at or above')).toHaveValue('warning');
|
||||
expect(screen.getByText('Model readiness')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Open Patrol/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Patrol settings/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Enable Pulse Intelligence' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Test Connection/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider Configuration')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('External agents')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves Patrol trigger settings from Pulse Intelligence Patrol settings', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
enabled: true,
|
||||
configured: true,
|
||||
patrol_alert_triggers_enabled: true,
|
||||
patrol_anomaly_triggers_enabled: false,
|
||||
patrol_alert_trigger_min_severity: 'critical',
|
||||
alert_triggered_analysis: true,
|
||||
});
|
||||
updateSettingsMock.mockImplementation(async (payload: Record<string, unknown>) => ({
|
||||
...baseSettings(),
|
||||
enabled: true,
|
||||
configured: true,
|
||||
...payload,
|
||||
}));
|
||||
|
||||
renderComponent('patrol');
|
||||
|
||||
await screen.findByLabelText('Enable alert-triggered Patrols');
|
||||
fireEvent.change(screen.getByLabelText('Investigate alerts at or above'), {
|
||||
target: { value: 'warning' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('Enable alert-triggered Patrols'));
|
||||
fireEvent.click(screen.getByLabelText('Enable anomaly-triggered Patrols'));
|
||||
fireEvent.click(screen.getByLabelText('Enable container update risk analysis'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save Patrol settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
patrol_alert_triggers_enabled: false,
|
||||
patrol_anomaly_triggers_enabled: true,
|
||||
patrol_alert_trigger_min_severity: 'warning',
|
||||
alert_triggered_analysis: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith('Patrol settings saved');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows inline warning when getModels throws a network error', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
@@ -376,13 +543,13 @@ describe('AISettings model loading error states', () => {
|
||||
hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_autofix');
|
||||
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
|
||||
|
||||
renderComponent();
|
||||
renderComponent('assistant');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('option', { name: /Autonomous/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: /Allow chat-only actions/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps an existing autonomous setting visible even when upgrade prompts are hidden', async () => {
|
||||
@@ -393,13 +560,13 @@ describe('AISettings model loading error states', () => {
|
||||
control_level: 'autonomous',
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
renderComponent('assistant');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('option', { name: /Autonomous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /Allow chat-only actions/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('(Pro)')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -420,14 +587,14 @@ describe('AISettings load failure error state', () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unable to load Assistant & Patrol settings/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unable to load Provider & Models settings/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Retry button should be present
|
||||
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
|
||||
|
||||
// Save button should NOT be present (form is hidden)
|
||||
expect(screen.queryByRole('button', { name: /save changes/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Save .* settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears error and shows form after successful retry', async () => {
|
||||
@@ -436,7 +603,7 @@ describe('AISettings load failure error state', () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unable to load Assistant & Patrol settings/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unable to load Provider & Models settings/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now mock a successful response for retry
|
||||
@@ -452,20 +619,18 @@ describe('AISettings load failure error state', () => {
|
||||
|
||||
// Wait for the form to fully render after successful retry (not just banner disappearing during loading)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save provider settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Error banner should be gone
|
||||
expect(
|
||||
screen.queryByText(/Unable to load Assistant & Patrol settings/),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Unable to load Provider & Models settings/)).not.toBeInTheDocument();
|
||||
|
||||
// Verify retry actually called getSettings again
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AISettings workload discovery persistence', () => {
|
||||
describe('AISettings service context persistence', () => {
|
||||
beforeEach(() => {
|
||||
resetAllMocks();
|
||||
setupDefaultMocks();
|
||||
@@ -475,7 +640,7 @@ describe('AISettings workload discovery persistence', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('saves workload discovery enablement and scan interval as an explicit pair', async () => {
|
||||
it('saves service context enablement and scan interval as an explicit pair', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
discovery_enabled: true,
|
||||
@@ -487,14 +652,14 @@ describe('AISettings workload discovery persistence', () => {
|
||||
discovery_interval_hours: payload.discovery_interval_hours as number,
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
renderComponent('discovery');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Workload Discovery Auto 24h/i }),
|
||||
screen.getByRole('button', { name: /Service Context Auto 24h/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Auto 24h/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Service Context Auto 24h/i }));
|
||||
const intervalSelect = screen.getByLabelText('Scan Interval');
|
||||
expect(intervalSelect).toHaveValue('24');
|
||||
|
||||
@@ -503,7 +668,7 @@ describe('AISettings workload discovery persistence', () => {
|
||||
});
|
||||
expect(intervalSelect).toHaveValue('6');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save service context settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith(
|
||||
@@ -515,22 +680,22 @@ describe('AISettings workload discovery persistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('runs a manual discovery refresh from workload discovery settings', async () => {
|
||||
it('runs a manual service context refresh from settings', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
discovery_enabled: true,
|
||||
discovery_interval_hours: 0,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
renderComponent('discovery');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Workload Discovery Manual only/i }),
|
||||
screen.getByRole('button', { name: /Service Context Manual only/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Manual only/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run discovery now/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Service Context Manual only/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run context scan/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runDiscoveryRefreshMock).toHaveBeenCalledTimes(1);
|
||||
@@ -547,19 +712,19 @@ describe('AISettings workload discovery persistence', () => {
|
||||
discovery_interval_hours: 0,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
renderComponent('discovery');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Workload Discovery Off/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Service Context Off/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Off/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Service Context Off/i }));
|
||||
|
||||
expect(screen.queryByLabelText('Scan Interval')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Runs a one-time workload discovery refresh without changing the schedule.'),
|
||||
screen.getByText('Runs one service context scan without changing the schedule.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run discovery now/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run context scan/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runDiscoveryRefreshMock).toHaveBeenCalledTimes(1);
|
||||
@@ -586,15 +751,15 @@ describe('AISettings workload discovery persistence', () => {
|
||||
last_run: '2026-05-15T12:00:00Z',
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
renderComponent('discovery');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Workload Discovery Manual only/i }),
|
||||
screen.getByRole('button', { name: /Service Context Manual only/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Workload Discovery Manual only/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run discovery now/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Service Context Manual only/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run context scan/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationInfoMock).toHaveBeenCalledWith(
|
||||
@@ -638,7 +803,7 @@ describe('AISettings OpenRouter flow', () => {
|
||||
fireEvent.input(await screen.findByPlaceholderText('sk-or-...'), {
|
||||
target: { value: 'sk-or-configured' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save provider settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith(
|
||||
@@ -703,7 +868,7 @@ describe('AISettings Ollama provider options', () => {
|
||||
fireEvent.input(await screen.findByLabelText('Ollama Keep Alive'), {
|
||||
target: { value: '24h' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save provider settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith(
|
||||
@@ -751,12 +916,12 @@ describe('AISettings provider save failure context', () => {
|
||||
provider === 'openrouter' ? 'Provider authentication issue' : `${provider} reachable`,
|
||||
recommendation:
|
||||
provider === 'openrouter'
|
||||
? 'Check the API key or provider authentication in Assistant and Patrol settings, then retry.'
|
||||
? 'Check the API key or provider authentication in Provider & Models settings, then retry.'
|
||||
: undefined,
|
||||
cause: provider === 'openrouter' ? 'provider_auth' : undefined,
|
||||
provider,
|
||||
}));
|
||||
updateSettingsMock.mockRejectedValue(new Error('Unable to save Assistant & Patrol settings.'));
|
||||
updateSettingsMock.mockRejectedValue(new Error('Unable to save Provider & Models settings.'));
|
||||
|
||||
renderComponent();
|
||||
|
||||
@@ -764,7 +929,7 @@ describe('AISettings provider save failure context', () => {
|
||||
expect(testProviderMock).toHaveBeenCalledWith('openrouter');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save provider settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationErrorMock).toHaveBeenCalledWith(
|
||||
@@ -775,7 +940,7 @@ describe('AISettings provider save failure context', () => {
|
||||
expect(message).toContain('model openrouter:deepseek/deepseek-r1');
|
||||
expect(message).toContain('Provider authentication issue');
|
||||
expect(message).toContain('Check the API key or provider authentication');
|
||||
expect(message).toContain('Unable to save Assistant & Patrol settings.');
|
||||
expect(message).toContain('Unable to save Provider & Models settings.');
|
||||
});
|
||||
|
||||
it('warns with Patrol provider and model when settings save but Patrol is not ready', async () => {
|
||||
@@ -811,21 +976,21 @@ describe('AISettings provider save failure context', () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save provider settings/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save provider settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Assistant & Patrol settings saved, but Patrol is not ready'),
|
||||
expect.stringContaining('Provider & Models settings saved, but Patrol is not ready'),
|
||||
);
|
||||
});
|
||||
const message = String(notificationWarningMock.mock.calls.at(-1)?.[0] ?? '');
|
||||
expect(message).toContain('Provider: OpenRouter');
|
||||
expect(message).toContain('Model: openrouter:deepseek/deepseek-r1');
|
||||
expect(message).toContain('reasoning-only model family');
|
||||
expect(notificationSuccessMock).not.toHaveBeenCalledWith('Assistant & Patrol settings saved');
|
||||
expect(notificationSuccessMock).not.toHaveBeenCalledWith('Provider & Models settings saved');
|
||||
});
|
||||
|
||||
it('does not attach provider context to specific backend validation errors', async () => {
|
||||
@@ -850,7 +1015,7 @@ describe('AISettings provider save failure context', () => {
|
||||
expect(testProviderMock).toHaveBeenCalledWith('openrouter');
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save provider settings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationErrorMock).toHaveBeenCalledWith(
|
||||
@@ -900,16 +1065,16 @@ describe('AISettings provider setup flow', () => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable assistant and patrol/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable pulse intelligence/i }));
|
||||
const setupDialog = await screen.findByRole('dialog', {
|
||||
name: 'Set up Assistant and Patrol',
|
||||
name: 'Set up Pulse Intelligence',
|
||||
});
|
||||
expect(within(setupDialog).getByText('Set Up Assistant & Patrol')).toBeInTheDocument();
|
||||
expect(within(setupDialog).getByText('Set Up Pulse Intelligence')).toBeInTheDocument();
|
||||
fireEvent.click(within(setupDialog).getByRole('button', { name: /OpenRouter/i }));
|
||||
fireEvent.input(screen.getByPlaceholderText('sk-or-...'), {
|
||||
target: { value: 'sk-or-test' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enable Assistant & Patrol' }));
|
||||
fireEvent.click(within(setupDialog).getByRole('button', { name: 'Enable Pulse Intelligence' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({
|
||||
@@ -919,7 +1084,7 @@ describe('AISettings provider setup flow', () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(notificationWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Assistant & Patrol enabled, but Patrol is not ready'),
|
||||
expect.stringContaining('Pulse Intelligence enabled, but Patrol is not ready'),
|
||||
);
|
||||
});
|
||||
const message = String(notificationWarningMock.mock.calls.at(-1)?.[0] ?? '');
|
||||
@@ -927,12 +1092,12 @@ describe('AISettings provider setup flow', () => {
|
||||
expect(message).toContain('Model: openrouter:deepseek/deepseek-r1');
|
||||
expect(message).toContain('reasoning-only model family');
|
||||
expect(notificationSuccessMock).not.toHaveBeenCalledWith(
|
||||
'Assistant & Patrol enabled! You can customize settings below.',
|
||||
'Pulse Intelligence enabled. You can customize settings below.',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the setup provider when provider setup save fails generically', async () => {
|
||||
updateSettingsMock.mockRejectedValue(new Error('Unable to save Assistant & Patrol settings.'));
|
||||
updateSettingsMock.mockRejectedValue(new Error('Unable to save Provider & Models settings.'));
|
||||
|
||||
renderComponent();
|
||||
|
||||
@@ -940,16 +1105,16 @@ describe('AISettings provider setup flow', () => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable assistant and patrol/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable pulse intelligence/i }));
|
||||
const setupDialog = await screen.findByRole('dialog', {
|
||||
name: 'Set up Assistant and Patrol',
|
||||
name: 'Set up Pulse Intelligence',
|
||||
});
|
||||
expect(within(setupDialog).getByText('Set Up Assistant & Patrol')).toBeInTheDocument();
|
||||
expect(within(setupDialog).getByText('Set Up Pulse Intelligence')).toBeInTheDocument();
|
||||
fireEvent.click(within(setupDialog).getByRole('button', { name: /OpenRouter/i }));
|
||||
fireEvent.input(screen.getByPlaceholderText('sk-or-...'), {
|
||||
target: { value: 'sk-or-test' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enable Assistant & Patrol' }));
|
||||
fireEvent.click(within(setupDialog).getByRole('button', { name: 'Enable Pulse Intelligence' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationErrorMock).toHaveBeenCalledWith(
|
||||
@@ -957,7 +1122,7 @@ describe('AISettings provider setup flow', () => {
|
||||
);
|
||||
});
|
||||
expect(String(notificationErrorMock.mock.calls.at(-1)?.[0] ?? '')).toContain(
|
||||
'Unable to save Assistant & Patrol settings.',
|
||||
'Unable to save Provider & Models settings.',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -975,13 +1140,13 @@ describe('AISettings provider setup flow', () => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable assistant and patrol/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable pulse intelligence/i }));
|
||||
|
||||
expect(getModelsMock).not.toHaveBeenCalled();
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('Set Up Assistant & Patrol')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Set Up Pulse Intelligence')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Connect a provider to power Pulse Assistant and Patrol.'),
|
||||
screen.getByText('Connect a provider to power Patrol, Assistant, and service context.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Patrol quickstart ready • 25\/25 runs left • no API key needed yet/i),
|
||||
@@ -1001,12 +1166,12 @@ describe('AISettings provider setup flow', () => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable assistant and patrol/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable pulse intelligence/i }));
|
||||
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('Set Up Assistant & Patrol')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Set Up Pulse Intelligence')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Connect a provider to power Pulse Assistant and Patrol.'),
|
||||
screen.getByText('Connect a provider to power Patrol, Assistant, and service context.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Hosted quickstart requires an activated entitlement/i),
|
||||
@@ -1028,12 +1193,12 @@ describe('AISettings provider setup flow', () => {
|
||||
expect(getSettingsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable assistant and patrol/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /enable pulse intelligence/i }));
|
||||
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('Set Up Assistant & Patrol')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Set Up Pulse Intelligence')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Connect a provider to power Pulse Assistant and Patrol.'),
|
||||
screen.getByText('Connect a provider to power Patrol, Assistant, and service context.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /open hosted handoff/i })).not.toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
AI_EXECUTE_SCOPE,
|
||||
AGENT_REPORT_SCOPE,
|
||||
AUDIT_READ_SCOPE,
|
||||
DOCKER_MANAGE_SCOPE,
|
||||
DOCKER_REPORT_SCOPE,
|
||||
MONITORING_READ_SCOPE,
|
||||
MONITORING_WRITE_SCOPE,
|
||||
SETTINGS_READ_SCOPE,
|
||||
SETTINGS_WRITE_SCOPE,
|
||||
} from '@/constants/apiScopes';
|
||||
import { API_TOKEN_CREATE_ANCHOR, PULSE_MCP_TOKEN_SETUP_PATH } from '@/routing/resourceLinks';
|
||||
import apiAccessPanelSource from '../APIAccessPanel.tsx?raw';
|
||||
import apiTokenManagerSource from '../APITokenManager.tsx?raw';
|
||||
import { APIAccessPanel } from '../APIAccessPanel';
|
||||
import { APITokenManager } from '../APITokenManager';
|
||||
|
||||
const listTokensMock = vi.fn();
|
||||
const createTokenMock = vi.fn();
|
||||
const deleteTokenMock = vi.fn();
|
||||
const fetchAgentCapabilitiesManifestMock = vi.fn();
|
||||
const notificationSuccessMock = vi.fn();
|
||||
const notificationErrorMock = vi.fn();
|
||||
const showTokenRevealMock = vi.fn();
|
||||
const loggerErrorMock = vi.fn();
|
||||
const markDockerRuntimesTokenRevokedMock = vi.fn();
|
||||
const markAgentsTokenRevokedMock = vi.fn();
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
|
||||
let mockResources: Resource[] = [];
|
||||
|
||||
@@ -33,6 +43,15 @@ vi.mock('@/api/security', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/agentCapabilities', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/api/agentCapabilities')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAgentCapabilitiesManifest: (...args: unknown[]) =>
|
||||
fetchAgentCapabilitiesManifestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: (...args: unknown[]) => notificationSuccessMock(...args),
|
||||
@@ -101,20 +120,27 @@ const makeResource = (overrides: Partial<Resource> = {}): Resource => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderAPIAccessPanel = () =>
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="/*"
|
||||
component={() => <APIAccessPanel onTokensChanged={vi.fn()} refreshing={false} canManage />}
|
||||
/>
|
||||
</Router>
|
||||
));
|
||||
|
||||
describe('APITokenManager security surface', () => {
|
||||
// The API Access tab is the canonical security surface for
|
||||
// operator-controlled machine access. Tokens minted here are
|
||||
// the credential the agent integrations panel directs operators
|
||||
// to use for MCP / HTTP agent wiring. Pin that the agent
|
||||
// integrations panel sits on this same tab so the security
|
||||
// story stays coherent: minting a token and seeing what an
|
||||
// agent does with it live side-by-side, not split across tabs.
|
||||
it('hosts the agent integrations panel on the same security surface as token management', () => {
|
||||
expect(apiAccessPanelSource).toContain(
|
||||
"import AgentIntegrationsPanel from './AgentIntegrationsPanel';",
|
||||
);
|
||||
expect(apiAccessPanelSource).toContain('<AgentIntegrationsPanel />');
|
||||
// operator-controlled machine access. Pulse Intelligence owns
|
||||
// external-agent setup; API Access only mints and manages the
|
||||
// scoped token that setup links to when credentials are needed.
|
||||
it('keeps API Access separate from Pulse Intelligence external-agent setup', () => {
|
||||
expect(apiAccessPanelSource).toContain('<APITokenManager');
|
||||
expect(apiAccessPanelSource).toContain('api-access-token-section');
|
||||
expect(apiAccessPanelSource).not.toContain('AgentIntegrationsPanel');
|
||||
expect(apiAccessPanelSource).not.toContain('isExternalAgentSetupHash');
|
||||
expect(apiAccessPanelSource).not.toContain('api-access-external-agent-section');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,9 +155,37 @@ describe('APITokenManager', () => {
|
||||
loggerErrorMock.mockReset();
|
||||
markDockerRuntimesTokenRevokedMock.mockReset();
|
||||
markAgentsTokenRevokedMock.mockReset();
|
||||
fetchAgentCapabilitiesManifestMock.mockReset();
|
||||
scrollIntoViewMock.mockReset();
|
||||
Element.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
mockResources = [];
|
||||
listTokensMock.mockResolvedValue([]);
|
||||
fetchAgentCapabilitiesManifestMock.mockResolvedValue({
|
||||
version: 'v1',
|
||||
surfaceContract: {
|
||||
core: { id: 'pulse_intelligence_core', label: 'Pulse Intelligence Core', description: '' },
|
||||
proactiveEngine: { id: 'pulse_patrol', label: 'Pulse Patrol', description: '' },
|
||||
operatorSurfaces: [],
|
||||
},
|
||||
mcpAdapter: {
|
||||
serverName: 'pulse',
|
||||
command: 'pulse-mcp',
|
||||
baseUrlFlag: '--base-url',
|
||||
defaultBaseUrl: 'http://localhost:7655',
|
||||
tokenEnv: 'PULSE_API_TOKEN',
|
||||
configFamilies: [],
|
||||
},
|
||||
requiredScopes: [
|
||||
MONITORING_READ_SCOPE,
|
||||
MONITORING_WRITE_SCOPE,
|
||||
SETTINGS_READ_SCOPE,
|
||||
SETTINGS_WRITE_SCOPE,
|
||||
AI_EXECUTE_SCOPE,
|
||||
],
|
||||
categories: [],
|
||||
capabilities: [],
|
||||
});
|
||||
createTokenMock.mockResolvedValue({
|
||||
token: 'pulse_secret_value',
|
||||
record: makeToken({
|
||||
@@ -145,6 +199,20 @@ describe('APITokenManager', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
it('keeps API Access focused on token management', async () => {
|
||||
window.history.pushState({}, '', '/settings/security/api');
|
||||
|
||||
renderAPIAccessPanel();
|
||||
|
||||
const tokenHeading = await screen.findByRole('heading', { name: 'API tokens' });
|
||||
const tokenSection = tokenHeading.closest('[data-testid="api-access-token-section"]');
|
||||
|
||||
expect(tokenSection).not.toBeNull();
|
||||
expect(screen.queryByRole('heading', { name: 'External agents' })).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid="api-access-external-agent-section"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('creates scoped tokens from the canonical preset path', async () => {
|
||||
@@ -204,6 +272,136 @@ describe('APITokenManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('requires an explicit scope selection before generating a token', async () => {
|
||||
render(() => <APITokenManager onTokensChanged={vi.fn()} canManage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listTokensMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Full access' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Choose a scoped preset for least privilege, or deliberately choose Full access.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kiosk / Monitoring' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Generate' })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Kiosk / Monitoring' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Full access' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates Patrol external agent tokens from the live manifest scopes', async () => {
|
||||
createTokenMock.mockResolvedValueOnce({
|
||||
token: 'pulse_agent_secret',
|
||||
record: makeToken({
|
||||
id: 'token-agent',
|
||||
name: 'OpenCode Pulse',
|
||||
scopes: [
|
||||
AI_EXECUTE_SCOPE,
|
||||
MONITORING_READ_SCOPE,
|
||||
MONITORING_WRITE_SCOPE,
|
||||
SETTINGS_READ_SCOPE,
|
||||
SETTINGS_WRITE_SCOPE,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(() => <APITokenManager onTokensChanged={vi.fn()} canManage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAgentCapabilitiesManifestMock).toHaveBeenCalledTimes(1);
|
||||
expect(listTokensMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const preset = await screen.findByRole('button', { name: 'Patrol external agent' });
|
||||
expect(preset).toHaveAttribute(
|
||||
'title',
|
||||
'Scopes for connected agents that read Pulse context and request Patrol work.',
|
||||
);
|
||||
|
||||
fireEvent.input(screen.getByPlaceholderText('e.g. Docker / Podman automation'), {
|
||||
target: { value: 'OpenCode Pulse' },
|
||||
});
|
||||
fireEvent.click(preset);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createTokenMock).toHaveBeenCalledWith('OpenCode Pulse', [
|
||||
AI_EXECUTE_SCOPE,
|
||||
MONITORING_READ_SCOPE,
|
||||
MONITORING_WRITE_SCOPE,
|
||||
SETTINGS_READ_SCOPE,
|
||||
SETTINGS_WRITE_SCOPE,
|
||||
]);
|
||||
});
|
||||
|
||||
expect(showTokenRevealMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: 'pulse_agent_secret',
|
||||
source: 'security',
|
||||
record: expect.objectContaining({
|
||||
id: 'token-agent',
|
||||
name: 'OpenCode Pulse',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preselects the Patrol external agent preset from the MCP setup route', async () => {
|
||||
window.history.pushState({}, '', PULSE_MCP_TOKEN_SETUP_PATH);
|
||||
createTokenMock.mockResolvedValueOnce({
|
||||
token: 'pulse_mcp_secret',
|
||||
record: makeToken({
|
||||
id: 'token-mcp',
|
||||
name: 'Patrol external agent',
|
||||
scopes: [
|
||||
AI_EXECUTE_SCOPE,
|
||||
MONITORING_READ_SCOPE,
|
||||
MONITORING_WRITE_SCOPE,
|
||||
SETTINGS_READ_SCOPE,
|
||||
SETTINGS_WRITE_SCOPE,
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(() => <APITokenManager onTokensChanged={vi.fn()} canManage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAgentCapabilitiesManifestMock).toHaveBeenCalledTimes(1);
|
||||
expect(listTokensMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await screen.findByDisplayValue('Patrol external agent');
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
expect(document.getElementById(API_TOKEN_CREATE_ANCHOR)?.className).toContain('ring-2');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createTokenMock).toHaveBeenCalledWith('Patrol external agent', [
|
||||
AI_EXECUTE_SCOPE,
|
||||
MONITORING_READ_SCOPE,
|
||||
MONITORING_WRITE_SCOPE,
|
||||
SETTINGS_READ_SCOPE,
|
||||
SETTINGS_WRITE_SCOPE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the dedicated audit scope in presets and grouped custom scopes', async () => {
|
||||
createTokenMock.mockResolvedValueOnce({
|
||||
token: 'pulse_audit_secret',
|
||||
@@ -223,6 +421,15 @@ describe('APITokenManager', () => {
|
||||
expect(screen.getByRole('button', { name: 'Audit read' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Custom scopes'));
|
||||
expect(screen.getByText('AI')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Pulse Assistant chat' })).toHaveAttribute(
|
||||
'title',
|
||||
'Use interactive Pulse Assistant sessions, models, and knowledge endpoints.',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Pulse Intelligence actions' })).toHaveAttribute(
|
||||
'title',
|
||||
'Use governed Patrol actions for plans, approvals, policy-allowed fixes, verification, and history.',
|
||||
);
|
||||
expect(screen.getByText('Security')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Audit logs (read)' })).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import type { AgentCapabilitiesManifest } from '@/api/agentCapabilities';
|
||||
import {
|
||||
EXTERNAL_AGENT_SETUP_ANCHOR,
|
||||
PATROL_CONTROL_PATH,
|
||||
PULSE_MCP_SETUP_ANCHOR,
|
||||
PULSE_MCP_TOKEN_SETUP_PATH,
|
||||
} from '@/routing/resourceLinks';
|
||||
import { AgentIntegrationsPanel } from '../AgentIntegrationsPanel';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchAgentCapabilitiesManifest: vi.fn(),
|
||||
}));
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
|
||||
vi.mock('@/api/agentCapabilities', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/api/agentCapabilities')>('@/api/agentCapabilities');
|
||||
return {
|
||||
...actual,
|
||||
fetchAgentCapabilitiesManifest: (...args: unknown[]) =>
|
||||
mocks.fetchAgentCapabilitiesManifest(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const manifest: AgentCapabilitiesManifest = {
|
||||
version: 'v1',
|
||||
surfaceContract: {
|
||||
core: {
|
||||
id: 'pulse_intelligence_core',
|
||||
label: 'Pulse Intelligence Core',
|
||||
description:
|
||||
'Canonical context, governed actions, safety gates, approval state, action audit, and verification.',
|
||||
},
|
||||
proactiveEngine: {
|
||||
id: 'pulse_patrol',
|
||||
label: 'Pulse Patrol',
|
||||
description: 'The proactive detection and investigation engine.',
|
||||
},
|
||||
operatorSurfaces: [
|
||||
{
|
||||
id: 'pulse_assistant',
|
||||
label: 'Pulse Assistant',
|
||||
description: 'The first-party in-app surface.',
|
||||
native: true,
|
||||
externalAdapter: false,
|
||||
affordances: {
|
||||
tools: true,
|
||||
interactiveQuestions: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pulse_mcp',
|
||||
label: 'Pulse MCP',
|
||||
description: 'The external-agent adapter.',
|
||||
native: false,
|
||||
externalAdapter: true,
|
||||
affordances: {
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
surfaceToolContracts: [
|
||||
{
|
||||
surfaceId: 'pulse_mcp',
|
||||
surfaceLabel: 'Pulse MCP',
|
||||
toolSource: 'capability_manifest',
|
||||
toolNames: ['get_resource_context'],
|
||||
capabilityNames: ['get_resource_context'],
|
||||
affordances: {
|
||||
tools: true,
|
||||
resources: true,
|
||||
prompts: true,
|
||||
capabilityMetadata: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
mcpAdapter: {
|
||||
serverName: 'pulse-test',
|
||||
command: 'pulse-test-mcp',
|
||||
baseUrlFlag: '--pulse-url',
|
||||
defaultBaseUrl: 'https://pulse.test',
|
||||
tokenEnv: 'PULSE_TEST_TOKEN',
|
||||
configFamilies: [
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'Test OpenCode',
|
||||
shape: 'opencode_mcp',
|
||||
clientLabels: ['OpenCode'],
|
||||
},
|
||||
{
|
||||
id: 'claude-style',
|
||||
label: 'Test MCP servers',
|
||||
shape: 'mcp_servers',
|
||||
clientLabels: ['Claude Desktop', 'Claude Code'],
|
||||
},
|
||||
{
|
||||
id: 'custom-mcp',
|
||||
label: 'custom clients',
|
||||
shape: 'custom',
|
||||
clientLabels: ['custom clients'],
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredScopes: [
|
||||
'monitoring:read',
|
||||
'monitoring:write',
|
||||
'settings:read',
|
||||
'settings:write',
|
||||
'ai:execute',
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
id: 'context',
|
||||
label: 'Context (read-only)',
|
||||
description: 'Discovery and read-only situated reads. Agents start here.',
|
||||
},
|
||||
],
|
||||
workflowPrompts: [
|
||||
{
|
||||
name: 'pulse_operations_loop',
|
||||
label: 'Ask Patrol to handle an issue',
|
||||
presentationKind: 'workflow',
|
||||
description:
|
||||
'Have Patrol investigate active findings, follow the configured Patrol mode, take approved actions, verify the outcome, and record what happened.',
|
||||
},
|
||||
{
|
||||
name: 'pulse_investigate_resource',
|
||||
label: 'Investigate resource',
|
||||
presentationKind: 'resource',
|
||||
description: 'Investigate one resource.',
|
||||
arguments: [
|
||||
{
|
||||
name: 'resourceId',
|
||||
description: 'Canonical resource id.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
capabilities: [
|
||||
{
|
||||
name: 'get_fleet_context',
|
||||
description: 'Return a thin per-resource triage rollup.',
|
||||
category: 'context',
|
||||
method: 'GET',
|
||||
path: '/api/agent/fleet-context',
|
||||
scope: 'monitoring:read',
|
||||
actionMode: 'read',
|
||||
approvalPolicy: 'scope_only',
|
||||
errorCodes: ['resource_not_found'],
|
||||
},
|
||||
{
|
||||
name: 'subscribe_events',
|
||||
description: 'Subscribe to Pulse Intelligence events.',
|
||||
category: 'events',
|
||||
method: 'GET',
|
||||
path: '/api/agent/events',
|
||||
scope: 'monitoring:read',
|
||||
actionMode: 'read',
|
||||
approvalPolicy: 'scope_only',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const renderPanel = () =>
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route path="/" component={() => <AgentIntegrationsPanel />} />
|
||||
</Router>
|
||||
));
|
||||
|
||||
describe('AgentIntegrationsPanel', () => {
|
||||
beforeEach(() => {
|
||||
mocks.fetchAgentCapabilitiesManifest.mockReset();
|
||||
mocks.fetchAgentCapabilitiesManifest.mockResolvedValue(manifest);
|
||||
scrollIntoViewMock.mockReset();
|
||||
Element.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it('keeps external agent setup brief and governed by Patrol mode', async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(
|
||||
(await screen.findByRole('heading', { name: 'External agents' })).closest(
|
||||
`#${EXTERNAL_AGENT_SETUP_ANCHOR}`,
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('Connect external tools to read Pulse context', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Patrol mode and scoped tokens control', { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain('Patrol remains the operator');
|
||||
expect(document.body.textContent).not.toContain('Connected tools do not get separate powers');
|
||||
expect(screen.getByRole('link', { name: 'Choose Patrol mode' })).toHaveAttribute(
|
||||
'href',
|
||||
PATROL_CONTROL_PATH,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Show connector setup' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
expect(screen.getAllByRole('link', { name: 'Choose Patrol mode' })).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'Connector setup' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Create token' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show connector setup' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Hide connector setup' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: 'Connector setup' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 1')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('link', { name: 'Choose Patrol mode' })).toHaveLength(1);
|
||||
expect(screen.getByRole('link', { name: 'Choose Patrol mode' })).toHaveAttribute(
|
||||
'href',
|
||||
PATROL_CONTROL_PATH,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Required before agents can request Patrol work',
|
||||
{
|
||||
exact: false,
|
||||
},
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create a scoped token')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol external agent')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Create a token with the', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Step 3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connect the agent')).toBeInTheDocument();
|
||||
expect(document.body.textContent).toContain('Install the connector');
|
||||
expect(document.body.textContent).toContain('paste the client config');
|
||||
expect(document.body.textContent).not.toContain('The installer and client snippets are below');
|
||||
expect(document.body.textContent).not.toContain('Install the Pulse MCP bridge');
|
||||
expect(document.body.textContent).not.toContain('on the machine that runs the external tool');
|
||||
|
||||
const createTokenLink = screen.getByRole('link', { name: 'Create token' });
|
||||
expect(createTokenLink).toHaveAttribute('href', PULSE_MCP_TOKEN_SETUP_PATH);
|
||||
const installerCommands = screen.getByText('Installer commands').closest('details');
|
||||
expect(installerCommands).not.toBeNull();
|
||||
expect(installerCommands).not.toHaveAttribute('open');
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.sh',
|
||||
);
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.ps1',
|
||||
);
|
||||
installerCommands!.open = true;
|
||||
fireEvent(installerCommands!, new Event('toggle'));
|
||||
expect(document.body.textContent).toContain(
|
||||
'https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.sh',
|
||||
);
|
||||
expect(document.body.textContent).toContain(
|
||||
'https://github.com/rcourtman/Pulse/releases/latest/download/install-mcp.ps1',
|
||||
);
|
||||
const clientConfigSnippets = screen.getByText('Client config').closest('details');
|
||||
expect(clientConfigSnippets).not.toBeNull();
|
||||
expect(clientConfigSnippets).not.toHaveAttribute('open');
|
||||
expect(document.body.textContent).not.toContain('pulse-test-mcp');
|
||||
expect(document.body.textContent).not.toContain('--pulse-url');
|
||||
expect(document.body.textContent).not.toContain('PULSE_TEST_TOKEN');
|
||||
clientConfigSnippets!.open = true;
|
||||
fireEvent(clientConfigSnippets!, new Event('toggle'));
|
||||
expect(document.body.textContent).toContain('pulse-test-mcp');
|
||||
expect(document.body.textContent).toContain('--pulse-url');
|
||||
expect(document.body.textContent).toContain('PULSE_TEST_TOKEN');
|
||||
expect(screen.getByText('Test OpenCode', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('Test MCP servers', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('"pulse-test":', { exact: false }).length).toBeGreaterThan(0);
|
||||
const advancedDetails = screen.getByText('Developer details').closest('details');
|
||||
expect(advancedDetails).not.toBeNull();
|
||||
expect(advancedDetails).not.toHaveAttribute('open');
|
||||
expect(
|
||||
screen.queryByText('Only open this when you are building or debugging a client', {
|
||||
exact: false,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol access model')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Live manifest details')).not.toBeInTheDocument();
|
||||
advancedDetails!.open = true;
|
||||
fireEvent(advancedDetails!, new Event('toggle'));
|
||||
expect(
|
||||
screen.getByText('Only open this when you are building or debugging a client', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol access model')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Built-in Pulse views and connected clients all sit behind', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Connected agents do not get separate powers', { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText('External agents').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText('Pulse MCP')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('External adapter')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Actions').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('Interactive questions')).toBeInTheDocument();
|
||||
expect(screen.getByText('Capability metadata')).toBeInTheDocument();
|
||||
expect(screen.getByText('Live manifest details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Manifest scopes:', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText('ai:execute')).toBeInTheDocument();
|
||||
expect(screen.getByText('Agent starting points')).toBeInTheDocument();
|
||||
expect(screen.getByText('(2 from manifest)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ask Patrol to handle an issue')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Wire name:').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('pulse_operations_loop')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol')).toBeInTheDocument();
|
||||
expect(screen.getByText('Investigate resource')).toBeInTheDocument();
|
||||
expect(screen.getByText('resourceId')).toBeInTheDocument();
|
||||
expect(screen.getByText('resourceId').closest('p')).toHaveTextContent(
|
||||
'Arguments: resourceId required',
|
||||
);
|
||||
const mcpToolPosture = screen.getByTestId('agent-mcp-tool-posture');
|
||||
expect(mcpToolPosture.closest('details')).toBe(advancedDetails);
|
||||
expect(mcpToolPosture).toHaveTextContent('External agents expose 1 capability');
|
||||
expect(mcpToolPosture).toHaveTextContent('through Patrol mode');
|
||||
expect(mcpToolPosture.getAttribute('title') || '').toContain('Capability manifest');
|
||||
expect(screen.getByText('Failure codes')).toBeInTheDocument();
|
||||
expect(screen.getByText('(1 from the live manifest)')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('resource_not_found').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('branch on these codes', { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('scrolls direct setup links to External agents instead of the API token top', async () => {
|
||||
window.history.pushState({}, '', `/#${EXTERNAL_AGENT_SETUP_ANCHOR}`);
|
||||
renderPanel();
|
||||
|
||||
const heading = await screen.findByRole('heading', { name: 'External agents' });
|
||||
const panel = heading.closest(`#${EXTERNAL_AGENT_SETUP_ANCHOR}`);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
expect(panel?.className).toContain('ring-2');
|
||||
expect(screen.getByRole('heading', { name: 'Connector setup' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('normalizes legacy MCP setup links to external agent setup', async () => {
|
||||
window.history.pushState({}, '', `/#${PULSE_MCP_SETUP_ANCHOR}`);
|
||||
renderPanel();
|
||||
|
||||
const heading = await screen.findByRole('heading', { name: 'External agents' });
|
||||
expect(heading.closest(`#${EXTERNAL_AGENT_SETUP_ANCHOR}`)).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe(`#${EXTERNAL_AGENT_SETUP_ANCHOR}`);
|
||||
});
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
||||
expect(screen.getByRole('heading', { name: 'Connector setup' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CopyCommandBlock } from '../CopyCommandBlock';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
|
||||
vi.mock('@/utils/clipboard', () => ({
|
||||
copyToClipboard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
describe('CopyCommandBlock', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('copies through the shared clipboard utility before notifying callers', async () => {
|
||||
const command = 'pulse-mcp --base-url http://localhost:7655';
|
||||
const onCopy = vi.fn();
|
||||
|
||||
render(() => <CopyCommandBlock command={command} onCopy={onCopy} />);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' }));
|
||||
|
||||
await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith(command));
|
||||
expect(onCopy).toHaveBeenCalledWith(command);
|
||||
});
|
||||
|
||||
it('does not report copied state when clipboard copy fails', async () => {
|
||||
vi.mocked(copyToClipboard).mockResolvedValueOnce(false);
|
||||
const onCopy = vi.fn();
|
||||
|
||||
render(() => <CopyCommandBlock command="install command" onCopy={onCopy} />);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' }));
|
||||
|
||||
await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith('install command'));
|
||||
expect(onCopy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,45 @@ describe('DiagnosticsResultsPanel', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('labels Pulse Assistant diagnostics as native runtime status', () => {
|
||||
const diagnosticsData = {
|
||||
version: '6.0.0',
|
||||
runtime: 'go',
|
||||
uptime: 3600,
|
||||
nodes: [],
|
||||
pbs: [],
|
||||
system: {
|
||||
os: 'linux',
|
||||
arch: 'amd64',
|
||||
goVersion: 'go1.25',
|
||||
numCPU: 8,
|
||||
numGoroutine: 32,
|
||||
memoryMB: 128,
|
||||
},
|
||||
aiChat: {
|
||||
enabled: true,
|
||||
running: true,
|
||||
healthy: true,
|
||||
model: 'ollama:llama3',
|
||||
assistantRuntimeConnected: true,
|
||||
notes: [],
|
||||
},
|
||||
errors: [],
|
||||
} as DiagnosticsData;
|
||||
|
||||
render(() => (
|
||||
<DiagnosticsResultsPanel
|
||||
diagnosticsData={diagnosticsData}
|
||||
loading={false}
|
||||
onRunDiagnostics={() => {}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Pulse Assistant Service')).toBeInTheDocument();
|
||||
expect(screen.getByText('Assistant runtime')).toBeInTheDocument();
|
||||
expect(screen.queryByText('MCP Connection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render internal analytics fields from diagnostics payloads', () => {
|
||||
const diagnosticsData = {
|
||||
version: '6.0.0',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/te
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
|
||||
import type { LicenseEntitlements } from '@/api/license';
|
||||
import type { AgentOperationsLoopStatus } from '@/api/agentCapabilities';
|
||||
import { ProLicensePanel } from '../ProLicensePanel';
|
||||
import proLicensePanelSource from '../ProLicensePanel.tsx?raw';
|
||||
import proLicensePanelStateSource from '../useProLicensePanelState.ts?raw';
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
} from '@/utils/pricingHandoff';
|
||||
import { PATROL_CONTROL_STARTER_URL } from '@/utils/licensePresentation';
|
||||
import { PATROL_CONTROL_PATH } from '@/routing/resourceLinks';
|
||||
|
||||
let mockEntitlements: LicenseEntitlements | null = null;
|
||||
|
||||
@@ -54,6 +57,43 @@ const useLocationMock = vi.fn(() => ({
|
||||
const navigateMock = vi.fn();
|
||||
const getUpgradeActionDestinationMock = vi.hoisted(() => vi.fn());
|
||||
const getUpgradeActionUrlOrFallbackMock = vi.hoisted(() => vi.fn());
|
||||
const fetchAgentOperationsLoopStatusMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const buildOperationsLoopStatus = (
|
||||
overrides: Partial<AgentOperationsLoopStatus> = {},
|
||||
): AgentOperationsLoopStatus => ({
|
||||
nextAction: 'run_patrol',
|
||||
progressLabel: 'No Patrol work activity yet.',
|
||||
steps: [],
|
||||
patrolEvidenceCount: 0,
|
||||
patrolIssueEvidenceCount: 0,
|
||||
activeFindingCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
governedActionCount: 0,
|
||||
approvedDecisionCount: 0,
|
||||
rejectedDecisionCount: 0,
|
||||
verifiedOutcomeCount: 0,
|
||||
operationsLoopStarterCount: 0,
|
||||
assistantOperationsLoopStarterCount: 0,
|
||||
patrolOperationsLoopStarterCount: 0,
|
||||
patrolControlOperationsLoopStarterCount: 0,
|
||||
patrolControlCompletedOperationsLoopCount: 0,
|
||||
patrolControlResolvedOperationsLoopCount: 0,
|
||||
patrolControlValueState: 'not_started',
|
||||
patrolAutonomyOperationsLoopStarterCount: 0,
|
||||
patrolAutonomyCompletedOperationsLoopCount: 0,
|
||||
patrolAutonomyResolvedOperationsLoopCount: 0,
|
||||
patrolAutonomyValueState: 'not_started',
|
||||
proActivationOperationsLoopStarterCount: 0,
|
||||
proActivationCompletedOperationsLoopCount: 0,
|
||||
proActivationResolvedOperationsLoopCount: 0,
|
||||
proActivationValueProofState: 'not_started',
|
||||
mcpOperationsLoopStarterCount: 0,
|
||||
externalAgentReady: false,
|
||||
windowStart: '2026-06-21T00:00:00Z',
|
||||
generatedAt: '2026-06-21T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock('@solidjs/router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@solidjs/router')>();
|
||||
@@ -95,6 +135,11 @@ vi.mock('@/api/license', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/agentCapabilities', () => ({
|
||||
fetchAgentOperationsLoopStatus: (...args: unknown[]) =>
|
||||
fetchAgentOperationsLoopStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: {
|
||||
success: (...args: unknown[]) => notificationSuccessMock(...args),
|
||||
@@ -136,9 +181,11 @@ describe('ProLicensePanel', () => {
|
||||
navigateMock.mockReset();
|
||||
getUpgradeActionDestinationMock.mockReset();
|
||||
getUpgradeActionUrlOrFallbackMock.mockReset();
|
||||
fetchAgentOperationsLoopStatusMock.mockReset();
|
||||
loadRuntimeLicenseStatusMock.mockResolvedValue(undefined);
|
||||
loadCommercialPostureMock.mockResolvedValue(undefined);
|
||||
loadLicenseEntitlementsMock.mockResolvedValue(undefined);
|
||||
fetchAgentOperationsLoopStatusMock.mockResolvedValue(buildOperationsLoopStatus());
|
||||
licenseEntitlementsLoadErrorMock.mockReturnValue(null);
|
||||
startProTrialMock.mockResolvedValue(undefined);
|
||||
activateLicenseMock.mockResolvedValue({ success: true });
|
||||
@@ -189,7 +236,7 @@ describe('ProLicensePanel', () => {
|
||||
expect(loadCommercialPostureMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('License and billing details are hidden')).toBeInTheDocument();
|
||||
expect(screen.getByText(/instead of creating a demo license/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Self-hosted plan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Plans & Billing')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /start 14-day pro trial/i }),
|
||||
@@ -203,7 +250,7 @@ describe('ProLicensePanel', () => {
|
||||
expect(loadLicenseEntitlementsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Self-hosted plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Plans & Billing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Current plan: Community')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
@@ -220,11 +267,11 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.getByText('Current plan: Community')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Expired$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Compare self-hosted plans')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Optional extras')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('What Relay adds')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('What Pulse Pro adds')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Compare plans' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Select a plan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Available plans')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Relay plan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pulse Pro plan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens compare-plan checkout from the explicit self-hosted billing handoff', async () => {
|
||||
@@ -240,7 +287,7 @@ describe('ProLicensePanel', () => {
|
||||
expect(loadLicenseEntitlementsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getAllByRole('link', { name: 'Compare plans' })[0]);
|
||||
await fireEvent.click(screen.getAllByRole('link', { name: 'View plans' })[0]);
|
||||
});
|
||||
|
||||
it('keeps explicit self-hosted plan comparison focused on optional paid extras', async () => {
|
||||
@@ -256,19 +303,19 @@ describe('ProLicensePanel', () => {
|
||||
expect(loadLicenseEntitlementsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Compare self-hosted plans')).toBeInTheDocument();
|
||||
expect(screen.getByText('Optional extras')).toBeInTheDocument();
|
||||
expect(screen.getByText('What Relay adds')).toBeInTheDocument();
|
||||
expect(screen.getByText('What Pulse Pro adds')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse Relay (Remote Access)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse Mobile Pairing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Push Notifications')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert Root-Cause Analysis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Safe Remediation Workflows')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select a plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Available plans')).toBeInTheDocument();
|
||||
expect(screen.getByText('Relay plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse Pro plan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote web access via Relay')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse Mobile pairing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Push notifications')).toBeInTheDocument();
|
||||
expect(screen.getByText('Choose Patrol mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol investigates issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol handles safe fixes')).toBeInTheDocument();
|
||||
expect(screen.getByText('90-day metric history')).toBeInTheDocument();
|
||||
expect(screen.getByText('Audit Logging')).toBeInTheDocument();
|
||||
expect(screen.getByText('PDF/CSV Reporting')).toBeInTheDocument();
|
||||
expect(screen.getByText('Centralized Agent Profiles')).toBeInTheDocument();
|
||||
expect(screen.getByText('RBAC and audit logging')).toBeInTheDocument();
|
||||
expect(screen.getByText('Agent profiles · PDF/CSV reports')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText(/unlimited/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/trial/i)).not.toBeInTheDocument();
|
||||
@@ -295,6 +342,32 @@ describe('ProLicensePanel', () => {
|
||||
expect(screen.queryByText('Your Pro trial has ended')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Community', 'community'],
|
||||
['Relay', 'relay'],
|
||||
])('does not load Patrol operator status for %s plans', async (planName, tier) => {
|
||||
mockEntitlements = {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier,
|
||||
trial_eligible: false,
|
||||
};
|
||||
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadLicenseEntitlementsMock).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`Current plan: ${planName}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(fetchAgentOperationsLoopStatusMock).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('Patrol work')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders trial countdown from entitlements payload', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: ['ai_patrol', 'ai_autofix'],
|
||||
@@ -318,7 +391,262 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.getAllByText('Trial').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByText('Safe Remediation Workflows')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Handles Safe Fixes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the Patrol mode entry point visible for existing active Pro plans', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: ['relay', 'mobile_app', 'push_notifications', 'ai_autofix'],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier: 'pro',
|
||||
licensed_email: 'owner@example.com',
|
||||
trial_eligible: false,
|
||||
runtime: PRO_RUNTIME_IDENTITY,
|
||||
};
|
||||
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Pulse Pro is now active')).not.toBeInTheDocument();
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Choose Patrol mode' });
|
||||
expect(patrolControlLink).toHaveAttribute('href', PATROL_CONTROL_STARTER_URL);
|
||||
expect(patrolControlLink).not.toHaveAttribute('target');
|
||||
patrolControlLink.addEventListener('click', (event) => event.preventDefault());
|
||||
fireEvent.click(patrolControlLink);
|
||||
});
|
||||
|
||||
it('keeps external-agent proof behind Patrol mode presentation', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: [
|
||||
'relay',
|
||||
'mobile_app',
|
||||
'push_notifications',
|
||||
'ai_alerts',
|
||||
'ai_autofix',
|
||||
'rbac',
|
||||
'audit_logging',
|
||||
'advanced_reporting',
|
||||
'agent_profiles',
|
||||
],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier: 'pro',
|
||||
licensed_email: 'owner@example.com',
|
||||
max_history_days: 90,
|
||||
trial_eligible: false,
|
||||
runtime: PRO_RUNTIME_IDENTITY,
|
||||
};
|
||||
fetchAgentOperationsLoopStatusMock.mockResolvedValue(
|
||||
buildOperationsLoopStatus({
|
||||
nextAction: 'open_mcp',
|
||||
progressLabel: 'Legacy status asked for MCP readiness after the outcome was verified.',
|
||||
patrolControlOperationsLoopStarterCount: 1,
|
||||
patrolControlResolvedOperationsLoopCount: 0,
|
||||
patrolControlValueState: 'verified_needs_mcp',
|
||||
externalAgentReady: false,
|
||||
}),
|
||||
);
|
||||
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAgentOperationsLoopStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText('Patrol work')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol autonomy loop')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Verified')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Patrol handled an infrastructure issue, verified the outcome, and recorded what happened.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Legacy status asked for MCP readiness after the outcome was verified.'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Continue through Patrol investigation/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Patrol history' })).not.toBeInTheDocument();
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Choose Patrol mode' });
|
||||
expect(patrolControlLink).toHaveAttribute('href', PATROL_CONTROL_STARTER_URL);
|
||||
});
|
||||
|
||||
it('does not turn legacy Pro activation starter evidence into plan-page Patrol work', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: [
|
||||
'relay',
|
||||
'mobile_app',
|
||||
'push_notifications',
|
||||
'ai_alerts',
|
||||
'ai_autofix',
|
||||
'rbac',
|
||||
'audit_logging',
|
||||
'advanced_reporting',
|
||||
'agent_profiles',
|
||||
],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier: 'pro',
|
||||
licensed_email: 'owner@example.com',
|
||||
max_history_days: 90,
|
||||
trial_eligible: false,
|
||||
runtime: PRO_RUNTIME_IDENTITY,
|
||||
};
|
||||
fetchAgentOperationsLoopStatusMock.mockResolvedValue(
|
||||
buildOperationsLoopStatus({
|
||||
nextAction: 'run_patrol',
|
||||
progressLabel: 'Legacy Pro activation entry recorded.',
|
||||
proActivationOperationsLoopStarterCount: 1,
|
||||
proActivationCompletedOperationsLoopCount: 0,
|
||||
proActivationResolvedOperationsLoopCount: 0,
|
||||
proActivationValueProofState: 'in_progress',
|
||||
externalAgentReady: false,
|
||||
}),
|
||||
);
|
||||
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAgentOperationsLoopStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText('Patrol work')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Started')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Legacy Pro activation entry recorded.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Continue Patrol work' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Choose Patrol mode' })).toHaveAttribute(
|
||||
'href',
|
||||
PATROL_CONTROL_STARTER_URL,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps terminal Patrol decisions out of plan capability details', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: [
|
||||
'relay',
|
||||
'mobile_app',
|
||||
'push_notifications',
|
||||
'ai_alerts',
|
||||
'ai_autofix',
|
||||
'rbac',
|
||||
'audit_logging',
|
||||
'advanced_reporting',
|
||||
'agent_profiles',
|
||||
],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier: 'pro',
|
||||
licensed_email: 'owner@example.com',
|
||||
max_history_days: 90,
|
||||
trial_eligible: false,
|
||||
runtime: PRO_RUNTIME_IDENTITY,
|
||||
};
|
||||
fetchAgentOperationsLoopStatusMock.mockResolvedValue(
|
||||
buildOperationsLoopStatus({
|
||||
nextAction: 'complete',
|
||||
progressLabel:
|
||||
'Patrol recorded a rejected change decision. Nothing was changed; approve a safer fix before marking the issue resolved.',
|
||||
patrolControlOperationsLoopStarterCount: 1,
|
||||
patrolControlCompletedOperationsLoopCount: 1,
|
||||
patrolControlResolvedOperationsLoopCount: 0,
|
||||
patrolControlValueState: 'governed_decision_recorded',
|
||||
externalAgentReady: false,
|
||||
}),
|
||||
);
|
||||
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAgentOperationsLoopStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText('Patrol work')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol autonomy loop')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Decision recorded')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Pulse Pro is active on this instance. Review the current Patrol decision.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/External-agent setup/i)).not.toBeInTheDocument();
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Review Patrol decision' });
|
||||
expect(patrolControlLink).toHaveAttribute('href', PATROL_CONTROL_PATH);
|
||||
patrolControlLink.addEventListener('click', (event) => event.preventDefault());
|
||||
fireEvent.click(patrolControlLink);
|
||||
});
|
||||
|
||||
it('keeps verified Patrol history behind the Patrol mode action', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: [
|
||||
'relay',
|
||||
'mobile_app',
|
||||
'push_notifications',
|
||||
'ai_alerts',
|
||||
'ai_autofix',
|
||||
'rbac',
|
||||
'audit_logging',
|
||||
'advanced_reporting',
|
||||
'agent_profiles',
|
||||
],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier: 'pro',
|
||||
licensed_email: 'owner@example.com',
|
||||
max_history_days: 90,
|
||||
trial_eligible: false,
|
||||
runtime: PRO_RUNTIME_IDENTITY,
|
||||
};
|
||||
fetchAgentOperationsLoopStatusMock.mockResolvedValue(
|
||||
buildOperationsLoopStatus({
|
||||
nextAction: 'complete',
|
||||
progressLabel:
|
||||
'Patrol verified the issue outcome and recorded the governed action history.',
|
||||
patrolControlOperationsLoopStarterCount: 1,
|
||||
patrolControlResolvedOperationsLoopCount: 1,
|
||||
patrolControlValueState: 'verified',
|
||||
externalAgentReady: true,
|
||||
}),
|
||||
);
|
||||
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchAgentOperationsLoopStatusMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText('Patrol work')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol autonomy loop')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Verified')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Patrol handled an infrastructure issue, verified the outcome, and recorded what happened.',
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Patrol history' })).not.toBeInTheDocument();
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Choose Patrol mode' });
|
||||
expect(patrolControlLink).toHaveAttribute('href', PATROL_CONTROL_STARTER_URL);
|
||||
patrolControlLink.addEventListener('click', (event) => event.preventDefault());
|
||||
fireEvent.click(patrolControlLink);
|
||||
});
|
||||
|
||||
it('shows active recurring v5 plan terms as unmetered even if stale limit metadata is present', async () => {
|
||||
@@ -378,9 +706,7 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.getByText('V5 Lifetime Grandfathered')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'See which self-hosted tier this instance is using and which capabilities are available on this install.',
|
||||
),
|
||||
screen.getByText('Current tier and enabled capabilities.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Guest Capacity')).not.toBeInTheDocument();
|
||||
@@ -499,27 +825,45 @@ describe('ProLicensePanel', () => {
|
||||
expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Pulse Pro is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.',
|
||||
'Pulse Pro is active on this instance. It includes Patrol modes, 90-day metric history, RBAC, audit logs, reports, and agent profiles.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Primary capabilities')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert Root-Cause Analysis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Safe Remediation Workflows')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Investigates Issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Handles Safe Fixes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Included extras')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pulse Pro value proof')).toBeInTheDocument();
|
||||
expect(screen.getByText('Capability details')).toBeInTheDocument();
|
||||
expect(screen.getByText('(5 items ready)')).toBeInTheDocument();
|
||||
const readinessDetails = screen
|
||||
.getByText('Capability details')
|
||||
.closest('details') as HTMLDetailsElement | null;
|
||||
expect(readinessDetails).not.toBeNull();
|
||||
expect(readinessDetails?.open).toBe(false);
|
||||
expect(
|
||||
screen.getByText(
|
||||
"These checks come from this instance's entitlement and runtime payloads, not from public pricing copy.",
|
||||
'Open this only when a Pro capability looks unavailable. Normal setup is choosing Patrol mode.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote access, pairing, and push')).toBeInTheDocument();
|
||||
expect(screen.getByText('Root-cause analysis and remediation')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Relay, Pulse Mobile pairing, and push notifications are available on this instance.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol investigation and remediation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team and admin controls')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Active').length).toBeGreaterThanOrEqual(4);
|
||||
expect(
|
||||
screen.queryByText(new RegExp(['optional', 'activation'].join(' '), 'i')),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(new RegExp(['recover', 'activation'].join(' '), 'i')),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/entitlement payload/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Needs attention')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Optional extras')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Available plans')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('90 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('Analysis, remediation, and admin controls')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol modes, history, and admin controls')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Guest Capacity')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Monitored-system policy')).not.toBeInTheDocument();
|
||||
@@ -547,14 +891,14 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Relay is active on this instance. Remote web access, Pulse Mobile pairing, push notifications, and longer history are available right now.',
|
||||
'Relay is active on this instance. It includes remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Optional extras')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('What Relay adds')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('What Pulse Pro adds')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'See all plans' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Relay value proof')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Available plans')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Relay plan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pulse Pro plan')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Relay status')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote access, pairing, and push')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('14-day metric history').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Pulse Relay (Remote Access)')).toBeInTheDocument();
|
||||
@@ -734,8 +1078,8 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
// Verify self-hosted displayable capabilities render with their expected labels.
|
||||
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
|
||||
expect(screen.getByText('Alert Root-Cause Analysis')).toBeInTheDocument();
|
||||
expect(screen.getByText('Safe Remediation Workflows')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Investigates Issues')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Handles Safe Fixes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Update Alerts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Role-Based Access Control (RBAC)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Audit Logging')).toBeInTheDocument();
|
||||
@@ -755,29 +1099,27 @@ describe('ProLicensePanel', () => {
|
||||
it('shows migration guidance when the pasted key looks like a legacy v5 license', async () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByText('Use existing key'));
|
||||
fireEvent.click(screen.getByText('Manual key recovery'));
|
||||
|
||||
fireEvent.input(screen.getByLabelText(/license or activation key/i), {
|
||||
fireEvent.input(screen.getByLabelText(/license key/i), {
|
||||
target: { value: 'header.payload.signature' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Legacy v5 license detected')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/exchange this key into the v6 activation model automatically/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/migrate this key automatically/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED,
|
||||
title: 'Pulse Pro is now active',
|
||||
actionLabel: null,
|
||||
actionHref: null,
|
||||
actionLabel: 'Choose Patrol mode',
|
||||
actionHref: PATROL_CONTROL_STARTER_URL,
|
||||
},
|
||||
{
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED,
|
||||
title: 'Checkout cancelled',
|
||||
actionLabel: 'Compare plans',
|
||||
actionLabel: 'View plans',
|
||||
actionHref: getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT),
|
||||
redirectedHref: getSelfHostedBillingHref('plan', {
|
||||
intent: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
@@ -786,12 +1128,12 @@ describe('ProLicensePanel', () => {
|
||||
{
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED,
|
||||
title: 'Upgrade return expired',
|
||||
actionLabel: 'Compare plans',
|
||||
actionLabel: 'View plans',
|
||||
actionHref: getSelfHostedPurchaseStartUrl(),
|
||||
},
|
||||
{
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED,
|
||||
title: 'Activation needs attention',
|
||||
title: 'Plan needs attention',
|
||||
actionLabel: 'Open recovery',
|
||||
actionHref: getSelfHostedBillingHref('plan', {
|
||||
detail: SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
|
||||
@@ -841,10 +1183,18 @@ describe('ProLicensePanel', () => {
|
||||
expect(screen.getByText(title)).toBeInTheDocument();
|
||||
if (purchase === SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED) {
|
||||
expect(
|
||||
screen.getByText(/Checkout completed and this instance is now running Pulse Pro/i),
|
||||
screen.getByText(
|
||||
/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Available now on this instance')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Safe Remediation Workflows').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Patrol Handles Safe Fixes').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('link', { name: 'Choose Patrol mode' })).toHaveLength(1);
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Choose Patrol mode' });
|
||||
expect(patrolControlLink).toHaveAttribute('href', PATROL_CONTROL_STARTER_URL);
|
||||
expect(patrolControlLink).not.toHaveAttribute('target');
|
||||
patrolControlLink.addEventListener('click', (event) => event.preventDefault());
|
||||
fireEvent.click(patrolControlLink);
|
||||
}
|
||||
if (actionLabel && actionHref) {
|
||||
const actionLinks = screen.getAllByRole('link', { name: actionLabel });
|
||||
@@ -859,6 +1209,49 @@ describe('ProLicensePanel', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps purchase activation success on Patrol mode when history is already verified', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: ['relay', 'mobile_app', 'push_notifications', 'ai_autofix'],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier: 'pro',
|
||||
licensed_email: 'owner@example.com',
|
||||
trial_eligible: false,
|
||||
runtime: PRO_RUNTIME_IDENTITY,
|
||||
};
|
||||
fetchAgentOperationsLoopStatusMock.mockResolvedValue(
|
||||
buildOperationsLoopStatus({
|
||||
nextAction: 'complete',
|
||||
progressLabel:
|
||||
'Patrol verified the issue outcome and recorded the governed action history.',
|
||||
patrolControlOperationsLoopStarterCount: 1,
|
||||
patrolControlCompletedOperationsLoopCount: 1,
|
||||
patrolControlResolvedOperationsLoopCount: 1,
|
||||
patrolControlValueState: 'verified',
|
||||
externalAgentReady: true,
|
||||
}),
|
||||
);
|
||||
useLocationMock.mockReturnValue({
|
||||
search: `?purchase=${SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED}`,
|
||||
pathname: '/settings/system/billing/plan',
|
||||
hash: '',
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('Pulse Pro is now active')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('link', { name: 'Choose Patrol mode' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Patrol history' })).not.toBeInTheDocument();
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Choose Patrol mode' });
|
||||
expect(patrolControlLink).toHaveAttribute('href', PATROL_CONTROL_STARTER_URL);
|
||||
patrolControlLink.addEventListener('click', (event) => event.preventDefault());
|
||||
fireEvent.click(patrolControlLink);
|
||||
});
|
||||
|
||||
it('returns self-hosted plan purchases to the plan surface instead of the legacy usage tab', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: ['relay', 'mobile_app', 'push_notifications', 'ai_autofix'],
|
||||
@@ -880,11 +1273,13 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.getByText('Pulse Pro is now active')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Checkout completed and this instance is now running Pulse Pro/i),
|
||||
screen.getByText(
|
||||
/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Compare self-hosted plans')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Safe Remediation Workflows').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Optional extras')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Select a plan')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Patrol Handles Safe Fixes').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Available plans')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Review plan' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -903,10 +1298,12 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.queryByText('Pulse Pro is now active')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Checkout completed and this instance is now running Pulse Pro/i),
|
||||
screen.queryByText(
|
||||
/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Current plan: Community')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Safe Remediation Workflows')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol Handles Safe Fixes')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Available now on this instance')).not.toBeInTheDocument();
|
||||
expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_PLAN_HREF, {
|
||||
replace: true,
|
||||
@@ -923,7 +1320,7 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
renderPanel();
|
||||
|
||||
const recoveryDisclosure = screen.getAllByText('Use existing key')[0]?.closest('details');
|
||||
const recoveryDisclosure = screen.getAllByText('Manual key recovery')[0]?.closest('details');
|
||||
expect(recoveryDisclosure).toHaveAttribute('open');
|
||||
});
|
||||
|
||||
@@ -997,9 +1394,10 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getAllByText('Compare self-hosted plans').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Select a plan').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Community is active on this instance/i)).toBeInTheDocument();
|
||||
const compareLinks = screen.getAllByRole('link', { name: 'Compare plans' });
|
||||
expect(screen.getAllByText('Watch-only Patrol').length).toBeGreaterThan(0);
|
||||
const compareLinks = screen.getAllByRole('link', { name: 'View plans' });
|
||||
expect(
|
||||
compareLinks.some(
|
||||
(link) =>
|
||||
@@ -1096,7 +1494,7 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.getByText('v5 license migration pending')).toBeInTheDocument();
|
||||
expect(screen.getByText(/rate-limited right now/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/retry activation from this instance/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/retry from this instance/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reason-specific guidance when the v5 key is unsupported', async () => {
|
||||
@@ -1159,6 +1557,7 @@ describe('ProLicensePanel', () => {
|
||||
expect(proLicensePanelStateSource).not.toContain('getSelfHostedBillingUsageDetail');
|
||||
expect(proLicensePanelStateSource).toContain('const setActiveSection = (section: string) => {');
|
||||
expect(proLicensePanelStateSource).toContain('loadLicenseEntitlements(true)');
|
||||
expect(proLicensePanelStateSource).toContain('fetchAgentOperationsLoopStatus');
|
||||
expect(proLicensePanelStateSource).toContain('loadCommercialPosture(true)');
|
||||
expect(proLicensePanelStateSource).toContain('loadRuntimeCapabilities(true)');
|
||||
expect(proLicensePanelStateSource).toContain('buildSelfHostedCommercialPlanModel');
|
||||
@@ -1168,7 +1567,7 @@ describe('ProLicensePanel', () => {
|
||||
expect(proLicensePanelStateSource).toContain('getSelfHostedActivationSuccessPresentation({');
|
||||
expect(proLicensePanelStateSource).not.toContain('runStartProTrialAction({');
|
||||
expect(proLicensePanelStateSource).not.toContain('startProTrial()');
|
||||
expect(proLicensePanelStateSource).toContain("'A license or activation key is required'");
|
||||
expect(proLicensePanelStateSource).toContain("'A license key is required'");
|
||||
expect(proLicensePanelSource).toContain('@/components/shared/Button');
|
||||
expect(proLicensePanelSource).toContain('variant="outline"');
|
||||
expect(proLicensePanelSource).toContain('size="settingsAction"');
|
||||
@@ -1215,9 +1614,7 @@ describe('ProLicensePanel', () => {
|
||||
"resolveSelfHostedPurchaseStartDestination('self_hosted_plan')",
|
||||
);
|
||||
expect(proLicensePlanSectionSource).not.toContain('Your Pro trial has ended');
|
||||
expect(proLicensePlanSectionSource).not.toContain(
|
||||
'Turn alert noise into root-cause analysis, safe remediation workflows, and 90-day history.',
|
||||
);
|
||||
expect(proLicensePlanSectionSource).not.toContain('Turn alert noise into');
|
||||
expect(selfHostedCommercialRecoverySectionSource).toContain(
|
||||
'SELF_HOSTED_RECOVERY_PRESENTATION',
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import settingsSource from '../Settings.tsx?raw';
|
||||
import { EN_MESSAGES } from '@/i18n/messages';
|
||||
import settingsDialogsSource from '../SettingsDialogs.tsx?raw';
|
||||
import settingsPageShellSource from '../SettingsPageShell.tsx?raw';
|
||||
import aiSettingsSource from '../AISettings.tsx?raw';
|
||||
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
|
||||
import aiSettingsStatusAndActionsSource from '../AISettingsStatusAndActions.tsx?raw';
|
||||
import aiChatMaintenanceSectionSource from '../AIChatMaintenanceSection.tsx?raw';
|
||||
@@ -25,6 +26,7 @@ import apiTokenManagerSource from '../APITokenManager.tsx?raw';
|
||||
import apiTokenManagerModelSource from '../apiTokenManagerModel.ts?raw';
|
||||
import apiTokenManagerStateSource from '../useAPITokenManagerState.ts?raw';
|
||||
import agentIntegrationsPanelSource from '../AgentIntegrationsPanel.tsx?raw';
|
||||
import agentCapabilitiesApiSource from '@/api/agentCapabilities.ts?raw';
|
||||
import dataHandlingPanelSource from '../DataHandlingPanel.tsx?raw';
|
||||
import auditLogPanelSource from '../AuditLogPanel.tsx?raw';
|
||||
import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw';
|
||||
@@ -85,6 +87,7 @@ import ssoProvidersStateSource from '../useSSOProvidersState.ts?raw';
|
||||
import infrastructureOnboardingPresentationSource from '../../../utils/infrastructureOnboardingPresentation.ts?raw';
|
||||
import selfHostedBillingPresentationSource from '../selfHostedBillingPresentation.ts?raw';
|
||||
import systemSettingsPresentationSource from '../../../utils/systemSettingsPresentation.ts?raw';
|
||||
import aiSettingsPresentationSource from '../../../utils/aiSettingsPresentation.ts?raw';
|
||||
import auditLogPresentationSource from '../../../utils/auditLogPresentation.ts?raw';
|
||||
import statusIndicatorBadgeSource from '../../shared/StatusIndicatorBadge.tsx?raw';
|
||||
|
||||
@@ -157,6 +160,34 @@ describe('settings architecture guardrails', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Assistant OAuth callback compatibility on the shared settings shell', () => {
|
||||
expect(settingsNavigationModelSource).toContain(
|
||||
'export const SETTINGS_PROVIDER_MODELS_PATH = `${SETTINGS_PULSE_INTELLIGENCE_PATH}/provider`;',
|
||||
);
|
||||
expect(settingsNavigationModelSource).toContain(
|
||||
"const LEGACY_SYSTEM_AI_PREFIX = '/settings/system-ai';",
|
||||
);
|
||||
expect(settingsNavigationModelSource).toContain('normalizedPath === LEGACY_SYSTEM_AI_PREFIX');
|
||||
expect(settingsNavigationModelSource).toContain(
|
||||
"case 'system-ai':\n return PULSE_INTELLIGENCE_PROVIDER_PREFIX;",
|
||||
);
|
||||
expect(settingsNavigationModelSource).toContain(
|
||||
'export function isAISettingsOAuthCallbackQuery(search: string): boolean',
|
||||
);
|
||||
expect(settingsNavigationModelSource).toContain(
|
||||
"return params.has('ai_oauth_success') || params.has('ai_oauth_error');",
|
||||
);
|
||||
expect(settingsNavigationModelSource).toContain(
|
||||
"if (isAISettingsOAuthCallbackQuery(search)) return 'system-ai';",
|
||||
);
|
||||
expect(settingsNavigationHookSource).toContain(
|
||||
"resolvedTab === 'system-ai' && isAISettingsOAuthCallbackQuery(search)",
|
||||
);
|
||||
expect(settingsNavigationHookSource).toContain(
|
||||
'const targetHref = shouldPreserveQuery ? `${target}${search}${hash}` : target;',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps retired operations paths out of the settings routing model', () => {
|
||||
expect(settingsNavigationModelSource).toContain(
|
||||
"const RETIRED_SETTINGS_OPERATIONS_PREFIX = '/settings/operations';",
|
||||
@@ -184,31 +215,41 @@ describe('settings architecture guardrails', () => {
|
||||
'params.securityStatus()?.proxyAuthUsername',
|
||||
);
|
||||
expect(settingsPanelRegistryContextSource).toContain(
|
||||
'|| params.securityStatus()?.ssoSessionUsername',
|
||||
);
|
||||
expect(settingsPanelRegistryContextSource).toContain(
|
||||
'|| params.securityStatus()?.authUsername;',
|
||||
'params.securityStatus()?.ssoSessionUsername',
|
||||
);
|
||||
expect(settingsPanelRegistryContextSource).toContain('params.securityStatus()?.authUsername;');
|
||||
expect(settingsPanelRegistryContextSource).toContain('getOrganizationOverviewPanelProps');
|
||||
expect(settingsPanelRegistryContextSource).toContain('getOrganizationAccessPanelProps');
|
||||
expect(settingsPanelRegistryContextSource).toContain('getOrganizationSharingPanelProps');
|
||||
});
|
||||
|
||||
it('keeps self-hosted commercial settings plan-owned under one shared presentation model', () => {
|
||||
expect(selfHostedBillingPresentationSource).toContain("navLabel: 'Plans'");
|
||||
expect(selfHostedBillingPresentationSource).toContain("shellTitle: 'Self-hosted plan'");
|
||||
expect(selfHostedBillingPresentationSource).toContain("navLabel: 'Plans & Billing'");
|
||||
expect(selfHostedBillingPresentationSource).toContain("shellTitle: 'Plans & Billing'");
|
||||
expect(selfHostedBillingPresentationSource).toContain(
|
||||
"shellDescription:\n 'Review the plan this instance is using and the optional capabilities connected to it.'",
|
||||
'Plan, license, and Patrol mode for this instance.',
|
||||
);
|
||||
expect(selfHostedBillingPresentationSource).toContain("planSectionTitle: 'Current plan'");
|
||||
expect(selfHostedBillingPresentationSource).toContain(
|
||||
"recoverySectionTitle: 'Existing purchases'",
|
||||
"recoverySectionTitle: 'License recovery'",
|
||||
);
|
||||
|
||||
expect(settingsNavCatalogSource).toContain(
|
||||
'label: SELF_HOSTED_PRO_BILLING_PRESENTATION.navLabel',
|
||||
);
|
||||
expect(settingsNavCatalogSource).toContain('hideFromSidebar: true');
|
||||
const pulseIntelligenceNavBlock = settingsNavCatalogSource.match(
|
||||
/id: 'pulse-intelligence',[\s\S]*?id: 'organization',/,
|
||||
);
|
||||
expect(pulseIntelligenceNavBlock?.[0]).toContain("id: 'system-billing'");
|
||||
const systemBillingNavBlock = settingsNavCatalogSource.match(
|
||||
/id: 'system-billing',[\s\S]*?hideWhenCommercialHidden: true,\n\s*},/,
|
||||
);
|
||||
expect(systemBillingNavBlock?.[0]).toContain(
|
||||
'label: SELF_HOSTED_PRO_BILLING_PRESENTATION.navLabel',
|
||||
);
|
||||
expect(systemBillingNavBlock?.[0]).not.toContain('hideFromSidebar');
|
||||
const systemNavBlock = settingsNavCatalogSource.match(/id: 'system',[\s\S]*?id: 'support',/);
|
||||
expect(systemNavBlock?.[0]).not.toContain("id: 'system-billing'");
|
||||
expect(settingsNavCatalogSource).toContain("features: ['rbac']");
|
||||
expect(settingsNavCatalogSource).toContain("features: ['audit_logging']");
|
||||
expect(settingsNavCatalogSource).toContain("features: ['relay']");
|
||||
@@ -271,11 +312,33 @@ describe('settings architecture guardrails', () => {
|
||||
expect(aiRuntimeControlsSectionSource).toContain('showAutonomousControlOption');
|
||||
expect(aiRuntimeControlsSectionSource).toContain("state.form.controlLevel === 'autonomous'");
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Controlled - Pulse Assistant asks before actions',
|
||||
'Ask first - Assistant asks before chat-only actions',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Autonomous - Pulse Assistant can run eligible actions',
|
||||
'Allow chat-only actions - Assistant may take eligible chat actions',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'This controls actions started from Assistant chat only',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Patrol handles infrastructure');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('Command auto-run');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('without per-command approval');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('Legal Disclaimer');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('Patrol uses the mode configured on');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('label="Chat action mode"');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('label="Command mode"');
|
||||
expect(aiSettingsPresentationSource).toContain('Assistant chat actions');
|
||||
expect(aiSettingsPresentationSource).not.toContain('Assistant chat command access');
|
||||
expect(aiSettingsPresentationSource).not.toContain('Assistant command access');
|
||||
expect(aiSettingsSource).toContain('AI_SETTINGS_PAGE_META');
|
||||
expect(aiSettingsSource).toContain("saveLabel: 'Save provider settings'");
|
||||
expect(aiSettingsSource).toContain("saveLabel: 'Save Patrol settings'");
|
||||
expect(aiSettingsSource).toContain("saveLabel: 'Save Assistant settings'");
|
||||
expect(aiSettingsSource).toContain("saveLabel: 'Save service context settings'");
|
||||
expect(aiSettingsStatusAndActionsSource).toContain('props.saveLabel');
|
||||
expect(aiSettingsStatusAndActionsSource).not.toContain('Save changes</button>');
|
||||
expect(aiSettingsStateSource).toContain('options.savedLabel');
|
||||
expect(aiSettingsStateSource).toContain('notificationStore.success(savedLabel)');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain('without approval (Pro)');
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain(
|
||||
'Pulse Assistant executes without approval',
|
||||
@@ -561,10 +624,13 @@ describe('settings architecture guardrails', () => {
|
||||
'aggregate self-hosted adoption',
|
||||
);
|
||||
expect(EN_MESSAGES['settings.general.telemetry.description']).toContain(
|
||||
'counts, and coarse feature flags',
|
||||
'counts, coarse feature flags, and coarse Patrol, Assistant, and external-agent usage counters',
|
||||
);
|
||||
expect(EN_MESSAGES['settings.general.telemetry.description']).not.toContain(
|
||||
'Pulse Intelligence loop adoption',
|
||||
);
|
||||
expect(EN_MESSAGES['settings.general.telemetry.description']).toContain(
|
||||
'identifiers, prompts, chat messages, or personal information are sent.',
|
||||
'identifiers, prompts, chat messages, command text, action output, token values, or personal information are sent.',
|
||||
);
|
||||
expect(generalSettingsPanelSource).toContain('settings.general.telemetry.payloadAriaLabel');
|
||||
expect(generalSettingsPanelSource).toContain('settings.general.telemetry.resetId');
|
||||
@@ -627,7 +693,7 @@ describe('settings architecture guardrails', () => {
|
||||
});
|
||||
|
||||
it('keeps Patrol tool-call preflight wired through the canonical settings state', () => {
|
||||
// The Verify Patrol button must drive the canonical
|
||||
// The Check Patrol model button must drive the canonical
|
||||
// /api/ai/patrol/preflight endpoint via the typed runPatrolPreflight
|
||||
// client and surface the result through state, not via inline fetch
|
||||
// calls in the section component.
|
||||
@@ -643,7 +709,7 @@ describe('settings architecture guardrails', () => {
|
||||
expect(aiModelSelectionSectionSource).not.toContain("fetch('/api/ai/patrol/preflight");
|
||||
});
|
||||
|
||||
it('keeps workload discovery manual refresh on the canonical settings state', () => {
|
||||
it('keeps service context manual refresh on the canonical settings state', () => {
|
||||
expect(aiSettingsStateSource).toContain(
|
||||
"import { runDiscoveryRefresh } from '@/api/discovery';",
|
||||
);
|
||||
@@ -651,20 +717,18 @@ describe('settings architecture guardrails', () => {
|
||||
expect(aiSettingsStateSource).toContain('const result = await runDiscoveryRefresh();');
|
||||
expect(aiSettingsStateSource).toContain('discoveryRunRunning');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('state.handleRunDiscoveryRefresh()');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Run discovery now');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Run context scan');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Auto ${state.form.discoveryIntervalHours}h');
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Manual only');
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Automatic workload scans will run at this interval.',
|
||||
'Recurring service context scans will run at this interval.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Automatic workload scans are off. Only manual refreshes will run.',
|
||||
'Recurring service context scans are off. Only manual refreshes will run.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain('Runs the same scan used by the schedule.');
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Runs the pending workload sweep used by the schedule.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).toContain(
|
||||
'Manual-only mode: runs the pending workload sweep once without enabling recurring scans.',
|
||||
'Manual-only mode: runs one scan without enabling recurring scans.',
|
||||
);
|
||||
expect(aiRuntimeControlsSectionSource).not.toContain("fetch('/api/discovery/run");
|
||||
});
|
||||
@@ -681,8 +745,8 @@ describe('settings architecture guardrails', () => {
|
||||
expect(aiModelSelectionSectionSource).toContain('last verified');
|
||||
});
|
||||
|
||||
it("passes the form's pending patrolModel to runPatrolPreflight so Verify Patrol tests the unsaved selection", () => {
|
||||
// Without this, clicking Verify Patrol after changing the model
|
||||
it("passes the form's pending patrolModel to runPatrolPreflight so the model check tests the unsaved selection", () => {
|
||||
// Without this, clicking Check Patrol model after changing the model
|
||||
// dropdown silently tested the previously-saved model and the
|
||||
// operator would believe their pending selection was verified.
|
||||
expect(aiSettingsStateSource).toContain('form.patrolModel');
|
||||
@@ -702,7 +766,7 @@ describe('settings architecture guardrails', () => {
|
||||
expect(aiModelSelectionSectionSource).toContain('cachedResultModel');
|
||||
expect(aiModelSelectionSectionSource).toContain('Verified result is for');
|
||||
expect(aiModelSelectionSectionSource).toContain(
|
||||
'Click Verify Patrol to test the pending selection',
|
||||
'Click Check Patrol model to test the pending selection',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -912,12 +976,25 @@ describe('settings architecture guardrails', () => {
|
||||
expect(aiModelSelectionSectionSource).not.toContain('<select');
|
||||
expect(aiModelSelectionSectionSource).not.toContain('<optgroup');
|
||||
|
||||
expect(aiModelSelectionSectionSource).toContain('Pulse Assistant Model');
|
||||
expect(aiModelSelectionSectionSource).toContain('Patrol Verification Model');
|
||||
expect(aiModelSelectionSectionSource).toContain('Discovery Model');
|
||||
expect(aiModelSelectionSectionSource).toContain('chatModelOptions');
|
||||
expect(aiModelSelectionSectionSource).toContain('patrolModelOptions');
|
||||
expect(aiModelSelectionSectionSource).toContain('discoveryModelOptions');
|
||||
expect(aiModelSelectionSectionSource).toContain('type AIModelOverrideKind');
|
||||
expect(aiModelSelectionSectionSource).toContain("formKey: 'chatModel' | 'patrolModel'");
|
||||
expect(aiModelSelectionSectionSource).toContain("label: 'Pulse Assistant model'");
|
||||
expect(aiModelSelectionSectionSource).toContain(
|
||||
'Used for chat, explanations, and review. Patrol handles infrastructure work.',
|
||||
);
|
||||
expect(aiModelSelectionSectionSource).not.toContain('approved fix execution');
|
||||
expect(aiModelSelectionSectionSource).toContain("label: 'Patrol model'");
|
||||
expect(aiModelSelectionSectionSource).toContain("label: 'Service context model'");
|
||||
expect(aiModelSelectionSectionSource).toContain('export const AIModelOverrideField');
|
||||
expect(aiSettingsSource).toContain(
|
||||
'<AIModelOverrideField state={props.state} kind="assistant" />',
|
||||
);
|
||||
expect(aiSettingsSource).toContain(
|
||||
'<AIModelOverrideField state={props.state} kind="patrol" includePatrolPreflight />',
|
||||
);
|
||||
expect(aiSettingsSource).toContain(
|
||||
'<AIModelOverrideField state={props.state} kind="discovery" />',
|
||||
);
|
||||
expect(aiSettingsStateSource).toContain('discoveryModel:');
|
||||
expect(aiSettingsStateSource).toContain('payload.discovery_model = form.discoveryModel');
|
||||
|
||||
@@ -930,13 +1007,25 @@ describe('settings architecture guardrails', () => {
|
||||
});
|
||||
|
||||
it('keeps system AI save feedback tied to provider and Patrol readiness context', () => {
|
||||
expect(aiSettingsSource).toContain('Choose a Patrol mode on the Patrol page.');
|
||||
expect(aiSettingsSource).toContain('Open Patrol');
|
||||
expect(aiSettingsSource).toContain("const isProviderPage = () => page() === 'provider';");
|
||||
expect(aiSettingsSource).toContain('showConnectionControls={isProviderPage()}');
|
||||
expect(aiSettingsStatusAndActionsSource).toContain('showConnectionControls?: boolean');
|
||||
expect(aiSettingsStatusAndActionsSource).toContain(
|
||||
'props.showConnectionControls && state.settings()?.configured',
|
||||
);
|
||||
expect(aiSettingsSource).not.toContain(
|
||||
'Set how much Patrol can do on its own from the Patrol page',
|
||||
);
|
||||
expect(aiSettingsSource).not.toContain('Patrol autonomy is the operations policy');
|
||||
expect(aiSettingsStateSource).toContain(
|
||||
"import { apiErrorDetails } from '@/api/responseUtils';",
|
||||
);
|
||||
expect(aiSettingsStateSource).toContain('resolveAISettingsSaveProviderFailure');
|
||||
expect(aiSettingsStateSource).toContain('getAISettingsPatrolReadinessSaveMessage(');
|
||||
expect(aiSettingsStateSource).toContain(
|
||||
"getAISettingsPatrolReadinessSaveMessage(\n updated.patrol_readiness,\n 'Assistant & Patrol enabled',",
|
||||
"getAISettingsPatrolReadinessSaveMessage(\n updated.patrol_readiness,\n 'Pulse Intelligence enabled',",
|
||||
);
|
||||
expect(aiSettingsStateSource).toContain('notificationStore.warning(patrolReadinessMessage);');
|
||||
expect(aiSettingsStateSource).toContain('getAISettingsSaveProviderFailureMessage(');
|
||||
@@ -1161,19 +1250,187 @@ describe('settings architecture guardrails', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes the agent integrations panel under API Access without growing the settings tab inventory', () => {
|
||||
// The agent integrations panel sits as a sibling section
|
||||
// under the existing API Access tab rather than as its own
|
||||
// navigation entry. This pin keeps the operator's mental
|
||||
// model coherent (one tab for machine-driven access:
|
||||
// tokens + what those tokens unlock) and prevents drift
|
||||
// toward fragmenting the agent surface across tabs.
|
||||
expect(apiAccessPanelSource).toContain(
|
||||
"import AgentIntegrationsPanel from './AgentIntegrationsPanel';",
|
||||
it('exposes external-agent setup from Pulse Intelligence while API Access stays token-only', () => {
|
||||
// External agents are a Pulse Intelligence access path over the same
|
||||
// governed Patrol policy, not a generic Security API feature. API Access
|
||||
// remains the place to mint the scoped credential when setup needs one.
|
||||
expect(aiSettingsSource).toContain(
|
||||
"import AgentIntegrationsPanel from '@/components/Settings/AgentIntegrationsPanel';",
|
||||
);
|
||||
expect(apiAccessPanelSource).toContain('<AgentIntegrationsPanel />');
|
||||
expect(aiSettingsSource).toContain(
|
||||
"const showExternalAgentAccess = () => page() === 'assistant';",
|
||||
);
|
||||
expect(aiSettingsSource).toContain('<AgentIntegrationsPanel />');
|
||||
expect(apiAccessPanelSource).not.toContain('AgentIntegrationsPanel');
|
||||
expect(apiAccessPanelSource).not.toContain('isExternalAgentSetupHash');
|
||||
expect(apiAccessPanelSource).not.toContain('api-access-external-agent-section');
|
||||
expect(apiAccessPanelSource).toContain('api-access-token-section');
|
||||
expect(agentIntegrationsPanelSource).toContain('ExternalTextLink');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('rel="noreferrer"');
|
||||
expect(agentIntegrationsPanelSource).toContain('External agents');
|
||||
expect(agentIntegrationsPanelSource).toContain('Connect external tools');
|
||||
expect(agentIntegrationsPanelSource).toContain('read Pulse context');
|
||||
expect(agentIntegrationsPanelSource).toContain('request Patrol work');
|
||||
expect(agentIntegrationsPanelSource).toContain('Patrol mode and scoped tokens control');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Optional connector access for Claude Desktop');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Patrol remains the operator');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Connected tools');
|
||||
expect(agentIntegrationsPanelSource).toContain('setupOpen');
|
||||
expect(agentIntegrationsPanelSource).toContain('Show connector setup');
|
||||
expect(agentIntegrationsPanelSource).toContain('Hide connector setup');
|
||||
expect(agentIntegrationsPanelSource).toContain('<Show when={!setupOpen()}>');
|
||||
expect(agentIntegrationsPanelSource).not.toContain(
|
||||
[
|
||||
'Choose',
|
||||
'what',
|
||||
'Patrol',
|
||||
'may',
|
||||
'handle',
|
||||
'automatically',
|
||||
'before',
|
||||
'any',
|
||||
'external',
|
||||
'agent',
|
||||
'can',
|
||||
].join(' '),
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain(
|
||||
'Required before agents can request Patrol work.',
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).not.toContain(
|
||||
'Set the Patrol mode before connected agents can request work.',
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain('Choose Patrol mode');
|
||||
expect(agentIntegrationsPanelSource).toContain('PATROL_CONTROL_PATH');
|
||||
expect(agentIntegrationsPanelSource).not.toContain("settingsTabPath('system-ai')");
|
||||
expect(agentIntegrationsPanelSource).toContain('EXTERNAL_AGENT_SETUP_ANCHOR');
|
||||
expect(agentIntegrationsPanelSource).toContain('PULSE_MCP_SETUP_ANCHOR');
|
||||
expect(agentIntegrationsPanelSource).toContain('focusPanelUntilLayoutSettles');
|
||||
expect(agentIntegrationsPanelSource).toContain('findScrollableAncestor');
|
||||
expect(agentIntegrationsPanelSource).toContain(
|
||||
'document.getElementById(EXTERNAL_AGENT_SETUP_ANCHOR)',
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain('window.history.replaceState');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Use Pulse MCP only');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('outside client');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Pulse Intelligence surfaces');
|
||||
expect(agentIntegrationsPanelSource).toContain('Connector setup');
|
||||
expect(agentIntegrationsPanelSource).toContain('Connect the agent');
|
||||
expect(agentIntegrationsPanelSource).toContain('Developer details');
|
||||
expect(agentIntegrationsPanelSource).toContain('advancedClientDetailsOpen');
|
||||
expect(agentIntegrationsPanelSource).toContain(
|
||||
'Only open this when you are building or debugging a client',
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain('Patrol access model');
|
||||
expect(agentIntegrationsPanelSource).toContain(
|
||||
'Built-in Pulse views and connected clients all sit behind',
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain('Live manifest details');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Build a custom client');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Instance manifest details');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('MCP client config');
|
||||
expect(agentIntegrationsPanelSource).toContain('normalizeAgentMCPAdapter');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('getAgentMCPClientExamples');
|
||||
expect(agentIntegrationsPanelSource).toContain('getAgentWorkflowPrompts');
|
||||
expect(agentIntegrationsPanelSource).toContain('Agent starting points');
|
||||
expect(agentIntegrationsPanelSource).toContain('Patrol');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Operations loop');
|
||||
expect(agentIntegrationsPanelSource).toContain('OpenCode');
|
||||
expect(agentIntegrationsPanelSource).toContain(
|
||||
"import { Button, ButtonLink } from '@/components/shared/Button';",
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain(
|
||||
"import KeyRoundIcon from 'lucide-solid/icons/key-round';",
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain('API_TOKEN_PATROL_EXTERNAL_AGENT_PRESET_LABEL');
|
||||
expect(agentIntegrationsPanelSource).toContain('PULSE_MCP_TOKEN_SETUP_PATH');
|
||||
expect(agentIntegrationsPanelSource).toContain('hardNavigation');
|
||||
expect(apiTokenManagerSource).toContain('API_TOKEN_CREATE_ANCHOR');
|
||||
expect(agentIntegrationsPanelSource).toContain('formatAgentOpenCodeMCPConfig');
|
||||
expect(agentIntegrationsPanelSource).toContain('formatAgentMCPServersConfig');
|
||||
expect(agentIntegrationsPanelSource).toContain('Installer commands');
|
||||
expect(agentIntegrationsPanelSource).toContain('installerCommandsOpen');
|
||||
expect(agentIntegrationsPanelSource).toContain('mcpInstallShellCommand');
|
||||
expect(agentIntegrationsPanelSource).toContain('mcpInstallPowerShellCommand');
|
||||
expect(agentIntegrationsPanelSource).toContain('Client config');
|
||||
expect(agentIntegrationsPanelSource).toContain('clientConfigOpen');
|
||||
expect(agentIntegrationsPanelSource).toContain('Install the connector');
|
||||
expect(agentIntegrationsPanelSource).toContain('paste the client config');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('The installer and');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('on the machine that runs the external tool');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Install the Pulse MCP bridge');
|
||||
expect(agentIntegrationsPanelSource).not.toContain(
|
||||
'Use the OpenCode or Claude-style snippet below',
|
||||
);
|
||||
expect(agentIntegrationsPanelSource).toContain('opencode.json');
|
||||
expect(agentCapabilitiesApiSource).toContain("type: 'local'");
|
||||
expect(agentCapabilitiesApiSource).toContain('environment');
|
||||
expect(agentIntegrationsPanelSource).toContain('mcpServersConfigFamily');
|
||||
expect(agentCapabilitiesApiSource).toContain('custom clients');
|
||||
expect(agentIntegrationsPanelSource).toContain('requiredScopes');
|
||||
expect(agentIntegrationsPanelSource).toContain('Manifest scopes');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('published contract');
|
||||
expect(agentIntegrationsPanelSource).toContain('External agents expose');
|
||||
expect(agentCapabilitiesApiSource).toContain('surfaceLabel,');
|
||||
expect(agentIntegrationsPanelSource).toContain('capability below shows its required scope');
|
||||
expect(agentIntegrationsPanelSource).toContain('getAgentSurfaceContractEntries');
|
||||
expect(agentIntegrationsPanelSource).toContain('surfaceContractEntries');
|
||||
expect(agentIntegrationsPanelSource).toContain('Built-in Pulse views');
|
||||
expect(agentCapabilitiesApiSource).toContain('AgentSurfaceAffordanceContract');
|
||||
expect(agentCapabilitiesApiSource).toContain('AgentSurfaceToolContract');
|
||||
expect(agentCapabilitiesApiSource).toContain('normalizeSurfaceAffordances');
|
||||
expect(agentCapabilitiesApiSource).toContain('surfaceAffordanceLabels');
|
||||
expect(agentCapabilitiesApiSource).toContain('AGENT_SURFACE_ID_PULSE_MCP');
|
||||
expect(agentCapabilitiesApiSource).toContain('getAgentManifestSurfaceToolContract');
|
||||
expect(agentCapabilitiesApiSource).toContain('getAgentManifestSurfaceToolContracts');
|
||||
expect(agentIntegrationsPanelSource).toContain('AGENT_SURFACE_ID_PULSE_MCP');
|
||||
expect(agentIntegrationsPanelSource).toContain('getAgentManifestSurfaceToolContract');
|
||||
expect(agentIntegrationsPanelSource).toContain('getAgentSurfaceToolPosturePresentation');
|
||||
expect(agentIntegrationsPanelSource).toContain('mcpSurfaceToolPosture');
|
||||
expect(agentIntegrationsPanelSource).toContain('data-testid="agent-mcp-tool-posture"');
|
||||
expect(agentIntegrationsPanelSource).toContain('External agents expose {posture().label}');
|
||||
expect(agentCapabilitiesApiSource).toContain('capability availability');
|
||||
expect(agentCapabilitiesApiSource).toContain("if (affordances.tools) labels.push('Actions')");
|
||||
expect(agentCapabilitiesApiSource).not.toContain('runtime tool availability');
|
||||
expect(agentIntegrationsPanelSource).toContain('getAgentCapabilityErrorCodeSummaries');
|
||||
expect(agentIntegrationsPanelSource).toContain('errorCodeSummaries');
|
||||
expect(agentIntegrationsPanelSource).toContain('Failure codes');
|
||||
expect(agentIntegrationsPanelSource).toContain('from the live manifest');
|
||||
expect(agentIntegrationsPanelSource).toContain('groupAgentCapabilitiesByManifestCategories');
|
||||
expect(agentIntegrationsPanelSource).toContain("from '@/api/agentCapabilities'");
|
||||
expect(agentIntegrationsPanelSource).not.toContain("fetch('/api/agent/capabilities'");
|
||||
expect(agentCapabilitiesApiSource).toContain(
|
||||
"export const AGENT_CAPABILITIES_PATH = '/api/agent/capabilities'",
|
||||
);
|
||||
expect(agentCapabilitiesApiSource).toContain("from './generated/agentCapabilities'");
|
||||
expect(agentCapabilitiesApiSource).toContain('skipAuth: true');
|
||||
expect(agentCapabilitiesApiSource).toContain('manifest.categories');
|
||||
expect(agentCapabilitiesApiSource).toContain('AgentMCPAdapterContract');
|
||||
expect(agentCapabilitiesApiSource).toContain('manifest?.surfaceToolContracts');
|
||||
expect(agentCapabilitiesApiSource).toContain('manifest?.mcpAdapter');
|
||||
expect(agentCapabilitiesApiSource).toContain('getAgentMCPClientExamples');
|
||||
expect(agentCapabilitiesApiSource).toContain('formatAgentOpenCodeMCPConfig');
|
||||
expect(agentCapabilitiesApiSource).toContain('formatAgentMCPServersConfig');
|
||||
expect(agentCapabilitiesApiSource).toContain('getAgentCapabilityErrorCodeSummaries');
|
||||
expect(agentCapabilitiesApiSource).not.toContain('export interface AgentCapability {');
|
||||
expect(agentCapabilitiesApiSource).not.toContain(
|
||||
'export interface AgentCapabilitiesManifest {',
|
||||
);
|
||||
expect(agentCapabilitiesApiSource).not.toContain('getAgentMCPSurfaceToolContract');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('MCP_CLIENT_EXAMPLES');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('function formatClaudeMcpConfig');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('function formatOpenCodeMcpConfig');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('CATEGORY_ORDER');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Claude Desktop / Claude Code config');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('any MCP client that accepts');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('getAgentMCPSurfaceToolContract');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('subscribe_events');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('capability.name');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Interactive questions');
|
||||
expect(agentIntegrationsPanelSource).not.toContain('Capability metadata');
|
||||
expect(agentIntegrationsPanelSource).not.toContain(
|
||||
'monitoring:write for the operator-state write tools',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps internal analytics off the user diagnostics boundary', () => {
|
||||
@@ -1182,8 +1439,15 @@ describe('settings architecture guardrails', () => {
|
||||
expect(diagnosticsResultsPanelSource).not.toContain('commercialFunnel');
|
||||
expect(diagnosticsResultsPanelSource).not.toContain('infrastructureOnboarding');
|
||||
expect(diagnosticsResultsPanelSource).not.toContain("apiFetchJSON('/api/diagnostics')");
|
||||
expect(diagnosticsResultsPanelSource).toContain('Assistant runtime');
|
||||
expect(diagnosticsResultsPanelSource).toContain('assistantRuntimeConnected');
|
||||
expect(diagnosticsResultsPanelSource).not.toContain('MCP Connection');
|
||||
expect(diagnosticsResultsPanelSource).not.toContain('mcpConnected');
|
||||
|
||||
expect(diagnosticsModelSource).toContain('stripInternalAnalyticsDiagnosticsFields');
|
||||
expect(diagnosticsModelSource).toContain('assistantRuntimeConnected');
|
||||
expect(diagnosticsModelSource).not.toContain('mcpConnected');
|
||||
expect(diagnosticsModelSource).not.toContain('mcpToolCount');
|
||||
expect(diagnosticsModelSource).not.toContain('export interface CommercialFunnelDiagnostic');
|
||||
expect(diagnosticsModelSource).not.toContain('export interface CommercialFunnelSummary');
|
||||
expect(diagnosticsModelSource).not.toContain(
|
||||
|
||||
@@ -19,9 +19,13 @@ describe('settings localization catalog', () => {
|
||||
it('keeps the canonical English settings catalog as the static baseline', () => {
|
||||
expect(SETTINGS_NAV_GROUPS[0]?.label).toBe('Infrastructure');
|
||||
expect(SETTINGS_HEADER_META['system-billing']).toEqual({
|
||||
title: 'Self-hosted plan',
|
||||
title: 'Plans & Billing',
|
||||
description: 'Plan, license, and Patrol mode for this instance.',
|
||||
});
|
||||
expect(SETTINGS_HEADER_META['system-ai-discovery']).toEqual({
|
||||
title: 'Service Context',
|
||||
description:
|
||||
'Review the plan this instance is using and the optional capabilities connected to it.',
|
||||
'Configure the model-backed service context Assistant and Patrol use. Infrastructure discovery and onboarding stay under Infrastructure.',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,13 +34,17 @@ describe('settings localization catalog', () => {
|
||||
expect(groups[0]?.label).toBe('Infrastruktur');
|
||||
expect(groups[0]?.items[0]?.label).toBe('Infrastruktur');
|
||||
expect(getSettingsNavItem('system-updates', 'de')?.label).toBe('Updates');
|
||||
expect(getSettingsNavItem('system-ai-discovery', 'de')?.label).toBe('Service-Kontext');
|
||||
expect(getSettingsNavItem('security-data-handling', 'de')?.label).toBe('Ressourcenschutz');
|
||||
});
|
||||
|
||||
it('localizes settings navigation groups and items for Spanish', () => {
|
||||
const groups = getSettingsNavGroups('es');
|
||||
expect(groups.find((group) => group.id === 'system')?.label).toBe('Sistema');
|
||||
expect(getSettingsNavItem('system-ai', 'es')?.label).toBe('Assistant y Patrol');
|
||||
expect(getSettingsNavItem('system-ai', 'es')?.label).toBe('Proveedores y modelos');
|
||||
expect(getSettingsNavItem('system-ai-patrol', 'es')?.label).toBe('Patrol');
|
||||
expect(getSettingsNavItem('system-ai-assistant', 'es')?.label).toBe('Assistant');
|
||||
expect(getSettingsNavItem('system-ai-discovery', 'es')?.label).toBe('Contexto de servicio');
|
||||
expect(getSettingsNavItem('support-diagnostics', 'es')?.label).toBe('Diagnóstico y salud');
|
||||
});
|
||||
|
||||
@@ -46,6 +54,38 @@ describe('settings localization catalog', () => {
|
||||
title: 'Red',
|
||||
description: 'Configura la URL pública, CORS, inserción y límites de red para webhooks.',
|
||||
});
|
||||
expect(spanishMeta['system-ai']).toEqual({
|
||||
title: 'Proveedores y modelos',
|
||||
description:
|
||||
'Configura proveedores, modelos predeterminados, salud de proveedores, presupuesto y uso para Pulse Intelligence.',
|
||||
});
|
||||
expect(spanishMeta['system-ai-assistant']).toEqual({
|
||||
title: 'Assistant',
|
||||
description:
|
||||
'Configura el comportamiento del chat, los permisos de acciones y las sesiones del Assistant.',
|
||||
});
|
||||
expect(spanishMeta['system-billing']).toEqual({
|
||||
title: 'Planes y facturacion',
|
||||
description: 'Plan, licencia y modo de Patrol para esta instancia.',
|
||||
});
|
||||
expect(getSettingsHeaderMeta('de')['system-ai']).toEqual({
|
||||
title: 'Anbieter & Modelle',
|
||||
description:
|
||||
'Konfigurieren Sie Anbieter, Standardmodelle, Anbieterzustand, Budget und Nutzung fuer Pulse Intelligence.',
|
||||
});
|
||||
expect(getSettingsHeaderMeta('de')['system-ai-discovery']).toEqual({
|
||||
title: 'Service-Kontext',
|
||||
description:
|
||||
'Konfigurieren Sie den KI-gestuetzten Service-Kontext fuer Assistant und Patrol. Infrastruktur-Erkennung und Onboarding bleiben unter Infrastruktur.',
|
||||
});
|
||||
expect(getSettingsHeaderMeta('de')['system-ai-assistant']).toEqual({
|
||||
title: 'Assistant',
|
||||
description: 'Konfigurieren Sie Chatverhalten, Aktionsrechte und Sitzungen des Assistant.',
|
||||
});
|
||||
expect(getSettingsHeaderMeta('de')['system-billing']).toEqual({
|
||||
title: 'Plaene & Abrechnung',
|
||||
description: 'Plan, Lizenz und Patrol-Modus fuer diese Instanz.',
|
||||
});
|
||||
expect(getInfrastructureReadOnlyHeaderDescription('es')).toContain('solo lectura');
|
||||
});
|
||||
|
||||
|
||||
+9
-5
@@ -12,9 +12,12 @@ const canonicalTabPaths = {
|
||||
'system-network': '/settings/system-network',
|
||||
'system-updates': '/settings/system-updates',
|
||||
'system-recovery': '/settings/system-recovery',
|
||||
'system-ai': '/settings/system-ai',
|
||||
'system-ai': '/settings/pulse-intelligence/provider',
|
||||
'system-ai-patrol': '/settings/pulse-intelligence/patrol',
|
||||
'system-ai-assistant': '/settings/pulse-intelligence/assistant',
|
||||
'system-ai-discovery': '/settings/pulse-intelligence/discovery',
|
||||
'system-relay': '/settings/system-relay',
|
||||
'system-billing': '/settings/system/billing/plan',
|
||||
'system-billing': '/settings/pulse-intelligence/billing/plan',
|
||||
'support-diagnostics': '/settings/support/diagnostics',
|
||||
'support-reporting': '/settings/support/reporting',
|
||||
'support-logs': '/settings/support/logs',
|
||||
@@ -111,10 +114,11 @@ describe('settingsNavigation integration scaffold', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps self-hosted billing as a direct route instead of a sidebar item', () => {
|
||||
it('keeps self-hosted billing discoverable from Pulse Intelligence settings', () => {
|
||||
expect(deriveTabFromPath('/settings/pulse-intelligence/billing/plan')).toBe('system-billing');
|
||||
expect(deriveTabFromPath('/settings/system/billing/plan')).toBe('system-billing');
|
||||
expect(settingsTabPath('system-billing')).toBe('/settings/system/billing/plan');
|
||||
expect(getSettingsNavItem('system-billing')?.hideFromSidebar).toBe(true);
|
||||
expect(settingsTabPath('system-billing')).toBe('/settings/pulse-intelligence/billing/plan');
|
||||
expect(getSettingsNavItem('system-billing')?.hideFromSidebar).not.toBe(true);
|
||||
});
|
||||
|
||||
it('keeps resource privacy route-backed but out of the normal sidebar', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_SETTINGS_TAB,
|
||||
deriveTabFromPath,
|
||||
deriveTabFromQuery,
|
||||
isAISettingsOAuthCallbackQuery,
|
||||
isRetiredSettingsCompatibilityPath,
|
||||
isRouteableSettingsLocation,
|
||||
isRouteableSettingsPath,
|
||||
@@ -23,9 +24,12 @@ const canonicalTabPaths = {
|
||||
'system-network': '/settings/system-network',
|
||||
'system-updates': '/settings/system-updates',
|
||||
'system-recovery': '/settings/system-recovery',
|
||||
'system-ai': '/settings/system-ai',
|
||||
'system-ai': '/settings/pulse-intelligence/provider',
|
||||
'system-ai-patrol': '/settings/pulse-intelligence/patrol',
|
||||
'system-ai-assistant': '/settings/pulse-intelligence/assistant',
|
||||
'system-ai-discovery': '/settings/pulse-intelligence/discovery',
|
||||
'system-relay': '/settings/system-relay',
|
||||
'system-billing': '/settings/system/billing/plan',
|
||||
'system-billing': '/settings/pulse-intelligence/billing/plan',
|
||||
'support-diagnostics': '/settings/support/diagnostics',
|
||||
'support-reporting': '/settings/support/reporting',
|
||||
'support-logs': '/settings/support/logs',
|
||||
@@ -82,6 +86,18 @@ describe('settingsNavigationModel', () => {
|
||||
expect(resolveCanonicalSettingsPath('/settings/monitoring/availability')).toBe(
|
||||
'/settings/monitoring/availability',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/pulse-intelligence')).toBe(
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/pulse-intelligence/provider')).toBe(
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/system-ai')).toBe(
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/pulse-intelligence/patrol')).toBe(
|
||||
'/settings/pulse-intelligence/patrol',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/infrastructure/install')).toBeNull();
|
||||
expect(resolveCanonicalSettingsPath('/settings/infrastructure/platforms')).toBeNull();
|
||||
expect(resolveCanonicalSettingsPath('/settings/infrastructure/platforms/truenas')).toBeNull();
|
||||
@@ -93,8 +109,17 @@ describe('settingsNavigationModel', () => {
|
||||
expect(resolveCanonicalSettingsPath('/settings/operations')).toBeNull();
|
||||
expect(resolveCanonicalSettingsPath('/settings/operations/reporting')).toBeNull();
|
||||
expect(resolveCanonicalSettingsPath('/settings/operations/logs')).toBeNull();
|
||||
expect(resolveCanonicalSettingsPath('/settings/pulse-intelligence/billing')).toBe(
|
||||
'/settings/pulse-intelligence/billing/plan',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/system/billing')).toBe(
|
||||
'/settings/system/billing/plan',
|
||||
'/settings/pulse-intelligence/billing/plan',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/system/billing/plan')).toBe(
|
||||
'/settings/pulse-intelligence/billing/plan',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/system/billing/usage')).toBe(
|
||||
'/settings/pulse-intelligence/billing/usage',
|
||||
);
|
||||
expect(resolveCanonicalSettingsPath('/settings/system-pro')).toBeNull();
|
||||
expect(resolveCanonicalSettingsPath('/not-settings')).toBeNull();
|
||||
@@ -127,6 +152,15 @@ describe('settingsNavigationModel', () => {
|
||||
expect(isRouteableSettingsPath('/settings')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/infrastructure')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/monitoring/availability')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/pulse-intelligence/provider')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/system-ai')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/pulse-intelligence/patrol')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/pulse-intelligence/assistant')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/pulse-intelligence/discovery')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/pulse-intelligence/billing/plan')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/pulse-intelligence/billing/usage')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/system/billing/plan')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/system/billing/usage')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/security/api')).toBe(true);
|
||||
expect(isRouteableSettingsPath('/settings/unknown')).toBe(false);
|
||||
});
|
||||
@@ -157,6 +191,8 @@ describe('settingsNavigationModel', () => {
|
||||
});
|
||||
|
||||
it('maps plan and usage billing subroutes back to the billing tab', () => {
|
||||
expect(deriveTabFromPath('/settings/pulse-intelligence/billing/plan')).toBe('system-billing');
|
||||
expect(deriveTabFromPath('/settings/pulse-intelligence/billing/usage')).toBe('system-billing');
|
||||
expect(deriveTabFromPath('/settings/system/billing/plan')).toBe('system-billing');
|
||||
expect(deriveTabFromPath('/settings/system/billing/usage')).toBe('system-billing');
|
||||
});
|
||||
@@ -167,6 +203,11 @@ describe('settingsNavigationModel', () => {
|
||||
['?tab=availability', 'monitoring-availability'],
|
||||
['?tab=monitoring-availability', 'monitoring-availability'],
|
||||
['?tab=system-ai', 'system-ai'],
|
||||
['?tab=patrol', 'system-ai-patrol'],
|
||||
['?tab=assistant', 'system-ai-assistant'],
|
||||
['?tab=discovery', 'system-ai-discovery'],
|
||||
['?ai_oauth_error=unsupported', 'system-ai'],
|
||||
['?ai_oauth_success=true', 'system-ai'],
|
||||
['?tab=system-relay', 'system-relay'],
|
||||
['?tab=system-billing', 'system-billing'],
|
||||
['?tab=diagnostics', 'support-diagnostics'],
|
||||
@@ -189,6 +230,13 @@ describe('settingsNavigationModel', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('detects Assistant OAuth callback queries so the settings shell preserves them', () => {
|
||||
expect(isAISettingsOAuthCallbackQuery('?ai_oauth_error=unsupported')).toBe(true);
|
||||
expect(isAISettingsOAuthCallbackQuery('?ai_oauth_success=true')).toBe(true);
|
||||
expect(isAISettingsOAuthCallbackQuery('?tab=system-ai')).toBe(false);
|
||||
expect(isAISettingsOAuthCallbackQuery('')).toBe(false);
|
||||
});
|
||||
|
||||
it('evaluates feature-lock behavior contracts', () => {
|
||||
expect(isFeatureLocked(undefined, hasFeatures([]), () => true)).toBe(false);
|
||||
expect(isFeatureLocked([], hasFeatures([]), () => true)).toBe(false);
|
||||
|
||||
@@ -55,6 +55,50 @@ describe('useSettingsNavigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves Assistant OAuth callback queries while routing /settings to Provider & Models', async () => {
|
||||
renderHarness('/settings', '?ai_oauth_error=unsupported');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateSpy).toHaveBeenCalledWith(
|
||||
'/settings/pulse-intelligence/provider?ai_oauth_error=unsupported',
|
||||
{
|
||||
replace: true,
|
||||
scroll: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('routes legacy external-agent API hashes to Pulse Intelligence Assistant', async () => {
|
||||
for (const hash of ['#external-agent-setup', '#pulse-mcp-setup']) {
|
||||
cleanup();
|
||||
navigateSpy.mockReset();
|
||||
renderHarness('/settings/security/api', '', hash);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateSpy).toHaveBeenCalledWith(
|
||||
'/settings/pulse-intelligence/assistant#external-agent-setup',
|
||||
{
|
||||
replace: true,
|
||||
scroll: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps token creation preset links on API Access', async () => {
|
||||
renderHarness(
|
||||
'/settings/security/api',
|
||||
'?tokenPreset=pulse-intelligence-agent',
|
||||
'#api-token-create',
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('routes setup-oriented infrastructure tab clicks back to systems when the session is read-only', () => {
|
||||
presentationPolicyIsReadOnlyMock.mockReturnValue(true);
|
||||
renderHarness('/settings/system-general');
|
||||
|
||||
+116
-127
@@ -1,5 +1,6 @@
|
||||
import { createRoot, createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TelemetryPingPreview } from '@/api/settings';
|
||||
import type { SettingsTab } from '../settingsNavigationModel';
|
||||
|
||||
type UseSystemSettingsStateModule = typeof import('../useSystemSettingsState');
|
||||
@@ -9,6 +10,111 @@ const flushAsync = async () => {
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const buildTelemetryPreviewPayload = (
|
||||
overrides: Partial<TelemetryPingPreview> = {},
|
||||
): TelemetryPingPreview => ({
|
||||
install_id: 'preview-install-id',
|
||||
version: '6.0.0',
|
||||
version_channel: 'stable',
|
||||
version_is_development: false,
|
||||
version_is_published_release: true,
|
||||
platform: 'docker',
|
||||
os: 'linux',
|
||||
arch: 'amd64',
|
||||
event: 'heartbeat',
|
||||
pve_nodes: 1,
|
||||
pbs_instances: 0,
|
||||
pmg_instances: 0,
|
||||
vms: 2,
|
||||
containers: 3,
|
||||
agent_hosts: 1,
|
||||
docker_hosts: 0,
|
||||
docker_containers: 0,
|
||||
kubernetes_clusters: 0,
|
||||
kubernetes_nodes: 0,
|
||||
kubernetes_pods: 0,
|
||||
kubernetes_deployments: 0,
|
||||
storage_pools: 1,
|
||||
physical_disks: 2,
|
||||
ceph_clusters: 0,
|
||||
network_shares: 0,
|
||||
truenas_systems: 0,
|
||||
truenas_vms: 0,
|
||||
truenas_apps: 0,
|
||||
vmware_hosts: 0,
|
||||
vmware_vms: 0,
|
||||
vmware_datastores: 0,
|
||||
availability_targets: 1,
|
||||
ai_enabled: false,
|
||||
patrol_enabled: false,
|
||||
discovery_enabled: false,
|
||||
notifications_enabled: false,
|
||||
ai_actions_enabled: false,
|
||||
active_alerts: 0,
|
||||
relay_enabled: false,
|
||||
sso_enabled: false,
|
||||
multi_tenant: false,
|
||||
paid_license: false,
|
||||
has_api_tokens: true,
|
||||
pulse_intelligence_loop_configured: false,
|
||||
pulse_intelligence_loop_active_30d: false,
|
||||
pulse_intelligence_complete_operations_loop_30d: false,
|
||||
pulse_intelligence_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_paid_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_patrol_control_paid_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_paid_completed_operations_loop_30d: false,
|
||||
pulse_intelligence_pro_activation_paid_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_governed_action_active_30d: false,
|
||||
pulse_intelligence_assistant_operations_loop_30d: false,
|
||||
pulse_intelligence_assistant_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_assistant_approved_action_success_loop_30d: false,
|
||||
pulse_intelligence_assistant_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_external_agent_operations_loop_30d: false,
|
||||
pulse_intelligence_external_agent_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_external_agent_approved_action_success_loop_30d: false,
|
||||
pulse_intelligence_external_agent_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_operations_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_approved_execution_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_approved_action_success_loop_30d: false,
|
||||
pulse_intelligence_mcp_adapter_resolved_operations_loop_30d: false,
|
||||
pulse_intelligence_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_assistant_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_patrol_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_patrol_control_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_pro_activation_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_mcp_operations_loop_starter_requests_30d: 0,
|
||||
pulse_intelligence_assistant_ai_calls_30d: 0,
|
||||
pulse_intelligence_assistant_context_ai_calls_30d: 0,
|
||||
pulse_intelligence_assistant_tool_calls_30d: 0,
|
||||
pulse_intelligence_patrol_ai_calls_30d: 0,
|
||||
pulse_intelligence_patrol_runs_30d: 0,
|
||||
pulse_intelligence_patrol_new_findings_30d: 0,
|
||||
pulse_intelligence_patrol_investigations_30d: 0,
|
||||
pulse_intelligence_patrol_resolved_findings_30d: 0,
|
||||
pulse_intelligence_patrol_autofixes_30d: 0,
|
||||
pulse_intelligence_external_agent_enabled: false,
|
||||
pulse_intelligence_external_agent_used_30d: false,
|
||||
pulse_intelligence_mcp_adapter_used_30d: false,
|
||||
pulse_intelligence_external_agent_context_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_event_stream_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_provisioning_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_operator_state_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_finding_requests_30d: 0,
|
||||
pulse_intelligence_external_agent_action_requests_30d: 0,
|
||||
pulse_intelligence_action_plans_30d: 0,
|
||||
pulse_intelligence_approval_requests_30d: 0,
|
||||
pulse_intelligence_rejected_action_decisions_30d: 0,
|
||||
pulse_intelligence_approved_action_decisions_30d: 0,
|
||||
pulse_intelligence_approved_action_attempts_30d: 0,
|
||||
pulse_intelligence_approved_action_successes_30d: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useSystemSettingsState', () => {
|
||||
let useSystemSettingsState: UseSystemSettingsStateModule['useSystemSettingsState'];
|
||||
let getSystemSettingsMock: ReturnType<typeof vi.fn>;
|
||||
@@ -303,51 +409,7 @@ describe('useSystemSettingsState', () => {
|
||||
it('loads the telemetry preview payload on demand', async () => {
|
||||
getTelemetryPreviewMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
payload: {
|
||||
install_id: 'preview-install-id',
|
||||
version: '6.0.0',
|
||||
version_channel: 'stable',
|
||||
version_is_development: false,
|
||||
version_is_published_release: true,
|
||||
platform: 'docker',
|
||||
os: 'linux',
|
||||
arch: 'amd64',
|
||||
event: 'heartbeat',
|
||||
pve_nodes: 1,
|
||||
pbs_instances: 0,
|
||||
pmg_instances: 0,
|
||||
vms: 2,
|
||||
containers: 3,
|
||||
agent_hosts: 1,
|
||||
docker_hosts: 0,
|
||||
docker_containers: 0,
|
||||
kubernetes_clusters: 0,
|
||||
kubernetes_nodes: 0,
|
||||
kubernetes_pods: 0,
|
||||
kubernetes_deployments: 0,
|
||||
storage_pools: 1,
|
||||
physical_disks: 2,
|
||||
ceph_clusters: 0,
|
||||
network_shares: 0,
|
||||
truenas_systems: 0,
|
||||
truenas_vms: 0,
|
||||
truenas_apps: 0,
|
||||
vmware_hosts: 0,
|
||||
vmware_vms: 0,
|
||||
vmware_datastores: 0,
|
||||
availability_targets: 1,
|
||||
ai_enabled: false,
|
||||
patrol_enabled: false,
|
||||
discovery_enabled: false,
|
||||
notifications_enabled: false,
|
||||
ai_actions_enabled: false,
|
||||
active_alerts: 0,
|
||||
relay_enabled: false,
|
||||
sso_enabled: false,
|
||||
multi_tenant: false,
|
||||
paid_license: false,
|
||||
has_api_tokens: true,
|
||||
},
|
||||
payload: buildTelemetryPreviewPayload(),
|
||||
});
|
||||
|
||||
const { hookState, dispose } = mountHookWithTab('system-general');
|
||||
@@ -357,6 +419,9 @@ describe('useSystemSettingsState', () => {
|
||||
|
||||
expect(getTelemetryPreviewMock).toHaveBeenCalledOnce();
|
||||
expect(hookState.telemetryPreviewPayload()).toContain('"install_id": "preview-install-id"');
|
||||
expect(hookState.telemetryPreviewPayload()).toContain(
|
||||
'"pulse_intelligence_patrol_control_completed_operations_loop_30d": false',
|
||||
);
|
||||
expect(hookState.telemetryPreviewEnabled()).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
@@ -364,51 +429,7 @@ describe('useSystemSettingsState', () => {
|
||||
it('copies the loaded telemetry preview payload', async () => {
|
||||
getTelemetryPreviewMock.mockResolvedValue({
|
||||
enabled: false,
|
||||
payload: {
|
||||
install_id: 'preview-install-id',
|
||||
version: '6.0.0',
|
||||
version_channel: 'stable',
|
||||
version_is_development: false,
|
||||
version_is_published_release: true,
|
||||
platform: 'docker',
|
||||
os: 'linux',
|
||||
arch: 'amd64',
|
||||
event: 'heartbeat',
|
||||
pve_nodes: 1,
|
||||
pbs_instances: 0,
|
||||
pmg_instances: 0,
|
||||
vms: 2,
|
||||
containers: 3,
|
||||
agent_hosts: 1,
|
||||
docker_hosts: 0,
|
||||
docker_containers: 0,
|
||||
kubernetes_clusters: 0,
|
||||
kubernetes_nodes: 0,
|
||||
kubernetes_pods: 0,
|
||||
kubernetes_deployments: 0,
|
||||
storage_pools: 1,
|
||||
physical_disks: 2,
|
||||
ceph_clusters: 0,
|
||||
network_shares: 0,
|
||||
truenas_systems: 0,
|
||||
truenas_vms: 0,
|
||||
truenas_apps: 0,
|
||||
vmware_hosts: 0,
|
||||
vmware_vms: 0,
|
||||
vmware_datastores: 0,
|
||||
availability_targets: 1,
|
||||
ai_enabled: false,
|
||||
patrol_enabled: false,
|
||||
discovery_enabled: false,
|
||||
notifications_enabled: false,
|
||||
ai_actions_enabled: false,
|
||||
active_alerts: 0,
|
||||
relay_enabled: false,
|
||||
sso_enabled: false,
|
||||
multi_tenant: false,
|
||||
paid_license: false,
|
||||
has_api_tokens: true,
|
||||
},
|
||||
payload: buildTelemetryPreviewPayload(),
|
||||
});
|
||||
|
||||
const { hookState, dispose } = mountHookWithTab('system-general');
|
||||
@@ -418,7 +439,9 @@ describe('useSystemSettingsState', () => {
|
||||
await hookState.handleCopyTelemetryPreview();
|
||||
await flushAsync();
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(expect.stringContaining('"event": "heartbeat"'));
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"pulse_intelligence_resolved_operations_loop_30d": false'),
|
||||
);
|
||||
expect(notificationStore.success).toHaveBeenCalledWith(
|
||||
'Telemetry payload copied to clipboard',
|
||||
2000,
|
||||
@@ -430,51 +453,17 @@ describe('useSystemSettingsState', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
resetTelemetryInstallIDMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
payload: {
|
||||
payload: buildTelemetryPreviewPayload({
|
||||
install_id: 'rotated-install-id',
|
||||
version: '6.0.0',
|
||||
version_channel: 'stable',
|
||||
version_is_development: false,
|
||||
version_is_published_release: true,
|
||||
platform: 'binary',
|
||||
os: 'linux',
|
||||
arch: 'amd64',
|
||||
event: 'heartbeat',
|
||||
pve_nodes: 0,
|
||||
pbs_instances: 0,
|
||||
pmg_instances: 0,
|
||||
vms: 0,
|
||||
containers: 0,
|
||||
agent_hosts: 0,
|
||||
docker_hosts: 0,
|
||||
docker_containers: 0,
|
||||
kubernetes_clusters: 0,
|
||||
kubernetes_nodes: 0,
|
||||
kubernetes_pods: 0,
|
||||
kubernetes_deployments: 0,
|
||||
storage_pools: 0,
|
||||
physical_disks: 0,
|
||||
ceph_clusters: 0,
|
||||
network_shares: 0,
|
||||
truenas_systems: 0,
|
||||
truenas_vms: 0,
|
||||
truenas_apps: 0,
|
||||
vmware_hosts: 0,
|
||||
vmware_vms: 0,
|
||||
vmware_datastores: 0,
|
||||
availability_targets: 0,
|
||||
ai_enabled: false,
|
||||
patrol_enabled: false,
|
||||
discovery_enabled: false,
|
||||
notifications_enabled: false,
|
||||
ai_actions_enabled: false,
|
||||
active_alerts: 0,
|
||||
relay_enabled: false,
|
||||
sso_enabled: false,
|
||||
multi_tenant: false,
|
||||
paid_license: false,
|
||||
has_api_tokens: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { hookState, dispose } = mountHookWithTab('system-general');
|
||||
|
||||
@@ -27,8 +27,18 @@ import type { Resource } from '@/types/resource';
|
||||
|
||||
export const API_TOKEN_SCOPES_DOC_URL = SHIPPED_API_TOKEN_SCOPES_DOC_URL;
|
||||
export const API_TOKEN_WILDCARD_SCOPE = '*';
|
||||
export const API_TOKEN_KIOSK_PRESET_ID = 'kiosk_monitoring';
|
||||
export const API_TOKEN_PULSE_INTELLIGENCE_AGENT_PRESET_ID = 'pulse_intelligence_agent';
|
||||
export const API_TOKEN_PATROL_EXTERNAL_AGENT_PRESET_LABEL = 'Patrol external agent';
|
||||
export const API_TOKEN_AGENT_PRESET_ID = 'agent';
|
||||
export const API_TOKEN_DOCKER_REPORT_PRESET_ID = 'docker_podman_reporting';
|
||||
export const API_TOKEN_DOCKER_MANAGE_PRESET_ID = 'docker_podman_lifecycle';
|
||||
export const API_TOKEN_SETTINGS_READ_PRESET_ID = 'settings_read';
|
||||
export const API_TOKEN_SETTINGS_ADMIN_PRESET_ID = 'settings_admin';
|
||||
export const API_TOKEN_AUDIT_READ_PRESET_ID = 'audit_read';
|
||||
|
||||
export interface APITokenPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
scopes: string[];
|
||||
description: string;
|
||||
@@ -42,47 +52,83 @@ export interface APITokenUsageEntry {
|
||||
type ScopeGroup = (typeof API_SCOPE_OPTIONS)[number]['group'];
|
||||
type ScopeOption = (typeof API_SCOPE_OPTIONS)[number];
|
||||
|
||||
const API_TOKEN_SCOPE_GROUP_ORDER: ScopeGroup[] = ['Monitoring', 'Agents', 'Settings', 'Security'];
|
||||
|
||||
export const getAPITokenScopePresets = (): APITokenPreset[] => [
|
||||
{
|
||||
label: 'Kiosk / Monitoring',
|
||||
scopes: [MONITORING_READ_SCOPE],
|
||||
description:
|
||||
'Read-only access for wall displays. Use ?token=xxx&kiosk=1 in the URL to hide navigation and filters.',
|
||||
},
|
||||
{
|
||||
label: 'Agent',
|
||||
scopes: [AGENT_REPORT_SCOPE],
|
||||
description: 'Allow the Pulse agent to submit OS, CPU, and disk metrics.',
|
||||
},
|
||||
{
|
||||
label: API_TOKEN_DOCKER_REPORT_PRESET_LABEL,
|
||||
scopes: [DOCKER_REPORT_SCOPE],
|
||||
description: API_TOKEN_DOCKER_REPORT_PRESET_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
label: API_TOKEN_DOCKER_MANAGE_PRESET_LABEL,
|
||||
scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE],
|
||||
description: API_TOKEN_DOCKER_MANAGE_PRESET_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
label: 'Settings read',
|
||||
scopes: [SETTINGS_READ_SCOPE],
|
||||
description: 'Read configuration snapshots and diagnostics without modifying anything.',
|
||||
},
|
||||
{
|
||||
label: 'Settings admin',
|
||||
scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE],
|
||||
description: 'Full settings read/write – equivalent to automation with admin privileges.',
|
||||
},
|
||||
{
|
||||
label: 'Audit read',
|
||||
scopes: [AUDIT_READ_SCOPE],
|
||||
description: 'Read audit events, verification history, summaries, and export activity.',
|
||||
},
|
||||
const API_TOKEN_SCOPE_GROUP_ORDER: ScopeGroup[] = [
|
||||
'Monitoring',
|
||||
'AI',
|
||||
'Agents',
|
||||
'Settings',
|
||||
'Security',
|
||||
];
|
||||
|
||||
const normalizePresetScopes = (scopes: readonly string[] | undefined): string[] =>
|
||||
Array.from(new Set((scopes ?? []).map((scope) => scope.trim()).filter(Boolean)));
|
||||
|
||||
export const getAPITokenScopePresets = (
|
||||
pulseIntelligenceRequiredScopes: readonly string[] = [],
|
||||
): APITokenPreset[] => {
|
||||
const pulseIntelligenceScopes = normalizePresetScopes(pulseIntelligenceRequiredScopes);
|
||||
const presets: APITokenPreset[] = [
|
||||
{
|
||||
id: API_TOKEN_KIOSK_PRESET_ID,
|
||||
label: 'Kiosk / Monitoring',
|
||||
scopes: [MONITORING_READ_SCOPE],
|
||||
description:
|
||||
'Read-only access for wall displays. Use ?token=xxx&kiosk=1 in the URL to hide navigation and filters.',
|
||||
},
|
||||
];
|
||||
|
||||
if (pulseIntelligenceScopes.length > 0) {
|
||||
presets.push({
|
||||
id: API_TOKEN_PULSE_INTELLIGENCE_AGENT_PRESET_ID,
|
||||
label: API_TOKEN_PATROL_EXTERNAL_AGENT_PRESET_LABEL,
|
||||
scopes: pulseIntelligenceScopes,
|
||||
description:
|
||||
'Scopes for connected agents that read Pulse context and request Patrol work.',
|
||||
});
|
||||
}
|
||||
|
||||
presets.push(
|
||||
{
|
||||
id: API_TOKEN_AGENT_PRESET_ID,
|
||||
label: 'Agent',
|
||||
scopes: [AGENT_REPORT_SCOPE],
|
||||
description: 'Allow the Pulse agent to submit OS, CPU, and disk metrics.',
|
||||
},
|
||||
{
|
||||
id: API_TOKEN_DOCKER_REPORT_PRESET_ID,
|
||||
label: API_TOKEN_DOCKER_REPORT_PRESET_LABEL,
|
||||
scopes: [DOCKER_REPORT_SCOPE],
|
||||
description: API_TOKEN_DOCKER_REPORT_PRESET_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
id: API_TOKEN_DOCKER_MANAGE_PRESET_ID,
|
||||
label: API_TOKEN_DOCKER_MANAGE_PRESET_LABEL,
|
||||
scopes: [DOCKER_REPORT_SCOPE, DOCKER_MANAGE_SCOPE],
|
||||
description: API_TOKEN_DOCKER_MANAGE_PRESET_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
id: API_TOKEN_SETTINGS_READ_PRESET_ID,
|
||||
label: 'Settings read',
|
||||
scopes: [SETTINGS_READ_SCOPE],
|
||||
description: 'Read configuration snapshots and diagnostics without modifying anything.',
|
||||
},
|
||||
{
|
||||
id: API_TOKEN_SETTINGS_ADMIN_PRESET_ID,
|
||||
label: 'Settings admin',
|
||||
scopes: [SETTINGS_READ_SCOPE, SETTINGS_WRITE_SCOPE],
|
||||
description: 'Full settings read/write - equivalent to automation with admin privileges.',
|
||||
},
|
||||
{
|
||||
id: API_TOKEN_AUDIT_READ_PRESET_ID,
|
||||
label: 'Audit read',
|
||||
scopes: [AUDIT_READ_SCOPE],
|
||||
description: 'Read audit events, verification history, summaries, and export activity.',
|
||||
},
|
||||
);
|
||||
|
||||
return presets;
|
||||
};
|
||||
|
||||
export const hasAgentScopeResource = (resource: Resource): boolean => {
|
||||
if (resource.type === 'docker-host') return false;
|
||||
return (
|
||||
@@ -203,6 +249,7 @@ export const countWildcardTokens = (tokens: APITokenRecord[]): number => {
|
||||
export const groupAPITokenScopes = (): [ScopeGroup, ScopeOption[]][] => {
|
||||
const grouped: Record<ScopeGroup, ScopeOption[]> = {
|
||||
Monitoring: [],
|
||||
AI: [],
|
||||
Agents: [],
|
||||
Settings: [],
|
||||
Security: [],
|
||||
|
||||
@@ -116,8 +116,7 @@ export interface AIChatDiagnostic {
|
||||
port?: number;
|
||||
url?: string;
|
||||
model?: string;
|
||||
mcpConnected: boolean;
|
||||
mcpToolCount?: number;
|
||||
assistantRuntimeConnected: boolean;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,22 +32,20 @@ export interface SelfHostedProBillingPresentation {
|
||||
}
|
||||
|
||||
export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentation = {
|
||||
navLabel: 'Plans',
|
||||
shellTitle: 'Self-hosted plan',
|
||||
shellDescription:
|
||||
'Review the plan this instance is using and the optional capabilities connected to it.',
|
||||
infrastructureRouteReferral: 'Billing and self-hosted plan changes live in Plans.',
|
||||
navLabel: 'Plans & Billing',
|
||||
shellTitle: 'Plans & Billing',
|
||||
shellDescription: 'Plan, license, and Patrol mode for this instance.',
|
||||
infrastructureRouteReferral: 'Billing and self-hosted plan changes live in Plans & Billing.',
|
||||
infrastructureWorkspaceReferral:
|
||||
'Self-hosted plan status, optional activation, and available capabilities live in Plans, not here.',
|
||||
sectionSelectorAriaLabel: 'Self-hosted plans section',
|
||||
'Self-hosted plan status, Patrol mode, and available capabilities live in Plans & Billing, not here.',
|
||||
sectionSelectorAriaLabel: 'Plans and billing section',
|
||||
refreshLabel: 'Refresh',
|
||||
planTabLabel: 'Plan',
|
||||
usageTabLabel: 'Usage',
|
||||
planSectionTitle: 'Current plan',
|
||||
planSectionDescription:
|
||||
'See which self-hosted tier this instance is using and which capabilities are available on this install.',
|
||||
planComparisonSectionTitle: 'Optional extras',
|
||||
planComparisonActionLabel: 'See all plans',
|
||||
planSectionDescription: 'Current tier and enabled capabilities.',
|
||||
planComparisonSectionTitle: 'Available plans',
|
||||
planComparisonActionLabel: 'View plans',
|
||||
usageSectionTitle: 'Usage',
|
||||
hiddenShellTitle: 'Demo mode',
|
||||
hiddenShellDescription: 'Commercial settings are hidden for this session.',
|
||||
@@ -57,16 +55,14 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat
|
||||
policyLoadingTitle: 'Loading settings access',
|
||||
policyLoadingBody:
|
||||
'Pulse waits for the session presentation policy before showing license, billing, or usage details.',
|
||||
planSelectionPromptTitle: 'Compare self-hosted plans',
|
||||
planSelectionPromptBody:
|
||||
'Community includes core monitoring at no cost. Relay is optional for secure access from anywhere, and Pulse Pro adds root-cause analysis, safe remediation workflows, and 90-day history.',
|
||||
planSelectionPromptActionLabel: 'Compare plans',
|
||||
planSelectionPromptTitle: 'Select a plan',
|
||||
planSelectionPromptBody: 'Choose the plan for this install.',
|
||||
planSelectionPromptActionLabel: 'View plans',
|
||||
purchaseActivatedPlanActionLabel: 'Review plan',
|
||||
purchaseCancelledActionLabel: 'Compare plans',
|
||||
purchaseExpiredActionLabel: 'Compare plans',
|
||||
purchaseCancelledActionLabel: 'View plans',
|
||||
purchaseExpiredActionLabel: 'View plans',
|
||||
purchaseFailedActionLabel: 'Open recovery',
|
||||
purchaseUnavailableActionLabel: 'Try again',
|
||||
recoverySectionTitle: 'Existing purchases',
|
||||
recoverySectionDescription:
|
||||
'Add an activation key you already have, recover a previous self-hosted purchase, or clear a local key from this instance.',
|
||||
recoverySectionTitle: 'License recovery',
|
||||
recoverySectionDescription: 'Paste a license key or clear the license on this install.',
|
||||
};
|
||||
|
||||
@@ -36,6 +36,19 @@ export const SETTINGS_HEADER_META: SettingsHeaderMetaMap = {
|
||||
title: AI_SETTINGS_PANEL_TITLE,
|
||||
description: AI_SETTINGS_PANEL_DESCRIPTION,
|
||||
},
|
||||
'system-ai-patrol': {
|
||||
title: 'Patrol',
|
||||
description: 'Set when Patrol runs, what starts it, and which model it uses.',
|
||||
},
|
||||
'system-ai-assistant': {
|
||||
title: 'Assistant',
|
||||
description: 'Configure Assistant chat behavior, chat action permissions, and sessions.',
|
||||
},
|
||||
'system-ai-discovery': {
|
||||
title: 'Service Context',
|
||||
description:
|
||||
'Configure the model-backed service context Assistant and Patrol use. Infrastructure discovery and onboarding stay under Infrastructure.',
|
||||
},
|
||||
'system-relay': {
|
||||
title: 'Remote Access',
|
||||
description: RELAY_SETTINGS_DESCRIPTION,
|
||||
@@ -149,6 +162,18 @@ const SETTINGS_HEADER_META_KEYS = {
|
||||
title: 'settings.header.systemAi.title',
|
||||
description: 'settings.header.systemAi.description',
|
||||
},
|
||||
'system-ai-patrol': {
|
||||
title: 'settings.header.systemAiPatrol.title',
|
||||
description: 'settings.header.systemAiPatrol.description',
|
||||
},
|
||||
'system-ai-assistant': {
|
||||
title: 'settings.header.systemAiAssistant.title',
|
||||
description: 'settings.header.systemAiAssistant.description',
|
||||
},
|
||||
'system-ai-discovery': {
|
||||
title: 'settings.header.systemAiDiscovery.title',
|
||||
description: 'settings.header.systemAiDiscovery.description',
|
||||
},
|
||||
'system-relay': {
|
||||
title: 'settings.header.systemRelay.title',
|
||||
description: 'settings.header.systemRelay.description',
|
||||
|
||||
@@ -53,6 +53,42 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pulse-intelligence',
|
||||
label: 'Pulse Intelligence',
|
||||
items: [
|
||||
{
|
||||
id: 'system-ai',
|
||||
label: 'Provider & Models',
|
||||
icon: Sparkles,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'system-ai-patrol',
|
||||
label: 'Patrol',
|
||||
icon: Activity,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'system-billing',
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.navLabel,
|
||||
icon: PulseLogoIcon,
|
||||
hideWhenCommercialHidden: true,
|
||||
},
|
||||
{
|
||||
id: 'system-ai-assistant',
|
||||
label: 'Assistant',
|
||||
icon: Terminal,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'system-ai-discovery',
|
||||
label: 'Service Context',
|
||||
icon: Network,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'organization',
|
||||
label: 'Organization',
|
||||
@@ -140,19 +176,6 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
|
||||
iconProps: { strokeWidth: 2 },
|
||||
saveBehavior: 'system',
|
||||
},
|
||||
{
|
||||
id: 'system-ai',
|
||||
label: 'Assistant & Patrol',
|
||||
icon: Sparkles,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'system-billing',
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.navLabel,
|
||||
icon: PulseLogoIcon,
|
||||
hideFromSidebar: true,
|
||||
hideWhenCommercialHidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -273,6 +296,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
|
||||
const SETTINGS_NAV_GROUP_LABEL_KEYS = {
|
||||
infrastructure: 'settings.nav.group.infrastructure',
|
||||
monitoring: 'settings.nav.group.monitoring',
|
||||
'pulse-intelligence': 'settings.nav.group.pulseIntelligence',
|
||||
organization: 'settings.nav.group.organization',
|
||||
system: 'settings.nav.group.system',
|
||||
support: 'settings.nav.group.support',
|
||||
@@ -286,7 +310,10 @@ const SETTINGS_NAV_ITEM_LABEL_KEYS = {
|
||||
'system-network': 'settings.nav.item.network',
|
||||
'system-updates': 'settings.nav.item.updates',
|
||||
'system-recovery': 'settings.nav.item.recovery',
|
||||
'system-ai': 'settings.nav.item.assistantPatrol',
|
||||
'system-ai': 'settings.nav.item.providerModels',
|
||||
'system-ai-patrol': 'settings.nav.item.patrol',
|
||||
'system-ai-assistant': 'settings.nav.item.assistant',
|
||||
'system-ai-discovery': 'settings.nav.item.discovery',
|
||||
'system-relay': 'settings.nav.item.remoteAccess',
|
||||
'system-billing': 'settings.nav.item.plans',
|
||||
'support-diagnostics': 'settings.nav.item.diagnosticsHealth',
|
||||
|
||||
@@ -11,9 +11,14 @@ import {
|
||||
shouldOpenAvailabilityTargetAddDialog,
|
||||
} from './availabilitySettingsModel';
|
||||
import {
|
||||
LEGACY_SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
LEGACY_SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
LEGACY_SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
|
||||
SELF_HOSTED_PRO_BILLING_ROUTE,
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
|
||||
} from '@/utils/pricingHandoff';
|
||||
import { SETTINGS_API_ACCESS_PATH } from '@/routing/resourceLinks';
|
||||
|
||||
export type SettingsTab =
|
||||
| 'infrastructure-systems'
|
||||
@@ -23,6 +28,9 @@ export type SettingsTab =
|
||||
| 'system-updates'
|
||||
| 'system-recovery'
|
||||
| 'system-ai'
|
||||
| 'system-ai-patrol'
|
||||
| 'system-ai-assistant'
|
||||
| 'system-ai-discovery'
|
||||
| 'system-relay'
|
||||
| 'system-billing'
|
||||
| 'support-diagnostics'
|
||||
@@ -54,6 +62,7 @@ export type ProxmoxPlatformType = Extract<
|
||||
export type SettingsNavGroupId =
|
||||
| 'infrastructure'
|
||||
| 'monitoring'
|
||||
| 'pulse-intelligence'
|
||||
| 'organization'
|
||||
| 'system'
|
||||
| 'support'
|
||||
@@ -98,6 +107,14 @@ export const DEFAULT_SETTINGS_TAB: SettingsTab = 'infrastructure-systems';
|
||||
const INFRASTRUCTURE_SYSTEMS_PREFIX = '/settings/infrastructure';
|
||||
const MONITORING_PREFIX = '/settings/monitoring';
|
||||
const MONITORING_AVAILABILITY_PREFIX = `${MONITORING_PREFIX}/availability`;
|
||||
export const SETTINGS_PULSE_INTELLIGENCE_PATH = '/settings/pulse-intelligence';
|
||||
export const SETTINGS_PROVIDER_MODELS_PATH = `${SETTINGS_PULSE_INTELLIGENCE_PATH}/provider`;
|
||||
const PULSE_INTELLIGENCE_PREFIX = SETTINGS_PULSE_INTELLIGENCE_PATH;
|
||||
const PULSE_INTELLIGENCE_PROVIDER_PREFIX = SETTINGS_PROVIDER_MODELS_PATH;
|
||||
const PULSE_INTELLIGENCE_PATROL_PREFIX = `${PULSE_INTELLIGENCE_PREFIX}/patrol`;
|
||||
const PULSE_INTELLIGENCE_ASSISTANT_PREFIX = `${PULSE_INTELLIGENCE_PREFIX}/assistant`;
|
||||
const PULSE_INTELLIGENCE_DISCOVERY_PREFIX = `${PULSE_INTELLIGENCE_PREFIX}/discovery`;
|
||||
const LEGACY_SYSTEM_AI_PREFIX = '/settings/system-ai';
|
||||
const RETIRED_SETTINGS_WORKLOADS_PREFIX = '/settings/workloads';
|
||||
const RETIRED_SETTINGS_OPERATIONS_PREFIX = '/settings/operations';
|
||||
const RETIRED_SETTINGS_INTEGRATIONS_API_PREFIX = '/settings/integrations/api';
|
||||
@@ -106,8 +123,9 @@ const SUPPORT_PREFIX = '/settings/support';
|
||||
const SUPPORT_DIAGNOSTICS_PREFIX = `${SUPPORT_PREFIX}/diagnostics`;
|
||||
const SUPPORT_REPORTING_PREFIX = `${SUPPORT_PREFIX}/reporting`;
|
||||
const SUPPORT_LOGS_PREFIX = `${SUPPORT_PREFIX}/logs`;
|
||||
const SECURITY_API_PREFIX = '/settings/security/api';
|
||||
const SYSTEM_BILLING_PREFIX = SELF_HOSTED_PRO_BILLING_ROUTE;
|
||||
const SECURITY_API_PREFIX = SETTINGS_API_ACCESS_PATH;
|
||||
const SELF_HOSTED_BILLING_PREFIX = SELF_HOSTED_PRO_BILLING_ROUTE;
|
||||
const LEGACY_SYSTEM_BILLING_PREFIX = LEGACY_SELF_HOSTED_PRO_BILLING_ROUTE;
|
||||
|
||||
const PROXMOX_AGENT_META: Record<
|
||||
AgentKey,
|
||||
@@ -171,12 +189,26 @@ export function resolveCanonicalSettingsPath(path: string): string | null {
|
||||
if (normalizedPath === MONITORING_PREFIX) {
|
||||
return MONITORING_AVAILABILITY_PREFIX;
|
||||
}
|
||||
if (
|
||||
normalizedPath === PULSE_INTELLIGENCE_PREFIX ||
|
||||
normalizedPath === PULSE_INTELLIGENCE_PROVIDER_PREFIX ||
|
||||
normalizedPath === LEGACY_SYSTEM_AI_PREFIX
|
||||
) {
|
||||
return settingsTabPath('system-ai');
|
||||
}
|
||||
if (normalizedPath === SUPPORT_PREFIX) {
|
||||
return SUPPORT_DIAGNOSTICS_PREFIX;
|
||||
}
|
||||
if (normalizedPath === SYSTEM_BILLING_PREFIX) {
|
||||
if (
|
||||
normalizedPath === SELF_HOSTED_BILLING_PREFIX ||
|
||||
normalizedPath === LEGACY_SYSTEM_BILLING_PREFIX ||
|
||||
normalizedPath === LEGACY_SELF_HOSTED_PRO_BILLING_PLAN_ROUTE
|
||||
) {
|
||||
return SELF_HOSTED_PRO_BILLING_PLAN_ROUTE;
|
||||
}
|
||||
if (normalizedPath === LEGACY_SELF_HOSTED_PRO_BILLING_USAGE_ROUTE) {
|
||||
return SELF_HOSTED_PRO_BILLING_USAGE_ROUTE;
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
@@ -190,6 +222,18 @@ export function deriveTabFromPath(path: string): SettingsTab {
|
||||
if (canonicalPath.startsWith(MONITORING_AVAILABILITY_PREFIX)) {
|
||||
return 'monitoring-availability';
|
||||
}
|
||||
if (canonicalPath.startsWith(PULSE_INTELLIGENCE_PATROL_PREFIX)) {
|
||||
return 'system-ai-patrol';
|
||||
}
|
||||
if (canonicalPath.startsWith(PULSE_INTELLIGENCE_ASSISTANT_PREFIX)) {
|
||||
return 'system-ai-assistant';
|
||||
}
|
||||
if (canonicalPath.startsWith(PULSE_INTELLIGENCE_DISCOVERY_PREFIX)) {
|
||||
return 'system-ai-discovery';
|
||||
}
|
||||
if (canonicalPath.startsWith(PULSE_INTELLIGENCE_PROVIDER_PREFIX)) {
|
||||
return 'system-ai';
|
||||
}
|
||||
|
||||
if (canonicalPath.includes('/settings/system-general')) return 'system-general';
|
||||
if (canonicalPath.includes('/settings/system-network')) return 'system-network';
|
||||
@@ -197,7 +241,7 @@ export function deriveTabFromPath(path: string): SettingsTab {
|
||||
if (canonicalPath.includes('/settings/system-recovery')) return 'system-recovery';
|
||||
if (canonicalPath.includes('/settings/system-ai')) return 'system-ai';
|
||||
if (canonicalPath.includes('/settings/system-relay')) return 'system-relay';
|
||||
if (canonicalPath.includes(SYSTEM_BILLING_PREFIX)) return 'system-billing';
|
||||
if (canonicalPath.includes(SELF_HOSTED_BILLING_PREFIX)) return 'system-billing';
|
||||
if (canonicalPath.startsWith(SUPPORT_LOGS_PREFIX)) return 'support-logs';
|
||||
if (canonicalPath.startsWith(SUPPORT_REPORTING_PREFIX)) return 'support-reporting';
|
||||
if (canonicalPath.startsWith(SUPPORT_DIAGNOSTICS_PREFIX) || canonicalPath === SUPPORT_PREFIX)
|
||||
@@ -251,6 +295,8 @@ export function agentKeyFromPlatformType(value: string | null | undefined): Agen
|
||||
|
||||
export function deriveTabFromQuery(search: string): SettingsTab | null {
|
||||
const params = new URLSearchParams(search);
|
||||
if (isAISettingsOAuthCallbackQuery(search)) return 'system-ai';
|
||||
|
||||
const tab = params.get('tab')?.trim().toLowerCase();
|
||||
if (!tab) return null;
|
||||
|
||||
@@ -269,7 +315,19 @@ export function deriveTabFromQuery(search: string): SettingsTab | null {
|
||||
case 'system-general':
|
||||
return 'system-general';
|
||||
case 'system-ai':
|
||||
case 'pulse-intelligence':
|
||||
case 'provider-models':
|
||||
case 'provider':
|
||||
return 'system-ai';
|
||||
case 'system-ai-patrol':
|
||||
case 'patrol':
|
||||
return 'system-ai-patrol';
|
||||
case 'system-ai-assistant':
|
||||
case 'assistant':
|
||||
return 'system-ai-assistant';
|
||||
case 'system-ai-discovery':
|
||||
case 'discovery':
|
||||
return 'system-ai-discovery';
|
||||
case 'system-relay':
|
||||
return 'system-relay';
|
||||
case 'system-billing':
|
||||
@@ -318,6 +376,11 @@ export function deriveTabFromQuery(search: string): SettingsTab | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function isAISettingsOAuthCallbackQuery(search: string): boolean {
|
||||
const params = new URLSearchParams(search);
|
||||
return params.has('ai_oauth_success') || params.has('ai_oauth_error');
|
||||
}
|
||||
|
||||
export function settingsTabPath(tab: SettingsTab): string {
|
||||
switch (tab) {
|
||||
case 'infrastructure-systems':
|
||||
@@ -338,6 +401,14 @@ export function settingsTabPath(tab: SettingsTab): string {
|
||||
return '/settings/organization/billing-admin';
|
||||
case 'api':
|
||||
return SECURITY_API_PREFIX;
|
||||
case 'system-ai':
|
||||
return PULSE_INTELLIGENCE_PROVIDER_PREFIX;
|
||||
case 'system-ai-patrol':
|
||||
return PULSE_INTELLIGENCE_PATROL_PREFIX;
|
||||
case 'system-ai-assistant':
|
||||
return PULSE_INTELLIGENCE_ASSISTANT_PREFIX;
|
||||
case 'system-ai-discovery':
|
||||
return PULSE_INTELLIGENCE_DISCOVERY_PREFIX;
|
||||
case 'system-relay':
|
||||
return '/settings/system-relay';
|
||||
case 'system-billing':
|
||||
@@ -361,9 +432,12 @@ const ROUTEABLE_SETTINGS_PATHS = new Set<string>([
|
||||
settingsTabPath('system-updates'),
|
||||
settingsTabPath('system-recovery'),
|
||||
settingsTabPath('system-ai'),
|
||||
settingsTabPath('system-ai-patrol'),
|
||||
settingsTabPath('system-ai-assistant'),
|
||||
settingsTabPath('system-ai-discovery'),
|
||||
settingsTabPath('system-relay'),
|
||||
settingsTabPath('system-billing'),
|
||||
`${SYSTEM_BILLING_PREFIX}/usage`,
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
|
||||
settingsTabPath('support-diagnostics'),
|
||||
settingsTabPath('support-reporting'),
|
||||
settingsTabPath('support-logs'),
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface SettingsPanelRegistryContext {
|
||||
getInfrastructurePanelProps: () => Parameters<typeof InfrastructureWorkspaceType>[0];
|
||||
systemGeneralPanel: Component;
|
||||
systemAiPanel: Component;
|
||||
systemAiPatrolPanel: Component;
|
||||
systemAiAssistantPanel: Component;
|
||||
systemAiDiscoveryPanel: Component;
|
||||
systemBillingPanel: Component;
|
||||
securitySsoPanel: Component;
|
||||
getNetworkPanelProps: () => Parameters<typeof NetworkSettingsPanelType>[0];
|
||||
@@ -72,6 +75,15 @@ export const createSettingsPanelRegistry = (
|
||||
'system-ai': {
|
||||
component: context.systemAiPanel,
|
||||
},
|
||||
'system-ai-patrol': {
|
||||
component: context.systemAiPatrolPanel,
|
||||
},
|
||||
'system-ai-assistant': {
|
||||
component: context.systemAiAssistantPanel,
|
||||
},
|
||||
'system-ai-discovery': {
|
||||
component: context.systemAiDiscoveryPanel,
|
||||
},
|
||||
'system-relay': {
|
||||
component: SETTINGS_PANEL_REGISTRY_LOADERS.RelaySettingsPanel,
|
||||
getProps: context.getRelayPanelProps,
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { Accessor, Component, Setter } from 'solid-js';
|
||||
import type { VersionInfo } from '@/api/updates';
|
||||
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
|
||||
import { AICostDashboard } from '@/components/AI/AICostDashboard';
|
||||
import { AISettings } from './AISettings';
|
||||
import {
|
||||
AIAssistantSettings,
|
||||
AIDiscoverySettings,
|
||||
AISettings,
|
||||
AIPatrolSettings,
|
||||
} from './AISettings';
|
||||
import { ProLicensePanel } from './ProLicensePanel';
|
||||
import { SSOProvidersPanel } from './SSOProvidersPanel';
|
||||
import type { InfrastructurePlatformSettingsProps } from './proxmoxSettingsModel';
|
||||
@@ -35,9 +40,9 @@ export function buildSettingsPanelRegistryContext(
|
||||
): SettingsPanelRegistryContext {
|
||||
const settingsCapabilities = () => params.securityStatus()?.settingsCapabilities ?? null;
|
||||
const currentUser = () =>
|
||||
params.securityStatus()?.proxyAuthUsername
|
||||
|| params.securityStatus()?.ssoSessionUsername
|
||||
|| params.securityStatus()?.authUsername;
|
||||
params.securityStatus()?.proxyAuthUsername ||
|
||||
params.securityStatus()?.ssoSessionUsername ||
|
||||
params.securityStatus()?.authUsername;
|
||||
|
||||
const systemAiPanel: Component = () => (
|
||||
<div class="space-y-6">
|
||||
@@ -52,6 +57,10 @@ export function buildSettingsPanelRegistryContext(
|
||||
</div>
|
||||
);
|
||||
|
||||
const systemAiPatrolPanel: Component = () => <AIPatrolSettings />;
|
||||
const systemAiAssistantPanel: Component = () => <AIAssistantSettings />;
|
||||
const systemAiDiscoveryPanel: Component = () => <AIDiscoverySettings />;
|
||||
|
||||
const securitySsoPanel: Component = () => (
|
||||
<div class="space-y-6">
|
||||
<SSOProvidersPanel
|
||||
@@ -65,6 +74,9 @@ export function buildSettingsPanelRegistryContext(
|
||||
getInfrastructurePanelProps: params.getInfrastructurePanelProps,
|
||||
systemGeneralPanel: params.systemPanels.systemGeneralPanel,
|
||||
systemAiPanel,
|
||||
systemAiPatrolPanel,
|
||||
systemAiAssistantPanel,
|
||||
systemAiDiscoveryPanel,
|
||||
systemBillingPanel,
|
||||
securitySsoPanel,
|
||||
getNetworkPanelProps: params.systemPanels.getNetworkPanelProps,
|
||||
|
||||
@@ -44,6 +44,11 @@ type AISettingsSaveProviderFailure = {
|
||||
providerMessage?: string;
|
||||
};
|
||||
|
||||
interface AISettingsStateOptions {
|
||||
saveErrorFallback?: string;
|
||||
savedLabel?: string;
|
||||
}
|
||||
|
||||
const AI_SETTINGS_PROVIDER_PAYLOAD_FIELDS: Record<AIProvider, string[]> = {
|
||||
anthropic: ['anthropic_api_key'],
|
||||
openai: ['openai_api_key', 'openai_base_url'],
|
||||
@@ -100,9 +105,13 @@ const isGenericAISettingsSaveFailure = (error: unknown): boolean => {
|
||||
if (!message) return true;
|
||||
return (
|
||||
message === 'failed to save assistant & patrol settings' ||
|
||||
message === 'failed to save provider & models settings' ||
|
||||
message === 'unable to save assistant & patrol settings.' ||
|
||||
message === 'unable to save assistant & patrol settings' ||
|
||||
message === 'unable to save provider & models settings.' ||
|
||||
message === 'unable to save provider & models settings' ||
|
||||
message.includes('failed to save assistant & patrol settings') ||
|
||||
message.includes('failed to save provider & models settings') ||
|
||||
message.startsWith('request failed with status')
|
||||
);
|
||||
};
|
||||
@@ -110,8 +119,9 @@ const isGenericAISettingsSaveFailure = (error: unknown): boolean => {
|
||||
export function getAISettingsSaveProviderFailureMessage(
|
||||
message: string | undefined,
|
||||
failure?: AISettingsSaveProviderFailure | null,
|
||||
fallback?: string,
|
||||
): string {
|
||||
const baseMessage = getAISettingsSaveErrorMessage(message);
|
||||
const baseMessage = getAISettingsSaveErrorMessage(message, fallback);
|
||||
if (!failure?.provider) return baseMessage;
|
||||
|
||||
const providerLabel = getAIProviderDisplayName(failure.provider);
|
||||
@@ -129,7 +139,7 @@ export function getAISettingsSaveProviderFailureMessage(
|
||||
|
||||
export function getAISettingsPatrolReadinessSaveMessage(
|
||||
readiness: AISettingsType['patrol_readiness'] | null | undefined,
|
||||
savedLabel = 'Assistant & Patrol settings saved',
|
||||
savedLabel = 'Provider & Models settings saved',
|
||||
): string | null {
|
||||
if (!readiness || readiness.status === 'ready') return null;
|
||||
|
||||
@@ -209,7 +219,7 @@ export function resolveAISettingsSaveProviderFailure(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const useAISettingsState = () => {
|
||||
export const useAISettingsState = (options: AISettingsStateOptions = {}) => {
|
||||
const [settings, setSettings] = createSignal<AISettingsType | null>(null);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [loadError, setLoadError] = createSignal(false);
|
||||
@@ -244,7 +254,7 @@ export const useAISettingsState = () => {
|
||||
|
||||
// hydratePatrolPreflightFromSettings projects the cached preflight
|
||||
// snapshot from /api/settings/ai into the same response shape the
|
||||
// manual Verify Patrol button writes, so the inline result panel can
|
||||
// manual Check Patrol model button writes, so the inline result panel can
|
||||
// render the most-recent outcome on page load without forcing a
|
||||
// re-click. Returns null when preflight has never run on the
|
||||
// current Pulse instance.
|
||||
@@ -288,6 +298,9 @@ export const useAISettingsState = () => {
|
||||
authMethod: 'api_key' as AuthMethod,
|
||||
patrolIntervalMinutes: 360,
|
||||
alertTriggeredAnalysis: true,
|
||||
patrolAlertTriggers: true,
|
||||
patrolAnomalyTriggers: true,
|
||||
patrolAlertTriggerMinSeverity: 'critical' as 'warning' | 'critical',
|
||||
patrolAutoFix: false,
|
||||
anthropicApiKey: '',
|
||||
openaiApiKey: '',
|
||||
@@ -315,6 +328,7 @@ export const useAISettingsState = () => {
|
||||
}),
|
||||
);
|
||||
const autoFixLocked = createMemo(() => !hasFeature('ai_autofix'));
|
||||
const alertAnalysisLocked = createMemo(() => !hasFeature('ai_alerts'));
|
||||
const providerIssueCount = createMemo(
|
||||
() => AI_PROVIDERS.filter((provider) => providerHealth[provider].status === 'error').length,
|
||||
);
|
||||
@@ -363,6 +377,9 @@ export const useAISettingsState = () => {
|
||||
authMethod: 'api_key',
|
||||
patrolIntervalMinutes: 360,
|
||||
alertTriggeredAnalysis: true,
|
||||
patrolAlertTriggers: true,
|
||||
patrolAnomalyTriggers: true,
|
||||
patrolAlertTriggerMinSeverity: 'critical',
|
||||
patrolAutoFix: false,
|
||||
anthropicApiKey: '',
|
||||
openaiApiKey: '',
|
||||
@@ -382,6 +399,9 @@ export const useAISettingsState = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyEventTriggersEnabled =
|
||||
data.patrol_event_triggers_enabled ?? data.alert_triggered_analysis !== false;
|
||||
|
||||
setForm({
|
||||
enabled: data.enabled,
|
||||
model: data.model || '',
|
||||
@@ -392,6 +412,10 @@ export const useAISettingsState = () => {
|
||||
authMethod: data.auth_method || 'api_key',
|
||||
patrolIntervalMinutes: data.patrol_interval_minutes ?? 360,
|
||||
alertTriggeredAnalysis: data.alert_triggered_analysis !== false,
|
||||
patrolAlertTriggers: data.patrol_alert_triggers_enabled ?? legacyEventTriggersEnabled,
|
||||
patrolAnomalyTriggers: data.patrol_anomaly_triggers_enabled ?? legacyEventTriggersEnabled,
|
||||
patrolAlertTriggerMinSeverity:
|
||||
data.patrol_alert_trigger_min_severity === 'warning' ? 'warning' : 'critical',
|
||||
patrolAutoFix: data.patrol_auto_fix || false,
|
||||
anthropicApiKey: '',
|
||||
openaiApiKey: '',
|
||||
@@ -596,7 +620,7 @@ export const useAISettingsState = () => {
|
||||
// which only confirms each provider's model catalog is reachable.
|
||||
//
|
||||
// Passes the form's pending patrolModel as a model override so clicking
|
||||
// Verify Patrol after changing the dropdown actually tests the operator's
|
||||
// Check Patrol model after changing the dropdown actually tests the operator's
|
||||
// pending selection, not whatever was previously saved. Empty form value
|
||||
// means "use the shared default" — the backend handles the fallback.
|
||||
const runPatrolToolPreflight = async () => {
|
||||
@@ -702,12 +726,12 @@ export const useAISettingsState = () => {
|
||||
handleCloseSetupModal();
|
||||
const patrolReadinessMessage = getAISettingsPatrolReadinessSaveMessage(
|
||||
updated.patrol_readiness,
|
||||
'Assistant & Patrol enabled',
|
||||
'Pulse Intelligence enabled',
|
||||
);
|
||||
if (patrolReadinessMessage) {
|
||||
notificationStore.warning(patrolReadinessMessage);
|
||||
} else {
|
||||
notificationStore.success('Assistant & Patrol enabled! You can customize settings below.');
|
||||
notificationStore.success('Pulse Intelligence enabled. You can customize settings below.');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AISettings] Setup failed:', error);
|
||||
@@ -806,6 +830,18 @@ export const useAISettingsState = () => {
|
||||
if (form.alertTriggeredAnalysis !== settings()?.alert_triggered_analysis) {
|
||||
payload.alert_triggered_analysis = form.alertTriggeredAnalysis;
|
||||
}
|
||||
if (form.patrolAlertTriggers !== (settings()?.patrol_alert_triggers_enabled ?? true)) {
|
||||
payload.patrol_alert_triggers_enabled = form.patrolAlertTriggers;
|
||||
}
|
||||
if (form.patrolAnomalyTriggers !== (settings()?.patrol_anomaly_triggers_enabled ?? true)) {
|
||||
payload.patrol_anomaly_triggers_enabled = form.patrolAnomalyTriggers;
|
||||
}
|
||||
if (
|
||||
form.patrolAlertTriggerMinSeverity !==
|
||||
(settings()?.patrol_alert_trigger_min_severity ?? 'critical')
|
||||
) {
|
||||
payload.patrol_alert_trigger_min_severity = form.patrolAlertTriggerMinSeverity;
|
||||
}
|
||||
if (form.patrolAutoFix !== settings()?.patrol_auto_fix) {
|
||||
payload.patrol_auto_fix = form.patrolAutoFix;
|
||||
}
|
||||
@@ -888,13 +924,15 @@ export const useAISettingsState = () => {
|
||||
syncModelCatalogForSettings(updated);
|
||||
hydratePatrolPreflightFromSettings(updated);
|
||||
void runProviderPreflight(updated);
|
||||
const savedLabel = options.savedLabel ?? 'Provider & Models settings saved';
|
||||
const patrolReadinessMessage = getAISettingsPatrolReadinessSaveMessage(
|
||||
updated.patrol_readiness,
|
||||
savedLabel,
|
||||
);
|
||||
if (patrolReadinessMessage) {
|
||||
notificationStore.warning(patrolReadinessMessage);
|
||||
} else {
|
||||
notificationStore.success('Assistant & Patrol settings saved');
|
||||
notificationStore.success(savedLabel);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AISettings] Failed to save settings:', error);
|
||||
@@ -914,6 +952,7 @@ export const useAISettingsState = () => {
|
||||
getAISettingsSaveProviderFailureMessage(
|
||||
error instanceof Error ? error.message : '',
|
||||
providerFailure,
|
||||
options.saveErrorFallback,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
@@ -1051,7 +1090,7 @@ export const useAISettingsState = () => {
|
||||
syncModelCatalogForSettings(updated);
|
||||
void runProviderPreflight(updated);
|
||||
notificationStore.success(
|
||||
newValue ? 'Assistant & Patrol enabled' : 'Assistant & Patrol disabled',
|
||||
newValue ? 'Pulse Intelligence enabled' : 'Pulse Intelligence disabled',
|
||||
);
|
||||
} catch (error) {
|
||||
setForm('enabled', !newValue);
|
||||
@@ -1095,6 +1134,7 @@ export const useAISettingsState = () => {
|
||||
});
|
||||
|
||||
return {
|
||||
alertAnalysisLocked,
|
||||
autoFixLocked,
|
||||
availableModels,
|
||||
chatSessions,
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from 'solid-js';
|
||||
import { fetchAgentCapabilitiesManifest } from '@/api/agentCapabilities';
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
import { SecurityAPI } from '@/api/security';
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
import { MONITORING_READ_SCOPE, API_SCOPE_LABELS } from '@/constants/apiScopes';
|
||||
import {
|
||||
API_TOKEN_PRESET_QUERY_PARAM,
|
||||
PULSE_INTELLIGENCE_AGENT_TOKEN_PRESET,
|
||||
} from '@/routing/resourceLinks';
|
||||
import { useResources } from '@/hooks/useResources';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal';
|
||||
@@ -19,6 +31,7 @@ import { logger } from '@/utils/logger';
|
||||
import { getPulseBaseUrl } from '@/utils/url';
|
||||
import {
|
||||
API_TOKEN_SCOPES_DOC_URL,
|
||||
API_TOKEN_PULSE_INTELLIGENCE_AGENT_PRESET_ID,
|
||||
API_TOKEN_WILDCARD_SCOPE,
|
||||
agentActionIdForResource,
|
||||
buildAgentTokenUsage,
|
||||
@@ -64,6 +77,7 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
|
||||
const [nameInput, setNameInput] = createSignal('');
|
||||
const [selectedScopes, setSelectedScopes] = createSignal<string[]>([]);
|
||||
const [agentCapabilitiesManifest] = createResource(fetchAgentCapabilitiesManifest);
|
||||
const tokenRevealState = useTokenRevealState();
|
||||
const canManage = () => props.canManage !== false;
|
||||
|
||||
@@ -73,21 +87,66 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
const scopedTokenCount = createMemo(() => totalTokens() - wildcardCount());
|
||||
const hasWildcardTokens = createMemo(() => wildcardCount() > 0);
|
||||
const scopeGroups = createMemo(() => groupAPITokenScopes());
|
||||
const scopePresets = getAPITokenScopePresets();
|
||||
const isFullAccessSelected = () =>
|
||||
selectedScopes().length === 0 || selectedScopes().includes(API_TOKEN_WILDCARD_SCOPE);
|
||||
const scopePresets = createMemo(() =>
|
||||
getAPITokenScopePresets(agentCapabilitiesManifest()?.requiredScopes ?? []),
|
||||
);
|
||||
const pulseIntelligenceAgentPreset = createMemo(() =>
|
||||
scopePresets().find((preset) => preset.id === API_TOKEN_PULSE_INTELLIGENCE_AGENT_PRESET_ID),
|
||||
);
|
||||
const hasScopeSelection = () => selectedScopes().length > 0;
|
||||
const isFullAccessSelected = () => selectedScopes().includes(API_TOKEN_WILDCARD_SCOPE);
|
||||
const canGenerateToken = () => canManage() && hasScopeSelection() && !isGenerating();
|
||||
|
||||
let createSectionRef: HTMLDivElement | undefined;
|
||||
const [createHighlight, setCreateHighlight] = createSignal(false);
|
||||
const [createSectionReady, setCreateSectionReady] = createSignal(false);
|
||||
const [requestedTokenPreset, setRequestedTokenPreset] = createSignal('');
|
||||
const [appliedRoutePreset, setAppliedRoutePreset] = createSignal('');
|
||||
const [focusedRoutePreset, setFocusedRoutePreset] = createSignal('');
|
||||
let highlightTimer: number | undefined;
|
||||
let routeFocusTimer: number | undefined;
|
||||
|
||||
const readRequestedTokenPreset = () => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return (
|
||||
new URLSearchParams(window.location.search).get(API_TOKEN_PRESET_QUERY_PARAM)?.trim() ?? ''
|
||||
);
|
||||
};
|
||||
|
||||
const setCreateSectionRef = (element: HTMLDivElement) => {
|
||||
createSectionRef = element;
|
||||
setCreateSectionReady(true);
|
||||
};
|
||||
|
||||
const findScrollableAncestor = (element: HTMLElement): HTMLElement | null => {
|
||||
let current = element.parentElement;
|
||||
while (current) {
|
||||
const style = window.getComputedStyle(current);
|
||||
const canScroll =
|
||||
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
|
||||
current.scrollHeight > current.clientHeight;
|
||||
if (canScroll) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const focusCreateSection = () => {
|
||||
if (!createSectionRef) return;
|
||||
createSectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (typeof createSectionRef.scrollIntoView === 'function') {
|
||||
createSectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
const scrollParent = findScrollableAncestor(createSectionRef);
|
||||
if (scrollParent && typeof scrollParent.scrollTo === 'function') {
|
||||
const parentRect = scrollParent.getBoundingClientRect();
|
||||
const sectionRect = createSectionRef.getBoundingClientRect();
|
||||
scrollParent.scrollTo({
|
||||
top: Math.max(0, scrollParent.scrollTop + sectionRect.top - parentRect.top - 16),
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
setCreateHighlight(true);
|
||||
window.clearTimeout(highlightTimer);
|
||||
highlightTimer = window.setTimeout(() => setCreateHighlight(false), 1600);
|
||||
@@ -95,6 +154,7 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
|
||||
onCleanup(() => {
|
||||
if (highlightTimer) window.clearTimeout(highlightTimer);
|
||||
if (routeFocusTimer) window.clearTimeout(routeFocusTimer);
|
||||
});
|
||||
|
||||
const loadTokens = async () => {
|
||||
@@ -113,7 +173,63 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
setRequestedTokenPreset(readRequestedTokenPreset());
|
||||
const handleRoutePresetChange = () => {
|
||||
setRequestedTokenPreset(readRequestedTokenPreset());
|
||||
};
|
||||
window.addEventListener('hashchange', handleRoutePresetChange);
|
||||
window.addEventListener('popstate', handleRoutePresetChange);
|
||||
void loadTokens();
|
||||
onCleanup(() => {
|
||||
window.removeEventListener('hashchange', handleRoutePresetChange);
|
||||
window.removeEventListener('popstate', handleRoutePresetChange);
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const requestedPreset = requestedTokenPreset();
|
||||
if (
|
||||
requestedPreset !== PULSE_INTELLIGENCE_AGENT_TOKEN_PRESET ||
|
||||
appliedRoutePreset() === requestedPreset
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = pulseIntelligenceAgentPreset();
|
||||
if (!preset || preset.scopes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyScopePreset(preset.scopes);
|
||||
if (!nameInput().trim()) {
|
||||
setNameInput(preset.label);
|
||||
}
|
||||
setAppliedRoutePreset(requestedPreset);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const requestedPreset = requestedTokenPreset();
|
||||
if (requestedPreset !== PULSE_INTELLIGENCE_AGENT_TOKEN_PRESET) {
|
||||
setFocusedRoutePreset('');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!createSectionReady() ||
|
||||
loading() ||
|
||||
appliedRoutePreset() !== requestedPreset ||
|
||||
focusedRoutePreset() === requestedPreset
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusCreateSection();
|
||||
window.clearTimeout(routeFocusTimer);
|
||||
routeFocusTimer = window.setTimeout(() => {
|
||||
if (requestedTokenPreset() === requestedPreset) {
|
||||
focusCreateSection();
|
||||
}
|
||||
}, 0);
|
||||
setFocusedRoutePreset(requestedPreset);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
@@ -163,6 +279,8 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
setSelectedScopes(Array.from(new Set(scopes)).filter(Boolean));
|
||||
};
|
||||
|
||||
const applyFullAccessPreset = () => applyScopePreset([API_TOKEN_WILDCARD_SCOPE]);
|
||||
|
||||
const clearScopes = () => setSelectedScopes([]);
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
@@ -176,6 +294,10 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!canManage()) return;
|
||||
if (!hasScopeSelection()) {
|
||||
notificationStore.error('Choose a scope preset or custom scope before generating a token.');
|
||||
return;
|
||||
}
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const trimmedName = nameInput().trim() || undefined;
|
||||
@@ -313,6 +435,7 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
API_SCOPE_LABELS,
|
||||
API_TOKEN_SCOPES_DOC_URL,
|
||||
agentTokenUsage,
|
||||
applyFullAccessPreset,
|
||||
applyScopePreset,
|
||||
canManage,
|
||||
clearScopes,
|
||||
@@ -325,6 +448,7 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
handleDelete,
|
||||
handleGenerate,
|
||||
hasWildcardTokens,
|
||||
hasScopeSelection,
|
||||
isFullAccessSelected,
|
||||
isGenerating,
|
||||
isRevealActiveForCurrentToken,
|
||||
@@ -347,5 +471,6 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
wildcardCount,
|
||||
presetMatchesSelection: (presetScopes: string[]) =>
|
||||
matchesScopePreset(selectedScopes(), presetScopes),
|
||||
canGenerateToken,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,13 +8,17 @@ import {
|
||||
licenseEntitlementsLoadError,
|
||||
loadLicenseEntitlements,
|
||||
} from '@/stores/licenseEntitlements';
|
||||
import {
|
||||
fetchAgentOperationsLoopStatus,
|
||||
type AgentOperationsLoopStatus,
|
||||
} from '@/api/agentCapabilities';
|
||||
import { LicenseAPI } from '@/api/license';
|
||||
import {
|
||||
formatLicensePlanVersion,
|
||||
getSelfHostedActivationSuccessPresentation,
|
||||
getCommercialMigrationNotice,
|
||||
getGrandfatheredPriceContinuityNotice,
|
||||
getSelfHostedActivationProofPresentation,
|
||||
getSelfHostedPlanStatusPresentation,
|
||||
getSelfHostedPlanComparisonPresentation,
|
||||
getSelfHostedCurrentPlanPresentation,
|
||||
getLicenseFeatureLabel,
|
||||
@@ -84,11 +88,34 @@ export function useProLicensePanelState() {
|
||||
const [activationSuccessSource, setActivationSuccessSource] = createSignal<
|
||||
'manual' | 'purchase' | null
|
||||
>(null);
|
||||
const [agentOperationsStatus, setAgentOperationsStatus] =
|
||||
createSignal<AgentOperationsLoopStatus | null>(null);
|
||||
|
||||
const entitlements = createMemo(() => licenseEntitlements());
|
||||
const subscriptionState = createMemo(() => entitlements()?.subscription_state);
|
||||
const trialExpiryUnix = createMemo(() => entitlements()?.trial_expires_at);
|
||||
const trialDaysRemaining = createMemo(() => entitlements()?.trial_days_remaining);
|
||||
const selfHostedPlanDefinition = createMemo(() =>
|
||||
getSelfHostedPlanDefinitionForBillingTier(entitlements()?.tier),
|
||||
);
|
||||
|
||||
const loadPatrolOperatorStatus = async () => {
|
||||
try {
|
||||
setAgentOperationsStatus(await fetchAgentOperationsLoopStatus());
|
||||
} catch {
|
||||
setAgentOperationsStatus(null);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldLoadPatrolOperatorStatus = () => selfHostedPlanDefinition()?.tier === 'pro';
|
||||
|
||||
const loadPatrolOperatorStatusIfNeeded = async () => {
|
||||
if (!shouldLoadPatrolOperatorStatus()) {
|
||||
setAgentOperationsStatus(null);
|
||||
return;
|
||||
}
|
||||
await loadPatrolOperatorStatus();
|
||||
};
|
||||
|
||||
const loadPanelData = async () => {
|
||||
setLoading(true);
|
||||
@@ -125,20 +152,18 @@ export function useProLicensePanelState() {
|
||||
}
|
||||
if (purchaseResult) {
|
||||
const nextSearch = params.toString();
|
||||
const nextPath = `${location.pathname}${nextSearch ? `?${nextSearch}` : ''}${location.hash ?? ''}`;
|
||||
const nextPath = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}${nextSearch ? `?${nextSearch}` : ''}${location.hash ?? ''}`;
|
||||
navigate(nextPath, { replace: true, scroll: false });
|
||||
}
|
||||
void loadPanelData();
|
||||
void loadPanelData().then(() => {
|
||||
void loadPatrolOperatorStatusIfNeeded();
|
||||
});
|
||||
});
|
||||
|
||||
const requestedSection = createMemo<SelfHostedBillingSection>(() =>
|
||||
resolveSelfHostedBillingSection(location.pathname, location.search, location.hash),
|
||||
);
|
||||
|
||||
const selfHostedPlanDefinition = createMemo(() =>
|
||||
getSelfHostedPlanDefinitionForBillingTier(entitlements()?.tier),
|
||||
);
|
||||
|
||||
const activeSection = createMemo<SelfHostedBillingSection>(() => {
|
||||
return requestedSection() === 'usage' ? 'plan' : requestedSection();
|
||||
});
|
||||
@@ -257,10 +282,34 @@ export function useProLicensePanelState() {
|
||||
}
|
||||
return getPurchaseActivationNotice(purchaseActivationResult());
|
||||
});
|
||||
const patrolOperatorStatus = createMemo(() => {
|
||||
const status = agentOperationsStatus();
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextAction: status.nextAction,
|
||||
progressLabel: status.progressLabel,
|
||||
patrolControlOperationsLoopStarterCount: status.patrolControlOperationsLoopStarterCount,
|
||||
patrolControlCompletedOperationsLoopCount: status.patrolControlCompletedOperationsLoopCount,
|
||||
patrolControlResolvedOperationsLoopCount: status.patrolControlResolvedOperationsLoopCount,
|
||||
patrolControlValueState: status.patrolControlValueState,
|
||||
patrolAutonomyOperationsLoopStarterCount: status.patrolAutonomyOperationsLoopStarterCount,
|
||||
patrolAutonomyCompletedOperationsLoopCount: status.patrolAutonomyCompletedOperationsLoopCount,
|
||||
patrolAutonomyResolvedOperationsLoopCount: status.patrolAutonomyResolvedOperationsLoopCount,
|
||||
patrolAutonomyValueState: status.patrolAutonomyValueState,
|
||||
proActivationOperationsLoopStarterCount: status.proActivationOperationsLoopStarterCount,
|
||||
proActivationCompletedOperationsLoopCount: status.proActivationCompletedOperationsLoopCount,
|
||||
proActivationResolvedOperationsLoopCount: status.proActivationResolvedOperationsLoopCount,
|
||||
proActivationValueProofState: status.proActivationValueProofState,
|
||||
externalAgentReady: status.externalAgentReady,
|
||||
};
|
||||
});
|
||||
const activationSuccessSummary = createMemo(() =>
|
||||
getSelfHostedActivationSuccessPresentation({
|
||||
entitlements: entitlements(),
|
||||
displayableCapabilities: formattedFeatures(),
|
||||
patrolOperatorStatus: patrolOperatorStatus(),
|
||||
source: activationSuccessSource(),
|
||||
}),
|
||||
);
|
||||
@@ -336,6 +385,7 @@ export function useProLicensePanelState() {
|
||||
const summary = getSelfHostedCurrentPlanPresentation({
|
||||
entitlements: entitlements(),
|
||||
displayableCapabilities: formattedFeatures(),
|
||||
patrolOperatorStatus: patrolOperatorStatus(),
|
||||
});
|
||||
return {
|
||||
...summary,
|
||||
@@ -343,7 +393,7 @@ export function useProLicensePanelState() {
|
||||
statusLabel: status.label,
|
||||
};
|
||||
});
|
||||
const activationProof = createMemo(() => getSelfHostedActivationProofPresentation(entitlements()));
|
||||
const planStatus = createMemo(() => getSelfHostedPlanStatusPresentation(entitlements()));
|
||||
const planComparisonSummary = createMemo(() => {
|
||||
if (!showPlanSelectionPrompt()) {
|
||||
return { cards: [], action: null };
|
||||
@@ -368,7 +418,7 @@ export function useProLicensePanelState() {
|
||||
const handleActivate = async () => {
|
||||
const trimmedKey = licenseKey().trim();
|
||||
if (!trimmedKey) {
|
||||
notificationStore.error('A license or activation key is required');
|
||||
notificationStore.error('A license key is required');
|
||||
return;
|
||||
}
|
||||
setActivating(true);
|
||||
@@ -386,6 +436,7 @@ export function useProLicensePanelState() {
|
||||
loadCommercialPosture(true),
|
||||
loadRuntimeCapabilities(true),
|
||||
]);
|
||||
await loadPatrolOperatorStatusIfNeeded();
|
||||
} catch (error) {
|
||||
notificationStore.error(
|
||||
error instanceof Error ? error.message : 'Failed to activate license',
|
||||
@@ -409,6 +460,7 @@ export function useProLicensePanelState() {
|
||||
loadCommercialPosture(true),
|
||||
loadRuntimeCapabilities(true),
|
||||
]);
|
||||
await loadPatrolOperatorStatusIfNeeded();
|
||||
} catch (error) {
|
||||
notificationStore.error(error instanceof Error ? error.message : 'Failed to clear license');
|
||||
} finally {
|
||||
@@ -421,7 +473,7 @@ export function useProLicensePanelState() {
|
||||
activating,
|
||||
clearing,
|
||||
activationSuccessSummary,
|
||||
activationProof,
|
||||
planStatus,
|
||||
commercialMigrationNotice,
|
||||
commercialPlanModel,
|
||||
currentPlanSummary,
|
||||
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
} from '@/stores/sessionPresentationPolicy';
|
||||
import { resolveCanonicalSelfHostedBillingHref } from '@/utils/pricingHandoff';
|
||||
import { buildInfrastructureWorkspacePath } from './infrastructureWorkspaceModel';
|
||||
import {
|
||||
EXTERNAL_AGENT_SETUP_PATH,
|
||||
SETTINGS_API_ACCESS_PATH,
|
||||
isExternalAgentSetupHash,
|
||||
} from '@/routing/resourceLinks';
|
||||
import {
|
||||
DEFAULT_SETTINGS_TAB,
|
||||
deriveTabFromPath,
|
||||
deriveTabFromQuery,
|
||||
isAISettingsOAuthCallbackQuery,
|
||||
resolveCanonicalSettingsPath,
|
||||
settingsTabPath,
|
||||
type AgentKey,
|
||||
@@ -65,9 +71,13 @@ export function useSettingsNavigation({ navigate, location }: UseSettingsNavigat
|
||||
const queryTab = deriveTabFromQuery(search);
|
||||
const resolvedTab = queryTab ?? DEFAULT_SETTINGS_TAB;
|
||||
const target = resolveTabPath(resolvedTab);
|
||||
const shouldPreserveQuery =
|
||||
resolvedTab === 'system-ai' && isAISettingsOAuthCallbackQuery(search);
|
||||
const targetHref = shouldPreserveQuery ? `${target}${search}${hash}` : target;
|
||||
const currentHref = `${path}${search}${hash}`;
|
||||
|
||||
if (target !== path) {
|
||||
navigate(target, { replace: true, scroll: false });
|
||||
if (targetHref !== currentHref) {
|
||||
navigate(targetHref, { replace: true, scroll: false });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -87,6 +97,14 @@ export function useSettingsNavigation({ navigate, location }: UseSettingsNavigat
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === SETTINGS_API_ACCESS_PATH && isExternalAgentSetupHash(hash)) {
|
||||
navigate(EXTERNAL_AGENT_SETUP_PATH, {
|
||||
replace: true,
|
||||
scroll: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
sessionPresentationPolicyResolved() &&
|
||||
presentationPolicyIsReadOnly() &&
|
||||
|
||||
@@ -353,6 +353,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||
total={props.guest.memory?.total || 0}
|
||||
percentOnly={memoryPercentOnly()}
|
||||
cache={props.guest.memory?.cache || 0}
|
||||
cacheInclusiveLabel="Shown in Proxmox"
|
||||
balloon={props.guest.memory?.balloon || 0}
|
||||
swapUsed={props.guest.memory?.swapUsed || 0}
|
||||
swapTotal={props.guest.memory?.swapTotal || 0}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { buildStackedMemoryBarPresentation } from '../stackedMemoryBarModel';
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
describe('buildStackedMemoryBarPresentation', () => {
|
||||
it('renders the v5 used | reclaimable cache split with the Proxmox reconciliation row', () => {
|
||||
it('renders the used | reclaimable cache split with a source-neutral reconciliation row', () => {
|
||||
const presentation = buildStackedMemoryBarPresentation(
|
||||
{ used: 4 * GiB, total: 16 * GiB, cache: 6 * GiB },
|
||||
400,
|
||||
@@ -23,7 +23,21 @@ describe('buildStackedMemoryBarPresentation', () => {
|
||||
expect(rows['Reclaimable cache']).toBe('6.00 GB');
|
||||
// Truly free excludes the reclaimable cache: 16 - 4 - 6.
|
||||
expect(rows['Free']).toBe('6.00 GB');
|
||||
// Proxmox counts cache as used: (4 + 6) / 16.
|
||||
expect(rows['Used with cache']).toBe('63%');
|
||||
});
|
||||
|
||||
it('allows provider-owned surfaces to name the cache-inclusive comparison', () => {
|
||||
const presentation = buildStackedMemoryBarPresentation(
|
||||
{
|
||||
used: 4 * GiB,
|
||||
total: 16 * GiB,
|
||||
cache: 6 * GiB,
|
||||
cacheInclusiveLabel: 'Shown in Proxmox',
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const rows = Object.fromEntries(presentation.tooltipRows.map((row) => [row.label, row.value]));
|
||||
expect(rows['Shown in Proxmox']).toBe('63%');
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface StackedMemoryBarProps {
|
||||
percentOnly?: number;
|
||||
/** Reclaimable buff/cache (available - truly free); used + cache + free ≈ total. */
|
||||
cache?: number;
|
||||
cacheInclusiveLabel?: string;
|
||||
swapUsed?: number;
|
||||
swapTotal?: number;
|
||||
balloon?: number;
|
||||
@@ -188,12 +189,12 @@ function getTooltipRows(
|
||||
value: formatBytes(Math.max(0, ceiling - props.used - cache)),
|
||||
});
|
||||
|
||||
// Proxmox's UI counts reclaimable cache as used; this row explains why
|
||||
// Pulse's percentage reads lower than the same guest in Proxmox.
|
||||
// Some providers count reclaimable cache as used; keep the shared default
|
||||
// source-neutral and let provider-owned surfaces name their comparison UI.
|
||||
if (cache > 0) {
|
||||
rows.push({
|
||||
borderTop: true,
|
||||
label: 'Shown in Proxmox',
|
||||
label: props.cacheInclusiveLabel ?? 'Used with cache',
|
||||
labelClass: 'text-slate-500 italic',
|
||||
value: formatPercent(((props.used + cache) / props.total) * 100),
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('CommercialMigrationBanner', () => {
|
||||
|
||||
fireEvent.click(screen.getByText('Open license settings'));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/settings/system/billing/plan');
|
||||
expect(navigateMock).toHaveBeenCalledWith('/settings/pulse-intelligence/billing/plan');
|
||||
});
|
||||
|
||||
/* ---------- Dismiss ---------- */
|
||||
|
||||
@@ -69,9 +69,9 @@ export const ApprovalBanner: Component<ApprovalBannerProps> = (props) => {
|
||||
setActionLoading(approval.id);
|
||||
try {
|
||||
await aiIntelligenceStore.denyInvestigationFix(approval.id);
|
||||
notificationStore.success('Fix denied');
|
||||
notificationStore.success('Fix rejected');
|
||||
} catch (err) {
|
||||
notificationStore.error((err as Error).message || 'Failed to deny fix');
|
||||
notificationStore.error((err as Error).message || 'Failed to reject fix');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export const ApprovalBanner: Component<ApprovalBannerProps> = (props) => {
|
||||
<Show when={actionLoading() !== firstApproval()!.id}>
|
||||
<CheckIcon class="w-3.5 h-3.5" />
|
||||
</Show>
|
||||
Approve & Execute
|
||||
Approve fix
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -154,7 +154,7 @@ export const ApprovalBanner: Component<ApprovalBannerProps> = (props) => {
|
||||
class="gap-1.5"
|
||||
>
|
||||
<XIcon class="w-3.5 h-3.5" />
|
||||
Deny
|
||||
Reject
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={pending().length > 1}>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import CheckIcon from 'lucide-solid/icons/check';
|
||||
import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
||||
import { Component, Show, createSignal, createResource, createMemo } from 'solid-js';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import { Component, For, Show, createSignal, createResource, createMemo } from 'solid-js';
|
||||
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
@@ -112,6 +113,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
'fix_queued',
|
||||
'fix_executed',
|
||||
'fix_failed',
|
||||
'fix_rejected',
|
||||
'fix_verified',
|
||||
'fix_verification_failed',
|
||||
'fix_verification_unknown',
|
||||
@@ -164,6 +166,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
props.investigationOutcome === 'fix_verification_failed' ||
|
||||
(executionResult() && !executionResult()!.success),
|
||||
);
|
||||
const isRejected = createMemo(() => props.investigationOutcome === 'fix_rejected');
|
||||
|
||||
// Show section only when there's something to display
|
||||
const shouldShow = createMemo(
|
||||
@@ -173,6 +176,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
isQueuedWithoutDetails() ||
|
||||
isExecuted() ||
|
||||
isFailed() ||
|
||||
isRejected() ||
|
||||
executionResult(),
|
||||
);
|
||||
|
||||
@@ -203,9 +207,9 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
setActionLoading(approval.id);
|
||||
try {
|
||||
await aiIntelligenceStore.denyInvestigationFix(approval.id);
|
||||
notificationStore.success('Fix denied');
|
||||
notificationStore.success('Fix rejected');
|
||||
} catch (err) {
|
||||
notificationStore.error((err as Error).message || 'Failed to deny fix');
|
||||
notificationStore.error((err as Error).message || 'Failed to reject fix');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -234,6 +238,32 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderTechnicalCommandDetails = (commands: readonly string[] | string | undefined) => {
|
||||
const commandList = Array.isArray(commands)
|
||||
? commands.filter(Boolean)
|
||||
: commands
|
||||
? [commands]
|
||||
: [];
|
||||
if (commandList.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<details class="rounded border border-border bg-surface-alt px-2 py-1.5 text-xs">
|
||||
<summary class="cursor-pointer font-medium text-muted">Technical details</summary>
|
||||
<div class="mt-2 space-y-1">
|
||||
<For each={commandList}>
|
||||
{(command) => (
|
||||
<code class="block break-all rounded bg-surface px-2 py-1 font-mono text-base-content">
|
||||
{command}
|
||||
</code>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
const renderRecoveryActions = (assistantLabel: string, onAssistantClick: (e: Event) => void) => (
|
||||
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
|
||||
<Show when={canAutoFix()}>
|
||||
@@ -251,7 +281,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
<Show when={actionLoading() !== 'reapprove'}>
|
||||
<CheckIcon class="w-3.5 h-3.5" />
|
||||
</Show>
|
||||
Re-approve & Execute
|
||||
Re-approve fix
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!canAutoFix()}>
|
||||
@@ -300,9 +330,10 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
</div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="text-muted">{approval.context}</div>
|
||||
<div class="bg-surface-alt rounded p-2 font-mono text-xs text-base-content break-all">
|
||||
{approval.command}
|
||||
</div>
|
||||
<Show when={approval.targetName}>
|
||||
<div class="text-xs text-muted">Target: {approval.targetName}</div>
|
||||
</Show>
|
||||
{renderTechnicalCommandDetails(approval.command)}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-border-subtle">
|
||||
<Show when={canAutoFix()}>
|
||||
@@ -320,7 +351,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
<Show when={actionLoading() !== approval.id}>
|
||||
<CheckIcon class="w-3.5 h-3.5" />
|
||||
</Show>
|
||||
Approve & Execute
|
||||
Approve fix
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -330,7 +361,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
disabled={actionLoading() === approval.id}
|
||||
class="text-muted"
|
||||
>
|
||||
Deny
|
||||
Reject
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!canAutoFix()}>
|
||||
@@ -382,14 +413,10 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
</div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="text-muted">{fix.description}</div>
|
||||
<Show when={fix.commands && fix.commands.length > 0}>
|
||||
<div class="bg-surface-alt rounded p-2 font-mono text-xs text-base-content break-all">
|
||||
{fix.commands![0]}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={fix.target_host}>
|
||||
<div class="text-xs text-muted">Target: {fix.target_host}</div>
|
||||
</Show>
|
||||
{renderTechnicalCommandDetails(fix.commands)}
|
||||
</div>
|
||||
{renderRecoveryActions('Fix with Assistant', (e) =>
|
||||
handleFixWithAssistant(null, fix, e),
|
||||
@@ -465,22 +492,37 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
{(fix) => (
|
||||
<div class="mt-2 space-y-1 text-sm">
|
||||
<div class="text-muted">{fix().description}</div>
|
||||
<Show when={fix().commands && fix().commands!.length > 0}>
|
||||
<div class="bg-surface-alt rounded p-2 font-mono text-xs text-base-content break-all">
|
||||
{fix().commands![0]}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={fix().target_host}>
|
||||
<div class="text-xs text-muted">Target: {fix().target_host}</div>
|
||||
</Show>
|
||||
<Show when={fix().rationale}>
|
||||
<div class="text-xs text-muted whitespace-pre-line mt-1">{fix().rationale}</div>
|
||||
</Show>
|
||||
{renderTechnicalCommandDetails(fix().commands)}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Rejected by operator */}
|
||||
<Show when={isRejected() && !executionResult()}>
|
||||
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
|
||||
<XIcon class="w-4 h-4" />
|
||||
<span class="text-sm font-medium">Fix rejected before execution</span>
|
||||
</div>
|
||||
<Show when={investigation()?.proposed_fix}>
|
||||
{(fix) => (
|
||||
<div class="mt-2 space-y-1 text-sm">
|
||||
<div class="text-muted">{fix().description}</div>
|
||||
<Show when={fix().target_host}>
|
||||
<div class="text-xs text-muted">Target: {fix().target_host}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
{renderRecoveryActions('Discuss with Assistant', handleDiscussQueuedFix)}
|
||||
</Show>
|
||||
|
||||
{/* Failed (from backend state, no local result) */}
|
||||
<Show when={isFailed() && !executionResult()}>
|
||||
<div class="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
@@ -502,17 +544,13 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
{(fix) => (
|
||||
<div class="mt-2 space-y-1 text-sm">
|
||||
<div class="text-muted">{fix().description}</div>
|
||||
<Show when={fix().commands && fix().commands!.length > 0}>
|
||||
<div class="bg-surface-alt rounded p-2 font-mono text-xs text-base-content break-all">
|
||||
{fix().commands![0]}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={fix().target_host}>
|
||||
<div class="text-xs text-muted">Target: {fix().target_host}</div>
|
||||
</Show>
|
||||
<Show when={fix().rationale}>
|
||||
<div class="text-xs text-muted whitespace-pre-line mt-1">{fix().rationale}</div>
|
||||
</Show>
|
||||
{renderTechnicalCommandDetails(fix().commands)}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -96,6 +96,7 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
inv.status === 'needs_attention' ||
|
||||
inv.outcome === 'cannot_fix' ||
|
||||
inv.outcome === 'timed_out' ||
|
||||
inv.outcome === 'fix_rejected' ||
|
||||
inv.outcome === 'fix_verification_failed' ||
|
||||
inv.outcome === 'fix_verification_unknown' ||
|
||||
inv.outcome === 'fix_failed'
|
||||
@@ -327,6 +328,7 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
|
||||
(inv().status === 'failed' ||
|
||||
inv().outcome === 'timed_out' ||
|
||||
inv().outcome === 'fix_failed' ||
|
||||
inv().outcome === 'fix_rejected' ||
|
||||
inv().outcome === 'fix_verification_failed' ||
|
||||
inv().outcome === 'fix_verification_unknown' ||
|
||||
inv().outcome === 'needs_attention' ||
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* PatrolStatusBar
|
||||
*
|
||||
* One-line status bar replacing the Activity tab.
|
||||
* Shows: Running status, last run time + trigger, today's run count + new findings.
|
||||
*/
|
||||
|
||||
import { Component, createMemo, createResource, Show } from 'solid-js';
|
||||
import {
|
||||
getPatrolRunHistory,
|
||||
type PatrolRunRecord,
|
||||
type PatrolRuntimeState,
|
||||
type PatrolTriggerStatus,
|
||||
} from '@/api/patrol';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge';
|
||||
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { formatTriggerReason } from '@/utils/patrolFormat';
|
||||
import {
|
||||
formatPatrolActivityBreakdown,
|
||||
getPatrolActivityBreakdown,
|
||||
getPatrolTriggerStatusSummary,
|
||||
getPatrolRunKindLabel,
|
||||
getPatrolRunStatusPresentation,
|
||||
} from '@/utils/patrolRunPresentation';
|
||||
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
|
||||
import ActivityIcon from 'lucide-solid/icons/activity';
|
||||
import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
|
||||
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
|
||||
|
||||
interface PatrolStatusBarProps {
|
||||
enabled?: boolean;
|
||||
refreshTrigger?: number;
|
||||
runtimeState?: PatrolRuntimeState;
|
||||
blockedReason?: string;
|
||||
triggerStatus?: PatrolTriggerStatus;
|
||||
}
|
||||
|
||||
function normalizePatrolRunType(type: string | undefined): string {
|
||||
return String(type || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
export const PatrolStatusBar: Component<PatrolStatusBarProps> = (props) => {
|
||||
const [runs] = createResource(
|
||||
() => props.refreshTrigger ?? 0,
|
||||
async (): Promise<PatrolRunRecord[]> => {
|
||||
try {
|
||||
return await getPatrolRunHistory(100);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const stats = createMemo(() => {
|
||||
const allRuns = runs() || [];
|
||||
if (allRuns.length === 0) return null;
|
||||
|
||||
const activityBreakdown = getPatrolActivityBreakdown(allRuns);
|
||||
|
||||
const lastRun = allRuns[0];
|
||||
const lastRunTime = lastRun ? new Date(lastRun.completed_at || lastRun.started_at) : null;
|
||||
const lastRunType = normalizePatrolRunType(lastRun?.type);
|
||||
const lastRunStatus = getPatrolRunStatusPresentation(
|
||||
lastRun?.status ?? 'unknown',
|
||||
lastRun?.error_count ?? 0,
|
||||
lastRun?.finding_ids !== undefined,
|
||||
);
|
||||
return {
|
||||
activityBreakdown,
|
||||
activityBreakdownLabel: formatPatrolActivityBreakdown(activityBreakdown),
|
||||
lastRun,
|
||||
runsToday: activityBreakdown.totalRuns,
|
||||
newFindingsToday: activityBreakdown.newFindings,
|
||||
lastRunTime: lastRunTime ? formatRelativeTime(lastRunTime, { compact: true }) : null,
|
||||
lastRunTimeLabel:
|
||||
lastRunType === '' || lastRunType === 'full' || lastRunType === 'patrol'
|
||||
? 'Last full patrol'
|
||||
: 'Last activity',
|
||||
lastRunTrigger: formatTriggerReason(lastRun?.trigger_reason),
|
||||
lastRunTypeLabel: getPatrolRunKindLabel(lastRun?.type),
|
||||
lastRunStatus,
|
||||
lastRunHasFindingsSnapshot: lastRun?.finding_ids !== undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const circuitBreaker = () => aiIntelligenceStore.circuitBreakerStatus;
|
||||
const runtimePresentation = createMemo(() =>
|
||||
getPatrolRuntimePresentation(props.runtimeState, props.blockedReason),
|
||||
);
|
||||
const showRuntimeState = createMemo(() => {
|
||||
const runtimeState = props.runtimeState;
|
||||
return (
|
||||
runtimeState === 'blocked' || runtimeState === 'disabled' || runtimeState === 'unavailable'
|
||||
);
|
||||
});
|
||||
const showRunInProgress = createMemo(() => props.runtimeState === 'running');
|
||||
const triggerStatusSummary = createMemo(() => getPatrolTriggerStatusSummary(props.triggerStatus));
|
||||
const resolvedStats = createMemo(() => (!runs.loading ? stats() : null));
|
||||
|
||||
return (
|
||||
<Show when={resolvedStats()}>
|
||||
<div class="bg-surface rounded-md border border-border px-4 py-2">
|
||||
{/* Circuit breaker warning */}
|
||||
<Show when={circuitBreaker()?.state === 'open'}>
|
||||
<div class="flex items-center gap-1.5 mb-1.5 pb-1.5 border-b border-red-200 dark:border-red-800">
|
||||
<AlertTriangleIcon class="w-3.5 h-3.5 text-red-500" />
|
||||
<span class="text-red-600 dark:text-red-400 font-medium text-xs">
|
||||
Provider circuit breaker tripped — Patrol paused after{' '}
|
||||
{circuitBreaker()!.consecutive_failures} consecutive failures
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={circuitBreaker()?.state === 'half-open'}>
|
||||
<div class="flex items-center gap-1.5 mb-1.5 pb-1.5 border-b border-amber-200 dark:border-amber-800">
|
||||
<AlertCircleIcon class="w-3.5 h-3.5 text-amber-500" />
|
||||
<span class="text-amber-600 dark:text-amber-400 font-medium text-xs">
|
||||
Provider circuit breaker recovering — testing with next patrol run
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
{/* Status */}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Show
|
||||
when={showRuntimeState()}
|
||||
fallback={
|
||||
<>
|
||||
<ActivityIcon class="w-3.5 h-3.5 text-blue-500" />
|
||||
<span class="text-blue-600 dark:text-blue-400 font-medium text-xs">
|
||||
Recent activity
|
||||
</span>
|
||||
<Show when={showRunInProgress()}>
|
||||
<StatusIndicatorBadge
|
||||
label="Run in progress"
|
||||
variant="info"
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={runtimePresentation().tone === 'warning'}
|
||||
fallback={
|
||||
<Show
|
||||
when={runtimePresentation().tone === 'error'}
|
||||
fallback={<AlertCircleIcon class="w-3.5 h-3.5 text-blue-500" />}
|
||||
>
|
||||
<AlertTriangleIcon class="w-3.5 h-3.5 text-red-500" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<AlertTriangleIcon class="w-3.5 h-3.5 text-amber-500" />
|
||||
</Show>
|
||||
<span
|
||||
class={`font-medium text-xs ${
|
||||
runtimePresentation().tone === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: runtimePresentation().tone === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{runtimePresentation().label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<span class="hidden sm:inline text-slate-300 ">|</span>
|
||||
|
||||
{/* Last run */}
|
||||
<Show when={resolvedStats()!.lastRunTime}>
|
||||
<span class="text-xs text-muted">
|
||||
{resolvedStats()!.lastRunTimeLabel}: {resolvedStats()!.lastRunTime}
|
||||
<Show when={resolvedStats()!.lastRunTrigger}>
|
||||
<span class=" "> ({resolvedStats()!.lastRunTrigger})</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<span class="hidden sm:inline text-slate-300 ">|</span>
|
||||
|
||||
<Show when={resolvedStats()!.lastRun}>
|
||||
<span class="text-xs text-muted">
|
||||
Latest: {resolvedStats()!.lastRunTypeLabel} <span class="text-muted">·</span>{' '}
|
||||
<StatusIndicatorBadge
|
||||
label={resolvedStats()!.lastRunStatus.label}
|
||||
variant={resolvedStats()!.lastRunStatus.variant}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
/>
|
||||
<Show when={resolvedStats()!.lastRunHasFindingsSnapshot === false}>
|
||||
{' '}
|
||||
<span class="text-muted">·</span>{' '}
|
||||
<MetadataBadge tone="info" size="xs" shape="rounded">
|
||||
Findings snapshot unavailable
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<span class="hidden sm:inline text-slate-300 ">|</span>
|
||||
|
||||
{/* Today */}
|
||||
<span class="text-xs text-muted">
|
||||
Today: {resolvedStats()!.runsToday} run{resolvedStats()!.runsToday === 1 ? '' : 's'}
|
||||
<Show when={resolvedStats()!.newFindingsToday > 0}>
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
, {resolvedStats()!.newFindingsToday} new finding
|
||||
{resolvedStats()!.newFindingsToday === 1 ? '' : 's'}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
|
||||
<Show when={resolvedStats()!.activityBreakdownLabel}>
|
||||
<span class="hidden sm:inline text-slate-300 ">|</span>
|
||||
<span class="text-xs text-muted">
|
||||
Recent activity mix: {resolvedStats()!.activityBreakdownLabel}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show when={triggerStatusSummary()}>
|
||||
<span class="hidden sm:inline text-slate-300 ">|</span>
|
||||
<span class="text-xs text-muted">Trigger mode: {triggerStatusSummary()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatrolStatusBar;
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getPatrolRunCoverageSummary,
|
||||
getPatrolRunResourcesHeading,
|
||||
getPatrolRunPrimaryActionPresentation,
|
||||
getPatrolRunOperatorRecordPresentation,
|
||||
getPatrolRunStatusPresentation,
|
||||
isPatrolRunHealthy,
|
||||
} from '@/utils/patrolRunPresentation';
|
||||
@@ -71,6 +72,7 @@ interface RunHistoryEntryProps {
|
||||
|
||||
export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
const [showRunAnalysis, setShowRunAnalysis] = createSignal(true);
|
||||
const [showTechnicalDetails, setShowTechnicalDetails] = createSignal(false);
|
||||
|
||||
// Live in-progress entry
|
||||
if (props.isLive) {
|
||||
@@ -151,8 +153,6 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
|
||||
// Completed run entry
|
||||
const run = props.run;
|
||||
const scopeSummary = formatScope(run);
|
||||
const duration = formatDurationMs(run.duration_ms);
|
||||
const canonicalScopeResourceIds = getCanonicalScopeResourceIds(run);
|
||||
const runIsHealthy = isPatrolRunHealthy(run.status, run.error_count);
|
||||
const coverageSummary = getPatrolRunCoverageSummary(run);
|
||||
@@ -162,6 +162,7 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
run.error_count,
|
||||
hasFindingsSnapshot,
|
||||
);
|
||||
const operatorRecord = getPatrolRunOperatorRecordPresentation(run);
|
||||
const runErrorSummary = formatPatrolRuntimeFailureDetail(run.error_summary);
|
||||
const runErrorDetail = formatPatrolRuntimeFailureDetail(run.error_detail);
|
||||
const hasRunErrorDetail = run.error_count > 0 && (runErrorSummary || runErrorDetail);
|
||||
@@ -185,37 +186,20 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
onClick={() => props.onSelect(props.selected ? null : run)}
|
||||
class={`w-full text-left px-3 py-2 rounded-md transition-colors ${!props.selected ? 'hover:bg-surface-hover' : ''}`}
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span class="text-base-content font-medium">
|
||||
{formatRelativeTime(run.started_at, { compact: true })}
|
||||
</span>
|
||||
<StatusIndicatorBadge
|
||||
label={runStatus.label}
|
||||
variant={runStatus.variant}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
/>
|
||||
<span>{formatTriggerReason(run.trigger_reason)}</span>
|
||||
<Show when={scopeSummary && !coverageSummary}>
|
||||
<span>• {scopeSummary}</span>
|
||||
</Show>
|
||||
<Show when={duration}>
|
||||
<span>• {duration}</span>
|
||||
</Show>
|
||||
<Show when={coverageSummary}>
|
||||
<span>• {coverageSummary}</span>
|
||||
</Show>
|
||||
<Show when={run.new_findings}>
|
||||
<span>• {run.new_findings} new</span>
|
||||
</Show>
|
||||
<Show when={run.rejected_findings}>
|
||||
<span class="text-muted">• {run.rejected_findings} rejected</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={!hasFindingsSnapshot && run.new_findings === 0 && run.existing_findings === 0}
|
||||
>
|
||||
<span class="text-blue-600 dark:text-blue-400">• Findings snapshot unavailable</span>
|
||||
</Show>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span class="text-base-content font-medium">
|
||||
{formatRelativeTime(run.started_at, { compact: true })}
|
||||
</span>
|
||||
<StatusIndicatorBadge
|
||||
label={runStatus.label}
|
||||
variant={runStatus.variant}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
/>
|
||||
<span class="font-medium text-base-content">{operatorRecord.headline}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">{operatorRecord.detail}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -227,70 +211,7 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
<div class="flex min-w-0 flex-1 items-start gap-2 text-sm text-base-content">
|
||||
<SparklesIcon class="w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<p>
|
||||
{coverageSummary ? (
|
||||
<>
|
||||
{coverageSummary}{' '}
|
||||
{formatDurationMs(run.duration_ms) ? (
|
||||
<>
|
||||
in <strong>{formatDurationMs(run.duration_ms)}</strong>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}{' '}
|
||||
{run.tool_call_count > 0 ? (
|
||||
<>
|
||||
using <strong>{run.tool_call_count}</strong> tool call
|
||||
{run.tool_call_count !== 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
.{' '}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Patrol completed
|
||||
{formatDurationMs(run.duration_ms) ? (
|
||||
<>
|
||||
{' '}
|
||||
in <strong>{formatDurationMs(run.duration_ms)}</strong>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
.{' '}
|
||||
</>
|
||||
)}
|
||||
{run.new_findings > 0 ? (
|
||||
<>
|
||||
Found <strong>{run.new_findings}</strong> new issue
|
||||
{run.new_findings !== 1 ? 's' : ''}
|
||||
{run.auto_fix_count > 0 ? (
|
||||
<>
|
||||
, remediated <strong>{run.auto_fix_count}</strong>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
.
|
||||
</>
|
||||
) : run.existing_findings > 0 ? (
|
||||
<>
|
||||
No new issues, but <strong>{run.existing_findings}</strong> existing issue
|
||||
{run.existing_findings !== 1 ? 's remain' : ' remains'}.
|
||||
</>
|
||||
) : !hasFindingsSnapshot ? (
|
||||
<span class="text-blue-600 dark:text-blue-400">
|
||||
This run predates findings snapshots, so run-specific findings cannot be fully
|
||||
verified.
|
||||
</span>
|
||||
) : runIsHealthy ? (
|
||||
<span class="text-green-600 dark:text-green-400">All clear — no new issues.</span>
|
||||
) : (
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
Patrol completed with issues requiring review.
|
||||
</span>
|
||||
)}
|
||||
<strong>{operatorRecord.headline}.</strong> {operatorRecord.detail}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -432,7 +353,7 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
when={!hasFindingsSnapshot && run.new_findings === 0 && run.existing_findings === 0}
|
||||
>
|
||||
<MetadataBadge tone="info">
|
||||
<ActivityIcon class="w-3 h-3" /> Snapshot unavailable
|
||||
<ActivityIcon class="w-3 h-3" /> Finding record unavailable
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
<Show
|
||||
@@ -450,44 +371,7 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: AI Effort Bar */}
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3 px-3 py-2 rounded-md bg-surface-alt text-xs text-muted">
|
||||
<Show when={formatDurationMs(run.duration_ms)}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ClockIcon class="w-3.5 h-3.5" /> {formatDurationMs(run.duration_ms)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.tool_call_count > 0}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ZapIcon class="w-3.5 h-3.5" /> {run.tool_call_count} tool call
|
||||
{run.tool_call_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.triage_flags > 0}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ShieldAlertIcon class="w-3.5 h-3.5" /> {run.triage_flags} triage flag
|
||||
{run.triage_flags !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.triage_skipped_llm}>
|
||||
<span class="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||
<BrainCircuitIcon class="w-3.5 h-3.5" /> LLM skipped
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={(run.input_tokens || 0) + (run.output_tokens || 0) > 0}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<BrainCircuitIcon class="w-3.5 h-3.5" />{' '}
|
||||
{((run.input_tokens || 0) + (run.output_tokens || 0)).toLocaleString()} tokens
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.type === 'scoped' && !coverageSummary}>
|
||||
<MetadataBadge tone="info" size="xs" shape="rounded">
|
||||
{formatScope(run) || 'Scoped'}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Section 5: Patrol Analysis */}
|
||||
{/* Section 4: Patrol Analysis */}
|
||||
<Show when={run.ai_analysis}>
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -515,10 +399,58 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Section 6: Tool Call Trace */}
|
||||
<RunToolCallTrace runId={run.id} toolCallCount={run.tool_call_count} />
|
||||
{/* Technical run trace: useful for debugging, hidden from the default operator record. */}
|
||||
<details
|
||||
class="mt-3 rounded-md border border-border bg-surface-alt px-3 py-2 text-xs text-muted"
|
||||
onToggle={(event) => setShowTechnicalDetails(event.currentTarget.open)}
|
||||
>
|
||||
<summary class="cursor-pointer font-medium text-base-content">
|
||||
Technical details
|
||||
</summary>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||
<Show when={formatDurationMs(run.duration_ms)}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ClockIcon class="w-3.5 h-3.5" /> {formatDurationMs(run.duration_ms)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.tool_call_count > 0}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ZapIcon class="w-3.5 h-3.5" /> {run.tool_call_count} tool call
|
||||
{run.tool_call_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.triage_flags > 0}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ShieldAlertIcon class="w-3.5 h-3.5" /> {run.triage_flags} triage flag
|
||||
{run.triage_flags !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.triage_skipped_llm}>
|
||||
<span class="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||
<BrainCircuitIcon class="w-3.5 h-3.5" /> LLM skipped
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={(run.input_tokens || 0) + (run.output_tokens || 0) > 0}>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<BrainCircuitIcon class="w-3.5 h-3.5" />{' '}
|
||||
{((run.input_tokens || 0) + (run.output_tokens || 0)).toLocaleString()} tokens
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={run.type === 'scoped' && !coverageSummary}>
|
||||
<MetadataBadge tone="info" size="xs" shape="rounded">
|
||||
{formatScope(run) || 'Scoped'}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
<Show when={formatTriggerReason(run.trigger_reason)}>
|
||||
<span>{formatTriggerReason(run.trigger_reason)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={showTechnicalDetails()}>
|
||||
<RunToolCallTrace runId={run.id} toolCallCount={run.tool_call_count} />
|
||||
</Show>
|
||||
</details>
|
||||
|
||||
{/* Section 7: Inline Findings */}
|
||||
{/* Section 5: Inline Findings */}
|
||||
<div class="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||
<FindingsPanel
|
||||
filterFindingIds={run.finding_ids}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show } from 'solid-js';
|
||||
import { createMemo, createSignal, For, Show } from 'solid-js';
|
||||
import type { Accessor } from 'solid-js';
|
||||
import type { PatrolRunRecord } from '@/api/patrol';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
@@ -11,6 +11,8 @@ import { RunHistoryEntry } from './RunHistoryEntry';
|
||||
|
||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||
|
||||
const INITIAL_VISIBLE_RUN_HISTORY_COUNT = 8;
|
||||
|
||||
interface PatrolStreamState {
|
||||
phase: Accessor<string>;
|
||||
currentTool: Accessor<string>;
|
||||
@@ -34,22 +36,25 @@ interface RunHistoryPanelProps {
|
||||
}
|
||||
|
||||
export function RunHistoryPanel(props: RunHistoryPanelProps) {
|
||||
const [showOlderRuns, setShowOlderRuns] = createSignal(false);
|
||||
const visibleRuns = createMemo(() =>
|
||||
showOlderRuns() ? props.runs : props.runs.slice(0, INITIAL_VISIBLE_RUN_HISTORY_COUNT),
|
||||
);
|
||||
const hiddenRunCount = createMemo(() => Math.max(props.runs.length - visibleRuns().length, 0));
|
||||
|
||||
return (
|
||||
<div class="bg-surface rounded-md border border-border p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-base-content">Recent patrol runs</h2>
|
||||
<p class="text-xs text-muted">
|
||||
{getRunHistorySelectionHint(props.runs, props.selectedRun)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<p class="min-w-0 text-xs text-muted">
|
||||
{getRunHistorySelectionHint(props.runs, props.selectedRun)}
|
||||
</p>
|
||||
<Show when={props.selectedRun}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelectRun(null)}
|
||||
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
class="shrink-0 text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
Clear filter
|
||||
Show all runs
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -67,18 +72,35 @@ export function RunHistoryPanel(props: RunHistoryPanelProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={!props.loading && props.runs.length > 0}>
|
||||
<div class="space-y-2">
|
||||
<For each={props.runs}>
|
||||
{(run) => (
|
||||
<RunHistoryEntry
|
||||
run={run}
|
||||
isLive={run.id === '__live__'}
|
||||
patrolStream={props.patrolStream}
|
||||
selected={props.selectedRun?.id === run.id}
|
||||
onSelect={props.onSelectRun}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div class="space-y-3">
|
||||
<div class="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
|
||||
<For each={visibleRuns()}>
|
||||
{(run) => (
|
||||
<RunHistoryEntry
|
||||
run={run}
|
||||
isLive={run.id === '__live__'}
|
||||
patrolStream={props.patrolStream}
|
||||
selected={props.selectedRun?.id === run.id}
|
||||
onSelect={props.onSelectRun}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.runs.length > INITIAL_VISIBLE_RUN_HISTORY_COUNT}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOlderRuns((expanded) => !expanded)}
|
||||
class="w-full rounded-md border border-border-subtle bg-surface-alt px-3 py-2 text-xs font-medium text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
|
||||
>
|
||||
<Show
|
||||
when={!showOlderRuns()}
|
||||
fallback={`Show recent ${INITIAL_VISIBLE_RUN_HISTORY_COUNT} runs`}
|
||||
>
|
||||
Show {hiddenRunCount()} older runs
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,17 @@ describe('ApprovalBanner', () => {
|
||||
expect(approvalBannerSource).not.toContain('px-3 py-1.5 bg-amber-600 hover:bg-amber-700');
|
||||
});
|
||||
|
||||
it('uses governed decision wording for a single approval', () => {
|
||||
state.pendingApprovals = [approvalRequest()];
|
||||
|
||||
render(() => <ApprovalBanner />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Approve fix' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Approve & Execute')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Deny')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reviews the first approval-linked finding in canonical urgency order', () => {
|
||||
state.pendingApprovals = [
|
||||
{
|
||||
|
||||
@@ -107,10 +107,47 @@ describe('ApprovalSection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('demotes raw commands behind technical details instead of making them the approval body', () => {
|
||||
expect(approvalSectionSource).toContain('renderTechnicalCommandDetails');
|
||||
expect(approvalSectionSource).toContain('Technical details');
|
||||
expect(approvalSectionSource).toContain('{renderTechnicalCommandDetails(approval.command)}');
|
||||
expect(approvalSectionSource).toContain('{renderTechnicalCommandDetails(fix.commands)}');
|
||||
expect(approvalSectionSource).toContain('{renderTechnicalCommandDetails(fix().commands)}');
|
||||
expect(approvalSectionSource).not.toContain('{approval.command}</div>');
|
||||
expect(approvalSectionSource).not.toContain('{fix.commands![0]}</div>');
|
||||
expect(approvalSectionSource).not.toContain('{fix().commands![0]}</div>');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('uses governed decision wording for live approvals', async () => {
|
||||
state.hasAutoFix = true;
|
||||
state.pendingApprovals = [
|
||||
{
|
||||
id: 'approval-1',
|
||||
toolId: 'investigation_fix',
|
||||
command: 'systemctl restart nginx',
|
||||
targetType: 'investigation',
|
||||
targetId: 'finding-1',
|
||||
targetName: 'node-1',
|
||||
context: 'Restart the workload service',
|
||||
riskLevel: 'high',
|
||||
status: 'pending',
|
||||
requestedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
render(() => <ApprovalSection findingId="finding-1" investigationOutcome="fix_queued" />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /approve fix/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /reject/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/approve & execute/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^deny$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps fix approvals out of commercial trial prompts', () => {
|
||||
expect(approvalSectionSource).not.toContain('canStartCommercialTrial');
|
||||
expect(approvalSectionSource).not.toContain('runStartProTrialAction');
|
||||
@@ -371,8 +408,8 @@ describe('ApprovalSection', () => {
|
||||
|
||||
render(() => <ApprovalSection findingId="finding-2" investigationOutcome="fix_queued" />);
|
||||
|
||||
expect(await screen.findAllByRole('button', { name: /re-approve & execute/i })).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /re-approve & execute/i }));
|
||||
expect(await screen.findAllByRole('button', { name: /re-approve fix/i })).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: /re-approve fix/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reapproveInvestigationFixMock).toHaveBeenCalledWith('finding-2');
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cleanup, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PatrolRunRecord, PatrolTriggerStatus } from '@/api/patrol';
|
||||
import { PatrolStatusBar } from '../PatrolStatusBar';
|
||||
|
||||
const getPatrolRunHistoryMock = vi.hoisted(() => vi.fn<() => Promise<PatrolRunRecord[]>>());
|
||||
|
||||
vi.mock('@/api/patrol', () => ({
|
||||
getPatrolRunHistory: getPatrolRunHistoryMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/aiIntelligence', () => ({
|
||||
aiIntelligenceStore: {
|
||||
circuitBreakerStatus: null,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PatrolStatusBar', () => {
|
||||
beforeEach(() => {
|
||||
getPatrolRunHistoryMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows recent activity instead of a healthy verdict for healthy patrol runs', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-healthy',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'patrol',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'All clear',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => <PatrolStatusBar refreshTrigger={1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recent activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Latest: Full patrol')).toBeInTheDocument();
|
||||
expect(screen.getByText('healthy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags latest runs whose findings snapshot is unavailable', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-legacy-healthy',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'patrol',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'All clear',
|
||||
finding_ids: undefined,
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => <PatrolStatusBar refreshTrigger={1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recent activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const latestRunSection = screen.getByText('Latest: Full patrol').closest('span');
|
||||
expect(latestRunSection).not.toBeNull();
|
||||
expect(latestRunSection).toHaveTextContent(
|
||||
'Latest: Full patrol · completed · Findings snapshot unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows patrol paused when the runtime is blocked even if the last run was healthy', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-healthy',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'patrol',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'All clear',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => (
|
||||
<PatrolStatusBar
|
||||
refreshTrigger={1}
|
||||
runtimeState="blocked"
|
||||
blockedReason="Quickstart credits exhausted. Connect your API key to continue using Patrol."
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Patrol Paused')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Recent activity')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the latest full-patrol result when the last patrol run found issues', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-critical',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'patrol',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 1,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'Critical issue detected',
|
||||
finding_ids: ['finding-1'],
|
||||
error_count: 0,
|
||||
status: 'critical',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => <PatrolStatusBar refreshTrigger={1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recent activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Latest: Full patrol')).toBeInTheDocument();
|
||||
expect(screen.getByText('critical')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows scoped/erroring activity factually instead of a healthy summary', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-error-count',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'scoped',
|
||||
trigger_reason: 'alert_fired',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'Run completed with errors',
|
||||
finding_ids: [],
|
||||
error_count: 2,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => <PatrolStatusBar refreshTrigger={1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recent activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const latestRunSection = screen.getByText('Latest: Scoped run').closest('span');
|
||||
expect(latestRunSection).not.toBeNull();
|
||||
expect(latestRunSection).toHaveTextContent('Latest: Scoped run · error');
|
||||
expect(within(latestRunSection as HTMLElement).queryByText('healthy')).not.toBeInTheDocument();
|
||||
expect(within(latestRunSection as HTMLElement).getByText('error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps running patrols factual with a run-in-progress indicator', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-startup',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'patrol',
|
||||
trigger_reason: 'startup',
|
||||
resources_checked: 57,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'Completed with errors',
|
||||
finding_ids: [],
|
||||
error_count: 1,
|
||||
status: 'error',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => <PatrolStatusBar refreshTrigger={1} runtimeState="running" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recent activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Run in progress')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol Running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a same-day activity breakdown and scoped trigger state when Patrol is noisy', async () => {
|
||||
const referenceDay = new Date();
|
||||
const atTime = (hour: number, minute: number) =>
|
||||
new Date(
|
||||
referenceDay.getFullYear(),
|
||||
referenceDay.getMonth(),
|
||||
referenceDay.getDate(),
|
||||
hour,
|
||||
minute,
|
||||
0,
|
||||
).toISOString();
|
||||
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-alert',
|
||||
started_at: atTime(10, 0),
|
||||
completed_at: atTime(10, 1),
|
||||
duration_ms: 60000,
|
||||
type: 'scoped',
|
||||
trigger_reason: 'alert_fired',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'ok',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'run-anomaly',
|
||||
started_at: atTime(9, 0),
|
||||
completed_at: atTime(9, 1),
|
||||
duration_ms: 60000,
|
||||
type: 'scoped',
|
||||
trigger_reason: 'anomaly',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'ok',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
{
|
||||
id: 'run-full',
|
||||
started_at: atTime(8, 0),
|
||||
completed_at: atTime(8, 5),
|
||||
duration_ms: 300000,
|
||||
type: 'patrol',
|
||||
resources_checked: 58,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'ok',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => (
|
||||
<PatrolStatusBar
|
||||
refreshTrigger={1}
|
||||
triggerStatus={{
|
||||
running: true,
|
||||
pending_triggers: 4,
|
||||
current_interval_ms: 300000,
|
||||
recent_events: 6,
|
||||
is_busy_mode: true,
|
||||
alert_triggers_enabled: true,
|
||||
anomaly_triggers_enabled: false,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Recent activity mix: 1 full, 1 alert-triggered, 1 anomaly-triggered'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('Trigger mode: 4 queued · busy mode · anomalies off'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces runtime-blocked event triggers in the activity bar', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-latest',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'patrol',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'ok',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(() => (
|
||||
<PatrolStatusBar
|
||||
refreshTrigger={1}
|
||||
triggerStatus={{
|
||||
running: true,
|
||||
pending_triggers: 0,
|
||||
current_interval_ms: 300000,
|
||||
recent_events: 0,
|
||||
is_busy_mode: false,
|
||||
alert_triggers_enabled: true,
|
||||
anomaly_triggers_enabled: true,
|
||||
event_triggers_blocked: true,
|
||||
event_triggers_blocked_reason: 'background_automation_disabled',
|
||||
event_triggers_blocked_message:
|
||||
'Automatic Patrol checks from alerts and anomalies are paused by the local development safety guard. Manual Patrol still works.',
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Trigger mode: Automatic Patrol checks from alerts and anomalies are paused by the local development safety guard. Manual Patrol still works.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates scoped trigger status when trigger data arrives after the initial render', async () => {
|
||||
getPatrolRunHistoryMock.mockResolvedValue([
|
||||
{
|
||||
id: 'run-busy-scoped',
|
||||
started_at: '2026-03-12T10:00:00Z',
|
||||
completed_at: '2026-03-12T10:01:00Z',
|
||||
duration_ms: 60000,
|
||||
type: 'scoped',
|
||||
trigger_reason: 'alert_fired',
|
||||
resources_checked: 1,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: 'ok',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const [triggerStatus, setTriggerStatus] = createSignal<PatrolTriggerStatus | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
render(() => <PatrolStatusBar refreshTrigger={1} triggerStatus={triggerStatus()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Recent activity')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Trigger mode:/)).not.toBeInTheDocument();
|
||||
|
||||
setTriggerStatus({
|
||||
running: true,
|
||||
pending_triggers: 4,
|
||||
current_interval_ms: 300000,
|
||||
recent_events: 6,
|
||||
is_busy_mode: true,
|
||||
alert_triggers_enabled: true,
|
||||
anomaly_triggers_enabled: false,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Trigger mode: 4 queued · busy mode · anomalies off'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PatrolRunRecord } from '@/api/patrol';
|
||||
import runHistoryEntrySource from '../RunHistoryEntry.tsx?raw';
|
||||
import { RunHistoryEntry } from '../RunHistoryEntry';
|
||||
|
||||
const { findingsPanelState, openMock } = vi.hoisted(() => ({
|
||||
@@ -142,7 +143,7 @@ describe('RunHistoryEntry', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('• Findings snapshot unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByText('Completed check')).toBeInTheDocument();
|
||||
expect(screen.getByText('completed')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('findings-panel')).toBeInTheDocument();
|
||||
expect(findingsPanelState.latestProps).toMatchObject({
|
||||
@@ -160,11 +161,13 @@ describe('RunHistoryEntry', () => {
|
||||
},
|
||||
});
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This run predates findings snapshots, so run-specific findings cannot be fully verified.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Snapshot unavailable')).toBeInTheDocument();
|
||||
screen.getAllByText(
|
||||
(_, element) =>
|
||||
element?.tagName === 'P' &&
|
||||
(element.textContent?.includes('This older run has no issue list.') ?? false),
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Finding record unavailable')).toBeInTheDocument();
|
||||
expect(screen.queryByText('All clear — no new issues.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^All clear$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('healthy')).not.toBeInTheDocument();
|
||||
@@ -190,20 +193,25 @@ describe('RunHistoryEntry', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('• Checked 1 of 2 scoped resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol needs attention')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Checked 1 of 2 scoped resources in 453ms. Runtime issue: 1 Patrol runtime error recorded.',
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Resources checked (1 of 2 scoped)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('• 1 resources')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Scoped to 2 resources')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.getAllByText(
|
||||
(_, element) =>
|
||||
element?.tagName === 'P' &&
|
||||
(element.textContent?.includes('Checked 1 of 2 scoped resources') ?? false) &&
|
||||
(element.textContent?.includes('453ms') ?? false) &&
|
||||
(element.textContent?.includes('Patrol completed with issues requiring review.') ??
|
||||
(element.textContent?.includes('Runtime issue: 1 Patrol runtime error recorded.') ??
|
||||
false),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('surfaces structured Patrol runtime error details on expanded runs', () => {
|
||||
@@ -226,13 +234,13 @@ describe('RunHistoryEntry', () => {
|
||||
));
|
||||
|
||||
expect(screen.getByText('Selected model does not support Patrol tools')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Provider rejected Patrol tool calls/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Provider rejected Patrol tool calls/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/tool_choice/)).toBeNull();
|
||||
expect(screen.queryByText(/No endpoints found/)).toBeNull();
|
||||
expect(screen.getByText('error')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Open Patrol provider settings' })).toHaveAttribute(
|
||||
expect(screen.getByRole('link', { name: 'Open Provider & Models' })).toHaveAttribute(
|
||||
'href',
|
||||
'/settings/system-ai',
|
||||
'/settings/pulse-intelligence/provider',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -252,7 +260,7 @@ describe('RunHistoryEntry', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Open Patrol provider settings' })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: 'Open Provider & Models' })).toBeNull();
|
||||
});
|
||||
|
||||
it('opens Assistant with structured run history context', () => {
|
||||
@@ -292,7 +300,7 @@ describe('RunHistoryEntry', () => {
|
||||
handoffMetadata: {
|
||||
kind: 'patrol_run',
|
||||
runId: 'run-runtime-error',
|
||||
runType: 'Scoped run',
|
||||
runType: 'Targeted check',
|
||||
runStatus: 'error',
|
||||
runtimeFailure: true,
|
||||
},
|
||||
@@ -332,18 +340,23 @@ describe('RunHistoryEntry', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('• Checked 0 of 2 scoped resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol needs attention')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Checked 0 of 2 scoped resources in 453ms. Runtime issue: 1 Patrol runtime error recorded.',
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Scoped to 2 resources')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.getAllByText(
|
||||
(_, element) =>
|
||||
element?.tagName === 'P' &&
|
||||
(element.textContent?.includes('Checked 0 of 2 scoped resources') ?? false) &&
|
||||
(element.textContent?.includes('453ms') ?? false) &&
|
||||
(element.textContent?.includes('Patrol completed with issues requiring review.') ??
|
||||
(element.textContent?.includes('Runtime issue: 1 Patrol runtime error recorded.') ??
|
||||
false),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/^Patrol completed in 453ms/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -364,18 +377,13 @@ describe('RunHistoryEntry', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
(_, element) =>
|
||||
element?.tagName === 'P' &&
|
||||
(element.textContent?.includes('No new issues, but 2 existing issues remain.') ?? false),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('2 issues still open')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Patrol ran in 1m. No new issues.').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('All clear — no new issues.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^All clear$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses singular verb agreement when a single existing issue remains', () => {
|
||||
it('uses singular issue wording when a single issue remains open', () => {
|
||||
render(() => (
|
||||
<RunHistoryEntry
|
||||
run={{
|
||||
@@ -392,13 +400,8 @@ describe('RunHistoryEntry', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
(_, element) =>
|
||||
element?.tagName === 'P' &&
|
||||
(element.textContent?.includes('No new issues, but 1 existing issue remains.') ?? false),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('1 issue still open')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Patrol ran in 1m. No new issues.').length).toBeGreaterThan(0);
|
||||
// Guard against the regression: the prior wording was "1 existing issue remain."
|
||||
expect(
|
||||
screen.queryByText(
|
||||
@@ -431,6 +434,39 @@ describe('RunHistoryEntry', () => {
|
||||
expect(screen.getByText('LLM skipped')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps technical run trace behind an explicit details disclosure', () => {
|
||||
expect(runHistoryEntrySource).toContain('const [showTechnicalDetails');
|
||||
expect(runHistoryEntrySource).toContain('Technical details');
|
||||
expect(runHistoryEntrySource).toContain('onToggle={(event) => setShowTechnicalDetails');
|
||||
expect(runHistoryEntrySource).toContain('<Show when={showTechnicalDetails()}>');
|
||||
|
||||
render(() => (
|
||||
<RunHistoryEntry
|
||||
run={{
|
||||
...run,
|
||||
id: 'run-technical-details',
|
||||
duration_ms: 60_000,
|
||||
tool_call_count: 2,
|
||||
triage_flags: 1,
|
||||
}}
|
||||
isLive={false}
|
||||
patrolStream={patrolStream}
|
||||
selected={true}
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Technical details')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tool-call-trace')).not.toBeInTheDocument();
|
||||
|
||||
const technicalDetails = screen.getByText('Technical details').closest('details');
|
||||
expect(technicalDetails).not.toBeNull();
|
||||
technicalDetails!.open = true;
|
||||
fireEvent(technicalDetails!, new Event('toggle'));
|
||||
|
||||
expect(screen.getByTestId('tool-call-trace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders TrueNAS run coverage separately from agent hosts', () => {
|
||||
render(() => (
|
||||
<RunHistoryEntry
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PatrolRunRecord } from '@/api/patrol';
|
||||
import { RunHistoryPanel } from '../RunHistoryPanel';
|
||||
|
||||
vi.mock('../RunHistoryEntry', () => ({
|
||||
RunHistoryEntry: () => <div data-testid="run-history-entry" />,
|
||||
RunHistoryEntry: (props: { run: PatrolRunRecord }) => (
|
||||
<div data-testid="run-history-entry">{props.run.id}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('RunHistoryPanel', () => {
|
||||
@@ -59,7 +61,7 @@ describe('RunHistoryPanel', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('warns that some older runs do not include findings snapshots', () => {
|
||||
it('frames history as Patrol checks even when older runs lack finding records', () => {
|
||||
render(() => (
|
||||
<RunHistoryPanel
|
||||
runs={[baseRun, { ...baseRun, id: 'run-legacy', finding_ids: undefined }]}
|
||||
@@ -70,14 +72,15 @@ describe('RunHistoryPanel', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'History' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Select a run to filter findings when available. Some older runs do not include findings snapshots.',
|
||||
'Open a check to review what Patrol found. Older checks may not have issue lists.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains when the selected run predates findings snapshots', () => {
|
||||
it('explains when the selected run lacks finding records', () => {
|
||||
const legacyRun = { ...baseRun, id: 'run-legacy', finding_ids: undefined };
|
||||
|
||||
render(() => (
|
||||
@@ -92,8 +95,40 @@ describe('RunHistoryPanel', () => {
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Selected run predates findings snapshots; run-scoped findings cannot be fully verified.',
|
||||
'This older check has no issue list.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps older run snapshots behind an explicit expansion', () => {
|
||||
const runs = Array.from({ length: 10 }, (_, index) => ({
|
||||
...baseRun,
|
||||
id: `run-${index + 1}`,
|
||||
}));
|
||||
|
||||
render(() => (
|
||||
<RunHistoryPanel
|
||||
runs={runs}
|
||||
loading={false}
|
||||
selectedRun={null}
|
||||
onSelectRun={vi.fn()}
|
||||
patrolStream={patrolStream}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getAllByTestId('run-history-entry')).toHaveLength(8);
|
||||
expect(screen.getByText('run-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('run-8')).toBeInTheDocument();
|
||||
expect(screen.queryByText('run-9')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 2 older runs' }));
|
||||
|
||||
expect(screen.getAllByTestId('run-history-entry')).toHaveLength(10);
|
||||
expect(screen.getByText('run-10')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show recent 8 runs' }));
|
||||
|
||||
expect(screen.getAllByTestId('run-history-entry')).toHaveLength(8);
|
||||
expect(screen.queryByText('run-10')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('FilterButtonGroup', () => {
|
||||
it('supports equal-width segmented controls for compact feature settings', () => {
|
||||
render(() => (
|
||||
<FilterButtonGroup
|
||||
ariaLabel="Patrol autonomy level"
|
||||
ariaLabel="Patrol control"
|
||||
options={[
|
||||
{ value: 'monitor', label: 'Monitor' },
|
||||
{ value: 'approval', label: 'Investigate', disabled: true },
|
||||
@@ -185,7 +185,7 @@ describe('FilterButtonGroup', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
const group = screen.getByRole('group', { name: 'Patrol autonomy level' });
|
||||
const group = screen.getByRole('group', { name: 'Patrol control' });
|
||||
const activeButton = within(group).getByRole('button', { name: 'Monitor' });
|
||||
const disabledButton = within(group).getByRole('button', { name: 'Investigate' });
|
||||
|
||||
|
||||
@@ -278,7 +278,6 @@ import resourcePickerSource from '@/components/Settings/ResourcePicker.tsx?raw';
|
||||
import settingsPageShellSource from '@/components/Settings/SettingsPageShell.tsx?raw';
|
||||
import loginSource from '@/components/Login.tsx?raw';
|
||||
import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw';
|
||||
import patrolIntelligenceSummarySource from '@/features/patrol/PatrolIntelligenceSummary.tsx?raw';
|
||||
import patrolIntelligenceWorkspaceSource from '@/features/patrol/PatrolIntelligenceWorkspace.tsx?raw';
|
||||
import approvalBannerSource from '@/components/patrol/ApprovalBanner.tsx?raw';
|
||||
import approvalSectionSource from '@/components/patrol/ApprovalSection.tsx?raw';
|
||||
@@ -287,7 +286,6 @@ import investigationSectionSource from '@/components/patrol/InvestigationSection
|
||||
import runHistoryPanelSource from '@/components/patrol/RunHistoryPanel.tsx?raw';
|
||||
import runHistoryEntrySource from '@/components/patrol/RunHistoryEntry.tsx?raw';
|
||||
import runToolCallTraceSource from '@/components/patrol/RunToolCallTrace.tsx?raw';
|
||||
import patrolStatusBarSource from '@/components/patrol/PatrolStatusBar.tsx?raw';
|
||||
import filterBarSource from '@/components/shared/FilterBar/FilterBar.tsx?raw';
|
||||
import filterChipSource from '@/components/shared/FilterBar/FilterChip.tsx?raw';
|
||||
import featureGateSectionSource from '@/components/shared/FeatureGateSection.tsx?raw';
|
||||
@@ -493,8 +491,9 @@ describe('shared primitive guardrails', () => {
|
||||
expect(reportingPanelSource).not.toContain('getReportingToggleButtonClass');
|
||||
expect(reportingPanelSource).not.toContain('<For each={REPORTING_RANGE_OPTIONS}>');
|
||||
expect(patrolIntelligenceHeaderSource).toContain('FilterButtonGroup');
|
||||
expect(patrolIntelligenceHeaderSource).toContain('variant="segmented"');
|
||||
expect(patrolIntelligenceHeaderSource).toContain('selectedAutonomyLevel');
|
||||
expect(patrolIntelligenceHeaderSource).toContain("variant={options.variant ?? 'segmented'}");
|
||||
expect(patrolIntelligenceHeaderSource).not.toContain("variant: 'prominent'");
|
||||
expect(patrolIntelligenceHeaderSource).toContain('selectedAutonomyPolicy');
|
||||
expect(patrolIntelligenceHeaderSource).not.toContain(
|
||||
'flex items-center bg-base rounded-md p-1 border shadow-inner',
|
||||
);
|
||||
@@ -657,7 +656,6 @@ describe('shared primitive guardrails', () => {
|
||||
'src/features/alerts/AlertAppriseDestinationsSection.tsx',
|
||||
'src/features/alerts/AlertEscalationSection.tsx',
|
||||
'src/features/alerts/AlertQuietHoursSection.tsx',
|
||||
'src/features/patrol/PatrolIntelligenceHeader.tsx',
|
||||
]);
|
||||
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/FormSelect.tsx');
|
||||
expect(registeredGuard?.canonical?.export).toBe('FormSelect');
|
||||
@@ -1954,8 +1952,6 @@ describe('shared primitive guardrails', () => {
|
||||
'src/components/Settings/AgentProfilesPanel.tsx',
|
||||
'src/components/Settings/DiagnosticsResultsPanel.tsx',
|
||||
'src/components/patrol/RunHistoryEntry.tsx',
|
||||
'src/components/patrol/PatrolStatusBar.tsx',
|
||||
'src/features/patrol/PatrolIntelligenceSummary.tsx',
|
||||
]);
|
||||
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/StatusIndicatorBadge.tsx');
|
||||
expect(registeredGuard?.canonical?.export).toBe('StatusIndicatorBadge');
|
||||
@@ -1977,16 +1973,7 @@ describe('shared primitive guardrails', () => {
|
||||
expect(diagnosticsResultsPanelSource).not.toContain('getStatusIndicatorBadgeToneClasses');
|
||||
expect(runHistoryEntrySource).toContain('StatusIndicatorBadge');
|
||||
expect(runHistoryEntrySource).not.toContain('runStatus.badgeClass');
|
||||
expect(patrolStatusBarSource).toContain('StatusIndicatorBadge');
|
||||
expect(patrolStatusBarSource).not.toContain('lastRunStatus.badgeClass');
|
||||
expect(patrolStatusBarSource).not.toContain(
|
||||
'rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700',
|
||||
);
|
||||
expect(patrolIntelligenceSummarySource).toContain('StatusIndicatorBadge');
|
||||
expect(patrolIntelligenceSummarySource).not.toContain('runtimeShellPresentation().badgeClass');
|
||||
expect(patrolIntelligenceSummarySource).not.toContain(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]',
|
||||
);
|
||||
expect(patrolIntelligenceWorkspaceSource).not.toContain('runtimeShellPresentation()');
|
||||
});
|
||||
|
||||
it('keeps platform alert severity indicators and filters on shared alert severity primitives', () => {
|
||||
@@ -2410,7 +2397,6 @@ describe('shared primitive guardrails', () => {
|
||||
'src/components/patrol/InvestigationSection.tsx',
|
||||
'src/components/patrol/RunHistoryEntry.tsx',
|
||||
'src/components/patrol/RunToolCallTrace.tsx',
|
||||
'src/components/patrol/PatrolStatusBar.tsx',
|
||||
'src/features/proxmox/proxmoxBackupsTableShared.tsx',
|
||||
'src/features/patrol/PatrolIntelligenceWorkspace.tsx',
|
||||
]);
|
||||
@@ -2511,10 +2497,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(runHistoryEntrySource).not.toContain(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-50',
|
||||
);
|
||||
expect(patrolStatusBarSource).toContain('MetadataBadge');
|
||||
expect(patrolStatusBarSource).not.toContain(
|
||||
'text-blue-600 dark:text-blue-400">Findings snapshot unavailable',
|
||||
);
|
||||
expect(patrolIntelligenceWorkspaceSource).toContain('MetadataBadge');
|
||||
expect(patrolIntelligenceWorkspaceSource).toContain('findingsBadgePresentation().tone');
|
||||
expect(patrolIntelligenceWorkspaceSource).not.toContain(
|
||||
@@ -2699,7 +2681,6 @@ describe('shared primitive guardrails', () => {
|
||||
'src/components/patrol/InvestigationMessages.tsx',
|
||||
'src/components/patrol/InvestigationSection.tsx',
|
||||
'src/components/patrol/RunToolCallTrace.tsx',
|
||||
'src/features/patrol/PatrolIntelligenceHeader.tsx',
|
||||
]);
|
||||
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/LoadingSpinner.tsx');
|
||||
expect(registeredGuard?.canonical?.export).toBe('LoadingSpinner');
|
||||
@@ -2824,7 +2805,6 @@ describe('shared primitive guardrails', () => {
|
||||
investigationMessagesSource,
|
||||
investigationSectionSource,
|
||||
runToolCallTraceSource,
|
||||
patrolIntelligenceHeaderSource,
|
||||
]) {
|
||||
expect(source).toContain('LoadingSpinner');
|
||||
expect(source).not.toMatch(/border(?:-\d)?[^\n]*border-t-transparent[^\n]*animate-spin/);
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface APIScopeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
group: 'Monitoring' | 'Agents' | 'Settings' | 'Security';
|
||||
group: 'Monitoring' | 'AI' | 'Agents' | 'Settings' | 'Security';
|
||||
}
|
||||
|
||||
export const AGENT_REPORT_SCOPE = 'agent:report';
|
||||
@@ -14,6 +14,8 @@ export const DOCKER_MANAGE_SCOPE = 'docker:manage';
|
||||
export const KUBERNETES_REPORT_SCOPE = 'kubernetes:report';
|
||||
export const KUBERNETES_MANAGE_SCOPE = 'kubernetes:manage';
|
||||
export const AGENT_EXEC_SCOPE = 'agent:exec';
|
||||
export const AI_EXECUTE_SCOPE = 'ai:execute';
|
||||
export const AI_CHAT_SCOPE = 'ai:chat';
|
||||
export const MONITORING_READ_SCOPE = 'monitoring:read';
|
||||
export const MONITORING_WRITE_SCOPE = 'monitoring:write';
|
||||
export const SETTINGS_READ_SCOPE = 'settings:read';
|
||||
@@ -35,6 +37,19 @@ export const API_SCOPE_OPTIONS: APIScopeOption[] = [
|
||||
description: 'Acknowledge, silence, and clear alerts.',
|
||||
group: 'Monitoring',
|
||||
},
|
||||
{
|
||||
value: AI_CHAT_SCOPE,
|
||||
label: 'Pulse Assistant chat',
|
||||
description: 'Use interactive Pulse Assistant sessions, models, and knowledge endpoints.',
|
||||
group: 'AI',
|
||||
},
|
||||
{
|
||||
value: AI_EXECUTE_SCOPE,
|
||||
label: 'Pulse Intelligence actions',
|
||||
description:
|
||||
'Use governed Patrol actions for plans, approvals, policy-allowed fixes, verification, and history.',
|
||||
group: 'AI',
|
||||
},
|
||||
{
|
||||
value: DOCKER_REPORT_SCOPE,
|
||||
label: `${DOCKER_PODMAN_SOURCE_LABEL} reporting`,
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Show } from 'solid-js';
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
import DownloadIcon from 'lucide-solid/icons/download';
|
||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import { AnimatedNumber } from '@/components/shared/AnimatedNumber';
|
||||
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
|
||||
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
|
||||
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
|
||||
import { PATROL_PROVIDER_SETTINGS_ACTION } from '@/utils/patrolRuntimeActions';
|
||||
import { getPatrolAutonomyAvailabilityPresentation } from './patrolAutonomyAvailability';
|
||||
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
|
||||
// Strip a provider prefix ("deepseek:deepseek-v4-flash" -> "deepseek-v4-flash")
|
||||
@@ -19,6 +24,17 @@ function modelIdWithoutProviderPrefix(modelId: string | undefined): string {
|
||||
|
||||
export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceState }) {
|
||||
const state = props.state;
|
||||
const autonomyAvailability = createMemo(() =>
|
||||
getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: state.autoFixLocked(),
|
||||
runtimeCapabilityBlock: state.autoFixCapabilityBlock(),
|
||||
runtime: state.licenseRuntimeIdentity(),
|
||||
planUpgradeDestination: getUpgradeActionDestination('ai_autofix'),
|
||||
}),
|
||||
);
|
||||
const shouldShowReadinessAction = createMemo(
|
||||
() => state.patrolReadiness()?.status !== 'not_ready',
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,24 +66,71 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.patrolLoadError()}>
|
||||
<div class="flex-shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-900">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||
Patrol could not refresh
|
||||
</p>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-300">
|
||||
Last known issues are shown if available.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void state.handleRefreshPatrol()}
|
||||
disabled={state.isManualRefreshRunning()}
|
||||
class="inline-flex items-center justify-center gap-2 rounded-md border border-amber-200 bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-900 transition-colors hover:bg-amber-200 disabled:cursor-wait disabled:opacity-60 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-100 dark:hover:bg-amber-900"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
class={`h-3.5 w-3.5 ${state.isManualRefreshRunning() ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={
|
||||
!presentationPolicyHidesUpgradePrompts() &&
|
||||
state.licenseRequired() &&
|
||||
autonomyAvailability().locked &&
|
||||
autonomyAvailability().kind === 'runtime_locked' &&
|
||||
!state.showBlockedBanner()
|
||||
}
|
||||
>
|
||||
<div class="flex-shrink-0 bg-blue-50 dark:bg-blue-900 border-b border-blue-200 dark:border-blue-800 px-3 py-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||
Safe remediation workflows and alert-triggered root-cause analysis are not enabled on
|
||||
this plan.
|
||||
<span class="font-semibold text-blue-900 dark:text-blue-100">
|
||||
{autonomyAvailability().title}.
|
||||
</span>{' '}
|
||||
{autonomyAvailability().body}
|
||||
</p>
|
||||
<Show when={autonomyAvailability().destination?.href}>
|
||||
<UpgradeButtonLink
|
||||
destination={autonomyAvailability().destination!}
|
||||
size="xs"
|
||||
mobileFullWidth={false}
|
||||
class="shrink-0"
|
||||
>
|
||||
<DownloadIcon class="h-3.5 w-3.5" />
|
||||
{autonomyAvailability().actionLabel}
|
||||
</UpgradeButtonLink>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.showReadinessBanner() && !state.showBlockedBanner()}>
|
||||
<Show
|
||||
when={
|
||||
state.showReadinessBanner() &&
|
||||
!state.showBlockedBanner() &&
|
||||
!state.shouldShowPatrolSetupOnly()
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={`flex-shrink-0 border-b px-4 py-3 ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
@@ -101,8 +164,8 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
|
||||
}`}
|
||||
>
|
||||
{state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'Patrol readiness issue'
|
||||
: 'Patrol readiness warning'}
|
||||
? 'Provider issue'
|
||||
: 'Provider warning'}
|
||||
</p>
|
||||
<p
|
||||
class={`text-xs ${
|
||||
@@ -113,15 +176,10 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
|
||||
>
|
||||
{state.patrolReadiness()?.summary}
|
||||
</p>
|
||||
{/* Concrete diagnosis: which provider and model failed, and
|
||||
the preflight recommendation. Without these the banner
|
||||
summary alone ("Provider connection issue") leaves the
|
||||
operator with nothing actionable. */}
|
||||
<Show
|
||||
when={
|
||||
state.patrolReadiness()?.provider || state.patrolReadiness()?.model
|
||||
}
|
||||
>
|
||||
{/* Keep the Patrol page at operator level: show the provider
|
||||
and model that need attention, while raw diagnostics stay
|
||||
in Provider & Models. */}
|
||||
<Show when={state.patrolReadiness()?.provider || state.patrolReadiness()?.model}>
|
||||
<p
|
||||
class={`text-xs mt-1 ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
@@ -134,9 +192,7 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
|
||||
{getAIProviderDisplayName(state.patrolReadiness()!.provider!)}
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
state.patrolReadiness()?.provider && state.patrolReadiness()?.model
|
||||
}
|
||||
when={state.patrolReadiness()?.provider && state.patrolReadiness()?.model}
|
||||
>
|
||||
{' · '}
|
||||
</Show>
|
||||
@@ -153,11 +209,8 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
|
||||
}
|
||||
>
|
||||
{' · '}
|
||||
<span class="font-medium">Preflight:</span>{' '}
|
||||
<span class="font-medium">Model check:</span>{' '}
|
||||
{(state.patrolPreflight()!.duration_ms / 1000).toFixed(1)}s
|
||||
<Show when={state.patrolPreflight()?.tool_call_observed === false}>
|
||||
, no tool call observed
|
||||
</Show>
|
||||
</Show>
|
||||
</p>
|
||||
</Show>
|
||||
@@ -174,17 +227,15 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={PATROL_PROVIDER_SETTINGS_ACTION.href}
|
||||
class={`inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-md border transition-colors ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900 border-red-200 dark:border-red-700 hover:bg-red-200 dark:hover:bg-red-900'
|
||||
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900 border-amber-200 dark:border-amber-700 hover:bg-amber-200 dark:hover:bg-amber-900'
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon class="w-3.5 h-3.5" />
|
||||
{PATROL_PROVIDER_SETTINGS_ACTION.label}
|
||||
</a>
|
||||
<Show when={shouldShowReadinessAction()}>
|
||||
<a
|
||||
href={PATROL_PROVIDER_SETTINGS_ACTION.href}
|
||||
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-md border border-amber-200 bg-amber-100 text-amber-900 transition-colors hover:bg-amber-200 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-100 dark:hover:bg-amber-900"
|
||||
>
|
||||
<SettingsIcon class="w-3.5 h-3.5" />
|
||||
{PATROL_PROVIDER_SETTINGS_ACTION.label}
|
||||
</a>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { createMemo, createUniqueId, For, Show } from 'solid-js';
|
||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import PlayIcon from 'lucide-solid/icons/play';
|
||||
import CircleHelpIcon from 'lucide-solid/icons/circle-help';
|
||||
import MessageSquareIcon from 'lucide-solid/icons/message-square';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import DownloadIcon from 'lucide-solid/icons/download';
|
||||
import CreditCardIcon from 'lucide-solid/icons/credit-card';
|
||||
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
|
||||
import { PageHeader } from '@/components/shared/PageHeader';
|
||||
import { Toggle, TogglePrimitive } from '@/components/shared/Toggle';
|
||||
import { TogglePrimitive } from '@/components/shared/Toggle';
|
||||
import { CountdownTimer } from '@/components/patrol';
|
||||
import { FormSelect } from '@/components/shared/FormSelect';
|
||||
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
|
||||
import type { PatrolAutonomyLevel } from '@/api/patrol';
|
||||
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
|
||||
import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
|
||||
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
|
||||
import {
|
||||
presentationPolicyHidesCommercialSurfaces,
|
||||
presentationPolicyHidesUpgradePrompts,
|
||||
} from '@/stores/sessionPresentationPolicy';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { groupModelsByProvider } from '@/utils/patrolFormat';
|
||||
import { getPatrolPageHeaderMeta } from '@/utils/patrolPagePresentation';
|
||||
import { getPatrolTriggerStatusSummary } from '@/utils/patrolRunPresentation';
|
||||
import { buildPatrolScheduleOptions } from '@/utils/aiPatrolSchedulePresentation';
|
||||
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
|
||||
import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions';
|
||||
import { getPatrolRecencyPresentation } from '@/utils/patrolSummaryPresentation';
|
||||
import { PATROL_PROVIDER_SETTINGS_ACTION } from '@/utils/patrolRuntimeActions';
|
||||
import { PATROL_CONTROL_ANCHOR, PATROL_OPERATIONS_LOOP_ANCHOR } from '@/routing/resourceLinks';
|
||||
import type { PatrolConfigurationFailureInput } from './patrolInvestigationContextModel';
|
||||
import { getPatrolAutonomyAvailabilityPresentation } from './patrolAutonomyAvailability';
|
||||
import { PATROL_AUTONOMY_POLICY_PRESENTATION } from './patrolControlPresentation';
|
||||
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
|
||||
type PatrolSelectableAutonomyLevel = Exclude<PatrolAutonomyLevel, 'full'>;
|
||||
export { PATROL_AUTONOMY_POLICY_PRESENTATION } from './patrolControlPresentation';
|
||||
|
||||
const isNonEmptyConfigurationDetail = (value?: string | null): value is string =>
|
||||
Boolean(value?.trim());
|
||||
@@ -40,7 +44,7 @@ export function getPatrolConfigurationFailureInlineDetails(
|
||||
|
||||
return [
|
||||
codeAndCause || undefined,
|
||||
readiness?.summary ? `Readiness: ${readiness.summary}` : undefined,
|
||||
readiness?.summary ? `Setup: ${readiness.summary}` : undefined,
|
||||
readiness?.provider ? `Provider: ${readiness.provider}` : undefined,
|
||||
readiness?.model ? `Model: ${readiness.model}` : undefined,
|
||||
].filter(isNonEmptyConfigurationDetail);
|
||||
@@ -48,20 +52,11 @@ export function getPatrolConfigurationFailureInlineDetails(
|
||||
|
||||
export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState }) {
|
||||
const state = props.state;
|
||||
const fieldIdPrefix = `patrol-config-${createUniqueId()}`;
|
||||
const fieldIds = {
|
||||
alertTriggeredAnalysis: `${fieldIdPrefix}-alert-triggered-analysis`,
|
||||
patrolAlertTriggers: `${fieldIdPrefix}-alert-triggered-patrols`,
|
||||
patrolAlertTriggerMinSeverity: `${fieldIdPrefix}-alert-trigger-min-severity`,
|
||||
patrolAnomalyTriggers: `${fieldIdPrefix}-anomaly-triggered-patrols`,
|
||||
autonomousCriticalRemediation: `${fieldIdPrefix}-autonomous-critical-remediation`,
|
||||
};
|
||||
const headerMeta = getPatrolPageHeaderMeta();
|
||||
const scheduleOptions = createMemo(() => buildPatrolScheduleOptions(state.patrolInterval()));
|
||||
const selectedScheduleLabel = createMemo(
|
||||
() =>
|
||||
scheduleOptions().find((option) => option.value === state.patrolInterval())?.label ??
|
||||
`${state.patrolInterval()} minutes`,
|
||||
const headerMeta = createMemo(() =>
|
||||
getPatrolPageHeaderMeta({
|
||||
autonomyLevel: state.autonomyLevel(),
|
||||
autonomyLocked: state.autoFixLocked(),
|
||||
}),
|
||||
);
|
||||
const runtimePresentation = createMemo(() =>
|
||||
getPatrolRuntimePresentation(state.runtimeState(), state.blockedReason()),
|
||||
@@ -74,110 +69,258 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
|
||||
}),
|
||||
);
|
||||
const triggerStatusSummary = createMemo(() =>
|
||||
getPatrolTriggerStatusSummary(state.patrolStatus()?.trigger_status),
|
||||
getPatrolTriggerStatusSummary(state.patrolStatus()?.trigger_status, {
|
||||
manualRunAvailable: state.canTriggerPatrol(),
|
||||
manualRunBlockedReason: state.triggerPatrolDisabledReason(),
|
||||
}),
|
||||
);
|
||||
const providerSetupAction = getPatrolProviderSettingsAction();
|
||||
const runControlBusy = createMemo(
|
||||
() =>
|
||||
state.isTriggeringPatrol() || state.manualRunRequested() || state.patrolStream.isStreaming(),
|
||||
);
|
||||
const runBlockedByProviderSetup = createMemo(
|
||||
() =>
|
||||
!runControlBusy() &&
|
||||
state.patrolEnabledLocal() &&
|
||||
state.runtimeState() === 'active' &&
|
||||
state.patrolReadiness()?.status === 'not_ready',
|
||||
);
|
||||
const runButtonDisabled = createMemo(() => runControlBusy() || !state.canTriggerPatrol());
|
||||
const runButtonLabel = createMemo(() =>
|
||||
state.isTriggeringPatrol()
|
||||
? 'Starting…'
|
||||
: state.manualRunRequested() || state.patrolStream.isStreaming()
|
||||
? 'Running…'
|
||||
: 'Run Patrol',
|
||||
);
|
||||
const renderRunControl = (className: string) => (
|
||||
<Show
|
||||
when={!runBlockedByProviderSetup()}
|
||||
fallback={
|
||||
<A
|
||||
href={providerSetupAction.href}
|
||||
aria-label={`Fix Patrol provider: ${state.triggerPatrolDisabledReason() || 'Provider needs attention'}`}
|
||||
title={state.triggerPatrolDisabledReason() || 'Open Provider & Models'}
|
||||
class={className}
|
||||
>
|
||||
<SettingsIcon class="w-4 h-4" />
|
||||
Fix provider
|
||||
</A>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => state.handleRunPatrol()}
|
||||
disabled={runButtonDisabled()}
|
||||
title={state.triggerPatrolDisabledReason()}
|
||||
class={className}
|
||||
>
|
||||
<PlayIcon class={`w-4 h-4 ${runControlBusy() ? 'animate-pulse' : ''}`} />
|
||||
{runButtonLabel()}
|
||||
</button>
|
||||
</Show>
|
||||
);
|
||||
const effectiveAutonomyLevel = createMemo<PatrolAutonomyLevel>(() =>
|
||||
state.autoFixLocked() ? 'monitor' : state.autonomyLevel(),
|
||||
);
|
||||
const selectedAutonomyPolicy = createMemo(
|
||||
() => PATROL_AUTONOMY_POLICY_PRESENTATION[effectiveAutonomyLevel()],
|
||||
);
|
||||
const upgradePromptsHidden = createMemo(() => presentationPolicyHidesUpgradePrompts());
|
||||
const commercialSurfacesHidden = createMemo(() => presentationPolicyHidesCommercialSurfaces());
|
||||
const canChooseAutonomyLevel = createMemo(() => !state.autoFixLocked());
|
||||
const autonomyAvailability = createMemo(() =>
|
||||
getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: state.autoFixLocked(),
|
||||
upgradePromptsHidden: upgradePromptsHidden(),
|
||||
commercialSurfacesHidden: commercialSurfacesHidden(),
|
||||
runtimeCapabilityBlock: state.autoFixCapabilityBlock(),
|
||||
runtime: state.licenseRuntimeIdentity(),
|
||||
planUpgradeDestination: getUpgradeActionDestination('ai_autofix'),
|
||||
}),
|
||||
);
|
||||
const shouldShowAutonomyOptions = createMemo(
|
||||
() => canChooseAutonomyLevel() || autonomyAvailability().kind === 'runtime_locked',
|
||||
);
|
||||
const showAutonomyUpgradeAction = createMemo(
|
||||
() =>
|
||||
!presentationPolicyHidesUpgradePrompts() &&
|
||||
state.autoFixLocked() &&
|
||||
autonomyAvailability().kind === 'runtime_locked',
|
||||
);
|
||||
const showAutonomyPlanBillingAction = createMemo(
|
||||
() =>
|
||||
state.autoFixLocked() &&
|
||||
autonomyAvailability().kind === 'plan_locked' &&
|
||||
Boolean(autonomyAvailability().actionLabel && autonomyAvailability().destination?.href),
|
||||
);
|
||||
const shouldShowAutonomyActionColumn = createMemo(
|
||||
() => shouldShowAutonomyOptions() || showAutonomyPlanBillingAction(),
|
||||
);
|
||||
const showAutonomyAvailabilityPrompt = createMemo(
|
||||
() =>
|
||||
state.autoFixLocked() &&
|
||||
autonomyAvailability().kind === 'runtime_locked' &&
|
||||
Boolean(autonomyAvailability().actionLabel),
|
||||
);
|
||||
const showAutonomyAvailabilityNote = createMemo(
|
||||
() =>
|
||||
state.autoFixLocked() &&
|
||||
autonomyAvailability().kind === 'runtime_locked' &&
|
||||
!autonomyAvailability().actionLabel,
|
||||
);
|
||||
const autonomyLevelOptions = createMemo<FilterOption<PatrolAutonomyLevel>[]>(() =>
|
||||
(['monitor', 'approval', 'assisted', 'full'] as const).map((level) => {
|
||||
const presentation = PATROL_AUTONOMY_POLICY_PRESENTATION[level];
|
||||
const lockedPaidMode = state.autoFixLocked() && level !== 'monitor';
|
||||
const showProBadge = lockedPaidMode && !commercialSurfacesHidden();
|
||||
return {
|
||||
value: level,
|
||||
label: presentation.label,
|
||||
compactLabel: presentation.compactLabel,
|
||||
ariaLabel: showProBadge ? `${presentation.label} Pro` : undefined,
|
||||
title: lockedPaidMode
|
||||
? `${presentation.detail} Available with Pulse Pro.`
|
||||
: presentation.detail,
|
||||
disabled: lockedPaidMode,
|
||||
visualLabel: showProBadge ? (
|
||||
<>
|
||||
<span>{presentation.label}</span>
|
||||
<span class="rounded border border-blue-200 bg-blue-50 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300">
|
||||
Pro
|
||||
</span>
|
||||
</>
|
||||
) : undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const renderAutonomyPolicyControl = (options: {
|
||||
ariaLabel: string;
|
||||
layoutClass: string;
|
||||
controlClass: string;
|
||||
variant?: 'segmented' | 'prominent';
|
||||
}) => (
|
||||
<>
|
||||
<div class={options.layoutClass}>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
Patrol mode
|
||||
</span>
|
||||
<span class="rounded border border-border-subtle bg-surface px-2 py-0.5 text-xs font-medium text-base-content">
|
||||
{selectedAutonomyPolicy().label}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm leading-5 text-muted">{selectedAutonomyPolicy().detail}</p>
|
||||
</div>
|
||||
|
||||
<Show when={shouldShowAutonomyActionColumn()}>
|
||||
<div class="flex w-full flex-col gap-2 lg:w-auto">
|
||||
<Show when={shouldShowAutonomyOptions()}>
|
||||
<FilterButtonGroup
|
||||
ariaLabel={options.ariaLabel}
|
||||
class={options.controlClass}
|
||||
disabled={!state.patrolEnabledLocal()}
|
||||
options={autonomyLevelOptions()}
|
||||
value={effectiveAutonomyLevel()}
|
||||
onChange={(level) => state.handleAutonomyChange(level)}
|
||||
variant={options.variant ?? 'segmented'}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={showAutonomyPlanBillingAction()}>
|
||||
<UpgradeButtonLink
|
||||
destination={autonomyAvailability().destination!}
|
||||
size="sm"
|
||||
mobileFullWidth={false}
|
||||
class="self-start lg:self-end"
|
||||
>
|
||||
<CreditCardIcon class="h-4 w-4" />
|
||||
{autonomyAvailability().actionLabel}
|
||||
</UpgradeButtonLink>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={showAutonomyAvailabilityNote()}>
|
||||
<p class="mt-2 text-xs leading-5 text-muted">{autonomyAvailability().body}</p>
|
||||
</Show>
|
||||
<Show when={showAutonomyAvailabilityPrompt()}>
|
||||
<div class="mt-3 flex flex-col gap-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-3 dark:border-blue-800 dark:bg-blue-950/40 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-blue-950 dark:text-blue-100">
|
||||
{autonomyAvailability().title}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-blue-800 dark:text-blue-200">
|
||||
{autonomyAvailability().body}
|
||||
</p>
|
||||
</div>
|
||||
<Show when={showAutonomyUpgradeAction() && autonomyAvailability().destination?.href}>
|
||||
<UpgradeButtonLink
|
||||
destination={autonomyAvailability().destination!}
|
||||
size="sm"
|
||||
mobileFullWidth={false}
|
||||
class="shrink-0"
|
||||
>
|
||||
<DownloadIcon class="h-4 w-4" />
|
||||
{autonomyAvailability().actionLabel}
|
||||
</UpgradeButtonLink>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={state.isUpdatingAutonomy()}>
|
||||
<div role="status" class="sr-only">
|
||||
Saving Patrol mode
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
const patrolModelStale = createMemo(() => {
|
||||
const model = state.patrolModel();
|
||||
const models = state.availableModels();
|
||||
if (!model || models.length === 0) return false;
|
||||
return !models.some((candidate) => candidate.id === model);
|
||||
});
|
||||
const selectedAutonomyLevel = createMemo<PatrolSelectableAutonomyLevel>(() => {
|
||||
const level = state.autonomyLevel();
|
||||
return level === 'full' ? 'assisted' : level;
|
||||
});
|
||||
const getAutonomyOptionTitle = (level: PatrolSelectableAutonomyLevel) => {
|
||||
const isProLocked = () => state.autoFixLocked() && level !== 'monitor';
|
||||
if (!presentationPolicyHidesUpgradePrompts() && isProLocked()) {
|
||||
return level === 'approval'
|
||||
? 'Investigation is not enabled on this plan'
|
||||
: 'Safe remediation workflows are not enabled on this plan';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const autonomyLevelOptions = createMemo<FilterOption<PatrolSelectableAutonomyLevel>[]>(() => [
|
||||
{ value: 'monitor', label: 'Monitor' },
|
||||
{
|
||||
value: 'approval',
|
||||
label: 'Investigate',
|
||||
disabled: state.autoFixLocked(),
|
||||
title: getAutonomyOptionTitle('approval'),
|
||||
},
|
||||
{
|
||||
value: 'assisted',
|
||||
label: 'Remediate',
|
||||
disabled: state.autoFixLocked(),
|
||||
title: getAutonomyOptionTitle('assisted'),
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<PageHeader
|
||||
id="patrol-title"
|
||||
description={headerMeta.description}
|
||||
description={headerMeta().description}
|
||||
title={
|
||||
<span class="inline-flex items-center gap-3" title={headerMeta.titleTooltip}>
|
||||
<span class="inline-flex items-center gap-3" title={headerMeta().titleTooltip}>
|
||||
<PulsePatrolLogo class="w-6 h-6 text-base-content" decorative />
|
||||
<span>{headerMeta.title}</span>
|
||||
<span>{headerMeta().title}</span>
|
||||
</span>
|
||||
}
|
||||
class="relative z-[200] mb-3"
|
||||
actions={
|
||||
<div class="hidden sm:flex flex-wrap items-center justify-end gap-3">
|
||||
<Show when={recency().timestamp}>
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<span>
|
||||
{recency().label}:{' '}
|
||||
{formatRelativeTime(recency().timestamp, {
|
||||
compact: true,
|
||||
emptyText: 'Never',
|
||||
})}
|
||||
<Show when={recency().resourcesCheckedLabel}>
|
||||
{' '}
|
||||
<span class="text-muted">— {recency().resourcesCheckedLabel}</span>
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={state.patrolStatus()?.next_patrol_at}>
|
||||
<span class="text-muted">|</span>
|
||||
<CountdownTimer
|
||||
targetDate={state.patrolStatus()!.next_patrol_at!}
|
||||
prefix="Next run: "
|
||||
class="font-variant-numeric tabular-nums font-medium text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
<Show when={!state.shouldShowPatrolSetupOnly()}>
|
||||
<>
|
||||
<Show when={recency().timestamp}>
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<span>
|
||||
{recency().label}:{' '}
|
||||
{formatRelativeTime(recency().timestamp, {
|
||||
compact: true,
|
||||
emptyText: 'Never',
|
||||
})}
|
||||
<Show when={recency().resourcesCheckedLabel}>
|
||||
{' '}
|
||||
<span class="text-muted">— {recency().resourcesCheckedLabel}</span>
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={state.patrolStatus()?.next_patrol_at}>
|
||||
<span class="text-muted">|</span>
|
||||
<CountdownTimer
|
||||
targetDate={state.patrolStatus()!.next_patrol_at!}
|
||||
prefix="Next run: "
|
||||
class="font-variant-numeric tabular-nums font-medium text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{renderRunControl(
|
||||
'flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted rounded-md transition-colors',
|
||||
)}
|
||||
</>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
onClick={() => state.handleRunPatrol()}
|
||||
disabled={
|
||||
state.isTriggeringPatrol() ||
|
||||
!state.canTriggerPatrol() ||
|
||||
state.manualRunRequested() ||
|
||||
state.patrolStream.isStreaming()
|
||||
}
|
||||
title={state.triggerPatrolDisabledReason()}
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted rounded-md transition-colors"
|
||||
>
|
||||
<PlayIcon
|
||||
class={`w-4 h-4 ${state.isTriggeringPatrol() || state.manualRunRequested() || state.patrolStream.isStreaming() ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
{state.isTriggeringPatrol()
|
||||
? 'Starting…'
|
||||
: state.manualRunRequested() || state.patrolStream.isStreaming()
|
||||
? 'Running…'
|
||||
: 'Run Patrol'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => state.loadAllData()}
|
||||
disabled={state.isRefreshing()}
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCwIcon class={`w-4 h-4 ${state.isRefreshing() ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -194,360 +337,39 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
|
||||
<span class="text-sm font-medium text-base-content">{runtimePresentation().label}</span>
|
||||
</div>
|
||||
|
||||
<Show when={triggerStatusSummary()}>
|
||||
<span class="max-w-full text-xs leading-5 text-muted">
|
||||
Trigger status: {triggerStatusSummary()}
|
||||
</span>
|
||||
<Show when={!state.shouldShowPatrolSetupOnly() && triggerStatusSummary()}>
|
||||
<span class="max-w-full text-xs leading-5 text-muted">{triggerStatusSummary()}</span>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
|
||||
<button
|
||||
onClick={() => state.handleRunPatrol()}
|
||||
disabled={
|
||||
state.isTriggeringPatrol() ||
|
||||
!state.canTriggerPatrol() ||
|
||||
state.manualRunRequested() ||
|
||||
state.patrolStream.isStreaming()
|
||||
}
|
||||
title={state.triggerPatrolDisabledReason()}
|
||||
class="sm:hidden flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted rounded-md transition-colors"
|
||||
>
|
||||
<PlayIcon
|
||||
class={`w-4 h-4 ${state.isTriggeringPatrol() || state.manualRunRequested() || state.patrolStream.isStreaming() ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
{state.isTriggeringPatrol()
|
||||
? 'Starting…'
|
||||
: state.manualRunRequested() || state.patrolStream.isStreaming()
|
||||
? 'Running…'
|
||||
: 'Run Patrol'}
|
||||
</button>
|
||||
<Show when={!state.shouldShowPatrolSetupOnly()}>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
|
||||
{renderRunControl(
|
||||
'sm:hidden flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted rounded-md transition-colors',
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => state.loadAllData()}
|
||||
disabled={state.isRefreshing()}
|
||||
aria-label="Refresh"
|
||||
class="sm:hidden flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCwIcon class={`w-4 h-4 ${state.isRefreshing() ? 'animate-spin' : ''}`} />
|
||||
<span class="sr-only sm:not-sr-only">Refresh</span>
|
||||
</button>
|
||||
|
||||
<div class="relative z-[120]" ref={state.setAdvancedSettingsRef}>
|
||||
<button
|
||||
onClick={() => state.setShowAdvancedSettings(!state.showAdvancedSettings())}
|
||||
disabled={!state.patrolEnabledLocal()}
|
||||
aria-label="Configure Patrol"
|
||||
title="Configure Patrol"
|
||||
class={`flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-all shadow-sm ${state.showAdvancedSettings() ? 'bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-800' : ' text-base-content border border-border hover:bg-surface-alt'} ${!state.patrolEnabledLocal() ? 'opacity-50 cursor-not-allowed hidden' : ''}`}
|
||||
<A
|
||||
href={settingsTabPath('system-ai-patrol')}
|
||||
aria-label="Open Patrol settings"
|
||||
title="Open Patrol settings"
|
||||
class="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt"
|
||||
>
|
||||
<SettingsIcon class="w-4 h-4" />
|
||||
<span class="sr-only sm:not-sr-only">Configure Patrol</span>
|
||||
</button>
|
||||
|
||||
<Show when={state.showAdvancedSettings()}>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Patrol Configuration"
|
||||
class="fixed right-4 top-32 z-[9999] isolate max-h-[calc(100vh-10rem)] w-[calc(100vw-2rem)] overflow-y-auto rounded-md border border-border bg-surface p-5 shadow-sm animate-slide-up transform origin-top-right sm:right-8 sm:top-[13rem] sm:max-h-[calc(100vh-14rem)] sm:w-[340px]"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-5 pb-3 border-b border-border-subtle">
|
||||
<h4 class="text-base font-semibold tracking-tight text-base-content">
|
||||
Patrol Configuration
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => state.setShowAdvancedSettings(false)}
|
||||
aria-label="Close patrol configuration"
|
||||
title="Close"
|
||||
class="p-1 rounded-md hover:text-base-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormSelect
|
||||
label="Provider model"
|
||||
labelClass="text-xs font-semibold uppercase tracking-wider text-muted"
|
||||
fieldClass="space-y-1.5"
|
||||
ref={state.setPatrolModelSelectRef}
|
||||
value={state.patrolModel()}
|
||||
onChange={(e) => state.handleModelChange(e.currentTarget.value)}
|
||||
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
|
||||
selectBaseClass="w-full text-sm bg-base border border-border rounded-md py-2 pl-3 pr-8 text-base-content focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
Default ({state.defaultModel().split(':').pop() || 'not set'})
|
||||
</option>
|
||||
<Show when={patrolModelStale()}>
|
||||
<option value={state.patrolModel()} disabled>
|
||||
{state.patrolModel().split(':').pop()} (unavailable)
|
||||
</option>
|
||||
</Show>
|
||||
{Array.from(groupModelsByProvider(state.availableModels()).entries()).map(
|
||||
([provider, models]) => (
|
||||
<optgroup label={provider.charAt(0).toUpperCase() + provider.slice(1)}>
|
||||
{models.map((model) => (
|
||||
<option value={model.id}>
|
||||
{model.name || model.id.split(':').pop()}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
),
|
||||
)}
|
||||
</FormSelect>
|
||||
|
||||
<FormSelect
|
||||
label="Run Every"
|
||||
labelClass="text-xs font-semibold uppercase tracking-wider text-muted"
|
||||
fieldClass="space-y-1.5"
|
||||
value={state.patrolInterval()}
|
||||
onChange={(e) =>
|
||||
state.handleIntervalChange(parseInt(e.currentTarget.value, 10))
|
||||
}
|
||||
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
|
||||
selectBaseClass="w-full text-sm bg-base border border-border rounded-md py-2 pl-3 pr-8 text-base-content focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<For each={scheduleOptions()}>
|
||||
{(preset) => <option value={preset.value}>{preset.label}</option>}
|
||||
</For>
|
||||
</FormSelect>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
|
||||
Operational Mode
|
||||
<div class="relative group">
|
||||
<CircleHelpIcon class="w-3.5 h-3.5 cursor-help" />
|
||||
<div class="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 p-3 bg-surface text-white rounded-md shadow-md text-xs z-50 pointer-events-none before:absolute before:top-full before:left-1/2 before:-translate-x-1/2 before:border-4 before:border-transparent before:border-t-slate-800">
|
||||
<strong>Monitor:</strong> Detect only.
|
||||
<br />
|
||||
<strong>Investigate:</strong> Detect & propose fixes.
|
||||
<br />
|
||||
<strong>Remediate:</strong> Execute approved safe actions under policy.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<FilterButtonGroup
|
||||
ariaLabel="Patrol autonomy level"
|
||||
class="w-full"
|
||||
disabled={!state.patrolEnabledLocal()}
|
||||
options={autonomyLevelOptions()}
|
||||
value={selectedAutonomyLevel()}
|
||||
onChange={(level) => state.handleAutonomyChange(level)}
|
||||
variant="segmented"
|
||||
/>
|
||||
<Show when={!presentationPolicyHidesUpgradePrompts() && state.autoFixLocked()}>
|
||||
<div class="pl-1 text-[11px] text-muted">
|
||||
Investigation and safe remediation workflows are not enabled on this plan.
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 pt-4 border-t border-border-subtle">
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/60 px-3 py-2.5">
|
||||
<p class="text-[11px] font-medium text-base-content">
|
||||
{state.patrolInterval() === 0
|
||||
? 'Scheduled full patrols are off. Manual runs and alert/anomaly triggers still work.'
|
||||
: `Full patrols run every ${selectedScheduleLabel().toLowerCase()}.`}
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] leading-tight text-muted">
|
||||
Alert and anomaly triggers run targeted scoped checks that update{' '}
|
||||
<span class="font-medium text-base-content">Last activity</span> without
|
||||
resetting{' '}
|
||||
<span class="font-medium text-base-content">Last full patrol</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<span
|
||||
id={fieldIds.patrolAlertTriggers}
|
||||
class="text-sm font-medium text-base-content"
|
||||
>
|
||||
Alert-Triggered Patrols
|
||||
</span>
|
||||
<p class="text-[11px] text-muted mt-0.5 leading-tight">
|
||||
Run a focused Patrol investigation of the alert's issue when it fires or
|
||||
clears.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={state.patrolAlertTriggers()}
|
||||
onChange={(e) =>
|
||||
state.handlePatrolAlertTriggersChange(e.currentTarget.checked)
|
||||
}
|
||||
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
|
||||
ariaLabelledBy={fieldIds.patrolAlertTriggers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={state.patrolAlertTriggers()}>
|
||||
<div class="pl-3 border-l-2 border-border ml-0.5">
|
||||
<FormSelect
|
||||
label="Investigate alerts at or above"
|
||||
labelClass="text-[11px] font-semibold uppercase tracking-wider text-muted"
|
||||
fieldClass="space-y-1"
|
||||
value={state.patrolAlertTriggerMinSeverity()}
|
||||
onChange={(e) =>
|
||||
state.handlePatrolAlertTriggerMinSeverityChange(
|
||||
e.currentTarget.value === 'warning' ? 'warning' : 'critical',
|
||||
)
|
||||
}
|
||||
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
|
||||
id={fieldIds.patrolAlertTriggerMinSeverity}
|
||||
selectBaseClass="w-full text-sm bg-base border border-border rounded-md py-1.5 pl-3 pr-8 text-base-content focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="critical">Critical only</option>
|
||||
<option value="warning">Warning and critical</option>
|
||||
</FormSelect>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<span
|
||||
id={fieldIds.patrolAnomalyTriggers}
|
||||
class="text-sm font-medium text-base-content"
|
||||
>
|
||||
Anomaly-Triggered Patrols
|
||||
</span>
|
||||
<p class="text-[11px] text-muted mt-0.5 leading-tight">
|
||||
Run scoped Patrol checks when learned baselines detect high-signal
|
||||
anomalies.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={state.patrolAnomalyTriggers()}
|
||||
onChange={(e) =>
|
||||
state.handlePatrolAnomalyTriggersChange(e.currentTarget.checked)
|
||||
}
|
||||
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
|
||||
ariaLabelledBy={fieldIds.patrolAnomalyTriggers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<span
|
||||
id={fieldIds.alertTriggeredAnalysis}
|
||||
class="text-sm font-medium text-base-content"
|
||||
>
|
||||
Container Update Risk
|
||||
</span>
|
||||
<p class="text-[11px] text-muted mt-0.5 leading-tight">
|
||||
Assess upgrade risk with AI when a container-update alert fires.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={state.alertTriggeredAnalysis()}
|
||||
onChange={(e) =>
|
||||
state.handleAlertTriggeredAnalysisChange(e.currentTarget.checked)
|
||||
}
|
||||
disabled={state.isUpdatingSettings() || state.alertAnalysisLocked()}
|
||||
ariaLabelledBy={fieldIds.alertTriggeredAnalysis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!presentationPolicyHidesUpgradePrompts() && state.alertAnalysisLocked()}
|
||||
>
|
||||
<div class="-my-1 pl-1 text-[11px]">
|
||||
Container update risk analysis is not enabled on this plan.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<span
|
||||
id={fieldIds.autonomousCriticalRemediation}
|
||||
class="text-sm font-medium text-red-600 dark:text-red-400"
|
||||
>
|
||||
Autonomous critical remediation
|
||||
</span>
|
||||
<p class="text-[11px] text-muted mt-0.5 leading-tight">
|
||||
Permit Patrol to execute critical remediation actions without approval.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!state.autoFixLocked() && state.fullModeUnlocked()}
|
||||
onChange={(e) => state.setFullModeUnlocked(e.currentTarget.checked)}
|
||||
disabled={
|
||||
state.autoFixLocked() ||
|
||||
!(
|
||||
state.autonomyLevel() === 'assisted' || state.autonomyLevel() === 'full'
|
||||
)
|
||||
}
|
||||
ariaLabelledBy={fieldIds.autonomousCriticalRemediation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-border-subtle">
|
||||
<Show when={state.advancedSettingsError()}>
|
||||
{(failure) => (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="patrol-configuration-error"
|
||||
class="mb-3 rounded-md border border-red-300 bg-red-50 px-3 py-2.5 text-red-950 dark:border-red-800 dark:bg-red-950/30 dark:text-red-100"
|
||||
>
|
||||
<p class="text-xs font-semibold">
|
||||
{failure().saved
|
||||
? 'Patrol configuration needs attention'
|
||||
: 'Patrol configuration was not saved'}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed">{failure().message}</p>
|
||||
<Show
|
||||
when={getPatrolConfigurationFailureInlineDetails(failure()).length > 0}
|
||||
>
|
||||
<ul class="mt-1 space-y-0.5 text-[11px] leading-relaxed opacity-80">
|
||||
<For each={getPatrolConfigurationFailureInlineDetails(failure())}>
|
||||
{(detail) => <li>{detail}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="patrol-configuration-error-assistant-button"
|
||||
onClick={state.openAdvancedSettingsErrorInAssistant}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-950 transition-colors hover:bg-white dark:border-red-700 dark:bg-red-950/40 dark:text-red-100 dark:hover:bg-red-900/50"
|
||||
>
|
||||
<MessageSquareIcon class="h-3.5 w-3.5" />
|
||||
Discuss with Assistant
|
||||
</button>
|
||||
<a
|
||||
href={PATROL_PROVIDER_SETTINGS_ACTION.href}
|
||||
data-testid="patrol-configuration-error-settings-link"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-950 transition-colors hover:bg-white dark:border-red-700 dark:bg-red-950/40 dark:text-red-100 dark:hover:bg-red-900/50"
|
||||
>
|
||||
<SettingsIcon class="h-3.5 w-3.5" />
|
||||
{PATROL_PROVIDER_SETTINGS_ACTION.label}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<button
|
||||
onClick={state.saveAdvancedSettings}
|
||||
disabled={state.isSavingAdvanced()}
|
||||
class="w-full py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-all focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-blue-300 disabled:hover:bg-blue-300 dark:disabled:bg-blue-900 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Show when={state.isSavingAdvanced()}>
|
||||
<LoadingSpinner size="md" />
|
||||
</Show>
|
||||
<Show when={!state.isSavingAdvanced()}>Apply Configuration</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<span class="sr-only sm:not-sr-only">Settings</span>
|
||||
</A>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={PATROL_CONTROL_ANCHOR}
|
||||
class="rounded-md border border-border-subtle bg-surface-alt/50 px-3 py-3"
|
||||
>
|
||||
<span id={PATROL_OPERATIONS_LOOP_ANCHOR} class="sr-only" aria-hidden="true" />
|
||||
{renderAutonomyPolicyControl({
|
||||
ariaLabel: 'Patrol mode',
|
||||
layoutClass: 'flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
|
||||
controlClass: 'w-full lg:w-[34rem]',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
import AlertCircleIcon from 'lucide-solid/icons/alert-circle';
|
||||
import {
|
||||
getPatrolAssessmentShellPresentation,
|
||||
getPatrolAssessmentPresentation,
|
||||
getPatrolRecencyPresentation,
|
||||
getPatrolSummaryMetricState,
|
||||
} from '@/utils/patrolSummaryPresentation';
|
||||
import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge';
|
||||
import {
|
||||
formatPatrolActivityBreakdown,
|
||||
getPatrolActivityBreakdown,
|
||||
getPatrolTriggerStatusSummary,
|
||||
} from '@/utils/patrolRunPresentation';
|
||||
import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
|
||||
function PatrolAssessmentLoadingShell() {
|
||||
return (
|
||||
<section
|
||||
data-testid="patrol-summary-loading"
|
||||
class="overflow-hidden rounded-md border border-border bg-surface shadow-sm animate-pulse pointer-events-none select-none"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle px-4 py-3">
|
||||
<div class="h-5 w-32 rounded bg-surface-hover" />
|
||||
<div class="h-5 w-24 rounded bg-surface-hover" />
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-11 w-11 rounded-md border border-border-subtle bg-surface-alt/60" />
|
||||
<div class="min-w-0 flex-1 space-y-2">
|
||||
<div class="h-5 w-44 rounded bg-surface-hover" />
|
||||
<div class="h-4 max-w-3xl rounded bg-surface-hover" />
|
||||
<div class="h-4 w-2/3 rounded bg-surface-hover" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-hidden rounded-md border border-border-subtle bg-surface-alt/60">
|
||||
<div class="grid divide-y divide-border-subtle lg:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)] lg:divide-x lg:divide-y-0">
|
||||
<div class="space-y-2 p-3">
|
||||
<div class="h-3 w-20 rounded bg-surface-hover" />
|
||||
<div class="h-4 w-40 rounded bg-surface-hover" />
|
||||
<div class="h-4 w-full rounded bg-surface-hover" />
|
||||
<div class="h-4 w-3/4 rounded bg-surface-hover" />
|
||||
</div>
|
||||
<div class="space-y-2 p-3">
|
||||
<div class="h-3 w-24 rounded bg-surface-hover" />
|
||||
<div class="h-4 w-36 rounded bg-surface-hover" />
|
||||
<div class="h-4 w-full rounded bg-surface-hover" />
|
||||
<div class="h-4 w-2/3 rounded bg-surface-hover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceState }) {
|
||||
const state = props.state;
|
||||
const summaryStats = createMemo(() => state.summaryStats());
|
||||
const metricState = createMemo(() =>
|
||||
getPatrolSummaryMetricState({
|
||||
activeFindings: state.activePatrolFindings(),
|
||||
fixedCount: summaryStats().fixedCount,
|
||||
}),
|
||||
);
|
||||
const runtimePresentation = createMemo(() =>
|
||||
getPatrolRuntimePresentation(state.runtimeState(), state.blockedReason()),
|
||||
);
|
||||
const runtimeShellPresentation = createMemo(() =>
|
||||
getPatrolAssessmentShellPresentation(runtimePresentation().tone),
|
||||
);
|
||||
const showRuntimeSummary = createMemo(() => {
|
||||
const runtimeState = state.runtimeState();
|
||||
return (
|
||||
runtimeState === 'blocked' || runtimeState === 'disabled' || runtimeState === 'unavailable'
|
||||
);
|
||||
});
|
||||
const hasLoadedPatrolEvidence = createMemo(
|
||||
() =>
|
||||
summaryStats().hasAnyPatrolFindings ||
|
||||
(state.patrolRunHistory.value()?.length ?? 0) > 0 ||
|
||||
Boolean(state.patrolStatus()),
|
||||
);
|
||||
const showLoadingSummary = createMemo(
|
||||
() =>
|
||||
!showRuntimeSummary() &&
|
||||
!state.intelligenceSummary() &&
|
||||
!state.initialSurfaceReady() &&
|
||||
!hasLoadedPatrolEvidence(),
|
||||
);
|
||||
const assessment = createMemo(() =>
|
||||
getPatrolAssessmentPresentation({
|
||||
overallHealth: state.intelligenceSummary()?.overall_health,
|
||||
runtimeState: state.runtimeState(),
|
||||
blockedReason: state.blockedReason(),
|
||||
criticalFindings: summaryStats().criticalFindings,
|
||||
warningFindings: summaryStats().warningFindings,
|
||||
activeFindings: state.activePatrolFindings(),
|
||||
runs: state.patrolRunHistory.value() ?? [],
|
||||
}),
|
||||
);
|
||||
const activityBreakdownLabel = createMemo(() =>
|
||||
formatPatrolActivityBreakdown(getPatrolActivityBreakdown(state.patrolRunHistory.value() ?? [])),
|
||||
);
|
||||
const triggerStatusSummary = createMemo(() =>
|
||||
getPatrolTriggerStatusSummary(state.patrolStatus()?.trigger_status),
|
||||
);
|
||||
const recency = createMemo(() =>
|
||||
getPatrolRecencyPresentation({
|
||||
runs: state.patrolRunHistory.value() ?? [],
|
||||
lastPatrolAt: state.patrolStatus()?.last_patrol_at,
|
||||
lastActivityAt: state.patrolStatus()?.last_activity_at,
|
||||
}),
|
||||
);
|
||||
const compactRiskSummary = createMemo(() => {
|
||||
const stats = summaryStats();
|
||||
const parts: string[] = [];
|
||||
const hasRuntimeIssues =
|
||||
metricState().secondaryLabel === 'Runtime issues' && metricState().secondaryValue > 0;
|
||||
|
||||
if (stats.criticalFindings > 0) {
|
||||
parts.push(`${stats.criticalFindings} critical`);
|
||||
}
|
||||
|
||||
if (stats.warningFindings > 0 && !hasRuntimeIssues) {
|
||||
parts.push(`${stats.warningFindings} warning`);
|
||||
}
|
||||
|
||||
if (hasRuntimeIssues) {
|
||||
parts.push(
|
||||
`${metricState().secondaryValue} runtime ${
|
||||
metricState().secondaryValue === 1 ? 'issue' : 'issues'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parts.length === 0 && stats.totalActive > 0) {
|
||||
parts.push(`${stats.totalActive} active`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
});
|
||||
const compactAssessmentSummary = createMemo(() => {
|
||||
const overallHealth = state.intelligenceSummary()?.overall_health;
|
||||
const parts: string[] = [compactRiskSummary() || assessment().compactLabel];
|
||||
const regressedCount = state.patrolStatus()?.trust?.regressed_at_least_once ?? 0;
|
||||
|
||||
if (regressedCount > 0) {
|
||||
parts.push(`${regressedCount} regressed`);
|
||||
}
|
||||
|
||||
if (overallHealth) {
|
||||
parts.push(`${Math.round(overallHealth.score)}/100`);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Show when={showLoadingSummary()}>
|
||||
<PatrolAssessmentLoadingShell />
|
||||
</Show>
|
||||
|
||||
<Show when={showRuntimeSummary()}>
|
||||
<section class="overflow-hidden rounded-md border border-border bg-surface shadow-sm">
|
||||
<div
|
||||
class={`flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle px-4 py-3 ${runtimeShellPresentation().headerClass}`}
|
||||
>
|
||||
<StatusIndicatorBadge
|
||||
label="Patrol runtime"
|
||||
variant={runtimeShellPresentation().badgeVariant}
|
||||
size="md"
|
||||
uppercase
|
||||
/>
|
||||
<Show when={recency().timestamp}>
|
||||
<p class="text-xs font-medium text-muted">
|
||||
{recency().label}{' '}
|
||||
{formatRelativeTime(recency().timestamp!, {
|
||||
compact: true,
|
||||
emptyText: 'never',
|
||||
})}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class={`flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-md border ${runtimeShellPresentation().iconContainerClass}`}
|
||||
>
|
||||
<AlertCircleIcon class={`w-5 h-5 ${runtimeShellPresentation().iconClass}`} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-base-content">
|
||||
{runtimePresentation().label}
|
||||
</h2>
|
||||
<p class="mt-1.5 max-w-3xl text-sm leading-6 text-muted">
|
||||
{runtimePresentation().description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={!showRuntimeSummary() && !showLoadingSummary()}>
|
||||
<Show when={state.intelligenceSummary()}>
|
||||
<section class="border-y border-border-subtle py-2">
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted">
|
||||
{assessment().eyebrow}
|
||||
</span>
|
||||
<span class="font-semibold text-base-content">{compactAssessmentSummary()}</span>
|
||||
</div>
|
||||
<Show when={activityBreakdownLabel() || triggerStatusSummary()}>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
<Show when={activityBreakdownLabel()}>
|
||||
<span>Recent activity mix: {activityBreakdownLabel()}</span>
|
||||
</Show>
|
||||
<Show when={activityBreakdownLabel() && triggerStatusSummary()}>
|
||||
<span aria-hidden="true" class="text-slate-300">
|
||||
|
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={triggerStatusSummary()}>
|
||||
<span>Trigger mode: {triggerStatusSummary()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { usePatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader';
|
||||
import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners';
|
||||
import { PatrolIntelligenceSummary } from './PatrolIntelligenceSummary';
|
||||
import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace';
|
||||
|
||||
export function PatrolIntelligenceSurface() {
|
||||
@@ -15,7 +14,6 @@ export function PatrolIntelligenceSurface() {
|
||||
<div
|
||||
class={`space-y-4 transition-opacity ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<PatrolIntelligenceSummary state={state} />
|
||||
<PatrolIntelligenceWorkspace state={state} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,112 @@
|
||||
import { Show } from 'solid-js';
|
||||
import { createMemo, Show } from 'solid-js';
|
||||
import HistoryIcon from 'lucide-solid/icons/history';
|
||||
import SettingsIcon from 'lucide-solid/icons/settings';
|
||||
import { FindingsPanel } from '@/components/AI/FindingsPanel';
|
||||
import { ApprovalBanner, RunHistoryPanel } from '@/components/patrol';
|
||||
import { getPatrolFindingsBadgePresentation } from '@/utils/aiFindingPresentation';
|
||||
import {
|
||||
getFindingTitlePresentation,
|
||||
buildPatrolFindingDisplayGroups,
|
||||
getPatrolFindingsBadgePresentation,
|
||||
isPatrolRuntimeFinding,
|
||||
} from '@/utils/aiFindingPresentation';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { formatTriggerReason } from '@/utils/patrolFormat';
|
||||
import { ResourcePolicySummary } from '@/components/Infrastructure/ResourcePolicySummary';
|
||||
import { ResourceCorrelationSummary } from '@/components/Infrastructure/ResourceCorrelationSummary';
|
||||
import { ResourceChangeSummary } from '@/components/Infrastructure/ResourceChangeSummary';
|
||||
import { Button } from '@/components/shared/Button';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import {
|
||||
getPatrolSupportingContextCorrelationSummary,
|
||||
getPatrolSupportingContextToggleLabel,
|
||||
PATROL_SUPPORTING_CONTEXT_CHANGE_SUBTITLE,
|
||||
PATROL_SUPPORTING_CONTEXT_DESCRIPTION,
|
||||
PATROL_SUPPORTING_CONTEXT_EVIDENCE_LABEL,
|
||||
PATROL_SUPPORTING_CONTEXT_EVIDENCE_NOTE,
|
||||
PATROL_SUPPORTING_CONTEXT_POLICY_SUBTITLE,
|
||||
PATROL_SUPPORTING_CONTEXT_TITLE,
|
||||
} from './patrolSupportingContextPresentation';
|
||||
getPatrolRunRecordSummaryPresentation,
|
||||
getPatrolRunStatusPresentation,
|
||||
PATROL_FINDING_RECORD_UNAVAILABLE_LABEL,
|
||||
} from '@/utils/patrolRunPresentation';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge';
|
||||
import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions';
|
||||
import {
|
||||
getPatrolQueueBadgeLabel,
|
||||
getPatrolQueueWorkspaceDescription,
|
||||
getPatrolSetupIssueReason,
|
||||
PATROL_WORKSPACE_HISTORY_DESCRIPTION,
|
||||
PATROL_WORKSPACE_QUEUE_TITLE,
|
||||
PATROL_WORKSPACE_RUN_RECORD_DESCRIPTION,
|
||||
PATROL_WORKSPACE_RUN_RECORD_TITLE,
|
||||
PATROL_WORKSPACE_SETUP_DESCRIPTION,
|
||||
PATROL_WORKSPACE_SETUP_TITLE,
|
||||
} from './patrolControlPresentation';
|
||||
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
|
||||
|
||||
export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceState }) {
|
||||
const state = props.state;
|
||||
const findingsBadgePresentation = () =>
|
||||
getPatrolFindingsBadgePresentation(state.findingsTabBadgeFindings());
|
||||
const queueDisplayGroups = createMemo(() =>
|
||||
buildPatrolFindingDisplayGroups(state.findingsTabBadgeFindings()),
|
||||
);
|
||||
const queueIssueCount = createMemo(
|
||||
() => state.findingsTabBadgeCount() ?? state.findingsTabBadgeFindings().length,
|
||||
);
|
||||
const queueAffectedResourceCount = createMemo(() => queueDisplayGroups().length);
|
||||
const queueBadgeLabel = createMemo(() =>
|
||||
getPatrolQueueBadgeLabel({
|
||||
affectedResourceCount: queueAffectedResourceCount(),
|
||||
findingCount: queueIssueCount(),
|
||||
}),
|
||||
);
|
||||
const isHistoryOpen = () => state.activeTab() === 'history';
|
||||
const isSetupOnly = () =>
|
||||
!isHistoryOpen() && !state.selectedRun() && state.shouldShowPatrolSetupOnly();
|
||||
const setupAction = getPatrolProviderSettingsAction();
|
||||
const setupFinding = () => state.findingsTabBadgeFindings().find(isPatrolRuntimeFinding);
|
||||
const setupReason = () => {
|
||||
const finding = setupFinding();
|
||||
return getPatrolSetupIssueReason({
|
||||
setupFindingTitle: finding ? getFindingTitlePresentation(finding).label : undefined,
|
||||
readinessSummary: state.patrolReadiness()?.summary,
|
||||
triggerDisabledReason: state.triggerPatrolDisabledReason(),
|
||||
blockedReason: state.blockedReason(),
|
||||
});
|
||||
};
|
||||
const workspaceTitle = () =>
|
||||
isHistoryOpen()
|
||||
? 'Patrol history'
|
||||
: isSetupOnly()
|
||||
? PATROL_WORKSPACE_SETUP_TITLE
|
||||
: state.selectedRun()
|
||||
? PATROL_WORKSPACE_RUN_RECORD_TITLE
|
||||
: PATROL_WORKSPACE_QUEUE_TITLE;
|
||||
const workspaceDescription = () =>
|
||||
isHistoryOpen()
|
||||
? PATROL_WORKSPACE_HISTORY_DESCRIPTION
|
||||
: isSetupOnly()
|
||||
? PATROL_WORKSPACE_SETUP_DESCRIPTION
|
||||
: state.selectedRun()
|
||||
? PATROL_WORKSPACE_RUN_RECORD_DESCRIPTION
|
||||
: getPatrolQueueWorkspaceDescription({
|
||||
autonomyLevel: state.autonomyLevel(),
|
||||
autonomyLocked: state.autoFixLocked(),
|
||||
affectedResourceCount: queueAffectedResourceCount(),
|
||||
findingCount: queueIssueCount(),
|
||||
});
|
||||
const openHistory = () => {
|
||||
state.setActiveTab('history');
|
||||
state.setFindingsFilterOverride(undefined);
|
||||
};
|
||||
const closeHistory = () => {
|
||||
state.setActiveTab('findings');
|
||||
};
|
||||
const selectHistoryRun = (run: ReturnType<typeof state.selectedRun>) => {
|
||||
state.setSelectedRun(run);
|
||||
if (run) {
|
||||
state.setActiveTab('findings');
|
||||
}
|
||||
};
|
||||
const selectedRunStatus = () => {
|
||||
const run = state.selectedRun();
|
||||
if (!run) return null;
|
||||
return getPatrolRunStatusPresentation(run.status ?? 'unknown', run.error_count ?? 0);
|
||||
};
|
||||
const selectedRunSummary = () => {
|
||||
const run = state.selectedRun();
|
||||
return run ? getPatrolRunRecordSummaryPresentation(run) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,80 +126,135 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.setActiveTab('findings')}
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
state.activeTab() === 'findings'
|
||||
? 'border-blue-500 text-base-content'
|
||||
: 'border-transparent text-muted hover:text-base-content hover:border-border'
|
||||
}`}
|
||||
>
|
||||
Findings
|
||||
<Show when={(state.findingsTabBadgeCount() ?? 0) > 0}>
|
||||
{' '}
|
||||
<MetadataBadge aria-hidden="true" tone={findingsBadgePresentation().tone} size="xs">
|
||||
{state.findingsTabBadgeCount()}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
state.setActiveTab('history');
|
||||
state.setFindingsFilterOverride(undefined);
|
||||
}}
|
||||
class={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
state.activeTab() === 'history'
|
||||
? 'border-blue-500 text-base-content'
|
||||
: 'border-transparent text-muted hover:text-base-content hover:border-border'
|
||||
}`}
|
||||
>
|
||||
Runs
|
||||
<Show when={state.displayRunHistory().length > 0}>
|
||||
{' '}
|
||||
<MetadataBadge aria-hidden="true" tone="muted" size="xs">
|
||||
{state.displayRunHistory().length}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</button>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 class="text-sm font-semibold text-base-content">{workspaceTitle()}</h2>
|
||||
<Show
|
||||
when={
|
||||
!isHistoryOpen() &&
|
||||
!isSetupOnly() &&
|
||||
!state.selectedRun() &&
|
||||
Boolean(queueBadgeLabel())
|
||||
}
|
||||
>
|
||||
<MetadataBadge aria-hidden="true" tone={findingsBadgePresentation().tone} size="xs">
|
||||
{queueBadgeLabel()}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">{workspaceDescription()}</p>
|
||||
</div>
|
||||
<Show when={!isSetupOnly()}>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
onClick={() => (isHistoryOpen() ? closeHistory() : openHistory())}
|
||||
>
|
||||
<HistoryIcon class="h-4 w-4" />
|
||||
{isHistoryOpen() ? PATROL_WORKSPACE_QUEUE_TITLE : 'History'}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={state.activeTab() === 'findings'}>
|
||||
<Show when={state.selectedRun()}>
|
||||
{(run) => (
|
||||
<div class="flex items-center justify-between px-3 py-2 rounded-md bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 text-xs text-blue-700 dark:text-blue-300">
|
||||
<span>
|
||||
Filtered to run {formatRelativeTime(run().started_at, { compact: true })} (
|
||||
{formatTriggerReason(run().trigger_reason)})
|
||||
<Show when={state.selectedRunHasFindingsSnapshot() === false}>
|
||||
{' · '}Findings snapshot unavailable
|
||||
<div class="flex flex-col gap-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="font-semibold text-blue-950 dark:text-blue-100">
|
||||
Patrol run {formatRelativeTime(run().started_at, { compact: true })}
|
||||
</span>
|
||||
<span>{formatTriggerReason(run().trigger_reason)}</span>
|
||||
<Show when={selectedRunStatus()}>
|
||||
{(status) => (
|
||||
<StatusIndicatorBadge
|
||||
label={status().label}
|
||||
variant={status().variant}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={state.selectedRunHasFindingsSnapshot() === false}>
|
||||
<MetadataBadge tone="info" size="xs" shape="rounded">
|
||||
{PATROL_FINDING_RECORD_UNAVAILABLE_LABEL}
|
||||
</MetadataBadge>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={selectedRunSummary()}>
|
||||
{(summary) => (
|
||||
<p class="text-xs leading-5 text-blue-800 dark:text-blue-200">
|
||||
{summary().summary} {summary().outcome}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={selectedRunSummary()?.action}>
|
||||
{(action) => (
|
||||
<ButtonLink
|
||||
href={action().href}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mt-1 gap-1.5"
|
||||
>
|
||||
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
|
||||
{action().label}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.setSelectedRun(null)}
|
||||
class="font-medium hover:underline"
|
||||
class="w-fit shrink-0 text-xs font-medium text-blue-700 hover:underline dark:text-blue-300"
|
||||
>
|
||||
Clear filter
|
||||
Show current work
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<FindingsPanel
|
||||
filterOverride={state.selectedRun() ? 'all' : state.findingsFilterOverride()}
|
||||
filterFindingIds={state.selectedRunFindingIds()}
|
||||
scopeResourceIds={state.selectedRunScopeResourceIds()}
|
||||
scopeResourceTypes={state.selectedRun()?.scope_resource_types}
|
||||
showScopeWarnings={Boolean(state.selectedRun())}
|
||||
runtimeState={state.runtimeState()}
|
||||
blockedReason={state.blockedReason()}
|
||||
overallHealth={state.intelligenceSummary()?.overall_health}
|
||||
findingsSource="patrol"
|
||||
runSnapshot={state.selectedRun() ?? undefined}
|
||||
/>
|
||||
<Show
|
||||
when={isSetupOnly()}
|
||||
fallback={
|
||||
<FindingsPanel
|
||||
filterOverride={state.selectedRun() ? 'all' : state.findingsFilterOverride()}
|
||||
filterFindingIds={state.selectedRunFindingIds()}
|
||||
scopeResourceIds={state.selectedRunScopeResourceIds()}
|
||||
scopeResourceTypes={state.selectedRun()?.scope_resource_types}
|
||||
showScopeWarnings={Boolean(state.selectedRun())}
|
||||
runtimeState={state.runtimeState()}
|
||||
blockedReason={state.blockedReason()}
|
||||
overallHealth={state.intelligenceSummary()?.overall_health}
|
||||
historicalRegressionCount={state.historicalRegressionCount()}
|
||||
patrolRuns={state.patrolRunHistory.value() ?? []}
|
||||
findingsSource="patrol"
|
||||
runSnapshot={state.selectedRun() ?? undefined}
|
||||
showControls={!state.selectedRun()}
|
||||
onAssistantHandoff={(finding) => state.handleAssistantFindingHandoff(finding.id)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="rounded-md border border-border bg-surface-alt p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0 max-w-2xl">
|
||||
<h3 class="text-sm font-semibold text-base-content">
|
||||
Provider needs attention
|
||||
</h3>
|
||||
<p class="mt-2 text-xs text-muted">Issue: {setupReason()}</p>
|
||||
</div>
|
||||
<ButtonLink href={setupAction.href} variant="primary" size="sm" class="gap-1.5">
|
||||
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
|
||||
{setupAction.label}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={state.activeTab() === 'history'}>
|
||||
@@ -125,79 +262,10 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
|
||||
runs={state.displayRunHistory()}
|
||||
loading={state.patrolRunHistory.loading()}
|
||||
selectedRun={state.selectedRun()}
|
||||
onSelectRun={state.setSelectedRun}
|
||||
onSelectRun={selectHistoryRun}
|
||||
patrolStream={state.patrolStream}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={state.shouldSurfaceInvestigationContext()}>
|
||||
<section class="rounded-md border border-border-subtle bg-surface-alt/40 p-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
{PATROL_SUPPORTING_CONTEXT_TITLE}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-muted">{PATROL_SUPPORTING_CONTEXT_DESCRIPTION}</p>
|
||||
<Show when={state.investigationContextSummary()}>
|
||||
<p class="mt-1 text-xs text-base-content">{state.investigationContextSummary()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="px-3"
|
||||
onClick={() => state.setShowInvestigationContext((value) => !value)}
|
||||
>
|
||||
{getPatrolSupportingContextToggleLabel(state.showInvestigationContext())}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Show when={state.showInvestigationContext()}>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div class="rounded-md border border-border-subtle bg-base px-3 py-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
{PATROL_SUPPORTING_CONTEXT_EVIDENCE_LABEL}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">{PATROL_SUPPORTING_CONTEXT_EVIDENCE_NOTE}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
<Show when={state.recentChangeCount() > 0}>
|
||||
<ResourceChangeSummary
|
||||
class="space-y-0"
|
||||
title="Recent changes"
|
||||
subtitle={PATROL_SUPPORTING_CONTEXT_CHANGE_SUBTITLE}
|
||||
changes={state.supportingRecentChanges()}
|
||||
maxChanges={3}
|
||||
compact
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Show when={state.correlations().length > 0}>
|
||||
<ResourceCorrelationSummary
|
||||
title="Learned correlations"
|
||||
correlations={state.correlations()}
|
||||
summaryText={getPatrolSupportingContextCorrelationSummary(
|
||||
state.correlationTotal(),
|
||||
)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<ResourcePolicySummary
|
||||
posture={state.policyPosture()}
|
||||
title="Policy coverage"
|
||||
subtitle={PATROL_SUPPORTING_CONTEXT_POLICY_SUBTITLE}
|
||||
resourceCountLabel="policy-covered resources"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getPatrolConfigurationFailureInlineDetails } from '../PatrolIntelligenceHeader';
|
||||
import {
|
||||
PATROL_AUTONOMY_POLICY_PRESENTATION,
|
||||
getPatrolConfigurationFailureInlineDetails,
|
||||
} from '../PatrolIntelligenceHeader';
|
||||
import {
|
||||
getPatrolAutonomyAvailabilityPresentation,
|
||||
PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON,
|
||||
} from '../patrolAutonomyAvailability';
|
||||
|
||||
const headerSource = readFileSync(
|
||||
resolve(__dirname, '..', 'PatrolIntelligenceHeader.tsx'),
|
||||
@@ -10,10 +17,10 @@ const headerSource = readFileSync(
|
||||
);
|
||||
|
||||
describe('PatrolIntelligenceHeader', () => {
|
||||
it('keeps Patrol configuration readiness context visible inline', () => {
|
||||
it('keeps Patrol mode readiness context visible inline', () => {
|
||||
expect(
|
||||
getPatrolConfigurationFailureInlineDetails({
|
||||
message: 'Patrol configuration could not be saved.',
|
||||
message: 'Patrol mode could not be saved.',
|
||||
code: 'patrol_readiness_not_ready',
|
||||
readiness: {
|
||||
status: 'not_ready',
|
||||
@@ -26,7 +33,7 @@ describe('PatrolIntelligenceHeader', () => {
|
||||
}),
|
||||
).toEqual([
|
||||
'patrol_readiness_not_ready · model_unsupported_tools',
|
||||
'Readiness: The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
'Setup: The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
'Provider: openrouter',
|
||||
'Model: openrouter:deepseek/deepseek-r1',
|
||||
]);
|
||||
@@ -35,23 +42,245 @@ describe('PatrolIntelligenceHeader', () => {
|
||||
it('falls back to the blocked cause when readiness cause is absent', () => {
|
||||
expect(
|
||||
getPatrolConfigurationFailureInlineDetails({
|
||||
message: 'Patrol configuration could not be saved.',
|
||||
message: 'Patrol mode could not be saved.',
|
||||
code: 'patrol_autonomy_pro_required',
|
||||
blockedCause: 'license_required',
|
||||
}),
|
||||
).toEqual(['patrol_autonomy_pro_required · license_required']);
|
||||
});
|
||||
|
||||
it('keeps trigger runtime status in the always-visible header chrome', () => {
|
||||
it('keeps trigger runtime status in the header chrome after setup', () => {
|
||||
expect(headerSource).toContain('getPatrolTriggerStatusSummary');
|
||||
expect(headerSource).toContain('state.patrolStatus()?.trigger_status');
|
||||
expect(headerSource).toContain('Trigger status:');
|
||||
expect(headerSource).toContain('!state.shouldShowPatrolSetupOnly() && triggerStatusSummary()');
|
||||
expect(headerSource).not.toContain('Automation:');
|
||||
expect(headerSource).not.toContain('Trigger status:');
|
||||
});
|
||||
|
||||
it('keeps Patrol mode visible while setup blocks running and configuration', () => {
|
||||
const setupOnlyGateCount = headerSource.match(/!state\.shouldShowPatrolSetupOnly\(\)/g)?.length;
|
||||
|
||||
expect(setupOnlyGateCount).toBeGreaterThanOrEqual(3);
|
||||
expect(headerSource).toContain('<Show when={!state.shouldShowPatrolSetupOnly()}>');
|
||||
expect(headerSource).toContain('id={PATROL_CONTROL_ANCHOR}');
|
||||
expect(headerSource).not.toContain(
|
||||
'<Show when={!state.shouldShowPatrolSetupOnly()}>\n <div\n id={PATROL_CONTROL_ANCHOR}',
|
||||
);
|
||||
expect(headerSource).toContain('Open Patrol settings');
|
||||
expect(headerSource).toContain("settingsTabPath('system-ai-patrol')");
|
||||
expect(headerSource).not.toContain('Patrol schedule and model settings');
|
||||
expect(headerSource).toContain('Run Patrol');
|
||||
});
|
||||
|
||||
it('turns provider-blocked manual run controls into setup actions', () => {
|
||||
expect(headerSource).toContain('runBlockedByProviderSetup');
|
||||
expect(headerSource).toContain("state.patrolReadiness()?.status === 'not_ready'");
|
||||
expect(headerSource).toContain('getPatrolProviderSettingsAction');
|
||||
expect(headerSource).toContain('providerSetupAction.href');
|
||||
expect(headerSource).toContain('Fix provider');
|
||||
expect(headerSource).toContain('Fix Patrol provider:');
|
||||
expect(headerSource).toContain('runButtonDisabled');
|
||||
expect(headerSource).not.toContain('!state.canTriggerPatrol() ||');
|
||||
});
|
||||
|
||||
it('keeps manual page sync out of the primary Patrol header actions', () => {
|
||||
expect(headerSource).not.toContain('handleRefreshPatrol');
|
||||
expect(headerSource).not.toContain('Sync page data');
|
||||
expect(headerSource).not.toContain('RefreshCwIcon');
|
||||
expect(headerSource).not.toContain('animate-spin');
|
||||
expect(headerSource).not.toContain('Refresh Patrol');
|
||||
});
|
||||
|
||||
it('makes Patrol mode a simple four-level choice without rendering plan-locked paid modes', () => {
|
||||
expect(PATROL_AUTONOMY_POLICY_PRESENTATION).toEqual({
|
||||
monitor: {
|
||||
label: 'Watch only',
|
||||
detail: 'Patrol reports issues without making changes.',
|
||||
compactLabel: 'Watch only',
|
||||
},
|
||||
approval: {
|
||||
label: 'Ask first',
|
||||
detail: 'Patrol investigates issues and prepares fixes. You approve every change.',
|
||||
compactLabel: 'Ask first',
|
||||
},
|
||||
assisted: {
|
||||
label: 'Safe auto-fix',
|
||||
detail: 'Patrol fixes safe policy-allowed issues. It asks before anything riskier.',
|
||||
compactLabel: 'Safe auto-fix',
|
||||
},
|
||||
full: {
|
||||
label: 'Autopilot',
|
||||
detail:
|
||||
'Patrol handles policy-approved issues automatically. It asks only when policy requires approval.',
|
||||
compactLabel: 'Autopilot',
|
||||
},
|
||||
});
|
||||
expect(headerSource).toContain("['monitor', 'approval', 'assisted', 'full']");
|
||||
expect(headerSource).toContain("state.autoFixLocked() ? 'monitor' : state.autonomyLevel()");
|
||||
expect(headerSource).toContain('value={effectiveAutonomyLevel()}');
|
||||
expect(headerSource).toContain('canChooseAutonomyLevel');
|
||||
expect(headerSource).toContain('shouldShowAutonomyOptions');
|
||||
expect(headerSource).toContain('shouldShowAutonomyActionColumn');
|
||||
expect(headerSource).toContain("autonomyAvailability().kind === 'runtime_locked'");
|
||||
expect(headerSource).toContain('<Show when={shouldShowAutonomyOptions()}>');
|
||||
expect(headerSource).toContain('<Show when={shouldShowAutonomyActionColumn()}>');
|
||||
expect(headerSource).toContain(
|
||||
"const lockedPaidMode = state.autoFixLocked() && level !== 'monitor'",
|
||||
);
|
||||
expect(headerSource).toContain('disabled: lockedPaidMode');
|
||||
expect(headerSource).toContain('compactLabel: presentation.compactLabel');
|
||||
expect(headerSource).not.toContain('showPlanLockedModeAction');
|
||||
expect(headerSource).toContain('Patrol mode');
|
||||
expect(headerSource).not.toContain('getPatrolAutonomyContractPresentation');
|
||||
expect(headerSource).not.toContain('selectedAutonomyContract');
|
||||
expect(headerSource).not.toContain('showControlDetails');
|
||||
expect(headerSource).not.toContain("'Limits'");
|
||||
expect(headerSource).not.toContain('Hide limits');
|
||||
expect(headerSource).not.toContain('Hard limits');
|
||||
expect(headerSource).not.toContain('requires Pulse Pro');
|
||||
expect(headerSource).not.toContain('const isProLocked = () =>');
|
||||
expect(headerSource).not.toContain('visualLabel: proLocked');
|
||||
expect(headerSource).not.toContain('Current mode:');
|
||||
expect(headerSource).not.toContain('How much can Patrol do?');
|
||||
expect(headerSource).not.toContain("level === 'full' ? 'assisted'");
|
||||
expect(headerSource).not.toContain('Autonomous critical remediation');
|
||||
});
|
||||
|
||||
it('keeps Patrol mode inline and routes configuration to settings', () => {
|
||||
const autonomyControlCallCount =
|
||||
headerSource.match(/renderAutonomyPolicyControl\(\{/g)?.length ?? 0;
|
||||
const configurationAutonomyIndex = headerSource.indexOf("ariaLabel: 'Patrol mode'");
|
||||
|
||||
expect(autonomyControlCallCount).toBe(1);
|
||||
expect(configurationAutonomyIndex).toBeGreaterThan(-1);
|
||||
expect(headerSource).toContain('Open Patrol settings');
|
||||
expect(headerSource).toContain("settingsTabPath('system-ai-patrol')");
|
||||
expect(headerSource).not.toContain('Schedule & model');
|
||||
expect(headerSource).not.toContain('Patrol schedule and model settings');
|
||||
expect(headerSource).not.toContain('label="Provider model"');
|
||||
expect(headerSource).not.toContain('label="Run Every"');
|
||||
expect(headerSource).not.toContain('FormSelect');
|
||||
expect(headerSource).not.toContain('showAdvancedSettings');
|
||||
expect(headerSource).not.toContain('Advanced settings');
|
||||
expect(headerSource).not.toContain('Advanced Patrol settings');
|
||||
expect(headerSource).not.toContain('Done');
|
||||
expect(headerSource).not.toContain('Set Patrol mode');
|
||||
expect(headerSource).not.toContain('Save Patrol mode');
|
||||
expect(headerSource).not.toContain('Patrol policy');
|
||||
expect(headerSource).not.toContain("variant: 'prominent'");
|
||||
expect(headerSource).not.toContain('Patrol Configuration');
|
||||
expect(headerSource).not.toContain('Apply Configuration');
|
||||
});
|
||||
|
||||
it('keeps the Patrol model catalog out of the operator page', () => {
|
||||
expect(headerSource).not.toContain('showModelCatalog');
|
||||
expect(headerSource).not.toContain('Provider model');
|
||||
expect(headerSource).not.toContain('Change model');
|
||||
expect(headerSource).not.toContain('Hide model list');
|
||||
expect(headerSource).not.toContain('Choose available model');
|
||||
expect(headerSource).not.toContain('formatPatrolModelOptionLabel(model)');
|
||||
});
|
||||
|
||||
it('explains when Patrol mode is locked by plan or runtime', () => {
|
||||
expect(
|
||||
getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: false,
|
||||
planUpgradeDestination: {
|
||||
href: '/settings/pulse-intelligence/billing/plan',
|
||||
external: false,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'available',
|
||||
locked: false,
|
||||
title: 'Patrol mode available',
|
||||
body: 'Choose the mode for this install.',
|
||||
});
|
||||
|
||||
expect(
|
||||
getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
planUpgradeDestination: {
|
||||
href: '/settings/pulse-intelligence/billing/plan',
|
||||
external: false,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'plan_locked',
|
||||
locked: true,
|
||||
title: 'Watch only',
|
||||
body: 'This install watches infrastructure and shows issues.',
|
||||
actionLabel: 'Plans & Billing',
|
||||
destination: {
|
||||
href: '/settings/pulse-intelligence/billing/plan',
|
||||
external: false,
|
||||
},
|
||||
});
|
||||
expect(headerSource).toContain('showAutonomyPlanBillingAction');
|
||||
expect(headerSource).toContain("autonomyAvailability().kind === 'plan_locked'");
|
||||
expect(headerSource).toContain('CreditCardIcon');
|
||||
expect(headerSource).toContain('{autonomyAvailability().actionLabel}');
|
||||
|
||||
const hiddenUpgradePresentation = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
upgradePromptsHidden: true,
|
||||
planUpgradeDestination: {
|
||||
href: '/settings/pulse-intelligence/billing/plan',
|
||||
external: false,
|
||||
},
|
||||
});
|
||||
expect(hiddenUpgradePresentation).toMatchObject({
|
||||
kind: 'plan_locked',
|
||||
locked: true,
|
||||
title: 'Watch only',
|
||||
body: 'This install watches infrastructure and shows issues.',
|
||||
});
|
||||
expect(hiddenUpgradePresentation).not.toHaveProperty('actionLabel');
|
||||
expect(hiddenUpgradePresentation).not.toHaveProperty('destination');
|
||||
|
||||
const hiddenCommercialPresentation = getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
commercialSurfacesHidden: true,
|
||||
planUpgradeDestination: {
|
||||
href: '/settings/pulse-intelligence/billing/plan',
|
||||
external: false,
|
||||
},
|
||||
});
|
||||
expect(hiddenCommercialPresentation).toMatchObject({
|
||||
kind: 'plan_locked',
|
||||
locked: true,
|
||||
title: 'Watch only',
|
||||
body: 'This install watches infrastructure and shows issues.',
|
||||
});
|
||||
expect(hiddenCommercialPresentation).not.toHaveProperty('actionLabel');
|
||||
expect(hiddenCommercialPresentation).not.toHaveProperty('destination');
|
||||
|
||||
expect(
|
||||
getPatrolAutonomyAvailabilityPresentation({
|
||||
autoFixLocked: true,
|
||||
runtimeCapabilityBlock: {
|
||||
key: 'ai_autofix',
|
||||
reason: PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON,
|
||||
action_url: 'https://pulserelay.pro/download.html',
|
||||
},
|
||||
runtime: { build: 'community', label: 'Pulse Community runtime' },
|
||||
planUpgradeDestination: {
|
||||
href: '/settings/pulse-intelligence/billing/plan',
|
||||
external: false,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'runtime_locked',
|
||||
locked: true,
|
||||
title: 'Pulse Pro runtime required',
|
||||
actionLabel: 'Open Pro downloads',
|
||||
destination: { href: 'https://pulserelay.pro/download.html', external: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps trust counters out of the page header chrome', () => {
|
||||
// The Patrol assessment strip owns the default status readout. The header
|
||||
// should stay focused on title, recency, and controls rather than adding
|
||||
// another active/regressed summary row.
|
||||
// Current work and deliberate history review own trust
|
||||
// state. The header should stay focused on title, recency, and controls.
|
||||
expect(headerSource).not.toContain('aria-label="Patrol trust summary header"');
|
||||
expect(headerSource).not.toContain('state.patrolStatus()?.trust');
|
||||
expect(headerSource).not.toContain('trust.currently_active');
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PatrolRunRecord } from '@/api/patrol';
|
||||
import { PatrolIntelligenceSummary } from '../PatrolIntelligenceSummary';
|
||||
import type { PatrolIntelligenceState } from '../usePatrolIntelligenceState';
|
||||
|
||||
describe('PatrolIntelligenceSummary', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the compact assessment strip descriptive only', () => {
|
||||
const patrolState = createPatrolState();
|
||||
render(() => <PatrolIntelligenceSummary state={patrolState} />);
|
||||
|
||||
expect(screen.getByText('Patrol assessment')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 critical · 84/100')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-recommended-next-step')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-recommended-next-step-action')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-summary-details-toggle')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-summary-details')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-assessment-assistant-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces recent activity mix and trigger mode in the compact assessment strip', () => {
|
||||
const patrolState = {
|
||||
...createPatrolState(),
|
||||
patrolRunHistory: makePatrolRunHistory([
|
||||
makePatrolRunRecord({
|
||||
id: 'run-alert-scoped',
|
||||
started_at: todayAt(11, 0),
|
||||
completed_at: todayAt(11, 2),
|
||||
type: 'scoped',
|
||||
trigger_reason: 'alert_fired',
|
||||
new_findings: 1,
|
||||
}),
|
||||
makePatrolRunRecord({
|
||||
id: 'run-anomaly-scoped',
|
||||
started_at: todayAt(10, 15),
|
||||
completed_at: todayAt(10, 16),
|
||||
type: 'scoped',
|
||||
trigger_reason: 'anomaly',
|
||||
}),
|
||||
makePatrolRunRecord({
|
||||
id: 'run-full-review',
|
||||
started_at: todayAt(9, 0),
|
||||
completed_at: todayAt(9, 3),
|
||||
type: 'full',
|
||||
trigger_reason: 'scheduled',
|
||||
}),
|
||||
]),
|
||||
patrolStatus: () => ({
|
||||
trigger_status: {
|
||||
running: false,
|
||||
pending_triggers: 4,
|
||||
current_interval_ms: 10000,
|
||||
recent_events: 12,
|
||||
is_busy_mode: true,
|
||||
alert_triggers_enabled: true,
|
||||
anomaly_triggers_enabled: false,
|
||||
},
|
||||
}),
|
||||
} as unknown as PatrolIntelligenceState;
|
||||
|
||||
render(() => <PatrolIntelligenceSummary state={patrolState} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Recent activity mix: 1 full, 1 alert-triggered, 1 anomaly-triggered'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Trigger mode: 4 queued · busy mode · anomalies off'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains runtime-blocked event triggers in the compact assessment strip', () => {
|
||||
const patrolState = {
|
||||
...createPatrolState(),
|
||||
patrolStatus: () => ({
|
||||
trigger_status: {
|
||||
running: false,
|
||||
pending_triggers: 0,
|
||||
current_interval_ms: 300000,
|
||||
recent_events: 0,
|
||||
is_busy_mode: false,
|
||||
alert_triggers_enabled: true,
|
||||
anomaly_triggers_enabled: true,
|
||||
event_triggers_blocked: true,
|
||||
event_triggers_blocked_reason: 'background_automation_disabled',
|
||||
event_triggers_blocked_message:
|
||||
'Automatic Patrol checks from alerts and anomalies are paused by the local development safety guard. Manual Patrol still works.',
|
||||
},
|
||||
}),
|
||||
} as unknown as PatrolIntelligenceState;
|
||||
|
||||
render(() => <PatrolIntelligenceSummary state={patrolState} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Trigger mode: Automatic Patrol checks from alerts and anomalies are paused by the local development safety guard. Manual Patrol still works.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not surface disabled run actions on the compact assessment strip', () => {
|
||||
const patrolState = {
|
||||
...createPatrolState(),
|
||||
activePatrolFindings: () => [],
|
||||
canTriggerPatrol: () => false,
|
||||
correlationTotal: () => 0,
|
||||
correlations: () => [],
|
||||
intelligenceSummary: () => ({
|
||||
timestamp: '2026-05-06T12:15:00Z',
|
||||
overall_health: {
|
||||
grade: 'C',
|
||||
score: 65,
|
||||
trend: 'stable',
|
||||
factors: [
|
||||
{
|
||||
category: 'coverage',
|
||||
name: 'Coverage',
|
||||
impact: -10,
|
||||
description: 'Patrol coverage is incomplete.',
|
||||
},
|
||||
],
|
||||
prediction: 'Patrol coverage is incomplete.',
|
||||
},
|
||||
findings_count: {
|
||||
critical: 0,
|
||||
warning: 0,
|
||||
watch: 0,
|
||||
info: 0,
|
||||
total: 0,
|
||||
},
|
||||
predictions_count: 0,
|
||||
recent_changes_count: 0,
|
||||
recent_changes: [],
|
||||
policy_posture: {
|
||||
total_resources: 4,
|
||||
sensitivity_counts: {},
|
||||
routing_counts: {},
|
||||
},
|
||||
learning: {
|
||||
resources_with_knowledge: 0,
|
||||
total_notes: 0,
|
||||
resources_with_baselines: 0,
|
||||
patterns_detected: 0,
|
||||
correlations_learned: 0,
|
||||
incidents_tracked: 0,
|
||||
},
|
||||
}),
|
||||
manualRunRequested: () => false,
|
||||
patrolRunHistory: makePatrolRunHistory([
|
||||
makePatrolRunRecord({
|
||||
id: 'run-scoped-1',
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
completed_at: '2026-05-06T12:01:00Z',
|
||||
type: 'scoped',
|
||||
status: 'error',
|
||||
error_count: 1,
|
||||
}),
|
||||
]),
|
||||
patrolStream: {
|
||||
phase: () => 'Running',
|
||||
currentTool: () => '',
|
||||
tokens: () => 0,
|
||||
resynced: () => false,
|
||||
resyncReason: () => '',
|
||||
bufferStartSeq: () => 0,
|
||||
bufferEndSeq: () => 0,
|
||||
outputTruncated: () => false,
|
||||
reconnectCount: () => 0,
|
||||
isStreaming: () => true,
|
||||
errorMessage: () => '',
|
||||
},
|
||||
runtimeState: () => 'running',
|
||||
summaryStats: () => ({
|
||||
criticalFindings: 0,
|
||||
warningFindings: 0,
|
||||
totalActive: 0,
|
||||
fixedCount: 0,
|
||||
hasAnyPatrolFindings: false,
|
||||
}),
|
||||
triggerPatrolDisabledReason: () => 'Patrol is already running',
|
||||
} satisfies PatrolIntelligenceState;
|
||||
|
||||
render(() => <PatrolIntelligenceSummary state={patrolState} />);
|
||||
|
||||
expect(screen.queryByTestId('patrol-recommended-next-step')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-recommended-next-step-action')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('patrol-summary-details-toggle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not leave the loading shell behind once patrol evidence has rendered', () => {
|
||||
const patrolState = {
|
||||
...createPatrolState(),
|
||||
initialSurfaceReady: () => false,
|
||||
intelligenceSummary: () => null,
|
||||
} satisfies PatrolIntelligenceState;
|
||||
|
||||
render(() => <PatrolIntelligenceSummary state={patrolState} />);
|
||||
|
||||
expect(screen.queryByTestId('patrol-summary-loading')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function todayAt(hours: number, minutes: number): string {
|
||||
const value = new Date();
|
||||
value.setHours(hours, minutes, 0, 0);
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function createPatrolState(): PatrolIntelligenceState {
|
||||
return {
|
||||
activePatrolFindings: () => [
|
||||
{
|
||||
id: 'finding-1',
|
||||
source: 'ai-patrol',
|
||||
resourceId: 'vm-100',
|
||||
resourceName: 'web-server',
|
||||
resourceType: 'vm',
|
||||
category: 'performance',
|
||||
severity: 'critical',
|
||||
title: 'High CPU usage',
|
||||
description: 'CPU stayed above 95%.',
|
||||
detectedAt: '2026-05-06T12:00:00Z',
|
||||
lastSeenAt: '2026-05-06T12:10:00Z',
|
||||
status: 'active',
|
||||
investigationStatus: 'completed',
|
||||
investigationOutcome: 'fix_queued',
|
||||
loopState: 'awaiting_approval',
|
||||
timesRaised: 3,
|
||||
regressionCount: 1,
|
||||
investigationRecord: {
|
||||
id: 'record-1',
|
||||
finding_id: 'finding-1',
|
||||
subject: {
|
||||
resource_id: 'vm-100',
|
||||
resource_name: 'web-server',
|
||||
resource_type: 'vm',
|
||||
node: 'pve-1',
|
||||
},
|
||||
trigger: { detected_at: '2026-05-06T12:00:00Z' },
|
||||
status: 'completed',
|
||||
outcome: 'fix_queued',
|
||||
confidence: 'high',
|
||||
conclusion: 'Backup job saturated CPU.',
|
||||
recommended_action: 'Approve a controlled restart after the backup completes.',
|
||||
evidence: [{ kind: 'metrics', summary: 'CPU stayed above 95% for 10 minutes' }],
|
||||
proposed_fix: {
|
||||
id: 'fix-1',
|
||||
description: 'Restart the workload service',
|
||||
commands: ['systemctl restart workload.service'],
|
||||
risk_level: 'medium',
|
||||
destructive: false,
|
||||
},
|
||||
verification: ['CPU returned below 50%'],
|
||||
tools_used: ['ssh.exec'],
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
blockedReason: () => undefined,
|
||||
canTriggerPatrol: () => true,
|
||||
circuitBreakerStatus: () => undefined,
|
||||
correlationTotal: () => 2,
|
||||
correlations: () => [
|
||||
{
|
||||
source_id: 'backup-job',
|
||||
source_name: 'Nightly backup job',
|
||||
source_type: 'job',
|
||||
target_id: 'vm-100',
|
||||
target_name: 'web-server',
|
||||
target_type: 'vm',
|
||||
event_pattern: 'backup_started -> cpu_spike',
|
||||
occurrences: 4,
|
||||
avg_delay: 120000000000,
|
||||
confidence: 0.92,
|
||||
last_seen: '2026-05-06T12:08:00Z',
|
||||
description: 'CPU pressure usually follows this backup job.',
|
||||
},
|
||||
],
|
||||
hasInvestigationContext: () => true,
|
||||
initialSurfaceReady: () => true,
|
||||
isTriggeringPatrol: () => false,
|
||||
intelligenceSummary: () => ({
|
||||
overall_health: {
|
||||
grade: 'B',
|
||||
score: 84,
|
||||
factors: [],
|
||||
prediction: 'Patrol surfaced one active critical finding.',
|
||||
},
|
||||
recent_changes_count: 2,
|
||||
recent_changes: [
|
||||
{
|
||||
id: 'change-1',
|
||||
observedAt: '2026-05-06T12:08:00Z',
|
||||
resourceId: 'vm-100',
|
||||
kind: 'metric_anomaly',
|
||||
sourceType: 'heuristic',
|
||||
sourceAdapter: 'proxmox_adapter',
|
||||
confidence: 'high',
|
||||
relatedResources: ['backup-job'],
|
||||
reason: 'CPU spike after backup job',
|
||||
},
|
||||
{
|
||||
id: 'change-2',
|
||||
observedAt: '2026-05-06T12:07:00Z',
|
||||
resourceId: 'vm-100',
|
||||
kind: 'command_executed',
|
||||
sourceType: 'agent_action',
|
||||
sourceAdapter: 'agent:ops-helper',
|
||||
confidence: 'medium',
|
||||
reason: 'systemctl restart workload.service',
|
||||
metadata: {
|
||||
command: 'systemctl restart workload.service',
|
||||
},
|
||||
},
|
||||
],
|
||||
policy_posture: {
|
||||
total_resources: 4,
|
||||
sensitivity_counts: {},
|
||||
routing_counts: {},
|
||||
},
|
||||
}),
|
||||
investigationContextSummary: () =>
|
||||
'2 recent changes · 2 correlations · 4 policy-covered resources',
|
||||
handleRunPatrol: vi.fn(),
|
||||
manualRunRequested: () => false,
|
||||
patrolRunHistory: makePatrolRunHistory([
|
||||
makePatrolRunRecord({
|
||||
id: 'run-1',
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
completed_at: '2026-05-06T12:10:00Z',
|
||||
status: 'issues_found',
|
||||
resources_checked: 12,
|
||||
new_findings: 1,
|
||||
finding_ids: ['finding-1'],
|
||||
}),
|
||||
]),
|
||||
patrolStatus: () => ({
|
||||
last_patrol_at: '2026-05-06T12:10:00Z',
|
||||
last_activity_at: '2026-05-06T12:10:00Z',
|
||||
}),
|
||||
policyPosture: () => ({
|
||||
total_resources: 4,
|
||||
sensitivity_counts: {},
|
||||
routing_counts: {},
|
||||
}),
|
||||
recentChangeCount: () => 2,
|
||||
runtimeState: () => 'active',
|
||||
patrolStream: {
|
||||
isStreaming: () => false,
|
||||
},
|
||||
supportingRecentChanges: () => [
|
||||
{
|
||||
id: 'change-1',
|
||||
observedAt: '2026-05-06T12:08:00Z',
|
||||
resourceId: 'vm-100',
|
||||
kind: 'metric_anomaly',
|
||||
sourceType: 'heuristic',
|
||||
sourceAdapter: 'proxmox_adapter',
|
||||
confidence: 'high',
|
||||
relatedResources: ['backup-job'],
|
||||
reason: 'CPU spike after backup job',
|
||||
},
|
||||
{
|
||||
id: 'change-2',
|
||||
observedAt: '2026-05-06T12:07:00Z',
|
||||
resourceId: 'vm-100',
|
||||
kind: 'command_executed',
|
||||
sourceType: 'agent_action',
|
||||
sourceAdapter: 'agent:ops-helper',
|
||||
confidence: 'medium',
|
||||
reason: 'systemctl restart workload.service',
|
||||
metadata: {
|
||||
command: 'systemctl restart workload.service',
|
||||
},
|
||||
},
|
||||
],
|
||||
setActiveTab: vi.fn(),
|
||||
setFindingsFilterOverride: vi.fn(),
|
||||
setSelectedRun: vi.fn(),
|
||||
summaryStats: () => ({
|
||||
criticalFindings: 1,
|
||||
warningFindings: 0,
|
||||
totalActive: 1,
|
||||
fixedCount: 0,
|
||||
hasAnyPatrolFindings: true,
|
||||
}),
|
||||
triggerPatrolDisabledReason: () => '',
|
||||
} as unknown as PatrolIntelligenceState;
|
||||
}
|
||||
|
||||
function makePatrolRunRecord(overrides: Partial<PatrolRunRecord>): PatrolRunRecord {
|
||||
return {
|
||||
id: 'run-1',
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
completed_at: '2026-05-06T12:10:00Z',
|
||||
duration_ms: 600000,
|
||||
type: 'full',
|
||||
resources_checked: 0,
|
||||
nodes_checked: 0,
|
||||
guests_checked: 0,
|
||||
docker_checked: 0,
|
||||
storage_checked: 0,
|
||||
hosts_checked: 0,
|
||||
truenas_checked: 0,
|
||||
pbs_checked: 0,
|
||||
pmg_checked: 0,
|
||||
kubernetes_checked: 0,
|
||||
new_findings: 0,
|
||||
existing_findings: 0,
|
||||
rejected_findings: 0,
|
||||
resolved_findings: 0,
|
||||
auto_fix_count: 0,
|
||||
findings_summary: '',
|
||||
finding_ids: [],
|
||||
error_count: 0,
|
||||
status: 'healthy',
|
||||
triage_flags: 0,
|
||||
tool_call_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePatrolRunHistory(
|
||||
runs: PatrolRunRecord[],
|
||||
): PatrolIntelligenceState['patrolRunHistory'] {
|
||||
return {
|
||||
error: () => null,
|
||||
loading: () => false,
|
||||
refetch: vi.fn(async () => runs),
|
||||
resolvedOnce: () => true,
|
||||
value: () => runs,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,15 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
PATROL_WORKSPACE_HISTORY_DESCRIPTION,
|
||||
PATROL_WORKSPACE_QUEUE_TITLE,
|
||||
PATROL_WORKSPACE_RUN_RECORD_DESCRIPTION,
|
||||
PATROL_WORKSPACE_RUN_RECORD_TITLE,
|
||||
PATROL_WORKSPACE_SETUP_DESCRIPTION,
|
||||
PATROL_WORKSPACE_SETUP_TITLE,
|
||||
} from '../patrolControlPresentation';
|
||||
|
||||
const workspaceSource = readFileSync(
|
||||
resolve(__dirname, '..', 'PatrolIntelligenceWorkspace.tsx'),
|
||||
'utf-8',
|
||||
@@ -9,9 +18,8 @@ const workspaceSource = readFileSync(
|
||||
|
||||
describe('PatrolIntelligenceWorkspace trust strip', () => {
|
||||
it('does not render a standalone Trust strip above the workspace tabs', () => {
|
||||
// The Patrol assessment strip owns the default active/regressed status
|
||||
// readout. The workspace should move directly into Findings/Runs instead
|
||||
// of repeating the same counters in a second strip.
|
||||
// Current work and deliberate history review own operator-facing trust state.
|
||||
// The workspace should not add another standalone counter strip.
|
||||
expect(workspaceSource).not.toContain('state.patrolStatus()?.trust');
|
||||
expect(workspaceSource).not.toContain('aria-label="Patrol trust summary"');
|
||||
expect(workspaceSource).not.toContain('trust.fix_verified');
|
||||
@@ -24,4 +32,48 @@ describe('PatrolIntelligenceWorkspace trust strip', () => {
|
||||
expect(workspaceSource).not.toMatch(/trust\.patrol_score/);
|
||||
expect(workspaceSource).not.toMatch(/trust\.health_grade/);
|
||||
});
|
||||
|
||||
it('frames findings and run records as Patrol operation records', () => {
|
||||
expect(workspaceSource).toContain('state.shouldShowPatrolSetupOnly()');
|
||||
expect(workspaceSource).not.toContain('PATROL_SUPPORTING_CONTEXT');
|
||||
expect(workspaceSource).not.toContain('ResourceCorrelationSummary');
|
||||
expect(workspaceSource).not.toContain('ResourcePolicySummary');
|
||||
expect(workspaceSource).not.toContain('ResourceChangeSummary');
|
||||
expect(workspaceSource).not.toContain('showInvestigationContext');
|
||||
expect(workspaceSource).not.toContain('shouldSurfaceInvestigationContext');
|
||||
expect(workspaceSource).toContain('PATROL_WORKSPACE_SETUP_TITLE');
|
||||
expect(workspaceSource).toContain('PATROL_WORKSPACE_SETUP_DESCRIPTION');
|
||||
expect(workspaceSource).toContain('buildPatrolFindingDisplayGroups');
|
||||
expect(workspaceSource).toContain('getPatrolQueueBadgeLabel');
|
||||
expect(workspaceSource).toContain('queueIssueCount');
|
||||
expect(workspaceSource).toContain('queueAffectedResourceCount');
|
||||
expect(workspaceSource).toContain('affectedResourceCount: queueAffectedResourceCount()');
|
||||
expect(workspaceSource).toContain('findingCount: queueIssueCount()');
|
||||
expect(workspaceSource).toContain('getPatrolQueueWorkspaceDescription');
|
||||
expect(workspaceSource).toContain('autonomyLevel: state.autonomyLevel()');
|
||||
expect(workspaceSource).toContain('autonomyLocked: state.autoFixLocked()');
|
||||
expect(workspaceSource).toContain('Boolean(queueBadgeLabel())');
|
||||
expect(workspaceSource).toContain('{queueBadgeLabel()}');
|
||||
const removedAllModeCopy =
|
||||
'Current work Patrol can ' +
|
||||
'watch, investigate, ask you to approve, or record under your Patrol mode.';
|
||||
expect(workspaceSource).not.toContain(removedAllModeCopy);
|
||||
expect(workspaceSource).toContain('Provider needs attention');
|
||||
expect(workspaceSource).toContain('Issue:');
|
||||
expect(workspaceSource).toContain('getPatrolProviderSettingsAction');
|
||||
expect(workspaceSource).toContain('<Show when={!isSetupOnly()}>');
|
||||
expect(workspaceSource).not.toContain('showControls={!state.selectedRun() && !isSetupOnly()}');
|
||||
expect(PATROL_WORKSPACE_SETUP_TITLE).toBe('Fix provider');
|
||||
expect(PATROL_WORKSPACE_SETUP_DESCRIPTION).toBe(
|
||||
'Open Provider & Models, then run Patrol from this page.',
|
||||
);
|
||||
expect(PATROL_WORKSPACE_QUEUE_TITLE).toBe('Current work');
|
||||
expect(workspaceSource).not.toContain('Current Patrol findings');
|
||||
expect(workspaceSource).toContain('Show current work');
|
||||
expect(workspaceSource).not.toContain('Show open issues');
|
||||
expect(workspaceSource).toContain('!state.selectedRun()');
|
||||
expect(PATROL_WORKSPACE_RUN_RECORD_TITLE).toBe('Check details');
|
||||
expect(PATROL_WORKSPACE_RUN_RECORD_DESCRIPTION).toBe('What Patrol found during this run.');
|
||||
expect(PATROL_WORKSPACE_HISTORY_DESCRIPTION).toBe('Past Patrol checks.');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user