diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c4c4c1f48..f9315ab7e 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -31,18 +31,19 @@ management, and fleet control surfaces. 7. `scripts/install.ps1` 8. `frontend-modern/src/api/agentProfiles.ts` 9. `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx` -10. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` -11. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` -12. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` -13. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` -14. `frontend-modern/src/components/Settings/NodeModal.tsx` -15. `frontend-modern/src/components/Settings/nodeModalModel.ts` -16. `frontend-modern/src/components/Settings/useNodeModalState.ts` -17. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` -18. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx` -19. `frontend-modern/src/utils/agentProfilesPresentation.ts` -20. `frontend-modern/src/utils/agentInstallCommand.ts` -21. `frontend-modern/src/api/nodes.ts` +10. `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts` +11. `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx` +12. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` +13. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` +14. `frontend-modern/src/components/Settings/UnifiedAgents.tsx` +15. `frontend-modern/src/components/Settings/NodeModal.tsx` +16. `frontend-modern/src/components/Settings/nodeModalModel.ts` +17. `frontend-modern/src/components/Settings/useNodeModalState.ts` +18. `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx` +19. `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx` +20. `frontend-modern/src/utils/agentProfilesPresentation.ts` +21. `frontend-modern/src/utils/agentInstallCommand.ts` +22. `frontend-modern/src/api/nodes.ts` ## Shared Boundaries @@ -69,7 +70,7 @@ management, and fleet control surfaces. 3. Add or change runtime-side Unified Agent startup, first-report assembly, and enroll/runtime continuity through `internal/hostagent/`. 4. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance. 5. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`. -6. Add or change profile management, the pure unified-agent inventory/install model, shared frontend install-command assembly, Proxmox setup/install API transport, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. +6. Add or change profile management, the extracted agent profiles runtime owner, the pure unified-agent inventory/install model, shared frontend install-command assembly, Proxmox setup/install API transport, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/InfrastructureOperationsController.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/NodeModal.tsx`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, `frontend-modern/src/components/Infrastructure/deploy/ResultsStep.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. ## Forbidden Paths @@ -419,14 +420,19 @@ leaving stale profile options visible. That same shared profile-management boundary must also fail closed on malformed list payloads: `frontend-modern/src/api/agentProfiles.ts` may not silently reinterpret non-array profile or assignment responses as an empty state, and -`AgentProfilesPanel.tsx` / `InfrastructureOperationsController.tsx` must surface that load failure -instead of pretending no profiles exist. +`useAgentProfilesPanelState.ts` / `InfrastructureOperationsController.tsx` must surface that load +failure instead of pretending no profiles exist. That same shared profile-management boundary must also fail closed on malformed profile-object, suggestion, schema, and validation payloads: the shared `agentProfiles` client may not trust partial profile objects, malformed schema definitions, or malformed validation/suggestion bodies, and the profile editor plus suggestion modal must surface those canonical contract failures instead of flattening them into generic save/delete/schema/validation fallback copy. +That same frontend profile-management boundary now keeps its render shell and +runtime owner separate: `AgentProfilesPanel.tsx` is the surface shell, while +`useAgentProfilesPanelState.ts` owns license gating, AI availability, profile +load/save mutations, assignment resync, and modal form lifecycle so the panel +does not carry a second inline controller. Canonical Proxmox auto-register must also preserve the legacy DHCP continuity contract: when a node reruns registration from a new IP but presents the same canonical node name and deterministic Pulse-managed token identity, Pulse diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index f071857ab..72819765b 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -357,6 +357,7 @@ "frontend-modern/src/components/Settings/NodeModal.tsx", "frontend-modern/src/components/Settings/nodeModalModel.ts", "frontend-modern/src/components/Settings/UnifiedAgents.tsx", + "frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts", "frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx", "frontend-modern/src/components/Settings/useNodeModalState.ts", "frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx", @@ -473,6 +474,7 @@ "match_files": [ "frontend-modern/src/api/agentProfiles.ts", "frontend-modern/src/components/Settings/AgentProfilesPanel.tsx", + "frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts", "frontend-modern/src/utils/agentProfilesPresentation.ts" ], "allow_same_subsystem_tests": false, diff --git a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx index 3cfcb3839..9d6fa17ff 100644 --- a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx +++ b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx @@ -1,63 +1,13 @@ -import { Component, createSignal, createMemo, createEffect, onMount, Show, For } from 'solid-js'; -import { useWebSocket } from '@/App'; -import { useResources } from '@/hooks/useResources'; +import { Component, For, Show } from 'solid-js'; import { Card } from '@/components/shared/Card'; import SettingsPanel from '@/components/shared/SettingsPanel'; -import { - AgentProfilesAPI, - MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE, - type AgentProfile, - type AgentProfileAssignment, - type ProfileSuggestion, -} from '@/api/agentProfiles'; -import { AIAPI } from '@/api/ai'; -import { notificationStore } from '@/stores/notifications'; -import { logger } from '@/utils/logger'; import { Dialog } from '@/components/shared/Dialog'; -import { formatRelativeTime } from '@/utils/format'; -import { - getUpgradeActionUrlOrFallback, - hasFeature as hasEntitlement, - licenseLoaded, - loadLicenseStatus, - licenseLoading, - startProTrial, - entitlements, -} from '@/stores/license'; -import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; import { SuggestProfileModal } from './SuggestProfileModal'; import { KNOWN_SETTINGS, type SelectSetting, type StringSetting } from './agentProfileSettings'; -import type { ConnectedInfrastructureItem } from '@/types/api'; -import type { Resource } from '@/types/resource'; -import { - getActionableAgentIdFromResource, - getActionableDockerRuntimeIdFromResource, - getActionableKubernetesClusterIdFromResource, - isAgentProfileAssignableResource, -} from '@/utils/agentResources'; import { getAgentProfileAssignmentsEmptyState, getAgentProfilesEmptyState, } from '@/utils/agentProfilesPresentation'; -import { - getPreferredNamedEntityLabel, - getPreferredResourceDisplayName, - getPreferredResourceHostname, -} from '@/utils/resourceIdentity'; -import { - getAgentStatusIndicator, - getStatusIndicatorBadgeToneClasses, - isConnectedHealthStatus, -} from '@/utils/status'; -import { - getProTrialStartedMessage, - getTrialAlreadyUsedMessage, - getTrialStartErrorMessage, - getUpgradeActionButtonClass, - UPGRADE_ACTION_LABEL, - UPGRADE_TRIAL_LABEL, - UPGRADE_TRIAL_LINK_CLASS, -} from '@/utils/upgradePresentation'; import Plus from 'lucide-solid/icons/plus'; import Pencil from 'lucide-solid/icons/pencil'; import Trash2 from 'lucide-solid/icons/trash-2'; @@ -66,360 +16,54 @@ import Users from 'lucide-solid/icons/users'; import Settings from 'lucide-solid/icons/settings'; import Lightbulb from 'lucide-solid/icons/lightbulb'; import { PulseDataGrid } from '@/components/shared/PulseDataGrid'; +import { useAgentProfilesPanelState } from './useAgentProfilesPanelState'; export const AgentProfilesPanel: Component = () => { - const { resources } = useResources(); - const { state } = useWebSocket(); - const resourcePriority = (resource: Resource): number => { - switch (resource.type) { - case 'agent': - return 0; - case 'pbs': - return 1; - case 'pmg': - return 2; - case 'truenas': - return 3; - case 'k8s-cluster': - return 4; - case 'docker-host': - return 5; - default: - return 99; - } - }; - - const checkingLicense = () => !licenseLoaded() || licenseLoading(); - const hasAgentProfiles = () => hasEntitlement('agent_profiles'); - const [startingTrial, setStartingTrial] = createSignal(false); - const canStartTrial = () => entitlements()?.trial_eligible !== false; - - const handleStartTrial = async () => { - if (startingTrial()) return; - setStartingTrial(true); - try { - const result = await startProTrial(); - if (result?.outcome === 'redirect') { - window.location.href = result.actionUrl; - return; - } - notificationStore.success(getProTrialStartedMessage()); - } catch (err) { - const statusCode = (err as { status?: number } | null)?.status; - if (statusCode === 409) { - notificationStore.error(getTrialAlreadyUsedMessage()); - } else { - notificationStore.error( - getTrialStartErrorMessage(err instanceof Error ? err.message : undefined), - ); - } - } finally { - setStartingTrial(false); - } - }; - - createEffect((wasPaywallVisible) => { - const isPaywallVisible = !checkingLicense() && !hasAgentProfiles(); - if (isPaywallVisible && !wasPaywallVisible) { - trackPaywallViewed('agent_profiles', 'settings_agent_profiles_panel'); - } - return isPaywallVisible; - }, false); - - // AI state - only show AI features if enabled - const [aiAvailable, setAiAvailable] = createSignal(false); - - // Data state - const [profiles, setProfiles] = createSignal([]); - const [assignments, setAssignments] = createSignal([]); - const [loading, setLoading] = createSignal(true); - - // Modal state - const [showModal, setShowModal] = createSignal(false); - const [showSuggestModal, setShowSuggestModal] = createSignal(false); - const [editingProfile, setEditingProfile] = createSignal(null); - const [saving, setSaving] = createSignal(false); - - // Form state - const [formName, setFormName] = createSignal(''); - const [formDescription, setFormDescription] = createSignal(''); - const [formSettings, setFormSettings] = createSignal>({}); - - const connectedInfrastructureItems = createMemo( - () => state.connectedInfrastructure, - ); - - const activeSurfaceControlIds = createMemo(() => { - const agent = new Set(); - const docker = new Set(); - const kubernetes = new Set(); - - connectedInfrastructureItems() - .filter((item) => item.status === 'active') - .forEach((item) => { - item.surfaces.forEach((surface) => { - const controlId = surface.controlId?.trim(); - if (!controlId) return; - if (surface.kind === 'agent') agent.add(controlId); - if (surface.kind === 'docker') docker.add(controlId); - if (surface.kind === 'kubernetes') kubernetes.add(controlId); - }); - }); - - return { agent, docker, kubernetes }; - }); - - const connectedAgents = createMemo(() => { - const sorted = resources() - .filter(isAgentProfileAssignableResource) - .filter((resource) => isConnectedHealthStatus(resource.status)) - .filter((resource) => { - if (resource.type === 'agent') { - const agentId = getActionableAgentIdFromResource(resource); - return !agentId || activeSurfaceControlIds().agent.has(agentId); - } - if (resource.type === 'docker-host') { - const runtimeId = getActionableDockerRuntimeIdFromResource(resource); - return !runtimeId || activeSurfaceControlIds().docker.has(runtimeId); - } - if (resource.type === 'k8s-cluster') { - const clusterId = getActionableKubernetesClusterIdFromResource(resource); - return !clusterId || activeSurfaceControlIds().kubernetes.has(clusterId); - } - return true; - }) - .map((resource) => ({ resource, assignmentId: getActionableAgentIdFromResource(resource) })) - .filter((entry): entry is { resource: Resource; assignmentId: string } => - Boolean(entry.assignmentId), - ) - .sort((a, b) => { - const byPriority = resourcePriority(a.resource) - resourcePriority(b.resource); - if (byPriority !== 0) return byPriority; - const aName = getPreferredResourceDisplayName(a.resource); - const bName = getPreferredResourceDisplayName(b.resource); - return aName.localeCompare(bName); - }); - - const byAssignmentId = new Map(); - for (const entry of sorted) { - if (byAssignmentId.has(entry.assignmentId)) continue; - byAssignmentId.set(entry.assignmentId, entry.resource); - } - - return Array.from(byAssignmentId.entries()) - .map(([assignmentId, resource]) => ({ - id: assignmentId, - assignmentId, - hostname: getPreferredResourceHostname(resource) || 'Unknown', - displayName: resource.displayName, - status: resource.status || 'unknown', - lastSeen: resource.lastSeen, - })) - .sort((a, b) => - getPreferredNamedEntityLabel(a).localeCompare(getPreferredNamedEntityLabel(b)), - ); - }); - - // Get assignment for a specific agent - const getAgentAssignment = (agentId: string) => { - return assignments().find((a) => a.agent_id === agentId); - }; - - // Get profile by ID - const getProfileById = (profileId: string) => { - return profiles().find((p) => p.id === profileId); - }; - - const getProfileOptionLabel = (profileId: string) => { - const profile = getProfileById(profileId); - if (profile) { - return profile.name || profile.id; - } - return `Missing profile (${profileId})`; - }; - - // Count agents assigned to a profile - const getAssignmentCount = (profileId: string) => { - return assignments().filter((a) => a.profile_id === profileId).length; - }; - - // Count settings in a profile - const getSettingsCount = (profile: AgentProfile) => { - return Object.keys(profile.config || {}).length; - }; - - // Get known setting keys for filtering - const knownKeys = KNOWN_SETTINGS.map((s) => s.key); - - // Get unknown keys in the form settings - const unknownKeys = createMemo(() => { - const settings = formSettings(); - return Object.keys(settings).filter((key) => !knownKeys.includes(key)); - }); - - // Load data - const loadData = async () => { - setLoading(true); - try { - const [profilesData, assignmentsData] = await Promise.all([ - AgentProfilesAPI.listProfiles(), - AgentProfilesAPI.listAssignments(), - ]); - setProfiles(profilesData); - setAssignments(assignmentsData); - } catch (err) { - logger.error('Failed to load agent profiles', err); - notificationStore.error(err instanceof Error ? err.message : 'Failed to load agent profiles'); - } finally { - setLoading(false); - } - }; - - // Check license and AI availability on mount - onMount(async () => { - await loadLicenseStatus(); - - // Check if AI is available (enabled and configured) - silently fail if not - try { - const aiSettings = await AIAPI.getSettings(); - setAiAvailable(aiSettings.enabled && aiSettings.configured); - } catch { - // AI not available - that's fine, just hide the Ideas button - setAiAvailable(false); - } - - if (hasAgentProfiles()) { - await loadData(); - } else { - setLoading(false); - } - }); - - // Open modal for creating a new profile - const handleCreate = () => { - setEditingProfile(null); - setFormName(''); - setFormDescription(''); - setFormSettings({}); - setShowModal(true); - }; - - // Open suggest modal - const handleSuggest = () => { - setShowSuggestModal(true); - }; - - // Handle AI suggestion acceptance - const handleSuggestionAccepted = (suggestion: ProfileSuggestion) => { - setShowSuggestModal(false); - setEditingProfile(null); - setFormName(suggestion.name); - setFormDescription(suggestion.description || ''); - setFormSettings(suggestion.config); - setShowModal(true); - }; - - // Open modal for editing a profile - const handleEdit = (profile: AgentProfile) => { - setEditingProfile(profile); - setFormName(profile.name); - setFormDescription(profile.description || ''); - setFormSettings({ ...profile.config }); - setShowModal(true); - }; - - // Delete a profile - const handleDelete = async (profile: AgentProfile) => { - const assignedCount = getAssignmentCount(profile.id); - const confirmMsg = - assignedCount > 0 - ? `Delete "${profile.name}"? ${assignedCount} agent(s) will be unassigned.` - : `Delete "${profile.name}"?`; - - if (!confirm(confirmMsg)) return; - - try { - await AgentProfilesAPI.deleteProfile(profile.id); - notificationStore.success(`Profile "${profile.name}" deleted`); - await loadData(); - } catch (err) { - logger.error('Failed to delete profile', err); - notificationStore.error( - err instanceof Error && err.message ? err.message : 'Failed to delete profile', - ); - } - }; - - // Save profile (create or update) - const handleSave = async () => { - const name = formName().trim(); - if (!name) { - notificationStore.error('Profile name is required'); - return; - } - - setSaving(true); - try { - const config = formSettings(); - const description = formDescription().trim() || undefined; - const existing = editingProfile(); - - if (existing) { - await AgentProfilesAPI.updateProfile(existing.id, name, config, description); - notificationStore.success(`Profile "${name}" updated`); - } else { - await AgentProfilesAPI.createProfile(name, config, description); - notificationStore.success(`Profile "${name}" created`); - } - - setShowModal(false); - await loadData(); - } catch (err) { - logger.error('Failed to save profile', err); - notificationStore.error( - err instanceof Error && err.message ? err.message : 'Failed to save profile', - ); - } finally { - setSaving(false); - } - }; - - // Assign profile to agent - const handleAssign = async (agentId: string, profileId: string) => { - try { - if (profileId === '') { - await AgentProfilesAPI.unassignProfile(agentId); - notificationStore.success('Profile unassigned'); - } else { - await AgentProfilesAPI.assignProfile(agentId, profileId); - const profile = getProfileById(profileId); - notificationStore.success(`Assigned "${profile?.name || profileId}"`); - } - await loadData(); - } catch (err) { - logger.error('Failed to assign profile', err); - if ( - err instanceof Error && - err.message === MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE - ) { - await loadData(); - } - notificationStore.error( - err instanceof Error && err.message ? err.message : 'Failed to assign profile', - ); - } - }; - - // Update a setting in the form - const updateSetting = (key: string, value: unknown) => { - if (value === '' || value === undefined) { - const updated = { ...formSettings() }; - delete updated[key]; - setFormSettings(updated); - } else { - setFormSettings({ ...formSettings(), [key]: value }); - } - }; + const { + aiAvailable, + canStartTrial, + checkingLicense, + connectedAgents, + editingProfile, + formDescription, + formName, + formSettings, + getAgentAssignment, + getAssignmentCount, + getAgentStatusIndicator, + getProfileById, + getProfileOptionLabel, + getSettingsCount, + getStatusIndicatorBadgeToneClasses, + getUpgradeActionButtonClass, + getUpgradeActionUrlOrFallback, + handleAssign, + handleCreate, + handleDelete, + handleEdit, + handleSave, + handleStartTrial, + handleSuggest, + handleSuggestionAccepted, + hasAgentProfiles, + loading, + profiles, + saving, + setFormDescription, + setFormName, + setShowModal, + setShowSuggestModal, + showModal, + showSuggestModal, + startingTrial, + trackUpgradeClicked, + unknownKeys, + updateSetting, + formatRelativeTime, + UPGRADE_ACTION_LABEL, + UPGRADE_TRIAL_LABEL, + UPGRADE_TRIAL_LINK_CLASS, + } = useAgentProfilesPanelState(); // License gate - using Show components for proper SolidJS reactivity // (early returns don't re-render when signals change in SolidJS) diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index abdbfae43..c7e4aca2a 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import agentProfilesPanelSource from '../AgentProfilesPanel.tsx?raw'; +import agentProfilesPanelStateSource from '../useAgentProfilesPanelState.ts?raw'; import apiTokenManagerSource from '../APITokenManager.tsx?raw'; import infrastructureOperationsStateSource from '../useInfrastructureOperationsState.tsx?raw'; import agentLedgerPanelSource from '../MonitoredSystemLedgerPanel.tsx?raw'; @@ -134,15 +135,25 @@ const recoverySource = [ describe('monitored-system model guardrails', () => { it('keeps AgentProfilesPanel on unified resources (not host-only slices)', () => { - expect(agentProfilesPanelSource).toContain('const { resources } = useResources()'); + expect(agentProfilesPanelSource).toContain("import { useAgentProfilesPanelState }"); + expect(agentProfilesPanelSource).toContain('useAgentProfilesPanelState()'); expect(agentProfilesPanelSource).toContain('@/utils/agentProfilesPresentation'); expect(agentProfilesPanelSource).toContain('getAgentProfilesEmptyState'); expect(agentProfilesPanelSource).toContain('getAgentProfileAssignmentsEmptyState'); + expect(agentProfilesPanelSource).not.toContain('const loadData = async () =>'); + expect(agentProfilesPanelSource).not.toContain('const handleSave = async () =>'); + expect(agentProfilesPanelSource).not.toContain('AIAPI.getSettings()'); expect(agentProfilesPanelSource).not.toContain("const hosts = byType('host')"); expect(agentProfilesPanelSource).not.toContain('No profiles yet. Create one to get started.'); expect(agentProfilesPanelSource).not.toContain( 'No agents connected. Install an agent to assign profiles.', ); + expect(agentProfilesPanelStateSource).toContain('const { resources } = useResources()'); + expect(agentProfilesPanelStateSource).toContain('const loadData = async () =>'); + expect(agentProfilesPanelStateSource).toContain('const handleSave = async () =>'); + expect(agentProfilesPanelStateSource).toContain('AIAPI.getSettings()'); + expect(agentProfilesPanelStateSource).toContain('AgentProfilesAPI.listProfiles()'); + expect(agentProfilesPanelStateSource).toContain('AgentProfilesAPI.listAssignments()'); expect(agentProfilesPresentationSource).toContain('export function getAgentProfilesEmptyState'); expect(agentProfilesPresentationSource).toContain( 'export function getAgentProfileAssignmentsEmptyState', @@ -207,7 +218,7 @@ describe('monitored-system model guardrails', () => { ); expect(infrastructureOperationsStateSource).not.toContain('isConnectedHealthStatus'); expect(infrastructureOperationsStateSource).not.toContain('const connectedFromStatus ='); - expect(agentProfilesPanelSource).toContain('isConnectedHealthStatus'); + expect(agentProfilesPanelStateSource).toContain('isConnectedHealthStatus'); expect(agentProfilesPanelSource).not.toContain('const connectedFromStatus ='); expect(statusUtilsSource).toContain('export function isConnectedHealthStatus'); expect(infrastructureOperationsStateSource).toContain('@/utils/unifiedAgentStatusPresentation'); @@ -475,7 +486,7 @@ describe('monitored-system model guardrails', () => { expect(aiCostPresentationSource).toContain('export const AI_COST_DAILY_TOKEN_EMPTY_STATE'); expect(rolesPanelSource).toContain('@/utils/upgradePresentation'); expect(userAssignmentsPanelSource).toContain('@/utils/upgradePresentation'); - expect(agentProfilesPanelSource).toContain('@/utils/upgradePresentation'); + expect(agentProfilesPanelStateSource).toContain('@/utils/upgradePresentation'); expect(auditLogPanelSource).toContain('@/utils/upgradePresentation'); expect(ssoProvidersPanelSource).toContain('@/utils/upgradePresentation'); expect(auditWebhookPanelSource).toContain('@/utils/upgradePresentation'); diff --git a/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts b/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts new file mode 100644 index 000000000..7190abbc6 --- /dev/null +++ b/frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts @@ -0,0 +1,432 @@ +import { createEffect, createMemo, createSignal, onMount } from 'solid-js'; +import { useWebSocket } from '@/App'; +import { AIAPI } from '@/api/ai'; +import { + AgentProfilesAPI, + MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE, + type AgentProfile, + type AgentProfileAssignment, + type ProfileSuggestion, +} from '@/api/agentProfiles'; +import { useResources } from '@/hooks/useResources'; +import { notificationStore } from '@/stores/notifications'; +import { + entitlements, + getUpgradeActionUrlOrFallback, + hasFeature as hasEntitlement, + licenseLoaded, + licenseLoading, + loadLicenseStatus, + startProTrial, +} from '@/stores/license'; +import type { ConnectedInfrastructureItem } from '@/types/api'; +import type { Resource } from '@/types/resource'; +import { formatRelativeTime } from '@/utils/format'; +import { logger } from '@/utils/logger'; +import { + getProTrialStartedMessage, + getTrialAlreadyUsedMessage, + getTrialStartErrorMessage, + getUpgradeActionButtonClass, + UPGRADE_ACTION_LABEL, + UPGRADE_TRIAL_LABEL, + UPGRADE_TRIAL_LINK_CLASS, +} from '@/utils/upgradePresentation'; +import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; +import { KNOWN_SETTINGS } from './agentProfileSettings'; +import { + getActionableAgentIdFromResource, + getActionableDockerRuntimeIdFromResource, + getActionableKubernetesClusterIdFromResource, + isAgentProfileAssignableResource, +} from '@/utils/agentResources'; +import { + getPreferredNamedEntityLabel, + getPreferredResourceDisplayName, + getPreferredResourceHostname, +} from '@/utils/resourceIdentity'; +import { + getAgentStatusIndicator, + getStatusIndicatorBadgeToneClasses, + isConnectedHealthStatus, +} from '@/utils/status'; + +const resourcePriority = (resource: Resource): number => { + switch (resource.type) { + case 'agent': + return 0; + case 'pbs': + return 1; + case 'pmg': + return 2; + case 'truenas': + return 3; + case 'k8s-cluster': + return 4; + case 'docker-host': + return 5; + default: + return 99; + } +}; + +export const useAgentProfilesPanelState = () => { + const { resources } = useResources(); + const { state } = useWebSocket(); + + const checkingLicense = () => !licenseLoaded() || licenseLoading(); + const hasAgentProfiles = () => hasEntitlement('agent_profiles'); + const [startingTrial, setStartingTrial] = createSignal(false); + const canStartTrial = () => entitlements()?.trial_eligible !== false; + + const [aiAvailable, setAiAvailable] = createSignal(false); + const [profiles, setProfiles] = createSignal([]); + const [assignments, setAssignments] = createSignal([]); + const [loading, setLoading] = createSignal(true); + + const [showModal, setShowModal] = createSignal(false); + const [showSuggestModal, setShowSuggestModal] = createSignal(false); + const [editingProfile, setEditingProfile] = createSignal(null); + const [saving, setSaving] = createSignal(false); + + const [formName, setFormName] = createSignal(''); + const [formDescription, setFormDescription] = createSignal(''); + const [formSettings, setFormSettings] = createSignal>({}); + + const connectedInfrastructureItems = createMemo( + () => state.connectedInfrastructure, + ); + + const activeSurfaceControlIds = createMemo(() => { + const agent = new Set(); + const docker = new Set(); + const kubernetes = new Set(); + + connectedInfrastructureItems() + .filter((item) => item.status === 'active') + .forEach((item) => { + item.surfaces.forEach((surface) => { + const controlId = surface.controlId?.trim(); + if (!controlId) return; + if (surface.kind === 'agent') agent.add(controlId); + if (surface.kind === 'docker') docker.add(controlId); + if (surface.kind === 'kubernetes') kubernetes.add(controlId); + }); + }); + + return { agent, docker, kubernetes }; + }); + + const connectedAgents = createMemo(() => { + const sorted = resources() + .filter(isAgentProfileAssignableResource) + .filter((resource) => isConnectedHealthStatus(resource.status)) + .filter((resource) => { + if (resource.type === 'agent') { + const agentId = getActionableAgentIdFromResource(resource); + return !agentId || activeSurfaceControlIds().agent.has(agentId); + } + if (resource.type === 'docker-host') { + const runtimeId = getActionableDockerRuntimeIdFromResource(resource); + return !runtimeId || activeSurfaceControlIds().docker.has(runtimeId); + } + if (resource.type === 'k8s-cluster') { + const clusterId = getActionableKubernetesClusterIdFromResource(resource); + return !clusterId || activeSurfaceControlIds().kubernetes.has(clusterId); + } + return true; + }) + .map((resource) => ({ resource, assignmentId: getActionableAgentIdFromResource(resource) })) + .filter((entry): entry is { resource: Resource; assignmentId: string } => + Boolean(entry.assignmentId), + ) + .sort((a, b) => { + const byPriority = resourcePriority(a.resource) - resourcePriority(b.resource); + if (byPriority !== 0) return byPriority; + const aName = getPreferredResourceDisplayName(a.resource); + const bName = getPreferredResourceDisplayName(b.resource); + return aName.localeCompare(bName); + }); + + const byAssignmentId = new Map(); + for (const entry of sorted) { + if (byAssignmentId.has(entry.assignmentId)) continue; + byAssignmentId.set(entry.assignmentId, entry.resource); + } + + return Array.from(byAssignmentId.entries()) + .map(([assignmentId, resource]) => ({ + id: assignmentId, + assignmentId, + hostname: getPreferredResourceHostname(resource) || 'Unknown', + displayName: resource.displayName, + status: resource.status || 'unknown', + lastSeen: resource.lastSeen, + })) + .sort((a, b) => + getPreferredNamedEntityLabel(a).localeCompare(getPreferredNamedEntityLabel(b)), + ); + }); + + const getAgentAssignment = (agentId: string) => { + return assignments().find((assignment) => assignment.agent_id === agentId); + }; + + const getProfileById = (profileId: string) => { + return profiles().find((profile) => profile.id === profileId); + }; + + const getProfileOptionLabel = (profileId: string) => { + const profile = getProfileById(profileId); + if (profile) return profile.name || profile.id; + return `Missing profile (${profileId})`; + }; + + const getAssignmentCount = (profileId: string) => { + return assignments().filter((assignment) => assignment.profile_id === profileId).length; + }; + + const getSettingsCount = (profile: AgentProfile) => { + return Object.keys(profile.config || {}).length; + }; + + const knownKeys = KNOWN_SETTINGS.map((setting) => setting.key); + + const unknownKeys = createMemo(() => { + const settings = formSettings(); + return Object.keys(settings).filter((key) => !knownKeys.includes(key)); + }); + + const loadData = async () => { + setLoading(true); + try { + const [profilesData, assignmentsData] = await Promise.all([ + AgentProfilesAPI.listProfiles(), + AgentProfilesAPI.listAssignments(), + ]); + setProfiles(profilesData); + setAssignments(assignmentsData); + } catch (err) { + logger.error('Failed to load agent profiles', err); + notificationStore.error(err instanceof Error ? err.message : 'Failed to load agent profiles'); + } finally { + setLoading(false); + } + }; + + const handleStartTrial = async () => { + if (startingTrial()) return; + setStartingTrial(true); + try { + const result = await startProTrial(); + if (result?.outcome === 'redirect') { + window.location.href = result.actionUrl; + return; + } + notificationStore.success(getProTrialStartedMessage()); + } catch (err) { + const statusCode = (err as { status?: number } | null)?.status; + if (statusCode === 409) { + notificationStore.error(getTrialAlreadyUsedMessage()); + } else { + notificationStore.error( + getTrialStartErrorMessage(err instanceof Error ? err.message : undefined), + ); + } + } finally { + setStartingTrial(false); + } + }; + + createEffect((wasPaywallVisible) => { + const isPaywallVisible = !checkingLicense() && !hasAgentProfiles(); + if (isPaywallVisible && !wasPaywallVisible) { + trackPaywallViewed('agent_profiles', 'settings_agent_profiles_panel'); + } + return isPaywallVisible; + }, false); + + onMount(async () => { + await loadLicenseStatus(); + + try { + const aiSettings = await AIAPI.getSettings(); + setAiAvailable(aiSettings.enabled && aiSettings.configured); + } catch { + setAiAvailable(false); + } + + if (hasAgentProfiles()) { + await loadData(); + } else { + setLoading(false); + } + }); + + const handleCreate = () => { + setEditingProfile(null); + setFormName(''); + setFormDescription(''); + setFormSettings({}); + setShowModal(true); + }; + + const handleSuggest = () => { + setShowSuggestModal(true); + }; + + const handleSuggestionAccepted = (suggestion: ProfileSuggestion) => { + setShowSuggestModal(false); + setEditingProfile(null); + setFormName(suggestion.name); + setFormDescription(suggestion.description || ''); + setFormSettings(suggestion.config); + setShowModal(true); + }; + + const handleEdit = (profile: AgentProfile) => { + setEditingProfile(profile); + setFormName(profile.name); + setFormDescription(profile.description || ''); + setFormSettings({ ...profile.config }); + setShowModal(true); + }; + + const handleDelete = async (profile: AgentProfile) => { + const assignedCount = getAssignmentCount(profile.id); + const confirmMessage = + assignedCount > 0 + ? `Delete "${profile.name}"? ${assignedCount} agent(s) will be unassigned.` + : `Delete "${profile.name}"?`; + + if (!confirm(confirmMessage)) return; + + try { + await AgentProfilesAPI.deleteProfile(profile.id); + notificationStore.success(`Profile "${profile.name}" deleted`); + await loadData(); + } catch (err) { + logger.error('Failed to delete profile', err); + notificationStore.error( + err instanceof Error && err.message ? err.message : 'Failed to delete profile', + ); + } + }; + + const handleSave = async () => { + const name = formName().trim(); + if (!name) { + notificationStore.error('Profile name is required'); + return; + } + + setSaving(true); + try { + const config = formSettings(); + const description = formDescription().trim() || undefined; + const existing = editingProfile(); + + if (existing) { + await AgentProfilesAPI.updateProfile(existing.id, name, config, description); + notificationStore.success(`Profile "${name}" updated`); + } else { + await AgentProfilesAPI.createProfile(name, config, description); + notificationStore.success(`Profile "${name}" created`); + } + + setShowModal(false); + await loadData(); + } catch (err) { + logger.error('Failed to save profile', err); + notificationStore.error( + err instanceof Error && err.message ? err.message : 'Failed to save profile', + ); + } finally { + setSaving(false); + } + }; + + const handleAssign = async (agentId: string, profileId: string) => { + try { + if (profileId === '') { + await AgentProfilesAPI.unassignProfile(agentId); + notificationStore.success('Profile unassigned'); + } else { + await AgentProfilesAPI.assignProfile(agentId, profileId); + const profile = getProfileById(profileId); + notificationStore.success(`Assigned "${profile?.name || profileId}"`); + } + await loadData(); + } catch (err) { + logger.error('Failed to assign profile', err); + if ( + err instanceof Error && + err.message === MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE + ) { + await loadData(); + } + notificationStore.error( + err instanceof Error && err.message ? err.message : 'Failed to assign profile', + ); + } + }; + + const updateSetting = (key: string, value: unknown) => { + if (value === '' || value === undefined) { + const updated = { ...formSettings() }; + delete updated[key]; + setFormSettings(updated); + return; + } + + setFormSettings({ ...formSettings(), [key]: value }); + }; + + return { + aiAvailable, + canStartTrial, + checkingLicense, + connectedAgents, + formDescription, + formName, + formSettings, + getAgentAssignment, + getAssignmentCount, + getProfileById, + getProfileOptionLabel, + getSettingsCount, + getStatusIndicatorBadgeToneClasses, + getUpgradeActionButtonClass, + getUpgradeActionUrlOrFallback, + handleAssign, + handleCreate, + handleDelete, + handleEdit, + handleSave, + handleStartTrial, + handleSuggest, + handleSuggestionAccepted, + hasAgentProfiles, + loading, + profiles, + saving, + setFormDescription, + setFormName, + setShowModal, + setShowSuggestModal, + showModal, + showSuggestModal, + startingTrial, + trackUpgradeClicked, + unknownKeys, + updateSetting, + editingProfile, + assignments, + setFormSettings, + formatRelativeTime, + getAgentStatusIndicator, + UPGRADE_ACTION_LABEL, + UPGRADE_TRIAL_LABEL, + UPGRADE_TRIAL_LINK_CLASS, + }; +};