mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-08 09:53:25 +00:00
Split resource detail drawer runtime owners
This commit is contained in:
parent
38c621fed3
commit
44249ed048
7 changed files with 796 additions and 828 deletions
|
|
@ -3166,6 +3166,8 @@
|
|||
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts",
|
||||
"frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx",
|
||||
|
|
@ -3248,6 +3250,8 @@
|
|||
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts",
|
||||
"frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx",
|
||||
|
|
|
|||
|
|
@ -53,12 +53,14 @@ cross-source deduplication.
|
|||
31. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx`
|
||||
32. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx`
|
||||
33. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts`
|
||||
34. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
|
||||
35. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
|
||||
36. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
|
||||
37. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
|
||||
38. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
|
||||
39. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
|
||||
34. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`
|
||||
35. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`
|
||||
36. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
|
||||
37. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
|
||||
38. `frontend-modern/src/components/Discovery/DiscoveryTab.tsx`
|
||||
39. `frontend-modern/src/components/Discovery/useDiscoveryTabState.ts`
|
||||
40. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
|
||||
41. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -146,9 +148,12 @@ Those same relationship changes now summarize the actual edge(s) in `from` and
|
|||
needing the drawer to reconstruct an edge summary from raw endpoints.
|
||||
The infrastructure resource drawer now follows the explicit shell/state/render
|
||||
split used elsewhere in v6: `ResourceDetailDrawer.tsx` owns composition,
|
||||
`useResourceDetailDrawerState.ts` owns runtime state and fetch orchestration,
|
||||
and the overview/debug render-heavy surfaces live in dedicated drawer-local
|
||||
owners instead of staying inline in the shell.
|
||||
`useResourceDetailDrawerState.ts` owns composition of drawer-local state,
|
||||
`useResourceDetailDrawerHistoryState.ts` owns facet/intelligence/timeline
|
||||
runtime orchestration, `useResourceDetailDrawerDerivedState.ts` owns the
|
||||
canonical drawer derivation layer, and the overview/debug render-heavy
|
||||
surfaces live in dedicated drawer-local owners instead of staying inline in
|
||||
the shell.
|
||||
The backend AI and Patrol context renderers now derive their canonical change
|
||||
kind, source type, source adapter, actor, reason, and related-resource
|
||||
fragments from `internal/unifiedresources/change_presentation.go`, so the
|
||||
|
|
@ -286,9 +291,12 @@ that shared routing contract instead of reintroducing dashboard-local path
|
|||
builders.
|
||||
That drawer shell now routes its canonical timeline filter, facet-bundle, and
|
||||
resource-intelligence state through
|
||||
`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`,
|
||||
so unified-resource history and investigation orchestration has one explicit
|
||||
frontend owner instead of accumulating inline beside the JSX surface.
|
||||
`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts`,
|
||||
while canonical identity, source, service, and debug derivations route through
|
||||
`frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts`
|
||||
and `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
|
||||
stays the composition owner, so unified-resource history and investigation
|
||||
orchestration no longer accumulate inline beside the drawer-local model layer.
|
||||
The shared `ResourceFacetSummary` consumer now omits capability and
|
||||
relationship badges from the default table/detail surface entirely, while the
|
||||
backend contract keeps capability and relationship data on the owned resource
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
|
|||
import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw';
|
||||
import resourceDetailDrawerShellSource from '@/components/Infrastructure/ResourceDetailDrawer.tsx?raw';
|
||||
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
|
||||
import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw';
|
||||
import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw';
|
||||
import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
|
||||
|
||||
|
|
@ -119,6 +122,14 @@ describe('ResourceDetailDrawer change history section', () => {
|
|||
);
|
||||
expect(resourceDetailDrawerShellSource).toContain("from './ResourceDetailDrawerDebugTab'");
|
||||
expect(resourceDetailDrawerShellSource).not.toContain('Change history');
|
||||
expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerHistoryState'");
|
||||
expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerDerivedState'");
|
||||
expect(resourceDetailDrawerStateSource).not.toContain('createResource(');
|
||||
expect(resourceDetailDrawerHistoryStateSource).toContain('createResource(');
|
||||
expect(resourceDetailDrawerHistoryStateSource).toContain('ResourceAPI.getFacetBundle');
|
||||
expect(resourceDetailDrawerHistoryStateSource).toContain('AIAPI.getResourceIntelligence');
|
||||
expect(resourceDetailDrawerDerivedStateSource).toContain('buildWorkloadsHref');
|
||||
expect(resourceDetailDrawerDerivedStateSource).toContain('toDiscoveryConfig');
|
||||
});
|
||||
|
||||
it('keeps compact timeline summary chips in overview while showing one embedded change history section', async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,591 @@
|
|||
import { createMemo, type Accessor } from 'solid-js';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { requiresGovernedResourceDisplay } from '@/types/resource';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import { getAgentStatusIndicator } from '@/utils/status';
|
||||
import {
|
||||
getPlatformBadge,
|
||||
getSourceBadge,
|
||||
getTypeBadge,
|
||||
getUnifiedSourceBadges,
|
||||
} from '@/utils/resourceBadgePresentation';
|
||||
import { buildWorkloadsHref } from '@/components/Infrastructure/workloadsLink';
|
||||
import { buildServiceDetailLinks } from '@/components/Infrastructure/serviceDetailLinks';
|
||||
import {
|
||||
getPrimaryResourceIdentity,
|
||||
getPrimaryResourceIdentityRows,
|
||||
getResourceIdentityAliases,
|
||||
getPreferredResourceClusterName,
|
||||
getPreferredResourceDisplayName,
|
||||
} from '@/utils/resourceIdentity';
|
||||
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
|
||||
import {
|
||||
getResourcePolicyBadges,
|
||||
getResourcePolicyDisplayLabel,
|
||||
getResourcePolicyRedactionLabels,
|
||||
getResourceRoutingScopeLabel,
|
||||
} from '@/utils/resourcePolicyPresentation';
|
||||
import type { ResourceIntelligence } from '@/types/aiIntelligence';
|
||||
import {
|
||||
ALIAS_COLLAPSE_THRESHOLD,
|
||||
buildTemperatureRows,
|
||||
formatInteger,
|
||||
toAgentFromResource,
|
||||
toDiscoveryConfig,
|
||||
toNodeFromProxmox,
|
||||
type AgentPlatformData,
|
||||
type DockerPlatformData,
|
||||
type KubernetesPlatformData,
|
||||
type PlatformData,
|
||||
} from '@/components/Infrastructure/resourceDetailMappers';
|
||||
import { formatIdentifierLabel } from '@/utils/textPresentation';
|
||||
|
||||
type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
|
||||
|
||||
interface UseResourceDetailDrawerDerivedStateOptions {
|
||||
resource: Resource;
|
||||
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
|
||||
debugEnabled: Accessor<boolean>;
|
||||
resourceIntelligence: Accessor<ResourceIntelligence | null>;
|
||||
}
|
||||
|
||||
export const useResourceDetailDrawerDerivedState = (
|
||||
options: UseResourceDetailDrawerDerivedStateOptions,
|
||||
) => {
|
||||
const { resource, resolveResourceLabel: resolveResourceLabelInput, debugEnabled, resourceIntelligence } =
|
||||
options;
|
||||
|
||||
const displayName = createMemo(() => getPreferredResourceDisplayName(resource));
|
||||
const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource) ?? '');
|
||||
const resolveResourceLabel = (resourceId: string): string =>
|
||||
resolveResourceLabelInput?.(resourceId)?.trim() || resourceId;
|
||||
const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource.status }));
|
||||
const lastSeen = createMemo(() => formatRelativeTime(resource.lastSeen));
|
||||
const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(resource.lastSeen));
|
||||
|
||||
const platformBadge = createMemo(() => getPlatformBadge(resource.platformType));
|
||||
const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType));
|
||||
const typeBadge = createMemo(() => getTypeBadge(resource.type));
|
||||
const platformData = createMemo(() => resource.platformData as PlatformData | undefined);
|
||||
const unifiedSourceBadges = createMemo(() =>
|
||||
getUnifiedSourceBadges(platformData()?.sources ?? []),
|
||||
);
|
||||
const hasUnifiedSources = createMemo(() => unifiedSourceBadges().length > 0);
|
||||
const policyBadges = createMemo(() => getResourcePolicyBadges(resource.policy));
|
||||
const policyRedactions = createMemo(() => getResourcePolicyRedactionLabels(resource.policy));
|
||||
const governanceSummary = createMemo(() =>
|
||||
requiresGovernedResourceDisplay(resource.policy)
|
||||
? getResourcePolicyDisplayLabel(resource)
|
||||
: (resource.aiSafeSummary?.trim() ?? ''),
|
||||
);
|
||||
const hasGovernanceData = createMemo(
|
||||
() => policyBadges().length > 0 || Boolean(governanceSummary()),
|
||||
);
|
||||
|
||||
const agentMeta = createMemo(
|
||||
() => resource.agent ?? (platformData()?.agent as AgentPlatformData | undefined),
|
||||
);
|
||||
const kubernetesMeta = createMemo(
|
||||
() => resource.kubernetes ?? (platformData()?.kubernetes as KubernetesPlatformData | undefined),
|
||||
);
|
||||
const kubernetesCapabilityBadges = createMemo(() => {
|
||||
const capabilities = kubernetesMeta()?.metricCapabilities;
|
||||
if (!capabilities) return [] as Array<{ label: string; classes: string; title: string }>;
|
||||
|
||||
const supportedBadge =
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400';
|
||||
const unsupportedBadge =
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-muted';
|
||||
const badges: { label: string; classes: string; title: string }[] = [];
|
||||
|
||||
if (capabilities.nodeCpuMemory) {
|
||||
badges.push({
|
||||
label: 'K8s Node CPU/Memory',
|
||||
classes: supportedBadge,
|
||||
title: 'Node CPU and memory metrics are available.',
|
||||
});
|
||||
}
|
||||
if (capabilities.nodeTelemetry) {
|
||||
badges.push({
|
||||
label: 'Node Telemetry (Agent)',
|
||||
classes: supportedBadge,
|
||||
title: 'Linked Pulse agent provides node uptime, temperature, disk, network, and disk I/O.',
|
||||
});
|
||||
}
|
||||
if (capabilities.podCpuMemory) {
|
||||
badges.push({
|
||||
label: 'Pod CPU/Memory',
|
||||
classes: supportedBadge,
|
||||
title: 'Pod CPU and memory metrics are available.',
|
||||
});
|
||||
}
|
||||
if (capabilities.podNetwork) {
|
||||
badges.push({
|
||||
label: 'Pod Network',
|
||||
classes: supportedBadge,
|
||||
title: 'Pod network throughput is available.',
|
||||
});
|
||||
}
|
||||
if (capabilities.podEphemeralDisk) {
|
||||
badges.push({
|
||||
label: 'Pod Ephemeral Disk',
|
||||
classes: supportedBadge,
|
||||
title: 'Pod ephemeral storage usage is available.',
|
||||
});
|
||||
}
|
||||
if (!capabilities.podDiskIo) {
|
||||
badges.push({
|
||||
label: 'Pod Disk I/O Unsupported',
|
||||
classes: unsupportedBadge,
|
||||
title:
|
||||
'Pod disk read/write throughput is not collected by the Kubernetes integration path today.',
|
||||
});
|
||||
}
|
||||
|
||||
return badges;
|
||||
});
|
||||
|
||||
const proxmoxNode = createMemo(() => toNodeFromProxmox(resource));
|
||||
const agentInfo = createMemo(() => toAgentFromResource(resource, agentMeta()));
|
||||
const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors));
|
||||
|
||||
const dockerHostData = createMemo(() => platformData()?.docker as DockerPlatformData | undefined);
|
||||
const dockerHostSourceId = createMemo(
|
||||
() => (dockerHostData()?.hostSourceId || '').trim() || null,
|
||||
);
|
||||
const dockerUpdatesAvailable = createMemo(() => dockerHostData()?.updatesAvailableCount ?? 0);
|
||||
const dockerContainerCount = createMemo(() => dockerHostData()?.containerCount ?? 0);
|
||||
const dockerUpdatesCheckedRelative = createMemo(() => {
|
||||
const raw = dockerHostData()?.updatesLastCheckedAt;
|
||||
if (!raw) return '';
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
return formatRelativeTime(parsed);
|
||||
});
|
||||
const dockerHostCommand = createMemo(() => dockerHostData()?.command);
|
||||
const dockerHostCommandActive = createMemo(() => {
|
||||
const status = (dockerHostCommand()?.status || '').trim().toLowerCase();
|
||||
return ['queued', 'dispatched', 'acknowledged', 'in_progress'].includes(status);
|
||||
});
|
||||
const dockerUpdateActionsDisabled = createMemo(() => shouldHideDockerUpdateActions());
|
||||
const dockerUpdateActionsLoading = createMemo(() => !areSystemSettingsLoaded());
|
||||
const dockerSwarmInfo = createMemo(() => dockerHostData()?.swarm);
|
||||
const dockerSwarmClusterKey = createMemo(() => {
|
||||
const swarm = dockerSwarmInfo();
|
||||
return (swarm?.clusterName || swarm?.clusterId || '').trim();
|
||||
});
|
||||
|
||||
const resourceDependencies = createMemo(() => resourceIntelligence()?.dependencies ?? []);
|
||||
const resourceDependents = createMemo(() => resourceIntelligence()?.dependents ?? []);
|
||||
const resourceCorrelations = createMemo(() => resourceIntelligence()?.correlations ?? []);
|
||||
const hasCorrelationContext = createMemo(
|
||||
() =>
|
||||
resourceDependencies().length > 0 ||
|
||||
resourceDependents().length > 0 ||
|
||||
resourceCorrelations().length > 0,
|
||||
);
|
||||
const hasInvestigationContext = createMemo(
|
||||
() => Boolean(resourceIntelligence()) || hasGovernanceData(),
|
||||
);
|
||||
const investigationContextSummary = createMemo(() => {
|
||||
const intel = resourceIntelligence();
|
||||
const summary: string[] = [];
|
||||
|
||||
if (intel) {
|
||||
summary.push(`AI health ${intel.health.grade} · ${Math.round(intel.health.score)}/100`);
|
||||
}
|
||||
if (resourceCorrelations().length > 0) {
|
||||
summary.push(
|
||||
`${resourceCorrelations().length} correlation${resourceCorrelations().length === 1 ? '' : 's'}`,
|
||||
);
|
||||
}
|
||||
if (resource.policy?.routing.scope) {
|
||||
summary.push(`Routing ${getResourceRoutingScopeLabel(resource.policy.routing.scope)}`);
|
||||
}
|
||||
|
||||
return summary.join(' · ');
|
||||
});
|
||||
|
||||
const pbsData = createMemo(() => platformData()?.pbs);
|
||||
const pmgData = createMemo(() => platformData()?.pmg);
|
||||
const pbsJobTotal = createMemo(() => {
|
||||
const pbs = pbsData();
|
||||
if (!pbs) return 0;
|
||||
return (
|
||||
(pbs.backupJobCount || 0) +
|
||||
(pbs.syncJobCount || 0) +
|
||||
(pbs.verifyJobCount || 0) +
|
||||
(pbs.pruneJobCount || 0) +
|
||||
(pbs.garbageJobCount || 0)
|
||||
);
|
||||
});
|
||||
const pmgQueueBacklog = createMemo(() => {
|
||||
const pmg = pmgData();
|
||||
if (!pmg) return 0;
|
||||
return (pmg.queueDeferred || 0) + (pmg.queueHold || 0);
|
||||
});
|
||||
const pmgUpdatedRelative = createMemo(() => {
|
||||
const raw = pmgData()?.lastUpdated;
|
||||
if (!raw) return '';
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
return formatRelativeTime(parsed);
|
||||
});
|
||||
const pbsJobBreakdown = createMemo(() => {
|
||||
const pbs = pbsData();
|
||||
if (!pbs) return [] as Array<{ label: string; value: number }>;
|
||||
return [
|
||||
{ label: 'Backup', value: pbs.backupJobCount || 0 },
|
||||
{ label: 'Sync', value: pbs.syncJobCount || 0 },
|
||||
{ label: 'Verify', value: pbs.verifyJobCount || 0 },
|
||||
{ label: 'Prune', value: pbs.pruneJobCount || 0 },
|
||||
{ label: 'Garbage', value: pbs.garbageJobCount || 0 },
|
||||
];
|
||||
});
|
||||
const pbsVisibleJobBreakdown = createMemo(() => {
|
||||
const all = pbsJobBreakdown();
|
||||
const nonZero = all.filter((entry) => entry.value > 0);
|
||||
return nonZero.length > 0 ? nonZero : all;
|
||||
});
|
||||
const pmgQueueBreakdown = createMemo(() => {
|
||||
const pmg = pmgData();
|
||||
if (!pmg) return [] as Array<{ label: string; value: number; warn?: boolean }>;
|
||||
return [
|
||||
{ label: 'Active', value: pmg.queueActive || 0 },
|
||||
{ label: 'Deferred', value: pmg.queueDeferred || 0, warn: (pmg.queueDeferred || 0) > 0 },
|
||||
{ label: 'Hold', value: pmg.queueHold || 0, warn: (pmg.queueHold || 0) > 0 },
|
||||
{ label: 'Incoming', value: pmg.queueIncoming || 0 },
|
||||
];
|
||||
});
|
||||
const pmgVisibleQueueBreakdown = createMemo(() => {
|
||||
const all = pmgQueueBreakdown();
|
||||
const nonZero = all.filter((entry) => entry.value > 0);
|
||||
return nonZero.length > 0 ? nonZero : all;
|
||||
});
|
||||
const pmgMailBreakdown = createMemo(() => {
|
||||
const pmg = pmgData();
|
||||
if (!pmg) return [] as Array<{ label: string; value: number }>;
|
||||
return [
|
||||
{ label: 'Mail', value: pmg.mailCountTotal || 0 },
|
||||
{ label: 'Spam', value: pmg.spamIn || 0 },
|
||||
{ label: 'Virus', value: pmg.virusIn || 0 },
|
||||
];
|
||||
});
|
||||
const pmgVisibleMailBreakdown = createMemo(() => {
|
||||
const all = pmgMailBreakdown();
|
||||
const nonZero = all.filter((entry) => entry.value > 0);
|
||||
return nonZero.length > 0 ? nonZero : all;
|
||||
});
|
||||
|
||||
const mergedSources = createMemo(() => platformData()?.sources ?? []);
|
||||
const sourceStatus = createMemo<NonNullable<PlatformData['sourceStatus']>>(
|
||||
() => platformData()?.sourceStatus ?? {},
|
||||
);
|
||||
const sourceHealthSummary = createMemo(() => {
|
||||
const entries = Object.entries(sourceStatus());
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
let healthy = 0;
|
||||
let warning = 0;
|
||||
let unhealthy = 0;
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const [source, status] of entries) {
|
||||
const normalized = (status?.status || '').trim().toLowerCase();
|
||||
parts.push(`${source}:${normalized || 'unknown'}`);
|
||||
if (['online', 'running', 'healthy', 'connected', 'ok'].includes(normalized)) {
|
||||
healthy += 1;
|
||||
} else if (['degraded', 'warning', 'stale'].includes(normalized)) {
|
||||
warning += 1;
|
||||
} else {
|
||||
unhealthy += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const total = entries.length;
|
||||
if (unhealthy > 0) {
|
||||
return {
|
||||
label: `${unhealthy}/${total} unhealthy`,
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: parts.join(' • '),
|
||||
};
|
||||
}
|
||||
if (warning > 0) {
|
||||
return {
|
||||
label: `${warning}/${total} degraded`,
|
||||
className: 'text-amber-600 dark:text-amber-400',
|
||||
title: parts.join(' • '),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${healthy}/${total} healthy`,
|
||||
className: 'text-emerald-600 dark:text-emerald-400',
|
||||
title: parts.join(' • '),
|
||||
};
|
||||
});
|
||||
const sourceSummary = createMemo(() => {
|
||||
const health = sourceHealthSummary();
|
||||
if (health) return health;
|
||||
const sources = mergedSources();
|
||||
if (sources.length === 0) return null;
|
||||
return {
|
||||
label: sources.length === 1 ? sources[0].toUpperCase() : `${sources.length} sources`,
|
||||
className: 'text-base-content',
|
||||
title: sources.join(' • '),
|
||||
};
|
||||
});
|
||||
|
||||
const identityAliasValues = createMemo(() => getResourceIdentityAliases(resource));
|
||||
const primaryIdentityRows = createMemo(() => getPrimaryResourceIdentityRows(resource));
|
||||
const identityCardHasRichData = createMemo(
|
||||
() =>
|
||||
primaryIdentityRows().length > 0 ||
|
||||
(resource.identity?.ips?.length || 0) > 0 ||
|
||||
(resource.tags?.length || 0) > 0 ||
|
||||
identityAliasValues().length > 0,
|
||||
);
|
||||
const aliasPreviewValues = createMemo(() =>
|
||||
identityAliasValues().slice(0, ALIAS_COLLAPSE_THRESHOLD),
|
||||
);
|
||||
const hasAliasOverflow = createMemo(
|
||||
() => identityAliasValues().length > ALIAS_COLLAPSE_THRESHOLD,
|
||||
);
|
||||
const hasIdentitySupportContext = createMemo(
|
||||
() =>
|
||||
(resource.identity?.ips?.length ?? 0) > 0 ||
|
||||
(resource.tags?.length ?? 0) > 0 ||
|
||||
identityAliasValues().length > 0,
|
||||
);
|
||||
const hasMergedSources = createMemo(() => mergedSources().length > 1);
|
||||
const discoveryConfig = createMemo(() => toDiscoveryConfig(resource));
|
||||
const discoveryContextSummary = createMemo(() => {
|
||||
const config = discoveryConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const discoveryMode =
|
||||
config.resourceType === 'agent'
|
||||
? 'Host discovery'
|
||||
: `${formatIdentifierLabel(config.resourceType)} discovery`;
|
||||
|
||||
return config.hostname ? `${discoveryMode} via ${config.hostname}` : discoveryMode;
|
||||
});
|
||||
|
||||
const hostDetailCards = createMemo(() => {
|
||||
const cards: string[] = [];
|
||||
|
||||
if (proxmoxNode()) {
|
||||
cards.push('system', 'hardware', 'storage');
|
||||
}
|
||||
|
||||
const agent = agentInfo();
|
||||
if (agent) {
|
||||
cards.push('system', 'hardware');
|
||||
if ((agent.networkInterfaces?.length ?? 0) > 0) cards.push('network');
|
||||
if ((agent.disks?.length ?? 0) > 0) cards.push('disks');
|
||||
if ((agentMeta()?.raid?.length ?? 0) > 0) cards.push('raid');
|
||||
if (temperatureRows().length > 0) cards.push('temperatures');
|
||||
}
|
||||
|
||||
return cards;
|
||||
});
|
||||
const hasHostDetails = createMemo(() => hostDetailCards().length > 0);
|
||||
const hostDetailSummary = createMemo(() => {
|
||||
const labels = Array.from(new Set(hostDetailCards()));
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
const categories =
|
||||
labels.length === 1
|
||||
? labels[0]
|
||||
: labels.length === 2
|
||||
? `${labels[0]} and ${labels[1]}`
|
||||
: `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
|
||||
|
||||
return `${hostDetailCards().length} detail card${hostDetailCards().length === 1 ? '' : 's'} covering ${categories}.`;
|
||||
});
|
||||
const hasServiceDetails = createMemo(
|
||||
() => resource.type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()),
|
||||
);
|
||||
const serviceDetailsSummary = createMemo(() => {
|
||||
if (resource.type === 'docker-host') {
|
||||
return `${formatInteger(dockerContainerCount())} containers · ${formatInteger(dockerUpdatesAvailable())} updates`;
|
||||
}
|
||||
|
||||
const pbs = pbsData();
|
||||
if (pbs) {
|
||||
return `${formatInteger(pbs.datastoreCount)} datastores · ${formatInteger(pbsJobTotal())} jobs`;
|
||||
}
|
||||
|
||||
const pmg = pmgData();
|
||||
if (pmg) {
|
||||
return `${formatInteger(pmg.queueTotal)} queue total · ${formatInteger(pmgQueueBacklog())} backlog`;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const workloadsHref = createMemo(() => buildWorkloadsHref(resource));
|
||||
const headerIdentity = createMemo(() => getPrimaryResourceIdentity(resource));
|
||||
const relatedLinks = createMemo(() => {
|
||||
const links: Array<{ href: string; label: string; compactLabel: string; ariaLabel: string }> =
|
||||
[];
|
||||
const workloads = workloadsHref();
|
||||
if (workloads) {
|
||||
links.push({
|
||||
href: workloads,
|
||||
label: 'Open in Workloads',
|
||||
compactLabel: 'Workloads',
|
||||
ariaLabel: `Open related workloads for ${displayName()}`,
|
||||
});
|
||||
}
|
||||
links.push(...buildServiceDetailLinks(resource));
|
||||
const seen = new Set<string>();
|
||||
return links.filter((link) => {
|
||||
if (seen.has(link.href)) return false;
|
||||
seen.add(link.href);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const hasRuntimeOperationalContext = createMemo(
|
||||
() => kubernetesCapabilityBadges().length > 0 || relatedLinks().length > 0,
|
||||
);
|
||||
|
||||
const sourceSections = createMemo(() => {
|
||||
const data = platformData();
|
||||
if (!data) {
|
||||
return [] as Array<{ id: string; label: string; payload: unknown }>;
|
||||
}
|
||||
const sections = [
|
||||
{ id: 'proxmox', label: 'Proxmox', payload: data.proxmox },
|
||||
{ id: 'agent', label: 'Agent', payload: data.agent },
|
||||
{ id: 'docker', label: 'Containers', payload: data.docker },
|
||||
{ id: 'pbs', label: 'PBS', payload: data.pbs },
|
||||
{ id: 'pmg', label: 'PMG', payload: data.pmg },
|
||||
{ id: 'kubernetes', label: 'Kubernetes', payload: data.kubernetes },
|
||||
{ id: 'metrics', label: 'Metrics', payload: data.metrics },
|
||||
];
|
||||
return sections.filter((section) => section.payload !== undefined);
|
||||
});
|
||||
const identityMatchInfo = createMemo(() => {
|
||||
const data = platformData();
|
||||
return (
|
||||
data?.identityMatch ??
|
||||
data?.matchResults ??
|
||||
data?.matchCandidates ??
|
||||
data?.matches ??
|
||||
undefined
|
||||
);
|
||||
});
|
||||
const debugBundle = createMemo(() => ({
|
||||
resource,
|
||||
identity: {
|
||||
resourceIdentity: resource.identity,
|
||||
matchInfo: identityMatchInfo(),
|
||||
},
|
||||
sources: {
|
||||
sourceStatus: sourceStatus(),
|
||||
proxmox: platformData()?.proxmox,
|
||||
agent: platformData()?.agent,
|
||||
docker: platformData()?.docker,
|
||||
pbs: platformData()?.pbs,
|
||||
pmg: platformData()?.pmg,
|
||||
kubernetes: platformData()?.kubernetes,
|
||||
metrics: platformData()?.metrics,
|
||||
},
|
||||
}));
|
||||
const debugJson = createMemo(() => JSON.stringify(debugBundle(), null, 2));
|
||||
|
||||
const tabs = createMemo(() => {
|
||||
const base = [
|
||||
{ id: 'overview' as DrawerTab, label: 'Overview' },
|
||||
...(resource.type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []),
|
||||
...(resource.type === 'k8s-cluster'
|
||||
? [{ id: 'namespaces' as DrawerTab, label: 'Namespaces' }]
|
||||
: []),
|
||||
...(resource.type === 'k8s-cluster'
|
||||
? [{ id: 'deployments' as DrawerTab, label: 'Deployments' }]
|
||||
: []),
|
||||
...(resource.type === 'docker-host' && dockerSwarmClusterKey()
|
||||
? [{ id: 'swarm' as DrawerTab, label: 'Swarm' }]
|
||||
: []),
|
||||
];
|
||||
if (debugEnabled()) {
|
||||
base.push({ id: 'debug' as DrawerTab, label: 'Debug' });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
return {
|
||||
displayName,
|
||||
kubernetesClusterName,
|
||||
resolveResourceLabel,
|
||||
statusIndicator,
|
||||
lastSeen,
|
||||
lastSeenAbsolute,
|
||||
platformBadge,
|
||||
sourceBadge,
|
||||
typeBadge,
|
||||
platformData,
|
||||
unifiedSourceBadges,
|
||||
hasUnifiedSources,
|
||||
policyBadges,
|
||||
policyRedactions,
|
||||
governanceSummary,
|
||||
hasGovernanceData,
|
||||
agentMeta,
|
||||
kubernetesMeta,
|
||||
kubernetesCapabilityBadges,
|
||||
proxmoxNode,
|
||||
agentInfo,
|
||||
temperatureRows,
|
||||
dockerHostData,
|
||||
dockerHostSourceId,
|
||||
dockerUpdatesAvailable,
|
||||
dockerContainerCount,
|
||||
dockerUpdatesCheckedRelative,
|
||||
dockerHostCommand,
|
||||
dockerHostCommandActive,
|
||||
dockerUpdateActionsDisabled,
|
||||
dockerUpdateActionsLoading,
|
||||
dockerSwarmInfo,
|
||||
dockerSwarmClusterKey,
|
||||
resourceDependencies,
|
||||
resourceDependents,
|
||||
resourceCorrelations,
|
||||
hasCorrelationContext,
|
||||
hasInvestigationContext,
|
||||
investigationContextSummary,
|
||||
pbsData,
|
||||
pmgData,
|
||||
pbsJobTotal,
|
||||
pmgQueueBacklog,
|
||||
pmgUpdatedRelative,
|
||||
pbsVisibleJobBreakdown,
|
||||
pmgVisibleQueueBreakdown,
|
||||
pmgVisibleMailBreakdown,
|
||||
mergedSources,
|
||||
sourceStatus,
|
||||
sourceSummary,
|
||||
identityAliasValues,
|
||||
primaryIdentityRows,
|
||||
identityCardHasRichData,
|
||||
aliasPreviewValues,
|
||||
hasAliasOverflow,
|
||||
hasIdentitySupportContext,
|
||||
hasMergedSources,
|
||||
discoveryConfig,
|
||||
discoveryContextSummary,
|
||||
hasHostDetails,
|
||||
hostDetailSummary,
|
||||
hasServiceDetails,
|
||||
serviceDetailsSummary,
|
||||
headerIdentity,
|
||||
relatedLinks,
|
||||
hasRuntimeOperationalContext,
|
||||
sourceSections,
|
||||
identityMatchInfo,
|
||||
debugJson,
|
||||
tabs,
|
||||
};
|
||||
};
|
||||
|
||||
export type ResourceDetailDrawerDerivedState = ReturnType<typeof useResourceDetailDrawerDerivedState>;
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { createMemo, createResource, createSignal } from 'solid-js';
|
||||
import type {
|
||||
Resource,
|
||||
ResourceChangeKind,
|
||||
ResourceChangeSourceAdapter,
|
||||
ResourceChangeSourceType,
|
||||
} from '@/types/resource';
|
||||
import type { ResourceIntelligence } from '@/types/aiIntelligence';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import { ResourceAPI } from '@/api/resources';
|
||||
|
||||
interface UseResourceDetailDrawerHistoryStateOptions {
|
||||
resource: Resource;
|
||||
}
|
||||
|
||||
export const useResourceDetailDrawerHistoryState = (
|
||||
options: UseResourceDetailDrawerHistoryStateOptions,
|
||||
) => {
|
||||
const { resource } = options;
|
||||
|
||||
const resourceFacetId = createMemo(() => resource.id.trim());
|
||||
const [timelineKindFilter, setTimelineKindFilter] = createSignal<ResourceChangeKind | ''>('');
|
||||
const [timelineSourceTypeFilter, setTimelineSourceTypeFilter] = createSignal<
|
||||
ResourceChangeSourceType | ''
|
||||
>('');
|
||||
const [timelineSourceAdapterFilter, setTimelineSourceAdapterFilter] = createSignal<
|
||||
ResourceChangeSourceAdapter | ''
|
||||
>('');
|
||||
|
||||
const resourceFacetRequest = createMemo(() => {
|
||||
const id = resourceFacetId();
|
||||
return id ? { id } : null;
|
||||
});
|
||||
|
||||
const [resourceFacets, { refetch: refetchResourceFacets }] = createResource(
|
||||
resourceFacetRequest,
|
||||
async (request) => {
|
||||
if (!request?.id) return null;
|
||||
return ResourceAPI.getFacetBundle(request.id, { limit: 25 });
|
||||
},
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
const [resourceIntelligence] = createResource(
|
||||
resourceFacetRequest,
|
||||
async (request) => {
|
||||
if (!request?.id) return null;
|
||||
return AIAPI.getResourceIntelligence(request.id);
|
||||
},
|
||||
{ initialValue: null as ResourceIntelligence | null },
|
||||
);
|
||||
|
||||
const timelineFacetRequest = createMemo(() => {
|
||||
const id = resourceFacetId();
|
||||
if (!id) return null;
|
||||
const kind = timelineKindFilter();
|
||||
const sourceType = timelineSourceTypeFilter();
|
||||
const sourceAdapter = timelineSourceAdapterFilter();
|
||||
if (!kind && !sourceType && !sourceAdapter) return null;
|
||||
return { id, kind, sourceType, sourceAdapter };
|
||||
});
|
||||
|
||||
const [timelineFacets, { refetch: refetchTimelineFacets }] = createResource(
|
||||
timelineFacetRequest,
|
||||
async (request) => {
|
||||
if (!request) return null;
|
||||
return ResourceAPI.getFacetBundle(request.id, {
|
||||
limit: 25,
|
||||
kind: request.kind || undefined,
|
||||
sourceType: request.sourceType || undefined,
|
||||
sourceAdapter: request.sourceAdapter || undefined,
|
||||
});
|
||||
},
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
const resourceTimeline = createMemo(
|
||||
() => resourceFacets()?.recentChanges ?? resource.recentChanges ?? [],
|
||||
);
|
||||
const resourceFacetCounts = createMemo(() => resourceFacets()?.counts ?? resource.facetCounts ?? null);
|
||||
const historyFacetBundle = createMemo(() =>
|
||||
timelineFacetRequest() ? (timelineFacets() ?? resourceFacets()) : resourceFacets(),
|
||||
);
|
||||
const historyFacetCounts = createMemo(
|
||||
() => historyFacetBundle()?.counts ?? resourceFacetCounts() ?? null,
|
||||
);
|
||||
const historyRecentChanges = createMemo(() => historyFacetBundle()?.recentChanges ?? resourceTimeline());
|
||||
const historyTimeline = createMemo(() => historyRecentChanges());
|
||||
const hasTimelineFilters = createMemo(() =>
|
||||
Boolean(timelineKindFilter() || timelineSourceTypeFilter() || timelineSourceAdapterFilter()),
|
||||
);
|
||||
const historyLoadingLabel = createMemo(() => {
|
||||
if (timelineFacetRequest()) {
|
||||
return timelineFacets.loading ? 'Refreshing filtered changes...' : 'Filtered changes loaded';
|
||||
}
|
||||
return resourceFacets.loading ? 'Refreshing changes...' : 'Changes loaded';
|
||||
});
|
||||
const resourceTimelineCount = createMemo(
|
||||
() => historyFacetCounts()?.recentChanges ?? historyRecentChanges().length,
|
||||
);
|
||||
const sortedResourceTimeline = createMemo(() =>
|
||||
[...historyTimeline()].sort((left, right) => {
|
||||
const leftTime = Date.parse(left.observedAt || '');
|
||||
const rightTime = Date.parse(right.observedAt || '');
|
||||
return (
|
||||
(Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0)
|
||||
);
|
||||
}),
|
||||
);
|
||||
const facetBundleError = createMemo(() => {
|
||||
const error = timelineFacetRequest() ? timelineFacets.error : resourceFacets.error;
|
||||
if (!error) return '';
|
||||
return (error as Error)?.message || 'Failed to load resource history';
|
||||
});
|
||||
|
||||
const refetchHistoryFacets = () => {
|
||||
if (timelineFacetRequest()) {
|
||||
return refetchTimelineFacets();
|
||||
}
|
||||
return refetchResourceFacets();
|
||||
};
|
||||
|
||||
return {
|
||||
timelineKindFilter,
|
||||
setTimelineKindFilter,
|
||||
timelineSourceTypeFilter,
|
||||
setTimelineSourceTypeFilter,
|
||||
timelineSourceAdapterFilter,
|
||||
setTimelineSourceAdapterFilter,
|
||||
resourceIntelligence,
|
||||
resourceTimeline,
|
||||
historyFacetCounts,
|
||||
historyRecentChanges,
|
||||
hasTimelineFilters,
|
||||
historyLoadingLabel,
|
||||
resourceTimelineCount,
|
||||
sortedResourceTimeline,
|
||||
facetBundleError,
|
||||
refetchHistoryFacets,
|
||||
};
|
||||
};
|
||||
|
||||
export type ResourceDetailDrawerHistoryState = ReturnType<typeof useResourceDetailDrawerHistoryState>;
|
||||
|
|
@ -1,59 +1,11 @@
|
|||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
type Accessor,
|
||||
type Setter,
|
||||
} from 'solid-js';
|
||||
import type {
|
||||
Resource,
|
||||
ResourceChangeKind,
|
||||
ResourceChangeSourceAdapter,
|
||||
ResourceChangeSourceType,
|
||||
} from '@/types/resource';
|
||||
import { requiresGovernedResourceDisplay } from '@/types/resource';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import { getAgentStatusIndicator } from '@/utils/status';
|
||||
import {
|
||||
getPlatformBadge,
|
||||
getSourceBadge,
|
||||
getTypeBadge,
|
||||
getUnifiedSourceBadges,
|
||||
} from '@/utils/resourceBadgePresentation';
|
||||
import { buildWorkloadsHref } from '@/components/Infrastructure/workloadsLink';
|
||||
import { buildServiceDetailLinks } from '@/components/Infrastructure/serviceDetailLinks';
|
||||
import {
|
||||
getPrimaryResourceIdentity,
|
||||
getPrimaryResourceIdentityRows,
|
||||
getResourceIdentityAliases,
|
||||
getPreferredResourceClusterName,
|
||||
getPreferredResourceDisplayName,
|
||||
} from '@/utils/resourceIdentity';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import { ResourceAPI } from '@/api/resources';
|
||||
import { areSystemSettingsLoaded, shouldHideDockerUpdateActions } from '@/stores/systemSettings';
|
||||
import {
|
||||
getResourcePolicyBadges,
|
||||
getResourcePolicyDisplayLabel,
|
||||
getResourcePolicyRedactionLabels,
|
||||
getResourceRoutingScopeLabel,
|
||||
} from '@/utils/resourcePolicyPresentation';
|
||||
import type { ResourceIntelligence } from '@/types/aiIntelligence';
|
||||
import {
|
||||
ALIAS_COLLAPSE_THRESHOLD,
|
||||
buildTemperatureRows,
|
||||
formatInteger,
|
||||
toAgentFromResource,
|
||||
toDiscoveryConfig,
|
||||
toNodeFromProxmox,
|
||||
type AgentPlatformData,
|
||||
type DockerPlatformData,
|
||||
type KubernetesPlatformData,
|
||||
type PlatformData,
|
||||
} from '@/components/Infrastructure/resourceDetailMappers';
|
||||
import { formatIdentifierLabel } from '@/utils/textPresentation';
|
||||
import { useResourceDetailDrawerHistoryState } from './useResourceDetailDrawerHistoryState';
|
||||
import { useResourceDetailDrawerDerivedState } from './useResourceDetailDrawerDerivedState';
|
||||
|
||||
type DrawerTab = 'overview' | 'mail' | 'namespaces' | 'deployments' | 'swarm' | 'debug';
|
||||
|
||||
|
|
@ -62,135 +14,7 @@ export interface UseResourceDetailDrawerStateOptions {
|
|||
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
|
||||
}
|
||||
|
||||
export interface UseResourceDetailDrawerStateResult {
|
||||
activeTab: Accessor<DrawerTab>;
|
||||
setActiveTab: Setter<DrawerTab>;
|
||||
debugEnabled: Accessor<boolean>;
|
||||
copied: Accessor<boolean>;
|
||||
showReportModal: Accessor<boolean>;
|
||||
setShowReportModal: Setter<boolean>;
|
||||
showInvestigationContext: Accessor<boolean>;
|
||||
setShowInvestigationContext: Setter<boolean>;
|
||||
showCorrelationContext: Accessor<boolean>;
|
||||
setShowCorrelationContext: Setter<boolean>;
|
||||
showDiscoveryContext: Accessor<boolean>;
|
||||
setShowDiscoveryContext: Setter<boolean>;
|
||||
showHostDetails: Accessor<boolean>;
|
||||
setShowHostDetails: Setter<boolean>;
|
||||
showServiceDetails: Accessor<boolean>;
|
||||
setShowServiceDetails: Setter<boolean>;
|
||||
showDockerUpdateControls: Accessor<boolean>;
|
||||
setShowDockerUpdateControls: Setter<boolean>;
|
||||
showPbsJobDetail: Accessor<boolean>;
|
||||
setShowPbsJobDetail: Setter<boolean>;
|
||||
showPmgMailFlowDetail: Accessor<boolean>;
|
||||
setShowPmgMailFlowDetail: Setter<boolean>;
|
||||
displayName: Accessor<string>;
|
||||
kubernetesClusterName: Accessor<string>;
|
||||
resolveResourceLabel: (resourceId: string) => string;
|
||||
statusIndicator: Accessor<ReturnType<typeof getAgentStatusIndicator>>;
|
||||
lastSeen: Accessor<string>;
|
||||
lastSeenAbsolute: Accessor<string>;
|
||||
platformBadge: Accessor<ReturnType<typeof getPlatformBadge>>;
|
||||
sourceBadge: Accessor<ReturnType<typeof getSourceBadge>>;
|
||||
typeBadge: Accessor<ReturnType<typeof getTypeBadge>>;
|
||||
unifiedSourceBadges: Accessor<ReturnType<typeof getUnifiedSourceBadges>>;
|
||||
hasUnifiedSources: Accessor<boolean>;
|
||||
policyBadges: Accessor<ReturnType<typeof getResourcePolicyBadges>>;
|
||||
policyRedactions: Accessor<string[]>;
|
||||
governanceSummary: Accessor<string>;
|
||||
hasGovernanceData: Accessor<boolean>;
|
||||
platformData: Accessor<PlatformData | undefined>;
|
||||
agentMeta: Accessor<AgentPlatformData | undefined>;
|
||||
kubernetesMeta: Accessor<KubernetesPlatformData | undefined>;
|
||||
kubernetesCapabilityBadges: Accessor<Array<{ label: string; classes: string; title: string }>>;
|
||||
proxmoxNode: Accessor<ReturnType<typeof toNodeFromProxmox>>;
|
||||
agentInfo: Accessor<ReturnType<typeof toAgentFromResource>>;
|
||||
temperatureRows: Accessor<ReturnType<typeof buildTemperatureRows>>;
|
||||
dockerHostData: Accessor<DockerPlatformData | undefined>;
|
||||
dockerHostSourceId: Accessor<string | null>;
|
||||
dockerUpdatesAvailable: Accessor<number>;
|
||||
dockerContainerCount: Accessor<number>;
|
||||
dockerUpdatesCheckedRelative: Accessor<string>;
|
||||
dockerHostCommand: Accessor<DockerPlatformData['command']>;
|
||||
dockerHostCommandActive: Accessor<boolean>;
|
||||
dockerUpdateActionsDisabled: Accessor<boolean>;
|
||||
dockerUpdateActionsLoading: Accessor<boolean>;
|
||||
dockerActionError: Accessor<string>;
|
||||
setDockerActionError: Setter<string>;
|
||||
dockerActionNote: Accessor<string>;
|
||||
setDockerActionNote: Setter<string>;
|
||||
confirmUpdateAll: Accessor<boolean>;
|
||||
setConfirmUpdateAll: Setter<boolean>;
|
||||
dockerActionBusy: Accessor<boolean>;
|
||||
setDockerActionBusy: Setter<boolean>;
|
||||
dockerSwarmInfo: Accessor<DockerPlatformData['swarm']>;
|
||||
dockerSwarmClusterKey: Accessor<string>;
|
||||
k8sDeploymentsPrefillNamespace: Accessor<string>;
|
||||
setK8sDeploymentsPrefillNamespace: Setter<string>;
|
||||
timelineKindFilter: Accessor<ResourceChangeKind | ''>;
|
||||
setTimelineKindFilter: Setter<ResourceChangeKind | ''>;
|
||||
timelineSourceTypeFilter: Accessor<ResourceChangeSourceType | ''>;
|
||||
setTimelineSourceTypeFilter: Setter<ResourceChangeSourceType | ''>;
|
||||
timelineSourceAdapterFilter: Accessor<ResourceChangeSourceAdapter | ''>;
|
||||
setTimelineSourceAdapterFilter: Setter<ResourceChangeSourceAdapter | ''>;
|
||||
resourceIntelligence: Accessor<ResourceIntelligence | null>;
|
||||
resourceDependencies: Accessor<ResourceIntelligence['dependencies']>;
|
||||
resourceDependents: Accessor<ResourceIntelligence['dependents']>;
|
||||
resourceCorrelations: Accessor<ResourceIntelligence['correlations']>;
|
||||
hasCorrelationContext: Accessor<boolean>;
|
||||
hasInvestigationContext: Accessor<boolean>;
|
||||
investigationContextSummary: Accessor<string>;
|
||||
pbsData: Accessor<PlatformData['pbs']>;
|
||||
pmgData: Accessor<PlatformData['pmg']>;
|
||||
pbsJobTotal: Accessor<number>;
|
||||
pmgQueueBacklog: Accessor<number>;
|
||||
pmgUpdatedRelative: Accessor<string>;
|
||||
pbsVisibleJobBreakdown: Accessor<Array<{ label: string; value: number }>>;
|
||||
pmgVisibleQueueBreakdown: Accessor<Array<{ label: string; value: number; warn?: boolean }>>;
|
||||
pmgVisibleMailBreakdown: Accessor<Array<{ label: string; value: number }>>;
|
||||
resourceTimeline: Accessor<Resource['recentChanges']>;
|
||||
historyFacetCounts: Accessor<
|
||||
Awaited<ReturnType<typeof ResourceAPI.getFacetBundle>>['counts'] | null
|
||||
>;
|
||||
historyRecentChanges: Accessor<Resource['recentChanges']>;
|
||||
hasTimelineFilters: Accessor<boolean>;
|
||||
historyLoadingLabel: Accessor<string>;
|
||||
resourceTimelineCount: Accessor<number>;
|
||||
sortedResourceTimeline: Accessor<Resource['recentChanges']>;
|
||||
facetBundleError: Accessor<string>;
|
||||
refetchHistoryFacets: () => unknown;
|
||||
mergedSources: Accessor<string[]>;
|
||||
sourceSummary: Accessor<{ label: string; className: string; title: string } | null>;
|
||||
identityAliasValues: Accessor<string[]>;
|
||||
primaryIdentityRows: Accessor<ReturnType<typeof getPrimaryResourceIdentityRows>>;
|
||||
identityCardHasRichData: Accessor<boolean>;
|
||||
aliasPreviewValues: Accessor<string[]>;
|
||||
hasAliasOverflow: Accessor<boolean>;
|
||||
hasIdentitySupportContext: Accessor<boolean>;
|
||||
hasMergedSources: Accessor<boolean>;
|
||||
discoveryConfig: Accessor<ReturnType<typeof toDiscoveryConfig>>;
|
||||
discoveryContextSummary: Accessor<string | null>;
|
||||
hasHostDetails: Accessor<boolean>;
|
||||
hostDetailSummary: Accessor<string | null>;
|
||||
hasServiceDetails: Accessor<boolean>;
|
||||
serviceDetailsSummary: Accessor<string | null>;
|
||||
headerIdentity: Accessor<string>;
|
||||
relatedLinks: Accessor<
|
||||
Array<{ href: string; label: string; compactLabel: string; ariaLabel: string }>
|
||||
>;
|
||||
hasRuntimeOperationalContext: Accessor<boolean>;
|
||||
sourceSections: Accessor<Array<{ id: string; label: string; payload: unknown }>>;
|
||||
sourceStatus: Accessor<NonNullable<PlatformData['sourceStatus']>>;
|
||||
identityMatchInfo: Accessor<unknown>;
|
||||
debugJson: Accessor<string>;
|
||||
tabs: Accessor<Array<{ id: DrawerTab; label: string }>>;
|
||||
handleCopyJson: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useResourceDetailDrawerState = (
|
||||
options: UseResourceDetailDrawerStateOptions,
|
||||
): UseResourceDetailDrawerStateResult => {
|
||||
export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerStateOptions) => {
|
||||
const { resource, resolveResourceLabel: resolveResourceLabelInput } = options;
|
||||
const [activeTab, setActiveTab] = createSignal<DrawerTab>('overview');
|
||||
const [debugEnabled] = createLocalStorageBooleanSignal(STORAGE_KEYS.DEBUG_MODE, false);
|
||||
|
|
@ -204,540 +28,19 @@ export const useResourceDetailDrawerState = (
|
|||
const [showDockerUpdateControls, setShowDockerUpdateControls] = createSignal(false);
|
||||
const [showPbsJobDetail, setShowPbsJobDetail] = createSignal(false);
|
||||
const [showPmgMailFlowDetail, setShowPmgMailFlowDetail] = createSignal(false);
|
||||
|
||||
const displayName = createMemo(() => getPreferredResourceDisplayName(resource));
|
||||
const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource) ?? '');
|
||||
const resolveResourceLabel = (resourceId: string): string =>
|
||||
resolveResourceLabelInput?.(resourceId)?.trim() || resourceId;
|
||||
const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource.status }));
|
||||
const lastSeen = createMemo(() => formatRelativeTime(resource.lastSeen));
|
||||
const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(resource.lastSeen));
|
||||
|
||||
const platformBadge = createMemo(() => getPlatformBadge(resource.platformType));
|
||||
const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType));
|
||||
const typeBadge = createMemo(() => getTypeBadge(resource.type));
|
||||
const platformData = createMemo(() => resource.platformData as PlatformData | undefined);
|
||||
const unifiedSourceBadges = createMemo(() =>
|
||||
getUnifiedSourceBadges(platformData()?.sources ?? []),
|
||||
);
|
||||
const hasUnifiedSources = createMemo(() => unifiedSourceBadges().length > 0);
|
||||
const policyBadges = createMemo(() => getResourcePolicyBadges(resource.policy));
|
||||
const policyRedactions = createMemo(() => getResourcePolicyRedactionLabels(resource.policy));
|
||||
const governanceSummary = createMemo(() =>
|
||||
requiresGovernedResourceDisplay(resource.policy)
|
||||
? getResourcePolicyDisplayLabel(resource)
|
||||
: (resource.aiSafeSummary?.trim() ?? ''),
|
||||
);
|
||||
const hasGovernanceData = createMemo(
|
||||
() => policyBadges().length > 0 || Boolean(governanceSummary()),
|
||||
);
|
||||
|
||||
const agentMeta = createMemo(
|
||||
() => resource.agent ?? (platformData()?.agent as AgentPlatformData | undefined),
|
||||
);
|
||||
const kubernetesMeta = createMemo(
|
||||
() => resource.kubernetes ?? (platformData()?.kubernetes as KubernetesPlatformData | undefined),
|
||||
);
|
||||
const kubernetesCapabilityBadges = createMemo(() => {
|
||||
const capabilities = kubernetesMeta()?.metricCapabilities;
|
||||
if (!capabilities) return [];
|
||||
|
||||
const supportedBadge =
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400';
|
||||
const unsupportedBadge =
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-muted';
|
||||
const badges: { label: string; classes: string; title: string }[] = [];
|
||||
|
||||
if (capabilities.nodeCpuMemory) {
|
||||
badges.push({
|
||||
label: 'K8s Node CPU/Memory',
|
||||
classes: supportedBadge,
|
||||
title: 'Node CPU and memory metrics are available.',
|
||||
});
|
||||
}
|
||||
if (capabilities.nodeTelemetry) {
|
||||
badges.push({
|
||||
label: 'Node Telemetry (Agent)',
|
||||
classes: supportedBadge,
|
||||
title: 'Linked Pulse agent provides node uptime, temperature, disk, network, and disk I/O.',
|
||||
});
|
||||
}
|
||||
if (capabilities.podCpuMemory) {
|
||||
badges.push({
|
||||
label: 'Pod CPU/Memory',
|
||||
classes: supportedBadge,
|
||||
title: 'Pod CPU and memory metrics are available.',
|
||||
});
|
||||
}
|
||||
if (capabilities.podNetwork) {
|
||||
badges.push({
|
||||
label: 'Pod Network',
|
||||
classes: supportedBadge,
|
||||
title: 'Pod network throughput is available.',
|
||||
});
|
||||
}
|
||||
if (capabilities.podEphemeralDisk) {
|
||||
badges.push({
|
||||
label: 'Pod Ephemeral Disk',
|
||||
classes: supportedBadge,
|
||||
title: 'Pod ephemeral storage usage is available.',
|
||||
});
|
||||
}
|
||||
if (!capabilities.podDiskIo) {
|
||||
badges.push({
|
||||
label: 'Pod Disk I/O Unsupported',
|
||||
classes: unsupportedBadge,
|
||||
title:
|
||||
'Pod disk read/write throughput is not collected by the Kubernetes integration path today.',
|
||||
});
|
||||
}
|
||||
|
||||
return badges;
|
||||
});
|
||||
|
||||
const proxmoxNode = createMemo(() => toNodeFromProxmox(resource));
|
||||
const agentInfo = createMemo(() => toAgentFromResource(resource, agentMeta()));
|
||||
const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors));
|
||||
|
||||
const dockerHostData = createMemo(() => platformData()?.docker as DockerPlatformData | undefined);
|
||||
const dockerHostSourceId = createMemo(
|
||||
() => (dockerHostData()?.hostSourceId || '').trim() || null,
|
||||
);
|
||||
const dockerUpdatesAvailable = createMemo(() => dockerHostData()?.updatesAvailableCount ?? 0);
|
||||
const dockerContainerCount = createMemo(() => dockerHostData()?.containerCount ?? 0);
|
||||
const dockerUpdatesCheckedRelative = createMemo(() => {
|
||||
const raw = dockerHostData()?.updatesLastCheckedAt;
|
||||
if (!raw) return '';
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
return formatRelativeTime(parsed);
|
||||
});
|
||||
const dockerHostCommand = createMemo(() => dockerHostData()?.command);
|
||||
const dockerHostCommandActive = createMemo(() => {
|
||||
const status = (dockerHostCommand()?.status || '').trim().toLowerCase();
|
||||
return ['queued', 'dispatched', 'acknowledged', 'in_progress'].includes(status);
|
||||
});
|
||||
const dockerUpdateActionsDisabled = createMemo(() => shouldHideDockerUpdateActions());
|
||||
const dockerUpdateActionsLoading = createMemo(() => !areSystemSettingsLoaded());
|
||||
|
||||
const [dockerActionError, setDockerActionError] = createSignal('');
|
||||
const [dockerActionNote, setDockerActionNote] = createSignal('');
|
||||
const [confirmUpdateAll, setConfirmUpdateAll] = createSignal(false);
|
||||
const [dockerActionBusy, setDockerActionBusy] = createSignal(false);
|
||||
const dockerSwarmInfo = createMemo(() => dockerHostData()?.swarm);
|
||||
const dockerSwarmClusterKey = createMemo(() => {
|
||||
const swarm = dockerSwarmInfo();
|
||||
return (swarm?.clusterName || swarm?.clusterId || '').trim();
|
||||
});
|
||||
|
||||
const [k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace] = createSignal('');
|
||||
const resourceFacetId = createMemo(() => resource.id.trim());
|
||||
const [timelineKindFilter, setTimelineKindFilter] = createSignal<ResourceChangeKind | ''>('');
|
||||
const [timelineSourceTypeFilter, setTimelineSourceTypeFilter] = createSignal<
|
||||
ResourceChangeSourceType | ''
|
||||
>('');
|
||||
const [timelineSourceAdapterFilter, setTimelineSourceAdapterFilter] = createSignal<
|
||||
ResourceChangeSourceAdapter | ''
|
||||
>('');
|
||||
const resourceFacetRequest = createMemo(() => {
|
||||
const id = resourceFacetId();
|
||||
return id ? { id } : null;
|
||||
});
|
||||
const [resourceFacets, { refetch: refetchResourceFacets }] = createResource(
|
||||
resourceFacetRequest,
|
||||
async (request) => {
|
||||
if (!request?.id) return null;
|
||||
return ResourceAPI.getFacetBundle(request.id, { limit: 25 });
|
||||
},
|
||||
{ initialValue: null },
|
||||
);
|
||||
const [resourceIntelligence] = createResource(
|
||||
resourceFacetRequest,
|
||||
async (request) => {
|
||||
if (!request?.id) return null;
|
||||
return AIAPI.getResourceIntelligence(request.id);
|
||||
},
|
||||
{ initialValue: null as ResourceIntelligence | null },
|
||||
);
|
||||
const resourceDependencies = createMemo(() => resourceIntelligence()?.dependencies ?? []);
|
||||
const resourceDependents = createMemo(() => resourceIntelligence()?.dependents ?? []);
|
||||
const resourceCorrelations = createMemo(() => resourceIntelligence()?.correlations ?? []);
|
||||
const hasCorrelationContext = createMemo(
|
||||
() =>
|
||||
resourceDependencies().length > 0 ||
|
||||
resourceDependents().length > 0 ||
|
||||
resourceCorrelations().length > 0,
|
||||
);
|
||||
const hasInvestigationContext = createMemo(
|
||||
() => Boolean(resourceIntelligence()) || hasGovernanceData(),
|
||||
);
|
||||
const investigationContextSummary = createMemo(() => {
|
||||
const intel = resourceIntelligence();
|
||||
const summary: string[] = [];
|
||||
|
||||
if (intel) {
|
||||
summary.push(`AI health ${intel.health.grade} · ${Math.round(intel.health.score)}/100`);
|
||||
}
|
||||
if (resourceCorrelations().length > 0) {
|
||||
summary.push(
|
||||
`${resourceCorrelations().length} correlation${resourceCorrelations().length === 1 ? '' : 's'}`,
|
||||
);
|
||||
}
|
||||
if (resource.policy?.routing.scope) {
|
||||
summary.push(`Routing ${getResourceRoutingScopeLabel(resource.policy.routing.scope)}`);
|
||||
}
|
||||
|
||||
return summary.join(' · ');
|
||||
});
|
||||
const timelineFacetRequest = createMemo(() => {
|
||||
const id = resourceFacetId();
|
||||
if (!id) return null;
|
||||
const kind = timelineKindFilter();
|
||||
const sourceType = timelineSourceTypeFilter();
|
||||
const sourceAdapter = timelineSourceAdapterFilter();
|
||||
if (!kind && !sourceType && !sourceAdapter) return null;
|
||||
return { id, kind, sourceType, sourceAdapter };
|
||||
});
|
||||
const [timelineFacets, { refetch: refetchTimelineFacets }] = createResource(
|
||||
timelineFacetRequest,
|
||||
async (request) => {
|
||||
if (!request) return null;
|
||||
return ResourceAPI.getFacetBundle(request.id, {
|
||||
limit: 25,
|
||||
kind: request.kind || undefined,
|
||||
sourceType: request.sourceType || undefined,
|
||||
sourceAdapter: request.sourceAdapter || undefined,
|
||||
});
|
||||
},
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
const pbsData = createMemo(() => platformData()?.pbs);
|
||||
const pmgData = createMemo(() => platformData()?.pmg);
|
||||
const pbsJobTotal = createMemo(() => {
|
||||
const pbs = pbsData();
|
||||
if (!pbs) return 0;
|
||||
return (
|
||||
(pbs.backupJobCount || 0) +
|
||||
(pbs.syncJobCount || 0) +
|
||||
(pbs.verifyJobCount || 0) +
|
||||
(pbs.pruneJobCount || 0) +
|
||||
(pbs.garbageJobCount || 0)
|
||||
);
|
||||
});
|
||||
const pmgQueueBacklog = createMemo(() => {
|
||||
const pmg = pmgData();
|
||||
if (!pmg) return 0;
|
||||
return (pmg.queueDeferred || 0) + (pmg.queueHold || 0);
|
||||
});
|
||||
const pmgUpdatedRelative = createMemo(() => {
|
||||
const raw = pmgData()?.lastUpdated;
|
||||
if (!raw) return '';
|
||||
const parsed = Date.parse(raw);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
return formatRelativeTime(parsed);
|
||||
});
|
||||
const pbsJobBreakdown = createMemo(() => {
|
||||
const pbs = pbsData();
|
||||
if (!pbs) return [] as Array<{ label: string; value: number }>;
|
||||
return [
|
||||
{ label: 'Backup', value: pbs.backupJobCount || 0 },
|
||||
{ label: 'Sync', value: pbs.syncJobCount || 0 },
|
||||
{ label: 'Verify', value: pbs.verifyJobCount || 0 },
|
||||
{ label: 'Prune', value: pbs.pruneJobCount || 0 },
|
||||
{ label: 'Garbage', value: pbs.garbageJobCount || 0 },
|
||||
];
|
||||
});
|
||||
const pbsVisibleJobBreakdown = createMemo(() => {
|
||||
const all = pbsJobBreakdown();
|
||||
const nonZero = all.filter((entry) => entry.value > 0);
|
||||
return nonZero.length > 0 ? nonZero : all;
|
||||
});
|
||||
const pmgQueueBreakdown = createMemo(() => {
|
||||
const pmg = pmgData();
|
||||
if (!pmg) return [] as Array<{ label: string; value: number; warn?: boolean }>;
|
||||
return [
|
||||
{ label: 'Active', value: pmg.queueActive || 0 },
|
||||
{ label: 'Deferred', value: pmg.queueDeferred || 0, warn: (pmg.queueDeferred || 0) > 0 },
|
||||
{ label: 'Hold', value: pmg.queueHold || 0, warn: (pmg.queueHold || 0) > 0 },
|
||||
{ label: 'Incoming', value: pmg.queueIncoming || 0 },
|
||||
];
|
||||
});
|
||||
const pmgVisibleQueueBreakdown = createMemo(() => {
|
||||
const all = pmgQueueBreakdown();
|
||||
const nonZero = all.filter((entry) => entry.value > 0);
|
||||
return nonZero.length > 0 ? nonZero : all;
|
||||
});
|
||||
const pmgMailBreakdown = createMemo(() => {
|
||||
const pmg = pmgData();
|
||||
if (!pmg) return [] as Array<{ label: string; value: number }>;
|
||||
return [
|
||||
{ label: 'Mail', value: pmg.mailCountTotal || 0 },
|
||||
{ label: 'Spam', value: pmg.spamIn || 0 },
|
||||
{ label: 'Virus', value: pmg.virusIn || 0 },
|
||||
];
|
||||
});
|
||||
const pmgVisibleMailBreakdown = createMemo(() => {
|
||||
const all = pmgMailBreakdown();
|
||||
const nonZero = all.filter((entry) => entry.value > 0);
|
||||
return nonZero.length > 0 ? nonZero : all;
|
||||
});
|
||||
const resourceTimeline = createMemo(
|
||||
() => resourceFacets()?.recentChanges ?? resource.recentChanges ?? [],
|
||||
);
|
||||
const resourceFacetCounts = createMemo(
|
||||
() => resourceFacets()?.counts ?? resource.facetCounts ?? null,
|
||||
);
|
||||
const historyFacetBundle = createMemo(() =>
|
||||
timelineFacetRequest() ? (timelineFacets() ?? resourceFacets()) : resourceFacets(),
|
||||
);
|
||||
const historyFacetCounts = createMemo(
|
||||
() => historyFacetBundle()?.counts ?? resourceFacetCounts() ?? null,
|
||||
);
|
||||
const historyRecentChanges = createMemo(
|
||||
() => historyFacetBundle()?.recentChanges ?? resourceTimeline(),
|
||||
);
|
||||
const historyTimeline = createMemo(() => historyRecentChanges());
|
||||
const hasTimelineFilters = createMemo(() =>
|
||||
Boolean(timelineKindFilter() || timelineSourceTypeFilter() || timelineSourceAdapterFilter()),
|
||||
);
|
||||
const historyLoadingLabel = createMemo(() => {
|
||||
if (timelineFacetRequest()) {
|
||||
return timelineFacets.loading ? 'Refreshing filtered changes...' : 'Filtered changes loaded';
|
||||
}
|
||||
return resourceFacets.loading ? 'Refreshing changes...' : 'Changes loaded';
|
||||
});
|
||||
const resourceTimelineCount = createMemo(
|
||||
() => historyFacetCounts()?.recentChanges ?? historyRecentChanges().length,
|
||||
);
|
||||
const sortedResourceTimeline = createMemo(() =>
|
||||
[...historyTimeline()].sort((left, right) => {
|
||||
const leftTime = Date.parse(left.observedAt || '');
|
||||
const rightTime = Date.parse(right.observedAt || '');
|
||||
return (
|
||||
(Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0)
|
||||
);
|
||||
}),
|
||||
);
|
||||
const facetBundleError = createMemo(() => {
|
||||
const error = timelineFacetRequest() ? timelineFacets.error : resourceFacets.error;
|
||||
if (!error) return '';
|
||||
return (error as Error)?.message || 'Failed to load resource history';
|
||||
});
|
||||
const refetchHistoryFacets = () => {
|
||||
if (timelineFacetRequest()) {
|
||||
return refetchTimelineFacets();
|
||||
}
|
||||
return refetchResourceFacets();
|
||||
};
|
||||
const mergedSources = createMemo(() => platformData()?.sources ?? []);
|
||||
const sourceStatus = createMemo(() => platformData()?.sourceStatus ?? {});
|
||||
const sourceHealthSummary = createMemo(() => {
|
||||
const entries = Object.entries(sourceStatus());
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
let healthy = 0;
|
||||
let warning = 0;
|
||||
let unhealthy = 0;
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const [source, status] of entries) {
|
||||
const normalized = (status?.status || '').trim().toLowerCase();
|
||||
parts.push(`${source}:${normalized || 'unknown'}`);
|
||||
if (['online', 'running', 'healthy', 'connected', 'ok'].includes(normalized)) {
|
||||
healthy += 1;
|
||||
} else if (['degraded', 'warning', 'stale'].includes(normalized)) {
|
||||
warning += 1;
|
||||
} else {
|
||||
unhealthy += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const total = entries.length;
|
||||
if (unhealthy > 0) {
|
||||
return {
|
||||
label: `${unhealthy}/${total} unhealthy`,
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: parts.join(' • '),
|
||||
};
|
||||
}
|
||||
if (warning > 0) {
|
||||
return {
|
||||
label: `${warning}/${total} degraded`,
|
||||
className: 'text-amber-600 dark:text-amber-400',
|
||||
title: parts.join(' • '),
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `${healthy}/${total} healthy`,
|
||||
className: 'text-emerald-600 dark:text-emerald-400',
|
||||
title: parts.join(' • '),
|
||||
};
|
||||
});
|
||||
const sourceSummary = createMemo(() => {
|
||||
const health = sourceHealthSummary();
|
||||
if (health) return health;
|
||||
const sources = mergedSources();
|
||||
if (sources.length === 0) return null;
|
||||
return {
|
||||
label: sources.length === 1 ? sources[0].toUpperCase() : `${sources.length} sources`,
|
||||
className: 'text-base-content',
|
||||
title: sources.join(' • '),
|
||||
};
|
||||
});
|
||||
const identityAliasValues = createMemo(() => getResourceIdentityAliases(resource));
|
||||
const primaryIdentityRows = createMemo(() => getPrimaryResourceIdentityRows(resource));
|
||||
const identityCardHasRichData = createMemo(
|
||||
() =>
|
||||
primaryIdentityRows().length > 0 ||
|
||||
(resource.identity?.ips?.length || 0) > 0 ||
|
||||
(resource.tags?.length || 0) > 0 ||
|
||||
identityAliasValues().length > 0,
|
||||
);
|
||||
const aliasPreviewValues = createMemo(() =>
|
||||
identityAliasValues().slice(0, ALIAS_COLLAPSE_THRESHOLD),
|
||||
);
|
||||
const hasAliasOverflow = createMemo(
|
||||
() => identityAliasValues().length > ALIAS_COLLAPSE_THRESHOLD,
|
||||
);
|
||||
const hasIdentitySupportContext = createMemo(
|
||||
() =>
|
||||
(resource.identity?.ips?.length ?? 0) > 0 ||
|
||||
(resource.tags?.length ?? 0) > 0 ||
|
||||
identityAliasValues().length > 0,
|
||||
);
|
||||
const hasMergedSources = createMemo(() => mergedSources().length > 1);
|
||||
const discoveryConfig = createMemo(() => toDiscoveryConfig(resource));
|
||||
const discoveryContextSummary = createMemo(() => {
|
||||
const config = discoveryConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const discoveryMode =
|
||||
config.resourceType === 'agent'
|
||||
? 'Host discovery'
|
||||
: `${formatIdentifierLabel(config.resourceType)} discovery`;
|
||||
|
||||
return config.hostname ? `${discoveryMode} via ${config.hostname}` : discoveryMode;
|
||||
});
|
||||
const hostDetailCards = createMemo(() => {
|
||||
const cards: string[] = [];
|
||||
|
||||
if (proxmoxNode()) {
|
||||
cards.push('system', 'hardware', 'storage');
|
||||
}
|
||||
|
||||
const agent = agentInfo();
|
||||
if (agent) {
|
||||
cards.push('system', 'hardware');
|
||||
if ((agent.networkInterfaces?.length ?? 0) > 0) cards.push('network');
|
||||
if ((agent.disks?.length ?? 0) > 0) cards.push('disks');
|
||||
if ((agentMeta()?.raid?.length ?? 0) > 0) cards.push('raid');
|
||||
if (temperatureRows().length > 0) cards.push('temperatures');
|
||||
}
|
||||
|
||||
return cards;
|
||||
});
|
||||
const hasHostDetails = createMemo(() => hostDetailCards().length > 0);
|
||||
const hostDetailSummary = createMemo(() => {
|
||||
const labels = Array.from(new Set(hostDetailCards()));
|
||||
if (labels.length === 0) return null;
|
||||
|
||||
const categories =
|
||||
labels.length === 1
|
||||
? labels[0]
|
||||
: labels.length === 2
|
||||
? `${labels[0]} and ${labels[1]}`
|
||||
: `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
|
||||
|
||||
return `${hostDetailCards().length} detail card${hostDetailCards().length === 1 ? '' : 's'} covering ${categories}.`;
|
||||
});
|
||||
const hasServiceDetails = createMemo(
|
||||
() => resource.type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()),
|
||||
);
|
||||
const serviceDetailsSummary = createMemo(() => {
|
||||
if (resource.type === 'docker-host') {
|
||||
return `${formatInteger(dockerContainerCount())} containers · ${formatInteger(dockerUpdatesAvailable())} updates`;
|
||||
}
|
||||
|
||||
const pbs = pbsData();
|
||||
if (pbs) {
|
||||
return `${formatInteger(pbs.datastoreCount)} datastores · ${formatInteger(pbsJobTotal())} jobs`;
|
||||
}
|
||||
|
||||
const pmg = pmgData();
|
||||
if (pmg) {
|
||||
return `${formatInteger(pmg.queueTotal)} queue total · ${formatInteger(pmgQueueBacklog())} backlog`;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
const workloadsHref = createMemo(() => buildWorkloadsHref(resource));
|
||||
const headerIdentity = createMemo(() => getPrimaryResourceIdentity(resource));
|
||||
const relatedLinks = createMemo(() => {
|
||||
const links: Array<{ href: string; label: string; compactLabel: string; ariaLabel: string }> =
|
||||
[];
|
||||
const workloads = workloadsHref();
|
||||
if (workloads) {
|
||||
links.push({
|
||||
href: workloads,
|
||||
label: 'Open in Workloads',
|
||||
compactLabel: 'Workloads',
|
||||
ariaLabel: `Open related workloads for ${displayName()}`,
|
||||
});
|
||||
}
|
||||
links.push(...buildServiceDetailLinks(resource));
|
||||
const seen = new Set<string>();
|
||||
return links.filter((link) => {
|
||||
if (seen.has(link.href)) return false;
|
||||
seen.add(link.href);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
const hasRuntimeOperationalContext = createMemo(
|
||||
() => kubernetesCapabilityBadges().length > 0 || relatedLinks().length > 0,
|
||||
);
|
||||
const sourceSections = createMemo(() => {
|
||||
const data = platformData();
|
||||
if (!data) return [];
|
||||
const sections = [
|
||||
{ id: 'proxmox', label: 'Proxmox', payload: data.proxmox },
|
||||
{ id: 'agent', label: 'Agent', payload: data.agent },
|
||||
{ id: 'docker', label: 'Containers', payload: data.docker },
|
||||
{ id: 'pbs', label: 'PBS', payload: data.pbs },
|
||||
{ id: 'pmg', label: 'PMG', payload: data.pmg },
|
||||
{ id: 'kubernetes', label: 'Kubernetes', payload: data.kubernetes },
|
||||
{ id: 'metrics', label: 'Metrics', payload: data.metrics },
|
||||
];
|
||||
return sections.filter((section) => section.payload !== undefined);
|
||||
});
|
||||
const identityMatchInfo = createMemo(() => {
|
||||
const data = platformData();
|
||||
return (
|
||||
data?.identityMatch ??
|
||||
data?.matchResults ??
|
||||
data?.matchCandidates ??
|
||||
data?.matches ??
|
||||
undefined
|
||||
);
|
||||
});
|
||||
const debugBundle = createMemo(() => ({
|
||||
const history = useResourceDetailDrawerHistoryState({ resource });
|
||||
const derived = useResourceDetailDrawerDerivedState({
|
||||
resource,
|
||||
identity: {
|
||||
resourceIdentity: resource.identity,
|
||||
matchInfo: identityMatchInfo(),
|
||||
},
|
||||
sources: {
|
||||
sourceStatus: sourceStatus(),
|
||||
proxmox: platformData()?.proxmox,
|
||||
agent: platformData()?.agent,
|
||||
docker: platformData()?.docker,
|
||||
pbs: platformData()?.pbs,
|
||||
pmg: platformData()?.pmg,
|
||||
kubernetes: platformData()?.kubernetes,
|
||||
metrics: platformData()?.metrics,
|
||||
},
|
||||
}));
|
||||
const debugJson = createMemo(() => JSON.stringify(debugBundle(), null, 2));
|
||||
resolveResourceLabel: resolveResourceLabelInput,
|
||||
debugEnabled,
|
||||
resourceIntelligence: history.resourceIntelligence,
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!debugEnabled() && activeTab() === 'debug') {
|
||||
|
|
@ -745,36 +48,16 @@ export const useResourceDetailDrawerState = (
|
|||
}
|
||||
});
|
||||
|
||||
const tabs = createMemo(() => {
|
||||
const base = [
|
||||
{ id: 'overview' as DrawerTab, label: 'Overview' },
|
||||
...(resource.type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []),
|
||||
...(resource.type === 'k8s-cluster'
|
||||
? [{ id: 'namespaces' as DrawerTab, label: 'Namespaces' }]
|
||||
: []),
|
||||
...(resource.type === 'k8s-cluster'
|
||||
? [{ id: 'deployments' as DrawerTab, label: 'Deployments' }]
|
||||
: []),
|
||||
...(resource.type === 'docker-host' && dockerSwarmClusterKey()
|
||||
? [{ id: 'swarm' as DrawerTab, label: 'Swarm' }]
|
||||
: []),
|
||||
];
|
||||
if (debugEnabled()) {
|
||||
base.push({ id: 'debug' as DrawerTab, label: 'Debug' });
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const current = activeTab();
|
||||
const available = new Set(tabs().map((tab) => tab.id));
|
||||
const available = new Set(derived.tabs().map((tab) => tab.id));
|
||||
if (!available.has(current)) {
|
||||
setActiveTab('overview');
|
||||
}
|
||||
});
|
||||
|
||||
const handleCopyJson = async () => {
|
||||
const payload = debugJson();
|
||||
const payload = derived.debugJson();
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(payload);
|
||||
|
|
@ -819,37 +102,6 @@ export const useResourceDetailDrawerState = (
|
|||
setShowPbsJobDetail,
|
||||
showPmgMailFlowDetail,
|
||||
setShowPmgMailFlowDetail,
|
||||
displayName,
|
||||
kubernetesClusterName,
|
||||
resolveResourceLabel,
|
||||
statusIndicator,
|
||||
lastSeen,
|
||||
lastSeenAbsolute,
|
||||
platformBadge,
|
||||
sourceBadge,
|
||||
typeBadge,
|
||||
unifiedSourceBadges,
|
||||
hasUnifiedSources,
|
||||
policyBadges,
|
||||
policyRedactions,
|
||||
governanceSummary,
|
||||
hasGovernanceData,
|
||||
platformData,
|
||||
agentMeta,
|
||||
kubernetesMeta,
|
||||
kubernetesCapabilityBadges,
|
||||
proxmoxNode,
|
||||
agentInfo,
|
||||
temperatureRows,
|
||||
dockerHostData,
|
||||
dockerHostSourceId,
|
||||
dockerUpdatesAvailable,
|
||||
dockerContainerCount,
|
||||
dockerUpdatesCheckedRelative,
|
||||
dockerHostCommand,
|
||||
dockerHostCommandActive,
|
||||
dockerUpdateActionsDisabled,
|
||||
dockerUpdateActionsLoading,
|
||||
dockerActionError,
|
||||
setDockerActionError,
|
||||
dockerActionNote,
|
||||
|
|
@ -858,63 +110,12 @@ export const useResourceDetailDrawerState = (
|
|||
setConfirmUpdateAll,
|
||||
dockerActionBusy,
|
||||
setDockerActionBusy,
|
||||
dockerSwarmInfo,
|
||||
dockerSwarmClusterKey,
|
||||
k8sDeploymentsPrefillNamespace,
|
||||
setK8sDeploymentsPrefillNamespace,
|
||||
timelineKindFilter,
|
||||
setTimelineKindFilter,
|
||||
timelineSourceTypeFilter,
|
||||
setTimelineSourceTypeFilter,
|
||||
timelineSourceAdapterFilter,
|
||||
setTimelineSourceAdapterFilter,
|
||||
resourceIntelligence,
|
||||
resourceDependencies,
|
||||
resourceDependents,
|
||||
resourceCorrelations,
|
||||
hasCorrelationContext,
|
||||
hasInvestigationContext,
|
||||
investigationContextSummary,
|
||||
pbsData,
|
||||
pmgData,
|
||||
pbsJobTotal,
|
||||
pmgQueueBacklog,
|
||||
pmgUpdatedRelative,
|
||||
pbsVisibleJobBreakdown,
|
||||
pmgVisibleQueueBreakdown,
|
||||
pmgVisibleMailBreakdown,
|
||||
resourceTimeline,
|
||||
historyFacetCounts,
|
||||
historyRecentChanges,
|
||||
hasTimelineFilters,
|
||||
historyLoadingLabel,
|
||||
resourceTimelineCount,
|
||||
sortedResourceTimeline,
|
||||
facetBundleError,
|
||||
refetchHistoryFacets,
|
||||
mergedSources,
|
||||
sourceSummary,
|
||||
identityAliasValues,
|
||||
primaryIdentityRows,
|
||||
identityCardHasRichData,
|
||||
aliasPreviewValues,
|
||||
hasAliasOverflow,
|
||||
hasIdentitySupportContext,
|
||||
hasMergedSources,
|
||||
discoveryConfig,
|
||||
discoveryContextSummary,
|
||||
hasHostDetails,
|
||||
hostDetailSummary,
|
||||
hasServiceDetails,
|
||||
serviceDetailsSummary,
|
||||
headerIdentity,
|
||||
relatedLinks,
|
||||
hasRuntimeOperationalContext,
|
||||
sourceSections,
|
||||
sourceStatus,
|
||||
identityMatchInfo,
|
||||
debugJson,
|
||||
tabs,
|
||||
...history,
|
||||
...derived,
|
||||
handleCopyJson,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseResourceDetailDrawerStateResult = ReturnType<typeof useResourceDetailDrawerState>;
|
||||
|
|
|
|||
|
|
@ -247,6 +247,8 @@ import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/Reso
|
|||
import resourceDetailDrawerDebugSource from '@/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx?raw';
|
||||
import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw';
|
||||
import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw';
|
||||
import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw';
|
||||
import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw';
|
||||
import resourceDetailDrawerStateSource from '@/components/Infrastructure/useResourceDetailDrawerState.ts?raw';
|
||||
import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResourceTable.tsx?raw';
|
||||
import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnifiedResourceTableState.ts?raw';
|
||||
|
|
@ -523,6 +525,8 @@ const resourceDetailDrawerSource = [
|
|||
resourceDetailDrawerShellSource,
|
||||
resourceDetailDrawerOverviewSource,
|
||||
resourceDetailDrawerDebugSource,
|
||||
resourceDetailDrawerHistoryStateSource,
|
||||
resourceDetailDrawerDerivedStateSource,
|
||||
resourceDetailDrawerStateSource,
|
||||
].join('\n');
|
||||
|
||||
|
|
@ -874,6 +878,11 @@ describe('frontend resource type boundaries', () => {
|
|||
"from './ResourceDetailDrawerOverviewTab'",
|
||||
);
|
||||
expect(resourceDetailDrawerShellSource).toContain("from './ResourceDetailDrawerDebugTab'");
|
||||
expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerHistoryState'");
|
||||
expect(resourceDetailDrawerStateSource).toContain("from './useResourceDetailDrawerDerivedState'");
|
||||
expect(resourceDetailDrawerStateSource).not.toContain('createResource(');
|
||||
expect(resourceDetailDrawerHistoryStateSource).toContain('createResource(');
|
||||
expect(resourceDetailDrawerDerivedStateSource).toContain('buildWorkloadsHref');
|
||||
expect(guestDrawerSource).toContain('useGuestDrawerState');
|
||||
expect(guestDrawerSource).toContain('GuestDrawerOverview');
|
||||
expect(guestDrawerStateSource).toContain('getCanonicalWorkloadId');
|
||||
|
|
@ -2480,7 +2489,7 @@ describe('frontend resource type boundaries', () => {
|
|||
'export const ENVIRONMENT_LOCK_BUTTON_TITLE',
|
||||
);
|
||||
expect(resourceDetailDrawerSource).toContain('getServiceHealthPresentation');
|
||||
expect(resourceDetailDrawerSource).toContain('ResourceAPI.getFacetBundle');
|
||||
expect(resourceDetailDrawerHistoryStateSource).toContain('ResourceAPI.getFacetBundle');
|
||||
expect(resourceDetailDrawerSource).toContain('History');
|
||||
expect(resourceDetailDrawerSource).toContain('RESOURCE_CHANGE_KIND_ORDER');
|
||||
expect(resourceDetailDrawerSource).toContain('RESOURCE_CHANGE_SOURCE_TYPE_ORDER');
|
||||
|
|
@ -2506,6 +2515,7 @@ describe('frontend resource type boundaries', () => {
|
|||
expect(approvalPresentationSource).toContain('getResourceApprovalLevelLabel');
|
||||
expect(throughputPresentationSource).toContain('formatThroughputRate');
|
||||
expect(resourceDetailDrawerSource).toContain('formatIdentifierLabel');
|
||||
expect(resourceDetailDrawerDerivedStateSource).toContain('formatIdentifierLabel');
|
||||
expect(resourceChangePresentationSource).toContain('humanizeToken');
|
||||
expect(textPresentationSource).toContain('humanizeArrowDelimitedLabel');
|
||||
expect(resourceCorrelationPresentationSource).not.toContain(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue