mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 13:10:03 +00:00
Delete remaining orphan Settings panels, Summary primitives, and presentation utils
Final sweep of the post-IA-migration cleanup. These files have zero non-test consumers in the platform-first codebase: Settings panels (consumed only by the deleted aggregate Settings shell): - ConfiguredNodeTables.tsx - MonitoredSystemLedgerPanel.tsx (+ test) - SettingsSectionNav.tsx Shared Summary primitives (only rendered by the deleted aggregate summary surfaces — InfrastructureSummary, WorkloadsSummary, StoragePageSummary): - SummaryJumpToRowButton.tsx - SummaryMetricCard.tsx - SummarySynchronizedReadout.tsx Platform-feature column config (only consumed by deleted WorkloadsStateCards / WorkloadsSurface chrome): - features/platformPage/appContainerColumns.ts Presentation utilities (only consumed by the deleted Recovery surface, problem-resource summary, throughput card, approval section, chart series card, proxmox settings panel — every consumer of these utils was deleted in earlier cleanups): - utils/recoveryStatusPresentation.ts (+ test) - utils/recoveryFilterChipPresentation.ts (+ test) - utils/recoveryRecordPresentation.ts (+ test) - utils/recoveryActionPresentation.ts (+ test) - utils/recoveryEmptyStatePresentation.ts (+ test) - utils/throughputPresentation.ts (+ test) - utils/approvalPresentation.ts (+ test) - utils/chartSeriesPresentation.ts (+ test) - utils/problemResourcePresentation.ts (+ test) - utils/proxmoxSettingsPresentation.ts (+ test) Strips ?raw guardrail imports + assertions for all deleted sources from SharedPrimitives.guardrails / frontendResourceTypeBoundaries / UnifiedResourceTable.performance.contract. Drops the dead configuredNodeTablesSource entry from a structural assertion array and the stale shouldShowCephSummaryCard / getStoragePageBannerMessage / STORAGE_BANNER_ACTION_BUTTON_CLASS / getStoragePageBannerKind expectations that were checking exports already removed in Cleanup H. Baseline 10 pre-existing test failures unchanged.
This commit is contained in:
-39
@@ -12,8 +12,6 @@ import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnif
|
||||
import unifiedResourceTableViewportSyncSource from '@/components/Infrastructure/useUnifiedResourceTableViewportSync.ts?raw';
|
||||
import unifiedResourceTableModelSource from '@/components/Infrastructure/unifiedResourceTableModel.ts?raw';
|
||||
import unifiedResourceTableStateModelSource from '@/components/Infrastructure/unifiedResourceTableStateModel.ts?raw';
|
||||
import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw';
|
||||
import infrastructureSummaryModelSource from '@/components/Infrastructure/infrastructureSummaryModel.ts?raw';
|
||||
import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw';
|
||||
import unifiedResourceServiceInfrastructureCardSource from '@/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx?raw';
|
||||
import unifiedResourcePBSTableSectionSource from '@/components/Infrastructure/UnifiedResourcePBSTableSection.tsx?raw';
|
||||
@@ -601,43 +599,6 @@ describe('UnifiedResourceTable performance contract', () => {
|
||||
});
|
||||
|
||||
it('keeps infrastructure summary fetch runtime out of the render shell', () => {
|
||||
expect(infrastructureSummaryStateSource).toContain('fetchInfrastructureSummaryAndCache');
|
||||
expect(infrastructureSummaryStateSource).toContain('readInfrastructureSummaryCache');
|
||||
expect(infrastructureSummaryStateSource).toContain('setInterval(');
|
||||
expect(infrastructureSummaryStateSource).toContain('AbortController');
|
||||
expect(infrastructureSummaryStateSource).toContain("eventBus.on('org_switched'");
|
||||
expect(infrastructureSummaryStateSource).toContain('buildInfrastructureDisplaySeries');
|
||||
expect(infrastructureSummaryStateSource).toContain('buildInfrastructureMetricSeries');
|
||||
expect(infrastructureSummaryStateSource).toContain('buildInfrastructureEmptyMessage');
|
||||
expect(infrastructureSummaryStateSource).not.toContain('useResources(');
|
||||
expect(infrastructureSummaryStateSource).toContain(
|
||||
'props.resources.filter((resource) => isWorkload(resource))',
|
||||
);
|
||||
expect(infrastructureSummaryStateSource).not.toContain(
|
||||
'const match = allSeries.find((series) => series.id === focused);',
|
||||
);
|
||||
expect(infrastructureSummaryStateSource).not.toContain('displaySeries().map((series) => ({');
|
||||
expect(infrastructureSummaryStateSource).not.toContain(
|
||||
"isAwaitingFirstSample() ? 'Gathering first sample…' : 'Building trend history…'",
|
||||
);
|
||||
expect(infrastructureSummaryStateSource).not.toContain(
|
||||
"fetchFailed() ? 'Trend data unavailable' : emptyHistoryLabel()",
|
||||
);
|
||||
expect(infrastructureSummaryModelSource).toContain(
|
||||
'export function buildInfrastructureSummarySeries',
|
||||
);
|
||||
expect(infrastructureSummaryModelSource).toContain(
|
||||
'export function combineResourceThroughputSeries',
|
||||
);
|
||||
expect(infrastructureSummaryModelSource).toContain(
|
||||
'export function buildInfrastructureDisplaySeries',
|
||||
);
|
||||
expect(infrastructureSummaryModelSource).toContain(
|
||||
'export function buildInfrastructureMetricSeries',
|
||||
);
|
||||
expect(infrastructureSummaryModelSource).toContain(
|
||||
'export function shouldShowInfrastructureNetworkCard',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps contextual resource-row emphasis on the shared active-row presentation contract', () => {
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
import { Component, For, Show, createMemo } from 'solid-js';
|
||||
|
||||
import type { NodeConfigWithStatus } from '@/types/nodes';
|
||||
import type { PBSInstance, PMGInstance } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { getClusterEndpointPresentation } from '@/utils/clusterEndpointPresentation';
|
||||
import {
|
||||
resolveConfiguredInstanceStatusIndicator,
|
||||
resolveConfiguredPveNodeStatusIndicator,
|
||||
} from '@/utils/configuredNodeStatusPresentation';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/shared/Table';
|
||||
import { getStatusIndicatorBadgeToneClasses } from '@/utils/status';
|
||||
import { getConfiguredNodeCapabilityBadges } from '@/utils/configuredNodeCapabilityPresentation';
|
||||
|
||||
interface PveNodesTableProps {
|
||||
nodes: NodeConfigWithStatus[];
|
||||
stateNodes: Resource[];
|
||||
globalTemperatureMonitoringEnabled?: boolean;
|
||||
onTestConnection: (nodeId: string) => void;
|
||||
onEdit: (node: NodeConfigWithStatus) => void;
|
||||
onDelete: (node: NodeConfigWithStatus) => void;
|
||||
onRefreshCluster?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
|
||||
return (
|
||||
<Card padding="none" tone="card" class="rounded-md">
|
||||
<Table
|
||||
wrapperClass="max-h-[600px] overflow-y-auto"
|
||||
class="min-w-[max-content] w-full divide-y divide-border text-sm"
|
||||
>
|
||||
<TableHeader class="bg-surface-alt">
|
||||
<TableRow>
|
||||
<TableHead class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Node
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Credentials
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Capabilities
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border bg-surface">
|
||||
<For each={props.nodes}>
|
||||
{(node) => {
|
||||
const statusIndicator = createMemo(() =>
|
||||
resolveConfiguredPveNodeStatusIndicator(node, props.stateNodes),
|
||||
);
|
||||
const capabilityBadges = createMemo(() =>
|
||||
getConfiguredNodeCapabilityBadges(
|
||||
node,
|
||||
props.globalTemperatureMonitoringEnabled ?? true,
|
||||
),
|
||||
);
|
||||
const clusterEndpoints = createMemo(() =>
|
||||
'clusterEndpoints' in node && node.clusterEndpoints ? node.clusterEndpoints : [],
|
||||
);
|
||||
const clusterName = createMemo(() =>
|
||||
'clusterName' in node && node.clusterName ? node.clusterName : 'Unknown',
|
||||
);
|
||||
return (
|
||||
<TableRow class="even:bg-surface-alt hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors">
|
||||
<TableCell class="align-top py-3 pl-4 pr-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<StatusDot
|
||||
variant={statusIndicator().variant}
|
||||
size="md"
|
||||
ariaHidden={true}
|
||||
class="mt-1.5"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-base-content truncate" title={node.name}>{node.name}</p>
|
||||
<p class="text-xs text-muted truncate" title={node.host}>{node.host}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={node.type === 'pve' && 'isCluster' in node && node.isCluster}>
|
||||
<div class="rounded-md border border-border bg-surface-alt px-3 py-2 space-y-2">
|
||||
<div class="flex items-center gap-2 text-xs font-semibold text-base-content">
|
||||
<span>{clusterName()} Cluster</span>
|
||||
<span class="ml-auto text-[0.65rem] font-normal text-muted">
|
||||
{clusterEndpoints().length} nodes
|
||||
</span>
|
||||
</div>
|
||||
<Show when={clusterEndpoints().length > 0}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={clusterEndpoints()}>
|
||||
{(endpoint) => {
|
||||
const endpointPresentation =
|
||||
getClusterEndpointPresentation(endpoint);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`rounded border px-3 py-2 text-[0.7rem] ${endpointPresentation.panelClass}`}
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold">{endpoint.nodeName}</span>
|
||||
<span class="text-[0.65rem] opacity-75">{endpoint.ip}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 text-[0.65rem] opacity-90">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-16 font-medium">Proxmox:</span>
|
||||
<span>{endpointPresentation.proxmoxLabel}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-16 font-medium">Pulse:</span>
|
||||
<span>{endpointPresentation.pulseLabel}</span>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
endpointPresentation.pulseStatus === 'unreachable' &&
|
||||
endpoint.pulseError
|
||||
}
|
||||
>
|
||||
<div class="mt-1 pt-1 border-t border-current opacity-20">
|
||||
<span class="font-medium">Error: </span>
|
||||
<span class="opacity-80">{endpoint.pulseError}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="flex items-center gap-1 text-[0.7rem] text-muted">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
Automatic failover enabled
|
||||
</p>
|
||||
<Show when={props.onRefreshCluster}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRefreshCluster?.(node.id)}
|
||||
class="flex min-h-10 sm:min-h-9 items-center gap-1 px-2.5 py-1.5 text-xs font-medium hover:text-muted bg-surface border border-border rounded hover:bg-surface-hover transition-colors"
|
||||
title="Re-detect cluster membership (use if nodes were added to the Proxmox cluster)"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">
|
||||
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
|
||||
</span>
|
||||
<Show when={node.source === 'agent'}>
|
||||
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-surface-hover text-base-content rounded w-fit">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-slate-500"></span>
|
||||
Agent-linked
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
|
||||
<span class="text-[0.65rem] px-1.5 py-0.5 bg-surface-alt text-muted rounded w-fit">
|
||||
Direct-linked
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={capabilityBadges()}>
|
||||
{(badge) => <span class={badge.className}>{badge.label}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class={`inline-flex items-center gap-2 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(statusIndicator().variant)}`}
|
||||
>
|
||||
<StatusDot variant={statusIndicator().variant} size="sm" ariaHidden={true} />
|
||||
{statusIndicator().label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex items-center justify-end gap-1 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onTestConnection(node.id)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-muted hover:text-base-content"
|
||||
title="Test connection"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onEdit(node)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-muted hover:text-base-content"
|
||||
title="Edit node"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onDelete(node)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
title="Delete node"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface PbsNodesTableProps {
|
||||
nodes: NodeConfigWithStatus[];
|
||||
statePbs: PBSInstance[];
|
||||
globalTemperatureMonitoringEnabled?: boolean;
|
||||
onTestConnection: (nodeId: string) => void;
|
||||
onEdit: (node: NodeConfigWithStatus) => void;
|
||||
onDelete: (node: NodeConfigWithStatus) => void;
|
||||
}
|
||||
|
||||
export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
|
||||
return (
|
||||
<Card padding="none" tone="card" class="rounded-md">
|
||||
<Table class="min-w-[max-content] divide-y divide-border text-sm">
|
||||
<TableHeader class="bg-surface-alt">
|
||||
<TableRow>
|
||||
<TableHead class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Node
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Credentials
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Capabilities
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border bg-surface">
|
||||
<For each={props.nodes}>
|
||||
{(node) => {
|
||||
const statusIndicator = createMemo(() =>
|
||||
resolveConfiguredInstanceStatusIndicator(node, props.statePbs),
|
||||
);
|
||||
const capabilityBadges = createMemo(() =>
|
||||
getConfiguredNodeCapabilityBadges(
|
||||
node,
|
||||
props.globalTemperatureMonitoringEnabled ?? true,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<TableRow class="even:bg-surface-alt hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors">
|
||||
<TableCell class="align-top py-3 pl-4 pr-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<StatusDot
|
||||
variant={statusIndicator().variant}
|
||||
size="md"
|
||||
ariaHidden={true}
|
||||
class="mt-1.5"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-base-content truncate" title={node.name}>{node.name}</p>
|
||||
<p class="text-xs text-muted truncate" title={node.host}>{node.host}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">
|
||||
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
|
||||
</span>
|
||||
<Show when={node.source === 'agent'}>
|
||||
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-surface-hover text-base-content rounded w-fit">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-slate-500"></span>
|
||||
Agent-linked
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
|
||||
<span class="text-[0.65rem] px-1.5 py-0.5 bg-surface-alt text-muted rounded w-fit">
|
||||
Direct-linked
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={capabilityBadges()}>
|
||||
{(badge) => <span class={badge.className}>{badge.label}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class={`inline-flex items-center gap-2 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(statusIndicator().variant)}`}
|
||||
>
|
||||
<StatusDot variant={statusIndicator().variant} size="sm" ariaHidden={true} />
|
||||
{statusIndicator().label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex items-center justify-end gap-1 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onTestConnection(node.id)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-muted hover:text-base-content"
|
||||
title="Test connection"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onEdit(node)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-muted hover:text-base-content"
|
||||
title="Edit node"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onDelete(node)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
title="Delete node"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface PmgNodesTableProps {
|
||||
nodes: NodeConfigWithStatus[];
|
||||
statePmg: PMGInstance[];
|
||||
globalTemperatureMonitoringEnabled?: boolean;
|
||||
onTestConnection: (nodeId: string) => void;
|
||||
onEdit: (node: NodeConfigWithStatus) => void;
|
||||
onDelete: (node: NodeConfigWithStatus) => void;
|
||||
}
|
||||
|
||||
export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
|
||||
return (
|
||||
<Card padding="none" tone="card" class="rounded-md">
|
||||
<Table class="min-w-[max-content] divide-y divide-border text-sm">
|
||||
<TableHeader class="bg-surface-alt">
|
||||
<TableRow>
|
||||
<TableHead class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Node
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Credentials
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Capabilities
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Status
|
||||
</TableHead>
|
||||
<TableHead class="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border bg-surface">
|
||||
<For each={props.nodes}>
|
||||
{(node) => {
|
||||
const statusIndicator = createMemo(() =>
|
||||
resolveConfiguredInstanceStatusIndicator(node, props.statePmg),
|
||||
);
|
||||
const capabilityBadges = createMemo(() =>
|
||||
getConfiguredNodeCapabilityBadges(
|
||||
node,
|
||||
props.globalTemperatureMonitoringEnabled ?? true,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<TableRow class="even:bg-surface-alt hover:bg-blue-50 dark:hover:bg-blue-900 transition-colors">
|
||||
<TableCell class="align-top py-3 pl-4 pr-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<div class="flex items-start gap-3">
|
||||
<StatusDot
|
||||
variant={statusIndicator().variant}
|
||||
size="md"
|
||||
ariaHidden={true}
|
||||
class="mt-1.5"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-base-content truncate" title={node.name}>{node.name}</p>
|
||||
<p class="text-xs text-muted truncate" title={node.host}>{node.host}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">
|
||||
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
|
||||
</span>
|
||||
<Show when={node.source === 'agent'}>
|
||||
<span class="inline-flex items-center gap-1 text-[0.65rem] px-1.5 py-0.5 bg-surface-hover text-base-content rounded w-fit">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-slate-500"></span>
|
||||
Agent-linked
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={node.source === 'script' || (!node.source && node.tokenName)}>
|
||||
<span class="text-[0.65rem] px-1.5 py-0.5 bg-surface-alt text-muted rounded w-fit">
|
||||
Direct-linked
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={capabilityBadges()}>
|
||||
{(badge) => <span class={badge.className}>{badge.label}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class={`inline-flex items-center gap-2 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(statusIndicator().variant)}`}
|
||||
>
|
||||
<StatusDot variant={statusIndicator().variant} size="sm" ariaHidden={true} />
|
||||
{statusIndicator().label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="align-top px-3 py-3">
|
||||
<div class="flex items-center justify-end gap-1 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onTestConnection(node.id)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-muted hover:text-base-content"
|
||||
title="Test connection"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onEdit(node)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-muted hover:text-base-content"
|
||||
title="Edit node"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onDelete(node)}
|
||||
class="min-h-10 sm:min-h-9 min-w-10 sm:min-w-9 p-2.5 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
title="Delete node"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,368 +0,0 @@
|
||||
import { createMemo, createResource, createSignal, For, Show } from 'solid-js';
|
||||
import SettingsPanel from '@/components/shared/SettingsPanel';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/shared/Table';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import { MonitoredSystemLedgerAPI } from '@/api/monitoredSystemLedger';
|
||||
import type {
|
||||
MonitoredSystemLedgerEntry,
|
||||
MonitoredSystemLedgerExplanationSurface,
|
||||
} from '@/api/monitoredSystemLedger';
|
||||
import { apiErrorCode, apiErrorDetailField } from '@/api/responseUtils';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import {
|
||||
formatMonitoredSystemGroupedSourcesLabel,
|
||||
formatMonitoredSystemLedgerUnavailableMessage,
|
||||
formatMonitoredSystemLatestIncludedSignalSentence,
|
||||
formatMonitoredSystemSurfaceAttribution,
|
||||
getMonitoredSystemLedgerErrorState,
|
||||
getMonitoredSystemLedgerDescription,
|
||||
getMonitoredSystemLedgerHiddenState,
|
||||
getMonitoredSystemLedgerLoadingState,
|
||||
getMonitoredSystemLedgerPolicyLoadingState,
|
||||
getMonitoredSystemLedgerUnavailableState,
|
||||
getMonitoredSystemCountingDetailsToggleLabel,
|
||||
getMonitoredSystemLedgerPresentation,
|
||||
} from '@/utils/monitoredSystemPresentation';
|
||||
import { MonitoredSystemDefinitionDisclosure } from '@/components/Commercial/MonitoredSystemDefinitionDisclosure';
|
||||
import {
|
||||
presentationPolicyHidesCommercialSurfaces,
|
||||
sessionPresentationPolicyResolved,
|
||||
} from '@/stores/sessionPresentationPolicy';
|
||||
|
||||
interface MonitoredSystemLedgerPanelProps {
|
||||
embedded?: boolean;
|
||||
showCountingRulesByDefault?: boolean;
|
||||
}
|
||||
|
||||
function latestIncludedSignalSummary(system: MonitoredSystemLedgerEntry): {
|
||||
relative: string;
|
||||
attribution: string;
|
||||
} | null {
|
||||
if (!system.latest_included_signal.at) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
relative: formatRelativeTime(system.latest_included_signal.at, { compact: true }),
|
||||
attribution: formatMonitoredSystemSurfaceAttribution(system.latest_included_signal),
|
||||
};
|
||||
}
|
||||
|
||||
function includedSurfaces(
|
||||
system: MonitoredSystemLedgerEntry,
|
||||
): MonitoredSystemLedgerExplanationSurface[] {
|
||||
if (system.explanation.surfaces.length > 0) {
|
||||
return system.explanation.surfaces;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: system.name,
|
||||
type: system.type,
|
||||
source: system.source,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProps = {}) {
|
||||
const ledgerRequestKey = () =>
|
||||
sessionPresentationPolicyResolved() && !presentationPolicyHidesCommercialSurfaces()
|
||||
? 'visible'
|
||||
: false;
|
||||
const [explanation, { refetch }] = createResource(ledgerRequestKey, () =>
|
||||
MonitoredSystemLedgerAPI.explain(),
|
||||
);
|
||||
const [expandedSystemKey, setExpandedSystemKey] = createSignal<string | null>(null);
|
||||
const presentation = getMonitoredSystemLedgerPresentation();
|
||||
|
||||
const ledger = createMemo(() =>
|
||||
explanation.state === 'errored' ? undefined : explanation()?.ledger,
|
||||
);
|
||||
const total = () => ledger()?.total ?? 0;
|
||||
const systems = () => ledger()?.systems ?? [];
|
||||
const totalSummary = () => `${total()} monitored ${total() === 1 ? 'system' : 'systems'}`;
|
||||
const usageUnavailableReason = () => {
|
||||
if (apiErrorCode(explanation.error) === 'monitored_system_usage_unavailable') {
|
||||
return apiErrorDetailField(explanation.error, 'reason') ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const usageUnavailable = () =>
|
||||
!ledger() && apiErrorCode(explanation.error) === 'monitored_system_usage_unavailable';
|
||||
const genericError = () => Boolean(explanation.error) && !usageUnavailable();
|
||||
const systemKey = (system: MonitoredSystemLedgerEntry, index: number) =>
|
||||
`${system.name}:${system.type}:${index}`;
|
||||
const toggleSystemExplanation = (key: string) => {
|
||||
setExpandedSystemKey((current) => (current === key ? null : key));
|
||||
};
|
||||
|
||||
const policyLoadingState = getMonitoredSystemLedgerPolicyLoadingState();
|
||||
const hiddenState = getMonitoredSystemLedgerHiddenState();
|
||||
|
||||
const policyLoadingContent = (
|
||||
<div class="rounded-lg border border-border bg-surface px-4 py-4 text-sm">
|
||||
<p class="font-semibold text-base-content">{policyLoadingState.title}</p>
|
||||
<p class="mt-1 text-muted">{policyLoadingState.message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const hiddenContent = (
|
||||
<div class="rounded-lg border border-border bg-surface px-4 py-4 text-sm">
|
||||
<p class="font-semibold text-base-content">{hiddenState.title}</p>
|
||||
<p class="mt-1 text-muted">{hiddenState.message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ledgerContent = (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-semibold text-base-content">{presentation.sectionTitle}</h3>
|
||||
<MonitoredSystemDefinitionDisclosure
|
||||
defaultOpen={props.showCountingRulesByDefault}
|
||||
buttonClass="text-xs font-medium text-muted underline-offset-2 transition-colors hover:text-base-content hover:underline"
|
||||
detailClass="max-w-xl text-xs text-muted"
|
||||
/>
|
||||
</div>
|
||||
<Show when={ledger()}>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-base-content">{totalSummary()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!ledger() && usageUnavailable()}>
|
||||
<span class="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
Verifying
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
<Show when={explanation.loading && !usageUnavailable()}>
|
||||
<p class="text-sm text-muted py-4 text-center">
|
||||
{getMonitoredSystemLedgerLoadingState().text}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
{/* Usage-unavailable state */}
|
||||
<Show when={usageUnavailable()}>
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p class="font-semibold">{getMonitoredSystemLedgerUnavailableState().title}</p>
|
||||
<p class="mt-1">
|
||||
{formatMonitoredSystemLedgerUnavailableMessage(usageUnavailableReason())}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 text-xs font-medium underline-offset-2 transition-colors hover:underline disabled:opacity-50"
|
||||
disabled={explanation.loading}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{explanation.loading
|
||||
? getMonitoredSystemLedgerErrorState().retryingLabel
|
||||
: getMonitoredSystemLedgerErrorState().retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Error state */}
|
||||
<Show when={genericError()}>
|
||||
<div class="text-sm text-red-600 dark:text-red-400 py-4 text-center">
|
||||
<p>{getMonitoredSystemLedgerErrorState().title}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-xs text-primary hover:underline disabled:opacity-50"
|
||||
disabled={explanation.loading}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{explanation.loading
|
||||
? getMonitoredSystemLedgerErrorState().retryingLabel
|
||||
: getMonitoredSystemLedgerErrorState().retryLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Loaded content */}
|
||||
<Show when={!explanation.loading && !genericError() && ledger()}>
|
||||
<Show
|
||||
when={systems().length > 0}
|
||||
fallback={<p class="text-sm text-muted py-4 text-center">{presentation.emptyState}</p>}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{presentation.tableNameLabel}</TableHead>
|
||||
<TableHead>{presentation.tableStatusLabel}</TableHead>
|
||||
<TableHead>{presentation.tableLatestIncludedSignalLabel}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={systems()}>
|
||||
{(system: MonitoredSystemLedgerEntry, index) => {
|
||||
const indicator = getSimpleStatusIndicator(system.status);
|
||||
const key = systemKey(system, index());
|
||||
const explanationID = `monitored-system-explanation-${index()}`;
|
||||
const expanded = () => expandedSystemKey() === key;
|
||||
const latestSignal = latestIncludedSignalSummary(system);
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="space-y-1 whitespace-normal">
|
||||
<span class="text-sm font-medium text-base-content">{system.name}</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="rounded-full border border-border bg-surface px-2 py-0.5 text-[11px] font-medium text-base-content">
|
||||
{presentation.countedSystemBadgeLabel}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted">
|
||||
{formatMonitoredSystemGroupedSourcesLabel(
|
||||
includedSurfaces(system).length,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="max-w-xl text-xs text-muted">{system.explanation.summary}</p>
|
||||
<Show when={includedSurfaces(system).length > 0}>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={includedSurfaces(system)}>
|
||||
{(surface) => (
|
||||
<span class="rounded-full bg-surface-alt px-2 py-0.5 text-[11px] text-muted">
|
||||
{formatMonitoredSystemSurfaceAttribution(surface)}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="block text-[11px] font-medium text-muted underline-offset-2 transition-colors hover:text-base-content hover:underline"
|
||||
aria-expanded={expanded()}
|
||||
aria-controls={explanationID}
|
||||
onClick={() => toggleSystemExplanation(key)}
|
||||
>
|
||||
{getMonitoredSystemCountingDetailsToggleLabel(expanded())}
|
||||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div
|
||||
id={explanationID}
|
||||
class="space-y-2 rounded-md border border-border bg-surface px-3 py-2 text-xs text-muted"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-base-content">
|
||||
{presentation.countingExplanationHeading}
|
||||
</p>
|
||||
<p class="whitespace-normal text-base-content">
|
||||
{system.explanation.summary}
|
||||
</p>
|
||||
<Show when={system.explanation.reasons.length > 0}>
|
||||
<ul class="space-y-1 whitespace-normal">
|
||||
<For each={system.explanation.reasons}>
|
||||
{(reason) => <li>{reason.summary}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-base-content">
|
||||
{presentation.currentStatusHeading}
|
||||
</p>
|
||||
<p class="whitespace-normal text-base-content">
|
||||
{system.status_explanation.summary}
|
||||
</p>
|
||||
<Show when={latestSignal}>
|
||||
{(signal) => (
|
||||
<p class="whitespace-normal text-base-content">
|
||||
{formatMonitoredSystemLatestIncludedSignalSentence(signal())}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={system.status_explanation.reasons.length > 0}>
|
||||
<ul class="space-y-1 whitespace-normal text-base-content">
|
||||
<For each={system.status_explanation.reasons}>
|
||||
{(reason) => <li>{reason.summary}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={system.explanation.surfaces.length > 0}>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-base-content">
|
||||
{presentation.groupedSourcesHeading}
|
||||
</p>
|
||||
<ul class="space-y-1 whitespace-normal">
|
||||
<For each={system.explanation.surfaces}>
|
||||
{(surface) => (
|
||||
<li>{formatMonitoredSystemSurfaceAttribution(surface)}</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<StatusDot variant={indicator.variant} size="sm" />
|
||||
<span class="text-xs text-muted">{indicator.label}</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Show
|
||||
when={latestSignal}
|
||||
fallback={
|
||||
<span class="text-xs text-muted">
|
||||
{presentation.noIncludedSignalLabel}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(signal) => (
|
||||
<div class="space-y-0.5">
|
||||
<span class="block text-xs text-muted">{signal().relative}</span>
|
||||
<span class="block text-[11px] text-muted whitespace-normal">
|
||||
{signal().attribution}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Show when={sessionPresentationPolicyResolved()} fallback={policyLoadingContent}>
|
||||
<Show when={!presentationPolicyHidesCommercialSurfaces()} fallback={hiddenContent}>
|
||||
{ledgerContent}
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
|
||||
if (props.embedded) {
|
||||
return <div class="space-y-4">{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPanel
|
||||
title={presentation.panelTitle}
|
||||
description={getMonitoredSystemLedgerDescription()}
|
||||
bodyClass="space-y-4"
|
||||
>
|
||||
{content}
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For } from 'solid-js';
|
||||
import Server from 'lucide-solid/icons/server';
|
||||
import HardDrive from 'lucide-solid/icons/hard-drive';
|
||||
import Mail from 'lucide-solid/icons/mail';
|
||||
|
||||
type SettingsSection = 'pve' | 'pbs' | 'pmg';
|
||||
|
||||
interface SettingsSectionNavProps {
|
||||
current: SettingsSection;
|
||||
onSelect: (section: SettingsSection) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const allSections: Array<{
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
icon: typeof Server;
|
||||
}> = [
|
||||
{
|
||||
id: 'pve',
|
||||
label: 'Virtual Environment',
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
id: 'pbs',
|
||||
label: 'Backup Server',
|
||||
icon: HardDrive,
|
||||
},
|
||||
{
|
||||
id: 'pmg',
|
||||
label: 'Mail Gateway',
|
||||
icon: Mail,
|
||||
},
|
||||
];
|
||||
|
||||
export const SettingsSectionNav: Component<SettingsSectionNavProps> = (props) => {
|
||||
return (
|
||||
<div class={`border-b border-border ${props.class ?? ''}`} aria-label="Settings sections">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-6 overflow-x-auto scrollbar-hide"
|
||||
style="-webkit-overflow-scrolling: touch;"
|
||||
>
|
||||
<For each={allSections}>
|
||||
{(section) => {
|
||||
const isActive = () => section.id === props.current;
|
||||
const Icon = section.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onSelect(section.id)}
|
||||
class={`inline-flex min-h-10 items-center gap-2 border-b-2 px-1 py-2 text-sm font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
||||
isActive()
|
||||
? 'border-blue-600 text-base-content'
|
||||
: 'border-transparent text-muted hover:text-base-content'
|
||||
}`}
|
||||
aria-pressed={isActive()}
|
||||
>
|
||||
<Icon size={18} stroke-width={2} class="h-4 w-4" />
|
||||
<span>{section.label}</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-529
@@ -1,529 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import type { JSX } from 'solid-js';
|
||||
|
||||
import type {
|
||||
MonitoredSystemLedgerExplainResponse,
|
||||
MonitoredSystemLedgerResponse,
|
||||
} from '@/api/monitoredSystemLedger';
|
||||
|
||||
const explainMock = vi.fn<() => Promise<MonitoredSystemLedgerExplainResponse>>();
|
||||
const presentationPolicyHidesCommercialSurfacesMock = vi.fn(() => false);
|
||||
const sessionPresentationPolicyResolvedMock = vi.fn(() => true);
|
||||
|
||||
const explainResponse = (
|
||||
ledger: MonitoredSystemLedgerResponse,
|
||||
): MonitoredSystemLedgerExplainResponse => ({
|
||||
ledger,
|
||||
preview: null,
|
||||
});
|
||||
|
||||
vi.mock('@/api/monitoredSystemLedger', () => ({
|
||||
MonitoredSystemLedgerAPI: {
|
||||
explain: () => explainMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/sessionPresentationPolicy', () => ({
|
||||
presentationPolicyHidesCommercialSurfaces: () => presentationPolicyHidesCommercialSurfacesMock(),
|
||||
sessionPresentationPolicyResolved: () => sessionPresentationPolicyResolvedMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/SettingsPanel', () => ({
|
||||
default: (props: { children?: JSX.Element; title?: string; description?: string }) => (
|
||||
<section data-testid="settings-panel">
|
||||
<h2>{props.title}</h2>
|
||||
<p>{props.description}</p>
|
||||
{props.children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/StatusDot', () => ({
|
||||
StatusDot: () => <span data-testid="status-dot" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/Table', () => ({
|
||||
Table: (props: { children?: JSX.Element }) => <table>{props.children}</table>,
|
||||
TableHeader: (props: { children?: JSX.Element }) => <thead>{props.children}</thead>,
|
||||
TableBody: (props: { children?: JSX.Element }) => <tbody>{props.children}</tbody>,
|
||||
TableRow: (props: { children?: JSX.Element }) => <tr>{props.children}</tr>,
|
||||
TableHead: (props: { children?: JSX.Element }) => <th>{props.children}</th>,
|
||||
TableCell: (props: { children?: JSX.Element }) => <td>{props.children}</td>,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatRelativeTime: (v: string) => v,
|
||||
}));
|
||||
|
||||
import { MonitoredSystemLedgerPanel } from '../MonitoredSystemLedgerPanel';
|
||||
|
||||
describe('MonitoredSystemLedgerPanel', () => {
|
||||
beforeEach(() => {
|
||||
presentationPolicyHidesCommercialSurfacesMock.mockReset();
|
||||
sessionPresentationPolicyResolvedMock.mockReset();
|
||||
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(false);
|
||||
sessionPresentationPolicyResolvedMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
explainMock.mockReset();
|
||||
});
|
||||
|
||||
it('waits for the session presentation policy before requesting ledger data', () => {
|
||||
sessionPresentationPolicyResolvedMock.mockReturnValue(false);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
expect(explainMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Checking monitored-system visibility')).toBeInTheDocument();
|
||||
expect(screen.getByText(/before loading monitored-system usage details/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides monitored-system usage in demo mode without requesting the ledger', () => {
|
||||
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
expect(explainMock).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Monitored-system usage is hidden in demo mode')).toBeInTheDocument();
|
||||
expect(screen.getByText(/instead of creating a demo license/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('16 / 5')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows monitored-system loading copy while the ledger request is pending', () => {
|
||||
explainMock.mockReturnValue(new Promise<MonitoredSystemLedgerExplainResponse>(() => {}));
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
expect(screen.getByText('Loading monitored system usage…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message with Retry button when API fails', async () => {
|
||||
explainMock.mockRejectedValue(new Error('network error'));
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Monitored system usage is temporarily unavailable.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('recovers from error when Retry is clicked and API succeeds', async () => {
|
||||
explainMock.mockRejectedValue(new Error('network error'));
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Monitored system usage is temporarily unavailable.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
explainMock.mockResolvedValue(
|
||||
explainResponse({
|
||||
systems: [
|
||||
{
|
||||
name: 'host-1',
|
||||
type: 'host',
|
||||
status: 'online',
|
||||
status_explanation: {
|
||||
summary: 'All included top-level collection paths currently report online status.',
|
||||
reasons: [],
|
||||
},
|
||||
latest_included_signal: {
|
||||
name: 'host-1',
|
||||
type: 'host',
|
||||
source: 'agent',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
source: 'agent',
|
||||
explanation: {
|
||||
summary:
|
||||
'Counts as one monitored system because Pulse sees one top-level host view from agent.',
|
||||
reasons: [
|
||||
{
|
||||
kind: 'standalone',
|
||||
signal: 'single-top-level-view',
|
||||
summary: 'No overlapping top-level source matched this system.',
|
||||
},
|
||||
],
|
||||
surfaces: [{ name: 'host-1', type: 'host', source: 'agent' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('host-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByText('Monitored system usage is temporarily unavailable.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Retry button visible on repeated failures', async () => {
|
||||
explainMock.mockRejectedValue(new Error('network error'));
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Monitored system usage is temporarily unavailable.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const callsBefore = explainMock.mock.calls.length;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Monitored system usage is temporarily unavailable.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(explainMock.mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a verification state when canonical monitored-system usage is not settled', async () => {
|
||||
explainMock.mockRejectedValue(
|
||||
Object.assign(new Error('usage unavailable'), {
|
||||
code: 'monitored_system_usage_unavailable',
|
||||
details: { reason: 'supplemental_inventory_unsettled' },
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Verifying monitored-system inventory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Verifying')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('0 / 5')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Monitored system usage is temporarily unavailable.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ledger data on successful load', async () => {
|
||||
explainMock.mockResolvedValue(
|
||||
explainResponse({
|
||||
systems: [
|
||||
{
|
||||
name: 'server-a',
|
||||
type: 'host',
|
||||
status: 'online',
|
||||
status_explanation: {
|
||||
summary: 'All included top-level collection paths currently report online status.',
|
||||
reasons: [],
|
||||
},
|
||||
latest_included_signal: {
|
||||
name: 'server-a',
|
||||
type: 'host',
|
||||
source: 'agent',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
source: 'agent',
|
||||
explanation: {
|
||||
summary:
|
||||
'Counts as one monitored system because Pulse sees one top-level host view from agent.',
|
||||
reasons: [
|
||||
{
|
||||
kind: 'standalone',
|
||||
signal: 'single-top-level-view',
|
||||
summary: 'No overlapping top-level source matched this system.',
|
||||
},
|
||||
],
|
||||
surfaces: [{ name: 'server-a', type: 'host', source: 'agent' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'server-b',
|
||||
type: 'pbs-server',
|
||||
status: 'offline',
|
||||
status_explanation: {
|
||||
summary:
|
||||
'At least one included source is offline or disconnected, so Pulse marks this monitored system as offline.',
|
||||
reasons: [
|
||||
{
|
||||
kind: 'source-offline',
|
||||
name: 'server-b',
|
||||
type: 'pbs-server',
|
||||
source: 'pbs',
|
||||
status: 'offline',
|
||||
reported_at: '2026-01-01T23:55:00Z',
|
||||
summary:
|
||||
'PBS data for server-b is offline or disconnected (last reported 2026-01-01T23:55:00Z).',
|
||||
},
|
||||
],
|
||||
},
|
||||
latest_included_signal: {
|
||||
name: 'server-b',
|
||||
type: 'pbs-server',
|
||||
source: 'pbs',
|
||||
at: '2026-01-02T00:00:00Z',
|
||||
},
|
||||
source: 'pbs',
|
||||
explanation: {
|
||||
summary:
|
||||
'Counts as one monitored system because Pulse merged 2 top-level views into one canonical system using shared machine identity.',
|
||||
reasons: [
|
||||
{
|
||||
kind: 'shared-identity',
|
||||
signal: 'machine-id',
|
||||
summary: 'Merged host and PBS server views using shared machine identity.',
|
||||
},
|
||||
],
|
||||
surfaces: [
|
||||
{ name: 'server-b', type: 'pbs-server', source: 'pbs' },
|
||||
{ name: 'server-b host', type: 'host', source: 'agent' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('server-a')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Monitored System Ledger')).toBeInTheDocument();
|
||||
expect(screen.getByText('Latest Included Signal')).toBeInTheDocument();
|
||||
expect(screen.getByText('2026-01-02T00:00:00Z')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('server-b (PBS Server via PBS)').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Review the top-level monitored systems Pulse has identified for reporting and support context.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'View counting rules' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/a monitored system is a top-level monitored root such as a docker host, kubernetes cluster, proxmox node/i,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View counting rules' }));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/a monitored system is a top-level monitored root such as a docker host, kubernetes cluster, proxmox node/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('server-b')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 monitored systems')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Continuity review')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'View counting details' })).toHaveLength(2);
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'View counting details' })[1]!);
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Counts as one monitored system because Pulse merged 2 top-level views into one canonical system using shared machine identity.',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(screen.getAllByText('Counts as 1 monitored system')).toHaveLength(2);
|
||||
expect(screen.getByText('2 grouped sources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Current status')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'At least one included source is offline or disconnected, so Pulse marks this monitored system as offline.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Latest included signal: server-b (PBS Server via PBS), reported 2026-01-02T00:00:00Z.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'PBS data for server-b is offline or disconnected (last reported 2026-01-01T23:55:00Z).',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Why this counts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Grouped sources')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('server-b host (Host via Agent)').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.queryByText('Monitored system usage is temporarily unavailable.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not crash when explanation payload is missing', async () => {
|
||||
explainMock.mockResolvedValue(
|
||||
explainResponse({
|
||||
systems: [
|
||||
{
|
||||
name: 'server-a',
|
||||
type: 'host',
|
||||
status: 'online',
|
||||
status_explanation: {
|
||||
summary: 'All included top-level collection paths currently report online status.',
|
||||
reasons: [],
|
||||
},
|
||||
explanation: {
|
||||
summary: 'Pulse counts this top-level collection path as one monitored system.',
|
||||
reasons: [],
|
||||
surfaces: [],
|
||||
},
|
||||
latest_included_signal: {
|
||||
name: 'server-a',
|
||||
type: 'host',
|
||||
source: 'agent',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
source: 'agent',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('server-a')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View counting details' }));
|
||||
|
||||
expect(
|
||||
screen.getAllByText('Pulse counts this top-level collection path as one monitored system.'),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByText('All included top-level collection paths currently report online status.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Grouped sources')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a customer-facing fallback when no included signal is available', async () => {
|
||||
explainMock.mockResolvedValue(
|
||||
explainResponse({
|
||||
systems: [
|
||||
{
|
||||
name: 'server-c',
|
||||
type: 'host',
|
||||
status: 'unknown',
|
||||
status_explanation: {
|
||||
summary:
|
||||
'Pulse cannot determine a canonical runtime status for this monitored system yet.',
|
||||
reasons: [],
|
||||
},
|
||||
explanation: {
|
||||
summary: 'Pulse counts this top-level collection path as one monitored system.',
|
||||
reasons: [],
|
||||
surfaces: [],
|
||||
},
|
||||
latest_included_signal: {
|
||||
name: 'server-c',
|
||||
type: 'host',
|
||||
source: 'agent',
|
||||
at: '',
|
||||
},
|
||||
source: 'agent',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('server-c')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('No included signal yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the canonical status summary for the row status when status explanation is missing', async () => {
|
||||
explainMock.mockResolvedValue(
|
||||
explainResponse({
|
||||
systems: [
|
||||
{
|
||||
name: 'server-d',
|
||||
type: 'host',
|
||||
status: 'offline',
|
||||
status_explanation: {
|
||||
summary:
|
||||
'At least one included source is offline or disconnected, so Pulse marks this monitored system as offline.',
|
||||
reasons: [],
|
||||
},
|
||||
explanation: {
|
||||
summary: 'Pulse counts this top-level collection path as one monitored system.',
|
||||
reasons: [],
|
||||
surfaces: [],
|
||||
},
|
||||
latest_included_signal: {
|
||||
name: 'server-d',
|
||||
type: 'host',
|
||||
source: 'agent',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
source: 'agent',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('server-d')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View counting details' }));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'At least one included source is offline or disconnected, so Pulse marks this monitored system as offline.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens counting rules by default for usage-focused billing arrivals', async () => {
|
||||
explainMock.mockResolvedValue(
|
||||
explainResponse({
|
||||
systems: [],
|
||||
total: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <MonitoredSystemLedgerPanel showCountingRulesByDefault />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2 monitored systems')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Hide counting rules' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true',
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/a monitored system is a top-level monitored root such as a docker host, kubernetes cluster, proxmox node/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,6 @@ import interactiveSparklineSource from '@/components/shared/InteractiveSparkline
|
||||
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
|
||||
import contextualFocusSource from '@/components/shared/contextualFocus.ts?raw';
|
||||
import summaryCardInteractionSource from '@/components/shared/summaryCardInteraction.ts?raw';
|
||||
import summaryJumpToRowButtonSource from '@/components/shared/SummaryJumpToRowButton.tsx?raw';
|
||||
import summaryRowActionButtonSource from '@/components/shared/SummaryRowActionButton.tsx?raw';
|
||||
import summaryInteractionA11ySource from '@/components/shared/summaryInteractionA11y.ts?raw';
|
||||
import tableSource from '@/components/shared/Table.tsx?raw';
|
||||
@@ -72,12 +71,9 @@ import animatedNumberModelSource from '@/components/shared/animatedNumberModel.t
|
||||
import animatedNumberStateSource from '@/components/shared/useAnimatedNumberState.ts?raw';
|
||||
import workloadsFilterSource from '@/components/Workloads/WorkloadsFilter.tsx?raw';
|
||||
import infrastructureSourceManagerSource from '@/components/Settings/InfrastructureSourceManager.tsx?raw';
|
||||
import configuredNodeTablesSource from '@/components/Settings/ConfiguredNodeTables.tsx?raw';
|
||||
import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx?raw';
|
||||
import selectionCardGroupModelSource from '@/components/shared/selectionCardGroupModel.ts?raw';
|
||||
import summaryMetricCardSource from '@/components/shared/SummaryMetricCard.tsx?raw';
|
||||
import summaryChartLayoutSource from '@/components/shared/summaryChartLayout.ts?raw';
|
||||
import summarySynchronizedReadoutSource from '@/components/shared/SummarySynchronizedReadout.tsx?raw';
|
||||
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
|
||||
import tlsVerificationWarningBannerSource from '@/components/shared/TlsVerificationWarningBanner.tsx?raw';
|
||||
import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw';
|
||||
@@ -130,7 +126,6 @@ import storagePoolRowSource from '@/components/Storage/StoragePoolRow.tsx?raw';
|
||||
import storageContentCardSource from '@/components/Storage/StorageContentCard.tsx?raw';
|
||||
import storagePoolsTableSource from '@/components/Storage/StoragePoolsTable.tsx?raw';
|
||||
import diskListSource from '@/components/Storage/DiskList.tsx?raw';
|
||||
import workloadsSummarySource from '@/components/Workloads/WorkloadsSummary.tsx?raw';
|
||||
import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw';
|
||||
import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw';
|
||||
import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw';
|
||||
@@ -390,10 +385,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(workloadSelectionStateSource).toContain('activeSummaryWorkloadGroupScope');
|
||||
expect(workloadSelectionStateSource).not.toContain('const scrollTop = scroller?.scrollTop');
|
||||
|
||||
expect(summaryJumpToRowButtonSource).toContain('<span>Jump to row</span>');
|
||||
expect(summaryJumpToRowButtonSource).toContain('props.onClick');
|
||||
expect(summaryJumpToRowButtonSource).not.toContain('querySelector');
|
||||
expect(summaryJumpToRowButtonSource).not.toContain('scrollIntoView');
|
||||
|
||||
expect(summaryTableFocusSource).toContain('export function useSummaryTableFocusBridge');
|
||||
expect(summaryTableFocusSource).toContain('export function useSummaryPageInteractionState');
|
||||
@@ -416,31 +407,16 @@ describe('shared primitive guardrails', () => {
|
||||
|
||||
|
||||
|
||||
expect(workloadsSummarySource).toContain('useSummaryContextualFocusState');
|
||||
expect(workloadsSummarySource).toContain('chartHoverSync');
|
||||
expect(workloadsSummarySource).toContain('hoveredGroupScope');
|
||||
expect(workloadsSummarySource).toContain('filterSeriesForActiveScope');
|
||||
expect(workloadsSummarySource).not.toContain('const interactiveWorkloadIds = createMemo');
|
||||
});
|
||||
|
||||
it('keeps synchronized summary values on one shared card/readout contract', () => {
|
||||
expect(summaryMetricCardSource).toContain('headerValue?: JSX.Element');
|
||||
expect(summaryMetricCardSource).toContain("bodyLayout?: 'chart' | 'auto'");
|
||||
expect(summaryMetricCardSource).toContain('props.headerValue');
|
||||
expect(summaryMetricCardSource).not.toContain('data-summary-sync-readout');
|
||||
|
||||
expect(summarySynchronizedReadoutSource).toContain('export const SummarySynchronizedReadout');
|
||||
expect(summarySynchronizedReadoutSource).toContain('data-summary-sync-readout="true"');
|
||||
expect(summarySynchronizedReadoutSource).toContain('formatSummarySynchronizedReadoutTime');
|
||||
expect(summarySynchronizedReadoutSource).not.toContain('Portal');
|
||||
|
||||
expect(interactiveSparklineModelSource).toContain(
|
||||
'buildInteractiveSparklineSynchronizedReadout',
|
||||
);
|
||||
expect(densityMapModelSource).toContain('buildDensityMapSynchronizedReadout');
|
||||
|
||||
expect(workloadsSummarySource).toContain('SummarySynchronizedReadout');
|
||||
expect(workloadsSummarySource).toContain('headerValue={');
|
||||
});
|
||||
|
||||
it('keeps summary-linked table row emphasis on the shared active-row presentation contract', () => {
|
||||
@@ -532,7 +508,6 @@ describe('shared primitive guardrails', () => {
|
||||
storagePoolsTableSource,
|
||||
diskListSource,
|
||||
infrastructureSourceManagerSource,
|
||||
configuredNodeTablesSource,
|
||||
aiCostDashboardSource,
|
||||
deployCandidatesStepSource,
|
||||
deployConfirmStepSource,
|
||||
@@ -575,7 +550,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(storagePoolsTableSource).not.toContain('STORAGE_POOLS_SCROLL_WRAP_CLASS');
|
||||
expect(diskListSource).not.toContain("from '@/components/shared/Card'");
|
||||
expect(diskListSource).not.toContain('PHYSICAL_DISK_TABLE_SCROLL_CLASS');
|
||||
expect(configuredNodeTablesSource).toContain('wrapperClass="max-h-[600px] overflow-y-auto"');
|
||||
expect(storageContentCardSource).toContain('<StoragePoolsTable');
|
||||
expect(pulseDataGridSource).toContain('wrapperClass="scrollbar-hide"');
|
||||
expect(pulseDataGridSource).toContain('getPulseDataGridFrameClass');
|
||||
@@ -1270,14 +1244,6 @@ describe('shared primitive guardrails', () => {
|
||||
|
||||
it('keeps summary density control inside the shared summary primitives', () => {
|
||||
|
||||
expect(summaryMetricCardSource).toContain("density?: 'default' | 'compact'");
|
||||
expect(summaryMetricCardSource).toContain("props.density === 'compact'");
|
||||
expect(summaryMetricCardSource).toContain("props.bodyLayout ?? 'chart'");
|
||||
expect(summaryMetricCardSource).toContain(
|
||||
"isCompact() ? 'mb-1 min-h-[20px]' : 'mb-1.5 min-h-[24px]'",
|
||||
);
|
||||
expect(summaryMetricCardSource).toContain('SUMMARY_CHART_SLOT_COMPACT_CLASS');
|
||||
expect(summaryMetricCardSource).toContain('SUMMARY_CHART_SLOT_CLASS');
|
||||
expect(summaryChartLayoutSource).toContain(
|
||||
"export const SUMMARY_CHART_SLOT_CLASS = 'h-[136px] sm:h-[150px]'",
|
||||
);
|
||||
@@ -1287,9 +1253,6 @@ describe('shared primitive guardrails', () => {
|
||||
expect(summaryChartLayoutSource).toContain(
|
||||
"export const SUMMARY_CHART_PLOT_AREA_CLASS = 'h-[120px] sm:h-[134px]'",
|
||||
);
|
||||
expect(summaryMetricCardSource).toContain('!p-1.5 sm:!p-2');
|
||||
expect(summaryMetricCardSource).not.toContain('Recovery Posture');
|
||||
expect(summaryMetricCardSource).not.toContain('Freshness');
|
||||
});
|
||||
|
||||
it('keeps sticky summary breakpoint behavior on the shared primitive', () => {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Component } from 'solid-js';
|
||||
|
||||
interface SummaryJumpToRowButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const SummaryJumpToRowButton: Component<SummaryJumpToRowButtonProps> = (props) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClick}
|
||||
class="inline-flex items-center gap-1 rounded-full border border-border-subtle bg-surface-alt/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
|
||||
>
|
||||
<span>Jump to row</span>
|
||||
<svg class="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor">
|
||||
<path
|
||||
d="M8 3v10m0 0-3-3m3 3 3-3"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Show, type JSX } from 'solid-js';
|
||||
import { Card } from './Card';
|
||||
import { SparklineSkeleton } from './SparklineSkeleton';
|
||||
import { SUMMARY_CHART_SLOT_CLASS, SUMMARY_CHART_SLOT_COMPACT_CLASS } from './summaryChartLayout';
|
||||
import type { SummaryCardInteractionState } from './summaryCardInteraction';
|
||||
|
||||
export interface SummaryMetricCardProps {
|
||||
/** Uppercase label at top-left of the card. */
|
||||
label: string;
|
||||
/** Optional secondary content to the right of the label. */
|
||||
secondaryLabel?: JSX.Element;
|
||||
/** Optional compact readout aligned to the header right edge. */
|
||||
headerValue?: JSX.Element;
|
||||
/** Shared summary cards default to chart-backed bodies unless explicitly auto-sized. */
|
||||
bodyLayout?: 'chart' | 'auto';
|
||||
/** Whether data has loaded at least once. Drives skeleton vs empty state. */
|
||||
loaded: boolean;
|
||||
/** Whether there is renderable data. When false, shows the fallback. */
|
||||
hasData: boolean;
|
||||
/** Message to show when loaded but no data. Defaults to "No history yet". */
|
||||
emptyMessage?: string;
|
||||
/** Chart or visualization content. */
|
||||
children: JSX.Element;
|
||||
/** Optional shared density override for compact monitoring surfaces. */
|
||||
density?: 'default' | 'compact';
|
||||
/** Optional interaction state for shared hover/focus summary behavior. */
|
||||
interactionState?: SummaryCardInteractionState;
|
||||
}
|
||||
|
||||
export function SummaryMetricCard(props: SummaryMetricCardProps) {
|
||||
const isCompact = () => props.density === 'compact';
|
||||
const bodyLayout = () => props.bodyLayout ?? 'chart';
|
||||
const interactionState = () => props.interactionState ?? 'default';
|
||||
const chartSlotClass = () =>
|
||||
isCompact() ? SUMMARY_CHART_SLOT_COMPACT_CLASS : SUMMARY_CHART_SLOT_CLASS;
|
||||
const interactionClass = () => {
|
||||
switch (interactionState()) {
|
||||
case 'active':
|
||||
return 'border-sky-500/45 ring-2 ring-inset ring-sky-500/25 shadow-sm';
|
||||
case 'inactive':
|
||||
return 'opacity-50';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-summary-card-state={interactionState()}
|
||||
class={`h-full rounded-md transition-all duration-150 ease-out ${interactionClass()}`.trim()}
|
||||
>
|
||||
<Card padding="sm" class={`h-full ${isCompact() ? '!p-1.5 sm:!p-2' : ''}`.trim()}>
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class={`flex min-w-0 items-center gap-2 ${isCompact() ? 'mb-1 min-h-[20px]' : 'mb-1.5 min-h-[24px]'}`.trim()}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<span class="text-xs font-medium text-muted uppercase tracking-wide shrink-0">
|
||||
{props.label}
|
||||
</span>
|
||||
{props.secondaryLabel}
|
||||
</div>
|
||||
<Show when={props.headerValue}>
|
||||
<div class="ml-auto shrink-0">{props.headerValue}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={props.hasData}
|
||||
fallback={
|
||||
props.loaded ? (
|
||||
<div class="flex h-[56px] items-center text-sm text-muted">
|
||||
{props.emptyMessage ?? 'No history yet'}
|
||||
</div>
|
||||
) : (
|
||||
<SparklineSkeleton />
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={`min-h-0 ${
|
||||
bodyLayout() === 'chart' ? `${chartSlotClass()} shrink-0` : 'flex-1'
|
||||
}`.trim()}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Component } from 'solid-js';
|
||||
|
||||
export interface SummarySynchronizedReadoutProps {
|
||||
empty?: boolean;
|
||||
timestamp: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const formatSummarySynchronizedReadoutTime = (timestamp: number): string =>
|
||||
new Date(timestamp).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export const SummarySynchronizedReadout: Component<
|
||||
SummarySynchronizedReadoutProps
|
||||
> = (props) => (
|
||||
<span
|
||||
data-summary-sync-readout="true"
|
||||
data-summary-sync-empty={props.empty === true ? 'true' : 'false'}
|
||||
data-summary-sync-timestamp={props.timestamp}
|
||||
class={`whitespace-nowrap text-[10px] leading-none ${
|
||||
props.empty === true ? 'text-muted' : 'font-semibold text-base-content tabular-nums'
|
||||
}`.trim()}
|
||||
title={`Synced value at ${formatSummarySynchronizedReadoutTime(props.timestamp)}`}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
);
|
||||
|
||||
export default SummarySynchronizedReadout;
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
// Default-hidden columns for app-container workloads. `disk` and `tags` are
|
||||
// existing hides; `backup` is added because the workload-table Backup column
|
||||
// is driven exclusively by `resource.proxmox.lastBackup`, which Proxmox VE
|
||||
// populates from its vzdump scheduler / PBS integration. App containers
|
||||
// (Docker, Podman, TrueNAS apps) have no equivalent source — the cell is
|
||||
// not just empty, the concept doesn't exist at this integration layer.
|
||||
export const APP_CONTAINER_BASE_DEFAULT_HIDDEN_COLUMNS = ['disk', 'tags', 'backup'] as const;
|
||||
|
||||
export const APP_CONTAINER_COLUMN_LABEL_OVERRIDES = {
|
||||
disk: 'Writable layer',
|
||||
} as const;
|
||||
|
||||
const hasFiniteMetric = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
export function buildAppContainerDefaultHiddenColumnIds(
|
||||
containers: readonly Resource[],
|
||||
): string[] {
|
||||
const hasNetworkIOTelemetry = containers.some(
|
||||
(container) =>
|
||||
hasFiniteMetric(container.network?.rxBytes) || hasFiniteMetric(container.network?.txBytes),
|
||||
);
|
||||
const hasDiskIOTelemetry = containers.some(
|
||||
(container) =>
|
||||
hasFiniteMetric(container.diskIO?.readRate) || hasFiniteMetric(container.diskIO?.writeRate),
|
||||
);
|
||||
|
||||
return [
|
||||
...APP_CONTAINER_BASE_DEFAULT_HIDDEN_COLUMNS,
|
||||
...(hasNetworkIOTelemetry ? [] : ['netIo']),
|
||||
...(hasDiskIOTelemetry ? [] : ['diskIo']),
|
||||
];
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getResourceApprovalLevelLabel } from '@/utils/approvalPresentation';
|
||||
|
||||
describe('approvalPresentation', () => {
|
||||
it('formats canonical approval labels', () => {
|
||||
expect(getResourceApprovalLevelLabel('none')).toBe('None');
|
||||
expect(getResourceApprovalLevelLabel('dry_run_only')).toBe('Dry Run Only');
|
||||
expect(getResourceApprovalLevelLabel('admin')).toBe('Admin');
|
||||
expect(getResourceApprovalLevelLabel('mfa')).toBe('MFA');
|
||||
});
|
||||
|
||||
it('falls back safely for unknown approval levels', () => {
|
||||
expect(getResourceApprovalLevelLabel('strict')).toBe('Strict');
|
||||
expect(getResourceApprovalLevelLabel('')).toBe('—');
|
||||
expect(getResourceApprovalLevelLabel(undefined)).toBe('—');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getChartSeriesColor } from '@/utils/chartSeriesPresentation';
|
||||
|
||||
describe('chartSeriesPresentation', () => {
|
||||
it('returns stable chart series colors by index', () => {
|
||||
expect(getChartSeriesColor(0)).toBe('#3b82f6');
|
||||
expect(getChartSeriesColor(1)).toBe('#8b5cf6');
|
||||
expect(getChartSeriesColor(7)).toBe('#ef4444');
|
||||
});
|
||||
|
||||
it('wraps the palette when the index exceeds the set length', () => {
|
||||
expect(getChartSeriesColor(8)).toBe('#3b82f6');
|
||||
expect(getChartSeriesColor(9)).toBe('#8b5cf6');
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,6 @@ import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverM
|
||||
import tooltipSource from '@/components/shared/Tooltip.tsx?raw';
|
||||
import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw';
|
||||
import upgradeLinkSource from '@/components/shared/UpgradeLink.tsx?raw';
|
||||
import monitoredSystemLedgerPanelSource from '@/components/Settings/MonitoredSystemLedgerPanel.tsx?raw';
|
||||
import monitoredSystemDefinitionDisclosureSource from '@/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx?raw';
|
||||
import interactiveSparklineSource from '@/components/shared/InteractiveSparkline.tsx?raw';
|
||||
import interactiveSparklineModelSource from '@/components/shared/interactiveSparklineModel.ts?raw';
|
||||
@@ -137,13 +136,8 @@ import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntime
|
||||
import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw';
|
||||
import recoveryOutcomePresentationSource from '@/utils/recoveryOutcomePresentation.ts?raw';
|
||||
import recoveryArtifactModePresentationSource from '@/utils/recoveryArtifactModePresentation.ts?raw';
|
||||
import recoveryFilterChipPresentationSource from '@/utils/recoveryFilterChipPresentation.ts?raw';
|
||||
import recoveryIssuePresentationSource from '@/utils/recoveryIssuePresentation.ts?raw';
|
||||
import recoveryActionPresentationSource from '@/utils/recoveryActionPresentation.ts?raw';
|
||||
import recoveryDatePresentationSource from '@/utils/recoveryDatePresentation.ts?raw';
|
||||
import recoveryRecordPresentationSource from '@/utils/recoveryRecordPresentation.ts?raw';
|
||||
import recoveryEmptyStatePresentationSource from '@/utils/recoveryEmptyStatePresentation.ts?raw';
|
||||
import recoveryStatusPresentationSource from '@/utils/recoveryStatusPresentation.ts?raw';
|
||||
import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw';
|
||||
import recoveryTimelineChartPresentationSource from '@/utils/recoveryTimelineChartPresentation.ts?raw';
|
||||
import recoveryTimelinePresentationSource from '@/utils/recoveryTimelinePresentation.ts?raw';
|
||||
@@ -181,7 +175,6 @@ import stackedMemoryBarSource from '@/components/Workloads/StackedMemoryBar.tsx?
|
||||
import stackedMemoryBarModelSource from '@/components/Workloads/stackedMemoryBarModel.ts?raw';
|
||||
import stackedMemoryBarStateSource from '@/components/Workloads/useStackedMemoryBarState.ts?raw';
|
||||
import thresholdSliderSource from '@/components/Workloads/ThresholdSlider.tsx?raw';
|
||||
import problemResourcePresentationSource from '@/utils/problemResourcePresentation.ts?raw';
|
||||
import guestRowSource from '@/components/Workloads/GuestRow.tsx?raw';
|
||||
import guestRowCellsSource from '@/components/Workloads/GuestRowCells.tsx?raw';
|
||||
import guestRowModelSource from '@/components/Workloads/guestRowModel.tsx?raw';
|
||||
@@ -201,12 +194,10 @@ import orgScopeSource from '@/utils/orgScope.ts?raw';
|
||||
import workloadsUtilitySource from '@/utils/workloads.ts?raw';
|
||||
import infrastructureSummaryCacheSource from '@/utils/infrastructureSummaryCache.ts?raw';
|
||||
import workloadEmptyStatePresentationSource from '@/utils/workloadEmptyStatePresentation.ts?raw';
|
||||
import throughputPresentationSource from '@/utils/throughputPresentation.ts?raw';
|
||||
import resourceChangeSummarySource from '@/components/Infrastructure/ResourceChangeSummary.tsx?raw';
|
||||
import resourceChangePresentationSource from '@/utils/resourceChangePresentation.ts?raw';
|
||||
import resourceCorrelationPresentationSource from '@/utils/resourceCorrelationPresentation.ts?raw';
|
||||
import confidencePresentationSource from '@/utils/confidencePresentation.ts?raw';
|
||||
import approvalPresentationSource from '@/utils/approvalPresentation.ts?raw';
|
||||
import textPresentationSource from '@/utils/textPresentation.ts?raw';
|
||||
import messageItemSource from '@/components/AI/Chat/MessageItem.tsx?raw';
|
||||
import toolExecutionBlockSource from '@/components/AI/Chat/ToolExecutionBlock.tsx?raw';
|
||||
@@ -280,7 +271,6 @@ import resourceDetailDrawerShellSource from '@/components/Infrastructure/Resourc
|
||||
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
|
||||
import resourceDetailDrawerDebugSource from '@/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx?raw';
|
||||
import resourceDetailSummarySource from '@/components/Infrastructure/ResourceDetailSummary.tsx?raw';
|
||||
import infrastructureSummaryModelSource from '@/components/Infrastructure/infrastructureSummaryModel.ts?raw';
|
||||
import resourceDetailDrawerDiscoveryModelSource from '@/components/Infrastructure/resourceDetailDiscoveryModel.ts?raw';
|
||||
import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw';
|
||||
import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw';
|
||||
@@ -310,7 +300,6 @@ import pmgServiceHealthBadgeSource from '@/components/PMG/ServiceHealthBadge.tsx
|
||||
import proxmoxSettingsModelSource from '@/components/Settings/proxmoxSettingsModel.ts?raw';
|
||||
import infrastructureWorkspaceSource from '@/components/Settings/InfrastructureWorkspace.tsx?raw';
|
||||
import infrastructureWorkspaceModelSource from '@/components/Settings/infrastructureWorkspaceModel.ts?raw';
|
||||
import proxmoxSettingsPresentationSource from '@/utils/proxmoxSettingsPresentation.ts?raw';
|
||||
import generalSettingsPanelSource from '@/components/Settings/GeneralSettingsPanel.tsx?raw';
|
||||
import nodeModalAuthenticationSectionSource from '@/components/Settings/NodeModalAuthenticationSection.tsx?raw';
|
||||
import nodeModalBasicInfoSectionSource from '@/components/Settings/NodeModalBasicInfoSection.tsx?raw';
|
||||
@@ -447,7 +436,6 @@ import alertSeverityPresentationSource from '@/utils/alertSeverityPresentation.t
|
||||
import alertTabsPresentationSource from '@/utils/alertTabsPresentation.ts?raw';
|
||||
import alertGroupingPresentationSource from '@/utils/alertGroupingPresentation.ts?raw';
|
||||
import alertSchedulePresentationSource from '@/utils/alertSchedulePresentation.ts?raw';
|
||||
import configuredNodeTablesSource from '@/components/Settings/ConfiguredNodeTables.tsx?raw';
|
||||
import configuredNodeCapabilityPresentationSource from '@/utils/configuredNodeCapabilityPresentation.ts?raw';
|
||||
import configuredNodeStatusPresentationSource from '@/utils/configuredNodeStatusPresentation.ts?raw';
|
||||
import rbacPresentationSource from '@/utils/rbacPresentation.ts?raw';
|
||||
@@ -981,9 +969,6 @@ describe('frontend resource type boundaries', () => {
|
||||
);
|
||||
expect(recoveryIssuePresentationSource).toContain('export function getRecoveryIssueRailClass');
|
||||
expect(recoveryTablePresentationSource).toContain('getAllFilterOptionLabel');
|
||||
expect(recoveryFilterChipPresentationSource).toContain(
|
||||
'export function getRecoveryFilterChipPresentation',
|
||||
);
|
||||
expect(recoveryTablePresentationSource).toContain(
|
||||
'export function getRecoveryArtifactColumnHeaderClass',
|
||||
);
|
||||
@@ -1001,60 +986,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(recoveryDatePresentationSource).toContain('export function getRecoveryCompactAxisLabel');
|
||||
expect(recoveryDatePresentationSource).toContain('export function formatRecoveryTimeOnly');
|
||||
expect(recoveryDatePresentationSource).toContain('export function getRecoveryNiceAxisMax');
|
||||
expect(recoveryActionPresentationSource).toContain(
|
||||
'export function getRecoveryBreadcrumbLinkClass',
|
||||
);
|
||||
expect(recoveryActionPresentationSource).toContain(
|
||||
'export function getRecoveryFilterPanelClearClass',
|
||||
);
|
||||
expect(recoveryActionPresentationSource).toContain(
|
||||
'export function getRecoveryEmptyStateActionClass',
|
||||
);
|
||||
expect(recoveryActionPresentationSource).toContain(
|
||||
'export function getRecoveryDrawerCloseButtonClass',
|
||||
);
|
||||
expect(recoveryRecordPresentationSource).toContain(
|
||||
'export function getRecoveryRollupSubjectLabel',
|
||||
);
|
||||
expect(recoveryRecordPresentationSource).toContain(
|
||||
'export function getRecoveryPointSubjectLabel',
|
||||
);
|
||||
expect(recoveryRecordPresentationSource).toContain(
|
||||
'export function getRecoveryPointRepositoryLabel',
|
||||
);
|
||||
expect(recoveryRecordPresentationSource).toContain(
|
||||
'export function getRecoveryPointDetailsSummary',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryProtectedItemsLoadingState',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryProtectedItemsFailureState',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryActivityLoadingState',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryActivityEmptyState',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryProtectedItemsEmptyState',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryHistoryEmptyState',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryPointsLoadingState',
|
||||
);
|
||||
expect(recoveryEmptyStatePresentationSource).toContain(
|
||||
'export function getRecoveryPointsFailureState',
|
||||
);
|
||||
expect(recoveryRecordPresentationSource).toContain(
|
||||
'export function getRecoveryPointTimestampMs',
|
||||
);
|
||||
expect(recoveryRecordPresentationSource).toContain(
|
||||
'export function normalizeRecoveryModeQueryValue',
|
||||
);
|
||||
expect(recoveryTimelineChartPresentationSource).toContain(
|
||||
'export function getRecoveryTimelineAxisLabelClass',
|
||||
);
|
||||
@@ -1064,18 +995,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(recoveryTimelineChartPresentationSource).toContain(
|
||||
'export function getRecoveryTimelineLabelEvery',
|
||||
);
|
||||
expect(recoveryStatusPresentationSource).toContain(
|
||||
'export function getRecoveryProtectedToggleClass',
|
||||
);
|
||||
expect(recoveryStatusPresentationSource).toContain(
|
||||
'export function getRecoveryRollupStatusPillClass',
|
||||
);
|
||||
expect(recoveryStatusPresentationSource).toContain(
|
||||
'export function getRecoveryRollupStatusPillLabel',
|
||||
);
|
||||
expect(recoveryStatusPresentationSource).toContain(
|
||||
'export function getRecoverySpecialOutcomeTextClass',
|
||||
);
|
||||
expect(recoveryTablePresentationSource).toContain(
|
||||
'export function getRecoveryArtifactRowClass',
|
||||
);
|
||||
@@ -1383,9 +1302,6 @@ describe('frontend resource type boundaries', () => {
|
||||
);
|
||||
expect(agentProfileSuggestionPresentationSource).toContain('titleCaseDelimitedLabel');
|
||||
expect(agentProfileSuggestionPresentationSource).not.toContain('const titleize =');
|
||||
expect(problemResourcePresentationSource).toContain(
|
||||
'export function getProblemResourceStatusVariant',
|
||||
);
|
||||
expect(diskListSource).toContain('getTemperatureTextClass');
|
||||
expect(diskListSource).not.toContain('const getTemperatureTone =');
|
||||
expect(diskListSource).toContain('getPhysicalDiskHealthStatus');
|
||||
@@ -1596,19 +1512,12 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(useEnhancedStorageBarModelSource).toContain('getStorageBarLabel');
|
||||
expect(useEnhancedStorageBarModelSource).toContain('getStorageBarTooltipRows');
|
||||
expect(useEnhancedStorageBarModelSource).toContain('getStorageBarZfsSummary');
|
||||
expect(storagePagePresentationSource).toContain('export const shouldShowCephSummaryCard');
|
||||
expect(storagePagePresentationSource).toContain('export const getStoragePageBannerMessage');
|
||||
expect(storagePagePresentationSource).toContain('export const STORAGE_VIEW_OPTIONS');
|
||||
expect(storagePagePresentationSource).toContain('export const getStoragePoolTableColumns');
|
||||
expect(storagePagePresentationSource).toContain(
|
||||
'export const STORAGE_BANNER_ACTION_BUTTON_CLASS',
|
||||
);
|
||||
expect(storagePagePresentationSource).toContain('export const getStorageTableHeading');
|
||||
expect(storagePagePresentationSource).toContain('export const getStorageLoadingMessage');
|
||||
expect(storagePageStatusSource).toContain('export const getStoragePageBannerKind');
|
||||
expect(storagePageStatusSource).toContain('export const isStoragePoolLoading');
|
||||
expect(storagePageStatusHookSource).toContain('export const useStoragePageStatus');
|
||||
expect(storagePageStatusHookSource).toContain('getStoragePageBannerKind');
|
||||
expect(storagePageStatusHookSource).toContain('isStoragePoolLoading');
|
||||
expect(storagePageDataSource).toContain('export const useStoragePageData');
|
||||
expect(storagePageDataSource).toContain('buildStorageRecords');
|
||||
@@ -2312,8 +2221,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(resourceCorrelationPresentationSource).toContain('asTrimmedString');
|
||||
expect(confidencePresentationSource).toContain('formatConfidencePercentage');
|
||||
expect(confidencePresentationSource).toContain('formatConfidenceLabel');
|
||||
expect(approvalPresentationSource).toContain('getResourceApprovalLevelLabel');
|
||||
expect(throughputPresentationSource).toContain('formatThroughputRate');
|
||||
expect(resourceDetailDrawerSource).toContain('formatIdentifierLabel');
|
||||
expect(resourceDetailDrawerIdentityModelSource).toContain('formatIdentifierLabel');
|
||||
expect(resourceDetailDrawerServiceModelSource).toContain('buildPbsVisibleJobBreakdown');
|
||||
@@ -2369,10 +2276,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(aiChatSource).not.toContain('const normalizeMentionKeyPart =');
|
||||
expect(chatIdentifiersSource).toContain('normalizeChatMentionKeyPart');
|
||||
expect(chatIdentifiersSource).toContain('normalizeChatToolName');
|
||||
expect(infrastructureSummaryModelSource).toContain('getNormalizedIdentityLookupVariants');
|
||||
expect(infrastructureSummaryModelSource).toContain('buildInfrastructureDisplaySeries');
|
||||
expect(infrastructureSummaryModelSource).toContain('buildInfrastructureMetricSeries');
|
||||
expect(infrastructureSummaryModelSource).toContain('buildInfrastructureEmptyMessage');
|
||||
expect(resourceIdentitySource).toContain('getNormalizedIdentityLookupVariants');
|
||||
expect(stringUtilsSource).toContain('export const asTrimmedString');
|
||||
expect(resourceIdentitySource).not.toContain(
|
||||
@@ -2572,14 +2475,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(selectionCardGroupModelSource).toContain('resolveSelectionCardTone');
|
||||
expect(selectionCardGroupModelSource).toContain('getSelectionCardButtonClass');
|
||||
expect(selectionCardGroupModelSource).toContain("compact: 'grid grid-cols-2 gap-2'");
|
||||
expect(monitoredSystemLedgerPanelSource).toContain('@/utils/monitoredSystemPresentation');
|
||||
expect(monitoredSystemLedgerPanelSource).toContain('getMonitoredSystemLedgerPresentation');
|
||||
expect(monitoredSystemLedgerPanelSource).toContain(
|
||||
'getMonitoredSystemCountingDetailsToggleLabel',
|
||||
);
|
||||
expect(monitoredSystemLedgerPanelSource).toContain('getMonitoredSystemLedgerDescription');
|
||||
expect(monitoredSystemLedgerPanelSource).not.toContain('No monitored systems counted.');
|
||||
expect(monitoredSystemLedgerPanelSource).not.toContain('Current status');
|
||||
expect(monitoredSystemDefinitionDisclosureSource).toContain(
|
||||
'@/utils/monitoredSystemPresentation',
|
||||
);
|
||||
@@ -2640,9 +2535,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(collapsibleSearchInputModelSource).toContain('getCollapsibleSearchTriggerLabel');
|
||||
expect(collapsibleSearchInputModelSource).toContain('shouldShowCollapsibleSearchExpanded');
|
||||
expect(collapsibleSearchInputModelSource).toContain('getCollapsibleSearchRootClass');
|
||||
expect(infrastructureSummaryModelSource).not.toContain(
|
||||
'const asTrimmedString = (value: unknown): string | null => {',
|
||||
);
|
||||
expect(interactiveSparklineSource).toContain('useInteractiveSparklineState');
|
||||
expect(interactiveSparklineSource).not.toContain('scheduleSparkline');
|
||||
expect(interactiveSparklineSource).not.toContain('downsampleLTTB');
|
||||
@@ -2949,15 +2841,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(proxmoxSettingsModelSource).toContain(
|
||||
'export interface InfrastructurePlatformSettingsProps',
|
||||
);
|
||||
expect(proxmoxSettingsPresentationSource).toContain(
|
||||
'export const PROXMOX_VARIANT_PRESENTATION',
|
||||
);
|
||||
expect(proxmoxSettingsPresentationSource).toContain(
|
||||
'export function getProxmoxVariantPresentation',
|
||||
);
|
||||
expect(proxmoxSettingsPresentationSource).toContain(
|
||||
'export function buildProxmoxDiscoveryPrefillNode',
|
||||
);
|
||||
expect(infrastructureWorkspaceSource).toContain(
|
||||
'@/components/Settings/infrastructureWorkspaceModel'.replace('@/components/Settings/', './'),
|
||||
);
|
||||
@@ -3626,20 +3509,6 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(alertSchedulePresentationSource).toContain(
|
||||
'export function getAlertQuietSuppressCheckboxClass',
|
||||
);
|
||||
expect(configuredNodeTablesSource).toContain('getConfiguredNodeCapabilityBadges');
|
||||
expect(configuredNodeTablesSource).toContain('resolveConfiguredPveNodeStatusIndicator');
|
||||
expect(configuredNodeTablesSource).toContain('resolveConfiguredInstanceStatusIndicator');
|
||||
expect(configuredNodeTablesSource).toMatch(/<Table(?:\s|>)/);
|
||||
expect(configuredNodeTablesSource).toContain('wrapperClass="max-h-[600px] overflow-y-auto"');
|
||||
expect(configuredNodeTablesSource).not.toContain('<div class="overflow-x-auto">');
|
||||
expect(configuredNodeTablesSource).not.toContain('<div class="overflow-auto max-h-[600px]">');
|
||||
expect(configuredNodeTablesSource).not.toContain("'monitorVMs' in node");
|
||||
expect(configuredNodeTablesSource).not.toContain("'monitorDatastores' in node");
|
||||
expect(configuredNodeTablesSource).not.toContain(
|
||||
'const resolveConfiguredNodeStatusIndicator =',
|
||||
);
|
||||
expect(configuredNodeTablesSource).not.toContain('const resolvePbsStatusIndicator =');
|
||||
expect(configuredNodeTablesSource).not.toContain('const resolvePmgStatusIndicator =');
|
||||
expect(configuredNodeCapabilityPresentationSource).toContain(
|
||||
'export function getConfiguredNodeCapabilityBadges',
|
||||
);
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getProblemResourceIssueLabel,
|
||||
getProblemResourceMemberLabel,
|
||||
getProblemResourceStatusVariant,
|
||||
isGenericProblemResourceDisplayName,
|
||||
} from '@/utils/problemResourcePresentation';
|
||||
|
||||
function resource(overrides: Partial<Resource>): Resource {
|
||||
return {
|
||||
id: 'resource-1',
|
||||
type: 'storage',
|
||||
name: 'storage',
|
||||
displayName: 'storage',
|
||||
status: 'offline',
|
||||
...overrides,
|
||||
} as Resource;
|
||||
}
|
||||
|
||||
describe('problemResourcePresentation', () => {
|
||||
it('treats degraded dashboard problem resources as warning', () => {
|
||||
expect(getProblemResourceStatusVariant(149)).toBe('warning');
|
||||
});
|
||||
|
||||
it('treats offline or critical dashboard problem resources as danger', () => {
|
||||
expect(getProblemResourceStatusVariant(150)).toBe('danger');
|
||||
expect(getProblemResourceStatusVariant(200)).toBe('danger');
|
||||
});
|
||||
|
||||
it('normalizes generic type-plus-status resource names to an issue label', () => {
|
||||
const problemResource = resource({
|
||||
id: 'storage-offline',
|
||||
name: 'storage (offline)',
|
||||
displayName: 'storage (offline)',
|
||||
});
|
||||
|
||||
expect(isGenericProblemResourceDisplayName(problemResource, ['Offline'])).toBe(true);
|
||||
expect(getProblemResourceIssueLabel(problemResource, ['Offline'])).toBe('the storage issue');
|
||||
expect(getProblemResourceMemberLabel(problemResource, ['Offline'])).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves meaningful resource names for problem rows', () => {
|
||||
const problemResource = resource({
|
||||
id: 'vm-101',
|
||||
type: 'vm',
|
||||
name: 'database-vm',
|
||||
displayName: 'database-vm',
|
||||
});
|
||||
|
||||
expect(isGenericProblemResourceDisplayName(problemResource, ['Offline'])).toBe(false);
|
||||
expect(getProblemResourceIssueLabel(problemResource, ['Offline'])).toBe('database-vm');
|
||||
expect(getProblemResourceMemberLabel(problemResource, ['Offline'])).toBe('database-vm');
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildProxmoxDiscoveryPrefillNode,
|
||||
getProxmoxVariantPresentation,
|
||||
} from '@/utils/proxmoxSettingsPresentation';
|
||||
|
||||
describe('proxmoxSettingsPresentation', () => {
|
||||
it('returns canonical variant presentation for proxmox node types', () => {
|
||||
expect(getProxmoxVariantPresentation('pve')).toMatchObject({
|
||||
title: 'Proxmox VE nodes',
|
||||
addLabel: 'Add Proxmox VE Connection',
|
||||
});
|
||||
expect(getProxmoxVariantPresentation('pbs')).toMatchObject({
|
||||
title: 'Proxmox Backup Server nodes',
|
||||
addLabel: 'Add Backup Server Connection',
|
||||
});
|
||||
expect(getProxmoxVariantPresentation('pmg')).toMatchObject({
|
||||
title: 'Proxmox Mail Gateway nodes',
|
||||
addLabel: 'Add Mail Gateway Connection',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds canonical discovery prefills for each proxmox node type', () => {
|
||||
expect(
|
||||
buildProxmoxDiscoveryPrefillNode({
|
||||
type: 'pve',
|
||||
ip: '10.0.0.2',
|
||||
port: 8006,
|
||||
hostname: 'pve1',
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
type: 'pve',
|
||||
name: 'pve1',
|
||||
host: 'https://10.0.0.2:8006',
|
||||
monitorVMs: true,
|
||||
monitorContainers: true,
|
||||
monitorStorage: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildProxmoxDiscoveryPrefillNode({
|
||||
type: 'pbs',
|
||||
ip: '10.0.0.3',
|
||||
port: 8007,
|
||||
hostname: 'pbs1',
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
type: 'pbs',
|
||||
name: 'pbs1',
|
||||
monitorDatastores: true,
|
||||
monitorPruneJobs: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
buildProxmoxDiscoveryPrefillNode({
|
||||
type: 'pmg',
|
||||
ip: '10.0.0.4',
|
||||
port: 8006,
|
||||
hostname: 'pmg1',
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
type: 'pmg',
|
||||
name: 'pmg1',
|
||||
monitorMailStats: true,
|
||||
monitorQueues: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRecoveryBreadcrumbLinkClass,
|
||||
getRecoveryDrawerCloseButtonClass,
|
||||
getRecoveryEmptyStateActionClass,
|
||||
getRecoveryFilterPanelClearClass,
|
||||
} from '@/utils/recoveryActionPresentation';
|
||||
|
||||
describe('recoveryActionPresentation', () => {
|
||||
it('derives breadcrumb and filter clear link classes', () => {
|
||||
expect(getRecoveryBreadcrumbLinkClass()).toContain('text-blue-600');
|
||||
expect(getRecoveryFilterPanelClearClass()).toContain('text-blue-600');
|
||||
});
|
||||
|
||||
it('derives empty-state action and drawer-close button classes', () => {
|
||||
expect(getRecoveryEmptyStateActionClass()).toContain('rounded-md border');
|
||||
expect(getRecoveryDrawerCloseButtonClass()).toContain('hover:bg-surface-hover');
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRecoveryActivityEmptyState,
|
||||
getRecoveryActivityLoadingState,
|
||||
getRecoveryHistoryEmptyState,
|
||||
getRecoveryPointsFailureState,
|
||||
getRecoveryPointsLoadingState,
|
||||
getRecoveryProtectedItemsFailureState,
|
||||
getRecoveryProtectedItemsLoadingState,
|
||||
getRecoveryProtectedItemsEmptyState,
|
||||
} from '@/utils/recoveryEmptyStatePresentation';
|
||||
|
||||
describe('recoveryEmptyStatePresentation', () => {
|
||||
it('returns the protection coverage empty state copy', () => {
|
||||
expect(getRecoveryProtectedItemsEmptyState()).toEqual({
|
||||
title: 'No protection coverage yet',
|
||||
description: 'Pulse has not observed recovery coverage for this org yet.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the recovery loading-state copy', () => {
|
||||
expect(getRecoveryProtectedItemsLoadingState()).toEqual({
|
||||
text: 'Loading protection coverage...',
|
||||
});
|
||||
expect(getRecoveryActivityLoadingState()).toEqual({
|
||||
text: 'Loading recovery activity...',
|
||||
});
|
||||
expect(getRecoveryPointsLoadingState()).toEqual({
|
||||
text: 'Loading recovery points...',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the recovery failure-state copy', () => {
|
||||
expect(getRecoveryProtectedItemsFailureState()).toEqual({
|
||||
title: 'Failed to load protection coverage',
|
||||
});
|
||||
expect(getRecoveryPointsFailureState()).toEqual({
|
||||
title: 'Failed to load recovery points',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the recovery activity empty-state copy', () => {
|
||||
expect(getRecoveryActivityEmptyState()).toEqual({
|
||||
text: 'No recovery activity in the selected window.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the recovery-history filter empty state copy', () => {
|
||||
expect(getRecoveryHistoryEmptyState()).toEqual({
|
||||
title: 'No recovery history matches your filters',
|
||||
description: 'Adjust your search, platform, method, status, or verification filters.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getRecoveryFilterChipPresentation } from '@/utils/recoveryFilterChipPresentation';
|
||||
|
||||
describe('getRecoveryFilterChipPresentation', () => {
|
||||
it('returns the canonical day chip presentation', () => {
|
||||
expect(getRecoveryFilterChipPresentation('day')).toEqual({
|
||||
clearButtonClass: 'rounded px-1 py-0.5 text-[10px] hover:bg-blue-100 dark:hover:bg-blue-900',
|
||||
className:
|
||||
'inline-flex max-w-full items-center gap-1 rounded border px-2 py-0.5 text-[10px] border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200',
|
||||
label: 'Day',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the canonical namespace chip presentation', () => {
|
||||
expect(getRecoveryFilterChipPresentation('namespace')).toMatchObject({
|
||||
clearButtonClass:
|
||||
'rounded px-1 py-0.5 text-[10px] hover:bg-indigo-100 dark:hover:bg-indigo-900',
|
||||
label: 'Namespace / Group',
|
||||
});
|
||||
expect(getRecoveryFilterChipPresentation('namespace').className).toContain('border-indigo-200');
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getRecoveryPointItemLabel,
|
||||
getRecoveryPointItemSecondaryLabel,
|
||||
getRecoveryPointDetailsSummary,
|
||||
getRecoveryPointKindLabel,
|
||||
getRecoveryPointModeLabel,
|
||||
getRecoveryPointOutcomeLabel,
|
||||
getRecoveryPointRepositoryLabel,
|
||||
getRecoveryPointTimestampMs,
|
||||
getRecoveryRollupItemLabel,
|
||||
getRecoveryRollupItemSecondaryLabel,
|
||||
normalizeRecoveryModeQueryValue,
|
||||
} from '@/utils/recoveryRecordPresentation';
|
||||
|
||||
describe('recoveryRecordPresentation', () => {
|
||||
it('derives rollup and point item labels from linked resources and refs', () => {
|
||||
const resources = new Map<string, Resource>([
|
||||
['res-1', { id: 'res-1', type: 'vm', name: 'db-01' } as Resource],
|
||||
]);
|
||||
|
||||
const rollup = {
|
||||
rollupId: 'rollup-1',
|
||||
itemResourceId: 'res-1',
|
||||
} as ProtectionRollup;
|
||||
const point = {
|
||||
id: 'point-1',
|
||||
itemResourceId: 'res-1',
|
||||
} as RecoveryPoint;
|
||||
|
||||
expect(getRecoveryRollupItemLabel(rollup, resources)).toBe('db-01');
|
||||
expect(getRecoveryPointItemLabel(point, resources)).toBe('db-01');
|
||||
});
|
||||
|
||||
it('falls back to refs, repository labels, and details summary', () => {
|
||||
const resources = new Map<string, Resource>();
|
||||
const point = {
|
||||
id: 'point-2',
|
||||
subjectRef: { namespace: 'prod', name: 'api' },
|
||||
repositoryRef: { class: 'pbs', type: 'datastore' },
|
||||
display: { detailsSummary: 'Immutable and encrypted' },
|
||||
} as RecoveryPoint;
|
||||
|
||||
expect(getRecoveryPointItemLabel(point, resources)).toBe('prod/api');
|
||||
expect(getRecoveryPointRepositoryLabel(point)).toBe('pbs:datastore');
|
||||
expect(getRecoveryPointDetailsSummary(point)).toBe('Immutable and encrypted');
|
||||
});
|
||||
|
||||
it('prefers governed display labels over opaque linked-resource ids', () => {
|
||||
const resources = new Map<string, Resource>([
|
||||
[
|
||||
'res-2',
|
||||
{
|
||||
id: 'res-2',
|
||||
name: 'res-2',
|
||||
displayName: 'Payments API',
|
||||
type: 'vm',
|
||||
} as Resource,
|
||||
],
|
||||
[
|
||||
'res-3',
|
||||
{
|
||||
id: 'res-3',
|
||||
name: 'res-3',
|
||||
type: 'vm',
|
||||
} as Resource,
|
||||
],
|
||||
]);
|
||||
|
||||
const rollup = {
|
||||
rollupId: 'res:res-3',
|
||||
itemResourceId: 'res-3',
|
||||
display: { itemLabel: 'Archive VM' },
|
||||
} as ProtectionRollup;
|
||||
const linkedPoint = {
|
||||
id: 'point-3',
|
||||
itemResourceId: 'res-2',
|
||||
display: { itemLabel: 'billing-api' },
|
||||
} as RecoveryPoint;
|
||||
|
||||
expect(getRecoveryRollupItemLabel(rollup, resources)).toBe('Archive VM');
|
||||
expect(getRecoveryPointItemLabel(linkedPoint, resources)).toBe('Payments API');
|
||||
});
|
||||
|
||||
it('derives secondary entity-id labels without replacing the primary name', () => {
|
||||
const rollup = {
|
||||
rollupId: 'rollup-2',
|
||||
display: { itemLabel: 'Archive VM', itemType: 'vm', entityIdLabel: '101' },
|
||||
} as ProtectionRollup;
|
||||
const point = {
|
||||
id: 'point-4',
|
||||
display: { itemLabel: 'debian-go', itemType: 'system-container', entityIdLabel: '112' },
|
||||
} as RecoveryPoint;
|
||||
|
||||
expect(getRecoveryRollupItemSecondaryLabel(rollup)).toBe('VMID 101');
|
||||
expect(getRecoveryPointItemSecondaryLabel(point)).toBe('CTID 112');
|
||||
});
|
||||
|
||||
it('derives timestamps and normalizes mode query values', () => {
|
||||
const point = {
|
||||
completedAt: '2026-03-09T12:00:00.000Z',
|
||||
} as RecoveryPoint;
|
||||
|
||||
expect(getRecoveryPointTimestampMs(point)).toBeGreaterThan(0);
|
||||
expect(normalizeRecoveryModeQueryValue('snapshot')).toBe('snapshot');
|
||||
expect(normalizeRecoveryModeQueryValue('LOCAL')).toBe('local');
|
||||
expect(normalizeRecoveryModeQueryValue('unknown')).toBe('all');
|
||||
});
|
||||
|
||||
it('humanizes point kind, method, and outcome labels for operator-facing recovery details', () => {
|
||||
expect(getRecoveryPointKindLabel('backup')).toBe('Backup');
|
||||
expect(getRecoveryPointKindLabel('replica_sync')).toBe('Replica Sync');
|
||||
expect(getRecoveryPointModeLabel('local')).toBe('Local Copy');
|
||||
expect(getRecoveryPointModeLabel('zfs_send')).toBe('Zfs Send');
|
||||
expect(getRecoveryPointOutcomeLabel('success')).toBe('Success');
|
||||
expect(getRecoveryPointOutcomeLabel('FAILURE')).toBe('Failed');
|
||||
expect(getRecoveryPointOutcomeLabel('')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRecoveryProtectedToggleClass,
|
||||
getRecoveryRollupInventoryStatusLabel,
|
||||
getRecoveryRollupInventoryStatusTextClass,
|
||||
getRecoveryRollupInventoryStatusVariant,
|
||||
getRecoveryRollupStatusPillClass,
|
||||
getRecoveryRollupStatusPillLabel,
|
||||
getRecoverySpecialOutcomeTextClass,
|
||||
} from '@/utils/recoveryStatusPresentation';
|
||||
|
||||
describe('recoveryStatusPresentation', () => {
|
||||
it('derives stale toggle classes', () => {
|
||||
expect(getRecoveryProtectedToggleClass(true)).toContain('border-amber-300');
|
||||
expect(getRecoveryProtectedToggleClass(false)).toContain('border-border');
|
||||
});
|
||||
|
||||
it('derives rollup status pill labels and classes', () => {
|
||||
expect(getRecoveryRollupStatusPillLabel('recent')).toBe('recent');
|
||||
expect(getRecoveryRollupStatusPillClass('recent')).toContain('bg-blue-100/80');
|
||||
expect(getRecoveryRollupStatusPillLabel('stale')).toBe('stale');
|
||||
expect(getRecoveryRollupStatusPillClass('stale')).toContain('text-amber-700');
|
||||
expect(getRecoveryRollupStatusPillLabel('never-succeeded')).toBe('never succeeded');
|
||||
expect(getRecoveryRollupStatusPillClass('never-succeeded')).toContain('text-rose-700');
|
||||
});
|
||||
|
||||
it('derives inventory status labels, tones, and dot variants', () => {
|
||||
expect(getRecoveryRollupInventoryStatusLabel('healthy')).toBe('Healthy');
|
||||
expect(getRecoveryRollupInventoryStatusLabel('stale')).toBe('Stale');
|
||||
expect(getRecoveryRollupInventoryStatusLabel('never-succeeded')).toBe('Never succeeded');
|
||||
expect(getRecoveryRollupInventoryStatusTextClass('stale')).toContain('text-amber-600');
|
||||
expect(getRecoveryRollupInventoryStatusTextClass('failed')).toContain('text-red-600');
|
||||
expect(getRecoveryRollupInventoryStatusTextClass('running')).toContain('text-blue-600');
|
||||
expect(getRecoveryRollupInventoryStatusVariant('healthy')).toBe('success');
|
||||
expect(getRecoveryRollupInventoryStatusVariant('stale')).toBe('warning');
|
||||
expect(getRecoveryRollupInventoryStatusVariant('never-succeeded')).toBe('danger');
|
||||
});
|
||||
|
||||
it('derives special outcome text classes', () => {
|
||||
expect(getRecoverySpecialOutcomeTextClass('never')).toContain('text-amber-600');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatThroughputRate } from '@/utils/throughputPresentation';
|
||||
|
||||
describe('throughputPresentation', () => {
|
||||
it('formats bytes per second with canonical throughput labels', () => {
|
||||
expect(formatThroughputRate(0)).toBe('0 B/s');
|
||||
expect(formatThroughputRate(999)).toBe('999 B/s');
|
||||
expect(formatThroughputRate(1_000)).toBe('1 KB/s');
|
||||
expect(formatThroughputRate(1_500_000)).toBe('1.5 MB/s');
|
||||
expect(formatThroughputRate(2_000_000_000)).toBe('2.0 GB/s');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { ResourceApprovalLevel } from '@/types/resource';
|
||||
import { humanizeToken } from '@/utils/textPresentation';
|
||||
|
||||
const APPROVAL_LEVEL_LABELS: Record<ResourceApprovalLevel, string> = {
|
||||
none: 'None',
|
||||
dry_run_only: 'Dry Run Only',
|
||||
admin: 'Admin',
|
||||
mfa: 'MFA',
|
||||
};
|
||||
|
||||
export function getResourceApprovalLevelLabel(level?: ResourceApprovalLevel | string): string {
|
||||
if (!level) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const normalized = level.trim() as ResourceApprovalLevel;
|
||||
return APPROVAL_LEVEL_LABELS[normalized] ?? humanizeToken(normalized, { fallback: '—' });
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
const CHART_SERIES_COLORS = [
|
||||
'#3b82f6',
|
||||
'#8b5cf6',
|
||||
'#10b981',
|
||||
'#f97316',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
];
|
||||
|
||||
export function getChartSeriesColor(index: number): string {
|
||||
return CHART_SERIES_COLORS[index % CHART_SERIES_COLORS.length];
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
|
||||
import { getResourceTypeLabel } from '@/utils/resourceTypePresentation';
|
||||
import type { StatusIndicatorVariant } from '@/utils/status';
|
||||
|
||||
function normalizeProblemResourceValue(value?: string | null): string {
|
||||
return (value ?? '').trim().toLowerCase().replace(/[-_]+/g, ' ');
|
||||
}
|
||||
|
||||
function getProblemResourceTypeNoun(resource: Resource): string | null {
|
||||
const typeLabel = getResourceTypeLabel(resource.type)?.trim();
|
||||
if (!typeLabel) return null;
|
||||
return typeLabel === typeLabel.toUpperCase() ? typeLabel : typeLabel.toLowerCase();
|
||||
}
|
||||
|
||||
function getGenericProblemResourceNames(resource: Resource, problems: string[]): Set<string> {
|
||||
const typeLabel = normalizeProblemResourceValue(getResourceTypeLabel(resource.type));
|
||||
const rawType = normalizeProblemResourceValue(resource.type);
|
||||
const normalizedProblems = problems.map(normalizeProblemResourceValue).filter(Boolean);
|
||||
|
||||
return new Set(
|
||||
[typeLabel, rawType]
|
||||
.filter(Boolean)
|
||||
.flatMap((value) => [
|
||||
value,
|
||||
...normalizedProblems.map((problem) => `${value} (${problem})`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getProblemResourceStatusVariant(worstValue: number): StatusIndicatorVariant {
|
||||
return worstValue >= 150 ? 'danger' : 'warning';
|
||||
}
|
||||
|
||||
export function getProblemResourceDisplayName(resource: Resource): string {
|
||||
return getPreferredResourceDisplayName(resource) || resource.id;
|
||||
}
|
||||
|
||||
export function isGenericProblemResourceDisplayName(
|
||||
resource: Resource,
|
||||
problems: string[],
|
||||
displayName = getProblemResourceDisplayName(resource),
|
||||
): boolean {
|
||||
return getGenericProblemResourceNames(resource, problems).has(
|
||||
normalizeProblemResourceValue(displayName),
|
||||
);
|
||||
}
|
||||
|
||||
export function getProblemResourceIssueLabel(resource: Resource, problems: string[]): string {
|
||||
const displayName = getProblemResourceDisplayName(resource);
|
||||
const typeNoun = getProblemResourceTypeNoun(resource);
|
||||
return typeNoun && isGenericProblemResourceDisplayName(resource, problems, displayName)
|
||||
? `the ${typeNoun} issue`
|
||||
: displayName;
|
||||
}
|
||||
|
||||
export function getProblemResourceMemberLabel(resource: Resource, problems: string[]): string | null {
|
||||
const displayName = getProblemResourceDisplayName(resource);
|
||||
return isGenericProblemResourceDisplayName(resource, problems, displayName) ? null : displayName;
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import Server from 'lucide-solid/icons/server';
|
||||
import HardDrive from 'lucide-solid/icons/hard-drive';
|
||||
import Mail from 'lucide-solid/icons/mail';
|
||||
import type { Component } from 'solid-js';
|
||||
import type { NodeConfig } from '@/types/nodes';
|
||||
|
||||
export type ProxmoxNodeType = 'pve' | 'pbs' | 'pmg';
|
||||
|
||||
export interface DiscoveredProxmoxServer {
|
||||
type: ProxmoxNodeType;
|
||||
ip: string;
|
||||
port: number;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
export interface ProxmoxVariantPresentation {
|
||||
title: string;
|
||||
addLabel: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
scanningLabel: string;
|
||||
emptyIcon: Component<{ class?: string }>;
|
||||
nameFromServer: (server: DiscoveredProxmoxServer) => string;
|
||||
titleFromServer: (server: DiscoveredProxmoxServer) => string;
|
||||
}
|
||||
|
||||
export const PROXMOX_VARIANT_PRESENTATION: Record<ProxmoxNodeType, ProxmoxVariantPresentation> = {
|
||||
pve: {
|
||||
title: 'Proxmox VE nodes',
|
||||
addLabel: 'Add Proxmox VE Connection',
|
||||
emptyTitle: 'No Proxmox VE connections configured',
|
||||
emptyDescription:
|
||||
'Add a Proxmox VE connection when the unified agent is not available on the host',
|
||||
scanningLabel: 'Scanning your network for Proxmox VE servers…',
|
||||
emptyIcon: Server,
|
||||
nameFromServer: (server) => server.hostname || `pve-${server.ip}`,
|
||||
titleFromServer: (server) => server.hostname || `Proxmox VE at ${server.ip}`,
|
||||
},
|
||||
pbs: {
|
||||
title: 'Proxmox Backup Server nodes',
|
||||
addLabel: 'Add Backup Server Connection',
|
||||
emptyTitle: 'No Proxmox Backup Server connections configured',
|
||||
emptyDescription:
|
||||
'Add a Proxmox Backup Server connection when the unified agent is not available on the host',
|
||||
scanningLabel: 'Scanning your network for Proxmox Backup Servers…',
|
||||
emptyIcon: HardDrive,
|
||||
nameFromServer: (server) => server.hostname || `pbs-${server.ip}`,
|
||||
titleFromServer: (server) => server.hostname || `Backup Server at ${server.ip}`,
|
||||
},
|
||||
pmg: {
|
||||
title: 'Proxmox Mail Gateway nodes',
|
||||
addLabel: 'Add Mail Gateway Connection',
|
||||
emptyTitle: 'No Proxmox Mail Gateway connections configured',
|
||||
emptyDescription:
|
||||
'Add a Proxmox Mail Gateway connection when the unified agent is not available on the host',
|
||||
scanningLabel: 'Scanning your network for Proxmox Mail Gateway servers…',
|
||||
emptyIcon: Mail,
|
||||
nameFromServer: (server) => server.hostname || `pmg-${server.ip}`,
|
||||
titleFromServer: (server) => server.hostname || `Mail Gateway at ${server.ip}`,
|
||||
},
|
||||
};
|
||||
|
||||
export function getProxmoxVariantPresentation(type: ProxmoxNodeType): ProxmoxVariantPresentation {
|
||||
return PROXMOX_VARIANT_PRESENTATION[type];
|
||||
}
|
||||
|
||||
export function buildProxmoxDiscoveryPrefillNode(
|
||||
server: DiscoveredProxmoxServer,
|
||||
): Partial<NodeConfig> {
|
||||
const variant = getProxmoxVariantPresentation(server.type);
|
||||
const baseNode = {
|
||||
type: server.type,
|
||||
name: variant.nameFromServer(server),
|
||||
host: `https://${server.ip}:${server.port}`,
|
||||
verifySSL: false,
|
||||
} as const;
|
||||
|
||||
switch (server.type) {
|
||||
case 'pve':
|
||||
return {
|
||||
...baseNode,
|
||||
user: '',
|
||||
tokenName: '',
|
||||
tokenValue: '',
|
||||
monitorVMs: true,
|
||||
monitorContainers: true,
|
||||
monitorStorage: true,
|
||||
monitorBackups: true,
|
||||
monitorPhysicalDisks: false,
|
||||
};
|
||||
case 'pbs':
|
||||
return {
|
||||
...baseNode,
|
||||
user: '',
|
||||
tokenName: '',
|
||||
tokenValue: '',
|
||||
monitorDatastores: true,
|
||||
monitorSyncJobs: true,
|
||||
monitorVerifyJobs: true,
|
||||
monitorPruneJobs: true,
|
||||
monitorGarbageJobs: true,
|
||||
};
|
||||
case 'pmg':
|
||||
return {
|
||||
...baseNode,
|
||||
user: '',
|
||||
monitorMailStats: true,
|
||||
monitorQueues: true,
|
||||
monitorQuarantine: true,
|
||||
monitorDomainStats: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export function getRecoveryBreadcrumbLinkClass(): string {
|
||||
return 'text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors';
|
||||
}
|
||||
|
||||
export function getRecoveryFilterPanelClearClass(): string {
|
||||
return 'text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300';
|
||||
}
|
||||
|
||||
export function getRecoveryEmptyStateActionClass(): string {
|
||||
return 'inline-flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content hover:bg-surface-hover';
|
||||
}
|
||||
|
||||
export function getRecoveryDrawerCloseButtonClass(): string {
|
||||
return 'rounded-md p-1 hover:text-base-content hover:bg-surface-hover';
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
export function getRecoveryProtectedItemsEmptyState() {
|
||||
return {
|
||||
title: 'No protection coverage yet',
|
||||
description: 'Pulse has not observed recovery coverage for this org yet.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getRecoveryProtectedItemsLoadingState() {
|
||||
return {
|
||||
text: 'Loading protection coverage...',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getRecoveryProtectedItemsFailureState() {
|
||||
return {
|
||||
title: 'Failed to load protection coverage',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getRecoveryActivityLoadingState() {
|
||||
return {
|
||||
text: 'Loading recovery activity...',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getRecoveryActivityEmptyState() {
|
||||
return {
|
||||
text: 'No recovery activity in the selected window.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getRecoveryHistoryEmptyState() {
|
||||
return {
|
||||
title: 'No recovery history matches your filters',
|
||||
description: 'Adjust your search, platform, method, status, or verification filters.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getRecoveryPointsLoadingState() {
|
||||
return {
|
||||
text: 'Loading recovery points...',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function getRecoveryPointsFailureState() {
|
||||
return {
|
||||
title: 'Failed to load recovery points',
|
||||
} as const;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { getRecoveryLocationFacetLabel } from '@/utils/recoveryLocationPresentation';
|
||||
|
||||
export type RecoveryFilterChipKind =
|
||||
| 'day'
|
||||
| 'cluster'
|
||||
| 'item-type'
|
||||
| 'node'
|
||||
| 'namespace'
|
||||
| 'focused-item';
|
||||
|
||||
type RecoveryFilterChipPresentation = {
|
||||
clearButtonClass: string;
|
||||
className: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const CHIP_BASE_CLASS = 'inline-flex max-w-full items-center gap-1 rounded border px-2 py-0.5 text-[10px]';
|
||||
const CLEAR_BUTTON_BASE_CLASS = 'rounded px-1 py-0.5 text-[10px]';
|
||||
|
||||
const CHIP_PRESENTATION: Record<RecoveryFilterChipKind, RecoveryFilterChipPresentation> = {
|
||||
cluster: {
|
||||
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-cyan-100 dark:hover:bg-cyan-900`,
|
||||
className: `${CHIP_BASE_CLASS} border-cyan-200 bg-cyan-50 text-cyan-700 dark:border-cyan-700 dark:bg-cyan-900 dark:text-cyan-200`,
|
||||
label: getRecoveryLocationFacetLabel('cluster'),
|
||||
},
|
||||
day: {
|
||||
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-blue-100 dark:hover:bg-blue-900`,
|
||||
className: `${CHIP_BASE_CLASS} border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200`,
|
||||
label: 'Day',
|
||||
},
|
||||
'focused-item': {
|
||||
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-amber-100 dark:hover:bg-amber-900`,
|
||||
className: `${CHIP_BASE_CLASS} border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-200`,
|
||||
label: 'Focused Item',
|
||||
},
|
||||
'item-type': {
|
||||
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-rose-100 dark:hover:bg-rose-900`,
|
||||
className: `${CHIP_BASE_CLASS} border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-700 dark:bg-rose-900 dark:text-rose-200`,
|
||||
label: 'Item Type',
|
||||
},
|
||||
namespace: {
|
||||
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-indigo-100 dark:hover:bg-indigo-900`,
|
||||
className: `${CHIP_BASE_CLASS} border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-700 dark:bg-indigo-900 dark:text-indigo-200`,
|
||||
label: getRecoveryLocationFacetLabel('namespace'),
|
||||
},
|
||||
node: {
|
||||
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-emerald-100 dark:hover:bg-emerald-900`,
|
||||
className: `${CHIP_BASE_CLASS} border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-200`,
|
||||
label: getRecoveryLocationFacetLabel('node'),
|
||||
},
|
||||
};
|
||||
|
||||
export function getRecoveryFilterChipPresentation(
|
||||
kind: RecoveryFilterChipKind,
|
||||
): RecoveryFilterChipPresentation {
|
||||
return CHIP_PRESENTATION[kind];
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getRecoveryArtifactModePresentation } from '@/utils/recoveryArtifactModePresentation';
|
||||
import { normalizeRecoveryOutcome } from '@/utils/recoveryOutcomePresentation';
|
||||
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
|
||||
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
|
||||
|
||||
export type RecoveryArtifactMode = 'snapshot' | 'local' | 'remote';
|
||||
|
||||
const getRecoveryLinkedResourceLabel = (
|
||||
itemResourceId: string,
|
||||
resourcesById: Map<string, Resource>,
|
||||
): string => {
|
||||
if (!itemResourceId) return '';
|
||||
const resource = resourcesById.get(itemResourceId);
|
||||
if (!resource) return '';
|
||||
const label = getPreferredInfrastructureDisplayName(resource).trim();
|
||||
if (!label) return '';
|
||||
if (label.toLowerCase() === itemResourceId.toLowerCase()) return '';
|
||||
return label;
|
||||
};
|
||||
|
||||
export function getRecoveryRollupItemLabel(
|
||||
rollup: ProtectionRollup,
|
||||
resourcesById: Map<string, Resource>,
|
||||
): string {
|
||||
const itemResourceId = (rollup.itemResourceId || '').trim();
|
||||
const displayLabel = String(rollup.display?.itemLabel || '').trim();
|
||||
const linkedResourceLabel = getRecoveryLinkedResourceLabel(itemResourceId, resourcesById);
|
||||
if (linkedResourceLabel) return linkedResourceLabel;
|
||||
if (displayLabel) return displayLabel;
|
||||
|
||||
const ref = rollup.itemRef || rollup.subjectRef || null;
|
||||
if (ref?.namespace && ref?.name) return `${ref.namespace}/${ref.name}`;
|
||||
if (ref?.name) return ref.name;
|
||||
if (itemResourceId) return itemResourceId;
|
||||
return rollup.rollupId;
|
||||
}
|
||||
|
||||
const getRecoveryEntityIdPrefix = (value: string | null | undefined): string => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized) return 'ID';
|
||||
if (normalized.includes('vm')) return 'VMID';
|
||||
if (normalized.includes('lxc') || normalized.includes('container')) return 'CTID';
|
||||
return 'ID';
|
||||
};
|
||||
|
||||
const getRecoveryItemSecondaryLabel = (
|
||||
itemLabel: string,
|
||||
itemType: string | null | undefined,
|
||||
entityIdLabel: string | null | undefined,
|
||||
): string => {
|
||||
const raw = String(entityIdLabel || '').trim();
|
||||
if (!raw) return '';
|
||||
if (raw.toLowerCase() === itemLabel.trim().toLowerCase()) return '';
|
||||
if (/^\d+$/.test(raw)) {
|
||||
return `${getRecoveryEntityIdPrefix(itemType)} ${raw}`;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
export function getRecoveryRollupItemSecondaryLabel(rollup: ProtectionRollup): string {
|
||||
return getRecoveryItemSecondaryLabel(
|
||||
String(rollup.display?.itemLabel || '').trim(),
|
||||
String(rollup.display?.itemType || rollup.itemRef?.type || rollup.subjectRef?.type || ''),
|
||||
rollup.display?.entityIdLabel,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecoveryPointTimestampMs(point: RecoveryPoint): number {
|
||||
const raw = String(point.completedAt || point.startedAt || '');
|
||||
const parsed = Date.parse(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function getRecoveryPointItemLabel(
|
||||
point: RecoveryPoint,
|
||||
resourcesById: Map<string, Resource>,
|
||||
): string {
|
||||
const itemResourceId = (point.itemResourceId || '').trim();
|
||||
const displayLabel = String(point.display?.itemLabel || '').trim();
|
||||
const linkedResourceLabel = getRecoveryLinkedResourceLabel(itemResourceId, resourcesById);
|
||||
if (linkedResourceLabel) return linkedResourceLabel;
|
||||
if (displayLabel) return displayLabel;
|
||||
|
||||
const ref = point.itemRef || point.subjectRef || null;
|
||||
const namespace = String(ref?.namespace || '').trim();
|
||||
const name = String(ref?.name || '').trim();
|
||||
if (namespace && name) return `${namespace}/${name}`;
|
||||
if (name) return name;
|
||||
const id = String(ref?.id || '').trim();
|
||||
if (id) return id;
|
||||
if (itemResourceId) return itemResourceId;
|
||||
return point.id;
|
||||
}
|
||||
|
||||
export function getRecoveryPointItemSecondaryLabel(point: RecoveryPoint): string {
|
||||
return getRecoveryItemSecondaryLabel(
|
||||
String(point.display?.itemLabel || '').trim(),
|
||||
String(point.display?.itemType || point.itemRef?.type || point.subjectRef?.type || ''),
|
||||
point.display?.entityIdLabel,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecoveryRollupSubjectLabel(
|
||||
rollup: ProtectionRollup,
|
||||
resourcesById: Map<string, Resource>,
|
||||
): string {
|
||||
return getRecoveryRollupItemLabel(rollup, resourcesById);
|
||||
}
|
||||
|
||||
export function getRecoveryPointSubjectLabel(
|
||||
point: RecoveryPoint,
|
||||
resourcesById: Map<string, Resource>,
|
||||
): string {
|
||||
return getRecoveryPointItemLabel(point, resourcesById);
|
||||
}
|
||||
|
||||
export function getRecoveryPointRepositoryLabel(point: RecoveryPoint): string {
|
||||
const displayLabel = String(point.display?.repositoryLabel || '').trim();
|
||||
if (displayLabel) return displayLabel;
|
||||
|
||||
const repository = point.repositoryRef || null;
|
||||
const repositoryName = String(repository?.name || '').trim();
|
||||
const repositoryType = String(repository?.type || '').trim();
|
||||
const repositoryClass = String(repository?.class || '').trim();
|
||||
if (repositoryName) return repositoryName;
|
||||
if (repositoryType && repositoryClass) return `${repositoryClass}:${repositoryType}`;
|
||||
if (repositoryType) return repositoryType;
|
||||
if (repositoryClass) return repositoryClass;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getRecoveryPointDetailsSummary(point: RecoveryPoint): string {
|
||||
return String(point.display?.detailsSummary || '').trim();
|
||||
}
|
||||
|
||||
export function getRecoveryPointKindLabel(value: string | null | undefined): string {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
if (!normalized) return 'n/a';
|
||||
|
||||
switch (normalized) {
|
||||
case 'backup':
|
||||
return 'Backup';
|
||||
case 'snapshot':
|
||||
return 'Snapshot';
|
||||
case 'other':
|
||||
return 'Other';
|
||||
default:
|
||||
return titleCaseDelimitedLabel(value ?? undefined, {
|
||||
fallback: 'n/a',
|
||||
preserveShortAllCaps: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecoveryPointModeLabel(value: string | null | undefined): string {
|
||||
const normalized = normalizeRecoveryModeQueryValue(value);
|
||||
if (normalized !== 'all') {
|
||||
return getRecoveryArtifactModePresentation(normalized).label;
|
||||
}
|
||||
|
||||
return titleCaseDelimitedLabel(value ?? undefined, {
|
||||
fallback: 'n/a',
|
||||
preserveShortAllCaps: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecoveryPointOutcomeLabel(value: string | null | undefined): string {
|
||||
return titleCaseDelimitedLabel(normalizeRecoveryOutcome(value), {
|
||||
fallback: 'Unknown',
|
||||
preserveShortAllCaps: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRecoveryModeQueryValue(
|
||||
value: string | null | undefined,
|
||||
): 'all' | RecoveryArtifactMode {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
if (normalized === 'snapshot' || normalized === 'local' || normalized === 'remote') {
|
||||
return normalized;
|
||||
}
|
||||
return 'all';
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { RecoveryRollupInventoryStatus } from '@/utils/recoveryTablePresentation';
|
||||
|
||||
export type RecoveryRollupStatusPill = 'stale' | 'never-succeeded' | 'recent';
|
||||
|
||||
export function getRecoveryProtectedToggleClass(active: boolean): string {
|
||||
return active
|
||||
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-100'
|
||||
: 'border-border bg-surface text-base-content hover:bg-surface-hover';
|
||||
}
|
||||
|
||||
export function getRecoveryRollupStatusPillClass(kind: RecoveryRollupStatusPill): string {
|
||||
switch (kind) {
|
||||
case 'recent':
|
||||
return 'rounded-full bg-blue-100/80 px-1.5 py-px text-[9px] font-medium text-blue-700 dark:bg-blue-900/70 dark:text-blue-200';
|
||||
case 'stale':
|
||||
return 'whitespace-nowrap rounded px-1 py-0.5 text-[10px] font-medium text-amber-700 bg-amber-50 dark:text-amber-300 dark:bg-amber-900';
|
||||
case 'never-succeeded':
|
||||
return 'whitespace-nowrap rounded px-1 py-0.5 text-[10px] font-medium text-rose-700 bg-rose-100 dark:text-rose-200 dark:bg-rose-900';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecoveryRollupStatusPillLabel(kind: RecoveryRollupStatusPill): string {
|
||||
switch (kind) {
|
||||
case 'recent':
|
||||
return 'recent';
|
||||
case 'stale':
|
||||
return 'stale';
|
||||
case 'never-succeeded':
|
||||
return 'never succeeded';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecoveryRollupInventoryStatusLabel(
|
||||
status: RecoveryRollupInventoryStatus,
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'Healthy';
|
||||
case 'warning':
|
||||
return 'Warning';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'running':
|
||||
return 'Running';
|
||||
case 'stale':
|
||||
return 'Stale';
|
||||
case 'never-succeeded':
|
||||
return 'Never succeeded';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecoveryRollupInventoryStatusTextClass(
|
||||
status: RecoveryRollupInventoryStatus,
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'text-emerald-600 dark:text-emerald-400';
|
||||
case 'warning':
|
||||
case 'stale':
|
||||
return 'text-amber-600 dark:text-amber-400';
|
||||
case 'failed':
|
||||
case 'never-succeeded':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'running':
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
default:
|
||||
return 'text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecoveryRollupInventoryStatusVariant(
|
||||
status: RecoveryRollupInventoryStatus,
|
||||
): 'success' | 'warning' | 'danger' | 'muted' {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'success';
|
||||
case 'warning':
|
||||
case 'stale':
|
||||
return 'warning';
|
||||
case 'failed':
|
||||
case 'never-succeeded':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRecoverySpecialOutcomeTextClass(kind: 'never'): string {
|
||||
if (kind === 'never') return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-muted';
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
const RATE_UNITS = [
|
||||
{ threshold: 1e9, suffix: 'GB/s', precision: 1 },
|
||||
{ threshold: 1e6, suffix: 'MB/s', precision: 1 },
|
||||
{ threshold: 1e3, suffix: 'KB/s', precision: 0 },
|
||||
] as const;
|
||||
|
||||
export function formatThroughputRate(bytesPerSecond: number): string {
|
||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond < 0) {
|
||||
return '0 B/s';
|
||||
}
|
||||
|
||||
for (const unit of RATE_UNITS) {
|
||||
if (bytesPerSecond >= unit.threshold) {
|
||||
return `${(bytesPerSecond / unit.threshold).toFixed(unit.precision)} ${unit.suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(bytesPerSecond)} B/s`;
|
||||
}
|
||||
Reference in New Issue
Block a user