Unify Docker thresholds with platform tables

This commit is contained in:
rcourtman
2025-10-07 20:02:06 +00:00
parent 21d756ab1b
commit 4d6ea7c48a
2 changed files with 167 additions and 221 deletions
@@ -84,6 +84,110 @@ export function ResourceTable(props: ResourceTableProps) {
const [activeMetricInput, setActiveMetricInput] = createSignal<{ resourceId: string; metric: string } | null>(null);
const normalizeMetricKey = (column: string): string => {
const key = column.trim().toLowerCase();
const mapped = (
new Map<string, string>([
['cpu %', 'cpu'],
['memory %', 'memory'],
['disk %', 'disk'],
['disk r mb/s', 'diskRead'],
['disk w mb/s', 'diskWrite'],
['net in mb/s', 'networkIn'],
['net out mb/s', 'networkOut'],
['usage %', 'usage'],
['temp °c', 'temperature'],
['temperature °c', 'temperature'],
['temperature', 'temperature'],
['restart count', 'restartCount'],
['restart window', 'restartWindow'],
['restart window (s)', 'restartWindow'],
['memory warn %', 'memoryWarnPct'],
['memory critical %', 'memoryCriticalPct'],
])
).get(key);
if (mapped) {
return mapped;
}
return key
.replace(' %', '')
.replace(' °c', '')
.replace(' mb/s', '')
.replace('disk r', 'diskRead')
.replace('disk w', 'diskWrite')
.replace('net in', 'networkIn')
.replace('net out', 'networkOut');
};
const metricBounds = (metric: string): { min: number; max: number } => {
if (metric === 'temperature') {
return { min: -1, max: 150 };
}
if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) {
return { min: -1, max: 10000 };
}
if (['cpu', 'memory', 'disk', 'usage', 'memoryWarnPct', 'memoryCriticalPct'].includes(metric)) {
return { min: -1, max: 100 };
}
if (metric === 'restartCount') {
return { min: -1, max: 50 };
}
if (metric === 'restartWindow') {
return { min: -1, max: 86400 };
}
return { min: -1, max: 10000 };
};
const getEnabledDefaultValue = (metric: string): number => {
if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) {
return 100;
}
if (metric === 'temperature') {
return 80;
}
if (metric === 'restartCount') {
return 3;
}
if (metric === 'restartWindow') {
return 300;
}
if (metric === 'memoryWarnPct') {
return 90;
}
if (metric === 'memoryCriticalPct') {
return 95;
}
return 80;
};
const resourceSupportsMetric = (resourceType: string | undefined, metric: string): boolean => {
if (!resourceType) return true;
if (
resourceType === 'node' &&
['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)
) {
return false;
}
if (resourceType === 'pbs') {
return ['cpu', 'memory'].includes(metric);
}
if (resourceType === 'storage') {
return metric === 'usage';
}
if (resourceType === 'dockerContainer') {
return [
'cpu',
'memory',
'restartCount',
'restartWindow',
'memoryWarnPct',
'memoryCriticalPct',
].includes(metric);
}
return true;
};
const renderGroupHeader = (groupKey: string, meta?: GroupHeaderMeta) => {
if (!meta || meta.type !== 'node') {
return <span class="text-xs font-medium text-gray-600 dark:text-gray-400">{groupKey}</span>;
@@ -271,42 +375,8 @@ export function ResourceTable(props: ResourceTableProps) {
</td>
<For each={props.columns}>
{(column) => {
const normalizedColumn = column.trim().toLowerCase();
const metric = (
{
'cpu %': 'cpu',
'memory %': 'memory',
'disk %': 'disk',
'disk r mb/s': 'diskRead',
'disk w mb/s': 'diskWrite',
'net in mb/s': 'networkIn',
'net out mb/s': 'networkOut',
'usage %': 'usage',
'temp °c': 'temperature',
'temperature °c': 'temperature',
temperature: 'temperature',
} as Record<string, string>
)[normalizedColumn]
?? normalizedColumn
.replace(' %', '')
.replace(' °c', '')
.replace(' mb/s', '')
.replace('disk r', 'diskRead')
.replace('disk w', 'diskWrite')
.replace('net in', 'networkIn')
.replace('net out', 'networkOut');
// Get default value when enabling a disabled metric
const getEnabledDefault = (m: string): number => {
if (m.includes('Read') || m.includes('Write') || m.includes('In') || m.includes('Out')) {
return 100; // 100 MB/s for I/O metrics
}
if (m === 'temperature') {
return 80; // 80°C for temperature
}
return 80; // 80% for percentage metrics
};
const metric = normalizeMetricKey(column);
const bounds = metricBounds(metric);
const val = () => props.globalDefaults?.[metric] ?? 0;
const isOff = () => val() === -1;
@@ -315,17 +385,8 @@ export function ResourceTable(props: ResourceTableProps) {
<div class="relative flex justify-center">
<input
type="number"
min="-1"
max={
metric === 'temperature'
? 150
: metric.includes('Read') ||
metric.includes('Write') ||
metric.includes('In') ||
metric.includes('Out')
? 10000
: 100
}
min={bounds.min}
max={bounds.max}
value={isOff() ? '' : val()}
placeholder={isOff() ? 'Off' : ''}
disabled={isOff()}
@@ -354,10 +415,9 @@ export function ResourceTable(props: ResourceTableProps) {
class="absolute inset-0 w-full rounded cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
onClick={() => {
if (!props.setGlobalDefaults) return;
const enabledValue = getEnabledDefault(metric);
props.setGlobalDefaults((prev) => ({
...prev,
[metric]: enabledValue,
[metric]: getEnabledDefaultValue(metric),
}));
props.setHasUnsavedChanges?.(true);
}}
@@ -549,53 +609,11 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
</td>
{/* Metric columns - dynamically rendered based on resource type */}
<For each={props.columns}>
<For each={props.columns}>
{(column) => {
const normalizedColumn = column.trim().toLowerCase();
const metric = (
{
'cpu %': 'cpu',
'memory %': 'memory',
'disk %': 'disk',
'disk r mb/s': 'diskRead',
'disk w mb/s': 'diskWrite',
'net in mb/s': 'networkIn',
'net out mb/s': 'networkOut',
'usage %': 'usage',
'temp °c': 'temperature',
'temperature °c': 'temperature',
temperature: 'temperature',
} as Record<string, string>
)[normalizedColumn]
?? normalizedColumn
.replace(' %', '')
.replace(' °c', '')
.replace(' mb/s', '')
.replace('disk r', 'diskRead')
.replace('disk w', 'diskWrite')
.replace('net in', 'networkIn')
.replace('net out', 'networkOut');
// Check if this metric applies to this resource type
const showMetric = () => {
if (
resource.type === 'node' &&
['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(
metric,
)
) {
return false;
}
if (resource.type === 'pbs') {
// PBS only has CPU and Memory metrics
return ['cpu', 'memory'].includes(metric);
}
if (resource.type === 'storage') {
return metric === 'usage';
}
return true;
};
const metric = normalizeMetricKey(column);
const showMetric = () => resourceSupportsMetric(resource.type, metric);
const bounds = metricBounds(metric);
const isDisabled = () => thresholds()?.[metric] === -1;
const openMetricEditor = (e: MouseEvent) => {
@@ -636,19 +654,10 @@ export function ResourceTable(props: ResourceTableProps) {
}
>
<div class="flex items-center justify-center">
<input
type="number"
min="-1"
max={
metric === 'temperature'
? 200
: metric.includes('disk') ||
metric.includes('memory') ||
metric.includes('cpu') ||
metric === 'usage'
? 100
: 10000
}
<input
type="number"
min={bounds.min}
max={bounds.max}
value={thresholds()?.[metric] ?? ''}
placeholder={isDisabled() ? 'Off' : ''}
ref={(el) => {
@@ -178,7 +178,14 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
if (value <= 0) return 'Off';
// Percentage-based metrics
if (metric === 'cpu' || metric === 'memory' || metric === 'disk' || metric === 'usage') {
if (
metric === 'cpu' ||
metric === 'memory' ||
metric === 'disk' ||
metric === 'usage' ||
metric === 'memoryWarnPct' ||
metric === 'memoryCriticalPct'
) {
return `${value}%`;
}
@@ -187,6 +194,14 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
return `${value}°C`;
}
if (metric === 'restartWindow') {
return `${value}s`;
}
if (metric === 'restartCount') {
return String(value);
}
// MB/s metrics
if (
metric === 'diskRead' ||
@@ -438,13 +453,14 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
const resourceId = `docker:${host.id}/${containerId}`;
const override = overridesMap.get(resourceId);
const defaults = props.dockerDefaults as Record<string, number | undefined>;
const hasCustomThresholds =
override?.thresholds &&
Object.keys(override.thresholds).some((key) => {
const k = key as keyof typeof override.thresholds;
return (
override.thresholds[k] !== undefined &&
override.thresholds[k] !== (props.guestDefaults as any)[k]
override.thresholds[k] !== defaults?.[k as keyof typeof defaults]
);
});
@@ -480,7 +496,7 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
disabled: override?.disabled || false,
disableConnectivity: override?.disableConnectivity || false,
thresholds: override?.thresholds || {},
defaults: props.guestDefaults,
defaults: props.dockerDefaults,
hostId: host.id,
image: container.image,
};
@@ -512,7 +528,7 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
disabled: override.disabled || false,
disableConnectivity: override.disableConnectivity || false,
thresholds: override.thresholds || {},
defaults: props.guestDefaults,
defaults: props.dockerDefaults,
});
});
@@ -1384,108 +1400,6 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
</Show>
<Show when={activeTab() === 'docker'}>
{/* Docker Global Settings */}
<Card padding="none">
<div class="p-4 pb-2">
<SectionHeader
title="Docker global settings"
description="Container behavior and policy thresholds"
size="sm"
/>
</div>
<div class="p-4 pt-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3">
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200 min-w-[120px]">
Restart Count:
</label>
<input
type="number"
min="1"
max="20"
value={props.dockerDefaults.restartCount}
onInput={(e) => {
const value = parseInt(e.currentTarget.value, 10);
props.setDockerDefaults((prev) => ({ ...prev, restartCount: Number.isNaN(value) ? 3 : value }));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">restarts to trigger alert</span>
</div>
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200 min-w-[120px]">
Restart Window:
</label>
<input
type="number"
min="60"
max="1800"
step="60"
value={props.dockerDefaults.restartWindow}
onInput={(e) => {
const value = parseInt(e.currentTarget.value, 10);
props.setDockerDefaults((prev) => ({ ...prev, restartWindow: Number.isNaN(value) ? 300 : value }));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">seconds</span>
</div>
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200 min-w-[120px]">
Memory Limit Warn:
</label>
<input
type="number"
min="0"
max="100"
value={props.dockerDefaults.memoryWarnPct}
onInput={(e) => {
const value = parseInt(e.currentTarget.value, 10);
props.setDockerDefaults((prev) => ({ ...prev, memoryWarnPct: Number.isNaN(value) ? 90 : value }));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">% of container limit</span>
</div>
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200 min-w-[120px]">
Memory Limit Critical:
</label>
<input
type="number"
min="0"
max="100"
value={props.dockerDefaults.memoryCriticalPct}
onInput={(e) => {
const value = parseInt(e.currentTarget.value, 10);
props.setDockerDefaults((prev) => ({ ...prev, memoryCriticalPct: Number.isNaN(value) ? 95 : value }));
props.setHasUnsavedChanges(true);
}}
class="w-16 px-2 py-1 text-xs text-center border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">% of container limit</span>
</div>
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200 min-w-[120px]">
Health Checks:
</label>
<span class="text-xs text-green-600 dark:text-green-400 font-medium">Always Enabled</span>
<span class="text-xs text-gray-500 dark:text-gray-400">(monitor container health status)</span>
</div>
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200 min-w-[120px]">
OOM Detection:
</label>
<span class="text-xs text-green-600 dark:text-green-400 font-medium">Always Enabled</span>
<span class="text-xs text-gray-500 dark:text-gray-400">(alert on exit code 137)</span>
</div>
</div>
</div>
</Card>
<Show when={hasSection('dockerHosts')}>
<div ref={registerSection('dockerHosts')} class="scroll-mt-24">
<ResourceTable
@@ -1534,14 +1448,37 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
setEditingThresholds={setEditingThresholds}
formatMetricValue={formatMetricValue}
hasActiveAlert={hasActiveAlert}
globalDefaults={{ cpu: props.dockerDefaults.cpu, memory: props.dockerDefaults.memory }}
globalDefaults={{
cpu: props.dockerDefaults.cpu,
memory: props.dockerDefaults.memory,
restartCount: props.dockerDefaults.restartCount,
restartWindow: props.dockerDefaults.restartWindow,
memoryWarnPct: props.dockerDefaults.memoryWarnPct,
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
}}
setGlobalDefaults={(value) => {
if (typeof value === 'function') {
const newValue = value({ cpu: props.dockerDefaults.cpu, memory: props.dockerDefaults.memory });
props.setDockerDefaults((prev) => ({ ...prev, cpu: newValue.cpu ?? prev.cpu, memory: newValue.memory ?? prev.memory }));
} else {
props.setDockerDefaults((prev) => ({ ...prev, cpu: value.cpu ?? prev.cpu, memory: value.memory ?? prev.memory }));
}
const current = {
cpu: props.dockerDefaults.cpu,
memory: props.dockerDefaults.memory,
restartCount: props.dockerDefaults.restartCount,
restartWindow: props.dockerDefaults.restartWindow,
memoryWarnPct: props.dockerDefaults.memoryWarnPct,
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
};
const next =
typeof value === 'function'
? value(current)
: { ...current, ...value };
props.setDockerDefaults((prev) => ({
...prev,
cpu: next.cpu ?? prev.cpu,
memory: next.memory ?? prev.memory,
restartCount: next.restartCount ?? prev.restartCount,
restartWindow: next.restartWindow ?? prev.restartWindow,
memoryWarnPct: next.memoryWarnPct ?? prev.memoryWarnPct,
memoryCriticalPct: next.memoryCriticalPct ?? prev.memoryCriticalPct,
}));
}}
setHasUnsavedChanges={props.setHasUnsavedChanges}
globalDisableFlag={props.disableAllDockerContainers}