Extract agent profiles panel state owner

This commit is contained in:
rcourtman
2026-03-20 22:37:23 +00:00
parent 07a900ac18
commit 6e09daf9be
5 changed files with 516 additions and 421 deletions
@@ -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
@@ -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,
@@ -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<AgentProfile[]>([]);
const [assignments, setAssignments] = createSignal<AgentProfileAssignment[]>([]);
const [loading, setLoading] = createSignal(true);
// Modal state
const [showModal, setShowModal] = createSignal(false);
const [showSuggestModal, setShowSuggestModal] = createSignal(false);
const [editingProfile, setEditingProfile] = createSignal<AgentProfile | null>(null);
const [saving, setSaving] = createSignal(false);
// Form state
const [formName, setFormName] = createSignal('');
const [formDescription, setFormDescription] = createSignal('');
const [formSettings, setFormSettings] = createSignal<Record<string, unknown>>({});
const connectedInfrastructureItems = createMemo<ConnectedInfrastructureItem[]>(
() => state.connectedInfrastructure,
);
const activeSurfaceControlIds = createMemo(() => {
const agent = new Set<string>();
const docker = new Set<string>();
const kubernetes = new Set<string>();
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<string, Resource>();
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)
@@ -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');
@@ -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<AgentProfile[]>([]);
const [assignments, setAssignments] = createSignal<AgentProfileAssignment[]>([]);
const [loading, setLoading] = createSignal(true);
const [showModal, setShowModal] = createSignal(false);
const [showSuggestModal, setShowSuggestModal] = createSignal(false);
const [editingProfile, setEditingProfile] = createSignal<AgentProfile | null>(null);
const [saving, setSaving] = createSignal(false);
const [formName, setFormName] = createSignal('');
const [formDescription, setFormDescription] = createSignal('');
const [formSettings, setFormSettings] = createSignal<Record<string, unknown>>({});
const connectedInfrastructureItems = createMemo<ConnectedInfrastructureItem[]>(
() => state.connectedInfrastructure,
);
const activeSurfaceControlIds = createMemo(() => {
const agent = new Set<string>();
const docker = new Set<string>();
const kubernetes = new Set<string>();
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<string, Resource>();
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,
};
};