mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 21:31:53 +00:00
Extract agent profiles panel state owner
This commit is contained in:
@@ -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)
|
||||
|
||||
+14
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user