mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
445 lines
17 KiB
TypeScript
445 lines
17 KiB
TypeScript
import { Component, Show, createEffect, createMemo, createSignal } from 'solid-js';
|
|
import { InteractiveSparkline } from '@/components/shared/InteractiveSparkline';
|
|
import type { InteractiveSparklineSeries } from '@/components/shared/InteractiveSparkline';
|
|
import {
|
|
useSummaryContextualFocusState,
|
|
type SummaryChartHoverSync,
|
|
} from '@/components/shared/contextualFocus';
|
|
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
|
|
import { SummaryJumpToRowButton } from '@/components/shared/SummaryJumpToRowButton';
|
|
import { SummaryPanel } from '@/components/shared/SummaryPanel';
|
|
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
|
|
import { SummarySynchronizedReadout } from '@/components/shared/SummarySynchronizedReadout';
|
|
import { buildInteractiveSparklineSynchronizedReadout } from '@/components/shared/interactiveSparklineModel';
|
|
import {
|
|
type MetricPoint,
|
|
type TimeRange,
|
|
type StorageSummaryChartsResponse,
|
|
} from '@/api/charts';
|
|
import {
|
|
SUMMARY_TIME_RANGE_LABEL,
|
|
type SummaryTimeRange,
|
|
} from '@/components/shared/summaryTimeRange';
|
|
import { formatBytes } from '@/utils/format';
|
|
import { getChartSeriesColor } from '@/utils/chartSeriesPresentation';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Props
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface StorageSummaryProps {
|
|
poolCount: number;
|
|
diskCount: number;
|
|
poolsDegraded?: number;
|
|
disksFailing?: number;
|
|
data: StorageSummaryChartsResponse | null;
|
|
loaded: boolean;
|
|
fetchFailed: boolean;
|
|
timeRange: SummaryTimeRange;
|
|
onTimeRangeChange?: (range: SummaryTimeRange) => void;
|
|
hoveredResourceId?: string | null;
|
|
hoveredGroupScope?: SummarySeriesGroupScope | null;
|
|
focusedResourceId?: string | null;
|
|
focusedGroupScope?: SummarySeriesGroupScope | null;
|
|
chartHoverSync?: SummaryChartHoverSync | null;
|
|
onChartHoverSyncChange?: (value: SummaryChartHoverSync | null) => void;
|
|
showJumpToActiveRow?: boolean;
|
|
onJumpToActiveRow?: () => void;
|
|
onScopeToDegradedPools?: () => void;
|
|
onScopeToFailingDisks?: () => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const StorageSummary: Component<StorageSummaryProps> = (props) => {
|
|
const [localChartHoverSync, setLocalChartHoverSync] = createSignal<SummaryChartHoverSync | null>(
|
|
null,
|
|
);
|
|
const chartHoverSync = () => props.chartHoverSync ?? localChartHoverSync();
|
|
const setChartHoverSync = (value: SummaryChartHoverSync | null) => {
|
|
if (props.chartHoverSync === undefined) {
|
|
setLocalChartHoverSync(value);
|
|
}
|
|
props.onChartHoverSyncChange?.(value);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Series builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const allPoolUsageSeries = createMemo((): InteractiveSparklineSeries[] => {
|
|
const d = props.data;
|
|
if (!d?.pools) return [];
|
|
const entries = Object.entries(d.pools);
|
|
return entries
|
|
.filter(([, pool]) => pool.usage && pool.usage.length >= 2)
|
|
.map(([id, pool], i) => ({
|
|
id,
|
|
name: pool.name || id,
|
|
color: getChartSeriesColor(i),
|
|
data: pool.usage as MetricPoint[],
|
|
}));
|
|
});
|
|
|
|
const allPoolUsedSeries = createMemo((): InteractiveSparklineSeries[] => {
|
|
const d = props.data;
|
|
if (!d?.pools) return [];
|
|
const entries = Object.entries(d.pools);
|
|
return entries
|
|
.filter(([, pool]) => pool.used && pool.used.length >= 2)
|
|
.map(([id, pool], i) => ({
|
|
id,
|
|
name: pool.name || id,
|
|
color: getChartSeriesColor(i),
|
|
data: pool.used as MetricPoint[],
|
|
}));
|
|
});
|
|
|
|
const allPoolAvailSeries = createMemo((): InteractiveSparklineSeries[] => {
|
|
const d = props.data;
|
|
if (!d?.pools) return [];
|
|
const entries = Object.entries(d.pools);
|
|
return entries
|
|
.filter(([, pool]) => pool.avail && pool.avail.length >= 2)
|
|
.map(([id, pool], i) => ({
|
|
id,
|
|
name: pool.name || id,
|
|
color: getChartSeriesColor(i),
|
|
data: pool.avail as MetricPoint[],
|
|
}));
|
|
});
|
|
|
|
const allDiskTempSeries = createMemo((): InteractiveSparklineSeries[] => {
|
|
const d = props.data;
|
|
if (!d?.disks) return [];
|
|
const entries = Object.entries(d.disks);
|
|
return entries
|
|
.filter(([, disk]) => disk.temperature && disk.temperature.length >= 2)
|
|
.map(([id, disk], i) => ({
|
|
id,
|
|
name: disk.name || id,
|
|
color: getChartSeriesColor(i),
|
|
data: disk.temperature as MetricPoint[],
|
|
}));
|
|
});
|
|
const interactiveSummarySeries = createMemo<InteractiveSparklineSeries[]>(() => [
|
|
...allPoolUsageSeries(),
|
|
...allPoolUsedSeries(),
|
|
...allPoolAvailSeries(),
|
|
...allDiskTempSeries(),
|
|
]);
|
|
const summaryFocus = useSummaryContextualFocusState({
|
|
chartHoveredSeriesId: () => chartHoverSync()?.seriesId ?? null,
|
|
interactiveSeries: interactiveSummarySeries,
|
|
focusedGroupScope: () => props.focusedGroupScope,
|
|
hoveredGroupScope: () => props.hoveredGroupScope,
|
|
hoveredSeriesId: () => props.hoveredResourceId,
|
|
focusedSeriesId: () => props.focusedResourceId,
|
|
});
|
|
|
|
createEffect(() => {
|
|
const hovered = chartHoverSync();
|
|
if (!hovered) return;
|
|
if (!summaryFocus.isSeriesIdVisibleInActiveScope(hovered.seriesId)) {
|
|
setChartHoverSync(null);
|
|
}
|
|
});
|
|
|
|
const poolUsageSeries = createMemo(() =>
|
|
summaryFocus.filterSeriesForActiveScope(allPoolUsageSeries()),
|
|
);
|
|
const poolUsedSeries = createMemo(() =>
|
|
summaryFocus.filterSeriesForActiveScope(allPoolUsedSeries()),
|
|
);
|
|
const poolAvailSeries = createMemo(() =>
|
|
summaryFocus.filterSeriesForActiveScope(allPoolAvailSeries()),
|
|
);
|
|
const diskTempSeries = createMemo(() =>
|
|
summaryFocus.filterSeriesForActiveScope(allDiskTempSeries()),
|
|
);
|
|
|
|
// Symmetric cross-card scoping: a pool-row hover scopes the three pool
|
|
// charts; a disk-row hover scopes the disk chart. The "foreign" cards
|
|
// fall back to default state instead of dimming, because the hovered
|
|
// entity has no representation in their dataset.
|
|
const poolSeriesIdSet = createMemo(() => {
|
|
const ids = new Set<string>();
|
|
for (const s of allPoolUsageSeries()) if (s.id) ids.add(s.id);
|
|
for (const s of allPoolUsedSeries()) if (s.id) ids.add(s.id);
|
|
for (const s of allPoolAvailSeries()) if (s.id) ids.add(s.id);
|
|
return ids;
|
|
});
|
|
const diskSeriesIdSet = createMemo(() => {
|
|
const ids = new Set<string>();
|
|
for (const s of allDiskTempSeries()) if (s.id) ids.add(s.id);
|
|
return ids;
|
|
});
|
|
type SeriesFamily = 'pool' | 'disk';
|
|
const activeFamily = (): SeriesFamily | null => {
|
|
const id = summaryFocus.activeSeriesId();
|
|
if (!id) return null;
|
|
if (poolSeriesIdSet().has(id)) return 'pool';
|
|
if (diskSeriesIdSet().has(id)) return 'disk';
|
|
return null;
|
|
};
|
|
const interactionStateForFamily = (
|
|
family: SeriesFamily,
|
|
series: InteractiveSparklineSeries[],
|
|
) => {
|
|
const af = activeFamily();
|
|
if (af && af !== family) return 'default' as const;
|
|
return summaryFocus.interactionStateFor(series);
|
|
};
|
|
const highlightIdForFamily = (family: SeriesFamily) => {
|
|
const af = activeFamily();
|
|
if (af && af !== family) return null;
|
|
return summaryFocus.activeSeriesId();
|
|
};
|
|
|
|
const hasPoolUsage = () => poolUsageSeries().length > 0;
|
|
const hasDiskTemp = () => diskTempSeries().length > 0;
|
|
const hasPoolUsed = () => poolUsedSeries().length > 0;
|
|
const hasPoolAvail = () => poolAvailSeries().length > 0;
|
|
|
|
const emptyLabel = () => {
|
|
if (props.fetchFailed) return 'Trend data unavailable';
|
|
if (summaryFocus.activeGroupScope()) return 'No group history yet';
|
|
return 'No history yet';
|
|
};
|
|
|
|
const rangeLabel = () => SUMMARY_TIME_RANGE_LABEL[props.timeRange] ?? props.timeRange;
|
|
|
|
const formatTemp = (value: number) => `${value.toFixed(0)}°C`;
|
|
|
|
const showComponent = () => props.poolCount > 0 || props.diskCount > 0;
|
|
const getFocusedSeriesName = (series: InteractiveSparklineSeries[]): string | null =>
|
|
summaryFocus.getActiveSeriesName(series);
|
|
const focusedLabel = (series: InteractiveSparklineSeries[]) => {
|
|
const name = getFocusedSeriesName(series);
|
|
if (!name) return undefined;
|
|
return <span class="text-xs text-muted ml-1.5 truncate">— {name}</span>;
|
|
};
|
|
const renderSyncedReadout = (
|
|
readout: { empty?: boolean; timestamp: number; value: string } | null,
|
|
) =>
|
|
readout ? (
|
|
<SummarySynchronizedReadout
|
|
empty={readout.empty}
|
|
timestamp={readout.timestamp}
|
|
value={readout.value}
|
|
/>
|
|
) : undefined;
|
|
const poolUsageSyncedReadout = () =>
|
|
buildInteractiveSparklineSynchronizedReadout({
|
|
hoverSourceKey: 'pool-usage',
|
|
hoverSync: chartHoverSync(),
|
|
series: poolUsageSeries(),
|
|
timeRange: props.timeRange as TimeRange,
|
|
});
|
|
const diskTempSyncedReadout = () =>
|
|
buildInteractiveSparklineSynchronizedReadout({
|
|
formatValue: formatTemp,
|
|
hoverSourceKey: 'disk-temperature',
|
|
hoverSync: chartHoverSync(),
|
|
series: diskTempSeries(),
|
|
timeRange: props.timeRange as TimeRange,
|
|
});
|
|
const poolUsedSyncedReadout = () =>
|
|
buildInteractiveSparklineSynchronizedReadout({
|
|
formatValue: (value) => formatBytes(value),
|
|
hoverSourceKey: 'used-capacity',
|
|
hoverSync: chartHoverSync(),
|
|
series: poolUsedSeries(),
|
|
timeRange: props.timeRange as TimeRange,
|
|
});
|
|
const poolAvailSyncedReadout = () =>
|
|
buildInteractiveSparklineSynchronizedReadout({
|
|
formatValue: (value) => formatBytes(value),
|
|
hoverSourceKey: 'available-space',
|
|
hoverSync: chartHoverSync(),
|
|
series: poolAvailSeries(),
|
|
timeRange: props.timeRange as TimeRange,
|
|
});
|
|
|
|
return (
|
|
<Show when={showComponent()}>
|
|
<div class="space-y-2">
|
|
<SummaryPanel
|
|
testId="storage-summary"
|
|
headerLeft={
|
|
<>
|
|
<span class="font-medium text-base-content">
|
|
{props.poolCount} {props.poolCount === 1 ? 'pool' : 'pools'}
|
|
</span>
|
|
<Show
|
|
when={(props.poolsDegraded ?? 0) > 0 || (props.disksFailing ?? 0) > 0}
|
|
fallback={
|
|
<>
|
|
<Show when={props.poolCount > 0 || props.diskCount > 0}>
|
|
<span class="text-emerald-600 dark:text-emerald-400">all healthy</span>
|
|
</Show>
|
|
<Show when={props.diskCount > 0}>
|
|
<span class="text-muted">
|
|
{props.diskCount} {props.diskCount === 1 ? 'disk' : 'disks'}
|
|
</span>
|
|
</Show>
|
|
</>
|
|
}
|
|
>
|
|
<Show when={(props.poolsDegraded ?? 0) > 0}>
|
|
<Show
|
|
when={props.onScopeToDegradedPools}
|
|
fallback={
|
|
<span class="text-amber-600 dark:text-amber-400">
|
|
{props.poolsDegraded} degraded
|
|
</span>
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => props.onScopeToDegradedPools?.()}
|
|
title="Show degraded pools"
|
|
class="text-amber-600 hover:text-amber-700 hover:underline focus:underline focus:outline-none dark:text-amber-400 dark:hover:text-amber-300"
|
|
>
|
|
{props.poolsDegraded} degraded
|
|
</button>
|
|
</Show>
|
|
</Show>
|
|
<Show when={(props.disksFailing ?? 0) > 0}>
|
|
<Show
|
|
when={props.onScopeToFailingDisks}
|
|
fallback={
|
|
<span class="text-amber-600 dark:text-amber-400">
|
|
{props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'}
|
|
</span>
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => props.onScopeToFailingDisks?.()}
|
|
title="Show physical disks"
|
|
class="text-amber-600 hover:text-amber-700 hover:underline focus:underline focus:outline-none dark:text-amber-400 dark:hover:text-amber-300"
|
|
>
|
|
{props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'}
|
|
</button>
|
|
</Show>
|
|
</Show>
|
|
</Show>
|
|
<Show when={props.showJumpToActiveRow && props.onJumpToActiveRow}>
|
|
<SummaryJumpToRowButton onClick={() => props.onJumpToActiveRow?.()} />
|
|
</Show>
|
|
</>
|
|
}
|
|
timeRange={props.timeRange}
|
|
onTimeRangeChange={props.onTimeRangeChange}
|
|
>
|
|
<SummaryMetricCard
|
|
label="Pool Usage"
|
|
secondaryLabel={focusedLabel(poolUsageSeries())}
|
|
headerValue={renderSyncedReadout(poolUsageSyncedReadout())}
|
|
loaded={props.loaded}
|
|
hasData={hasPoolUsage()}
|
|
emptyMessage={emptyLabel()}
|
|
interactionState={interactionStateForFamily('pool', poolUsageSeries())}
|
|
>
|
|
<InteractiveSparkline
|
|
series={poolUsageSeries()}
|
|
rangeLabel={rangeLabel()}
|
|
timeRange={props.timeRange as TimeRange}
|
|
activeSeriesDisplay="isolate"
|
|
yMode="percent"
|
|
highlightNearestSeriesOnHover
|
|
hoverSourceKey="pool-usage"
|
|
hoverSync={chartHoverSync()}
|
|
highlightSeriesId={highlightIdForFamily('pool')}
|
|
interactionState={interactionStateForFamily('pool', poolUsageSeries())}
|
|
onHoverSyncChange={setChartHoverSync}
|
|
/>
|
|
</SummaryMetricCard>
|
|
|
|
<SummaryMetricCard
|
|
label="Disk Temperature"
|
|
secondaryLabel={focusedLabel(diskTempSeries())}
|
|
headerValue={renderSyncedReadout(diskTempSyncedReadout())}
|
|
loaded={props.loaded}
|
|
hasData={hasDiskTemp()}
|
|
emptyMessage={emptyLabel()}
|
|
interactionState={interactionStateForFamily('disk', diskTempSeries())}
|
|
>
|
|
<InteractiveSparkline
|
|
series={diskTempSeries()}
|
|
rangeLabel={rangeLabel()}
|
|
timeRange={props.timeRange as TimeRange}
|
|
activeSeriesDisplay="isolate"
|
|
yMode="auto"
|
|
formatValue={formatTemp}
|
|
formatTopLabel={(max) => `${max.toFixed(0)}°C`}
|
|
highlightNearestSeriesOnHover
|
|
hoverSourceKey="disk-temperature"
|
|
hoverSync={chartHoverSync()}
|
|
highlightSeriesId={highlightIdForFamily('disk')}
|
|
interactionState={interactionStateForFamily('disk', diskTempSeries())}
|
|
onHoverSyncChange={setChartHoverSync}
|
|
/>
|
|
</SummaryMetricCard>
|
|
|
|
<SummaryMetricCard
|
|
label="Used Capacity"
|
|
secondaryLabel={focusedLabel(poolUsedSeries())}
|
|
headerValue={renderSyncedReadout(poolUsedSyncedReadout())}
|
|
loaded={props.loaded}
|
|
hasData={hasPoolUsed()}
|
|
emptyMessage={emptyLabel()}
|
|
interactionState={interactionStateForFamily('pool', poolUsedSeries())}
|
|
>
|
|
<InteractiveSparkline
|
|
series={poolUsedSeries()}
|
|
rangeLabel={rangeLabel()}
|
|
timeRange={props.timeRange as TimeRange}
|
|
activeSeriesDisplay="isolate"
|
|
yMode="auto"
|
|
formatValue={(v) => formatBytes(v)}
|
|
formatTopLabel={(max) => formatBytes(max)}
|
|
highlightNearestSeriesOnHover
|
|
hoverSourceKey="used-capacity"
|
|
hoverSync={chartHoverSync()}
|
|
highlightSeriesId={highlightIdForFamily('pool')}
|
|
interactionState={interactionStateForFamily('pool', poolUsedSeries())}
|
|
onHoverSyncChange={setChartHoverSync}
|
|
/>
|
|
</SummaryMetricCard>
|
|
|
|
<SummaryMetricCard
|
|
label="Available Space"
|
|
secondaryLabel={focusedLabel(poolAvailSeries())}
|
|
headerValue={renderSyncedReadout(poolAvailSyncedReadout())}
|
|
loaded={props.loaded}
|
|
hasData={hasPoolAvail()}
|
|
emptyMessage={emptyLabel()}
|
|
interactionState={interactionStateForFamily('pool', poolAvailSeries())}
|
|
>
|
|
<InteractiveSparkline
|
|
series={poolAvailSeries()}
|
|
rangeLabel={rangeLabel()}
|
|
timeRange={props.timeRange as TimeRange}
|
|
activeSeriesDisplay="isolate"
|
|
yMode="auto"
|
|
formatValue={(v) => formatBytes(v)}
|
|
formatTopLabel={(max) => formatBytes(max)}
|
|
highlightNearestSeriesOnHover
|
|
hoverSourceKey="available-space"
|
|
hoverSync={chartHoverSync()}
|
|
highlightSeriesId={highlightIdForFamily('pool')}
|
|
interactionState={interactionStateForFamily('pool', poolAvailSeries())}
|
|
onHoverSyncChange={setChartHoverSync}
|
|
/>
|
|
</SummaryMetricCard>
|
|
</SummaryPanel>
|
|
</div>
|
|
</Show>
|
|
);
|
|
};
|
|
|
|
export default StorageSummary;
|