feat(thresholds): add collapsible accordion sections and UX improvements

- Add CollapsibleSection component with animated expand/collapse
- Wrap all 6 resource sections (Nodes, VMs, PBS, Storage, Backups, Snapshots) with accordion UI
- Add section icons and resource counts in headers
- Add expand all / collapse all buttons for quick navigation
- Make help banner dismissible with localStorage persistence
- Add Ctrl/Cmd+F keyboard shortcut to focus search
- Add keyboard shortcut hint badge on search input
- Add icons to tab navigation for quick identification
- Improve mobile tab labels with shorter text on small screens
- Create reusable components: ThresholdBadge, ResourceCard, GlobalDefaultsRow
- Create useCollapsedSections hook with localStorage persistence
- Default less-used sections (Storage, Backups, Snapshots, PBS) to collapsed
This commit is contained in:
rcourtman 2025-12-18 15:47:44 +00:00
parent c91307be94
commit 0182cc8310
12 changed files with 3278 additions and 401 deletions

View file

@ -0,0 +1,325 @@
# Alert Thresholds Page Redesign
## Executive Summary
The current Alert Thresholds page suffers from information overload, poor scalability, and a monolithic codebase (~3000 lines in a single component). This plan outlines a comprehensive redesign focused on:
1. **Collapsible accordion-based layout** - Users can focus on what matters
2. **Component decomposition** - Maintainable, testable code
3. **Progressive disclosure** - Show summaries first, details on demand
4. **Responsive design** - Works on all screen sizes
5. **Improved visual hierarchy** - Clear information architecture
---
## Current Problems
### User Experience Issues
| Problem | Impact | Current State |
|---------|--------|---------------|
| Information overload | High | 6+ tables stacked vertically, no way to collapse |
| Wide tables | High | 7+ columns cause horizontal scroll |
| No visual hierarchy | Medium | Everything looks equal priority |
| Help banner always visible | Low | Takes space after users understand |
| No density controls | Medium | Can't see more resources at once |
| Unclear tab labels | Low | "Proxmox / PBS" bundles too much |
### Technical Debt
| Problem | Impact |
|---------|--------|
| `ThresholdsTable.tsx` is ~3000 lines | Very hard to maintain |
| Tightly coupled rendering and state | Difficult to test |
| Repeated code patterns | Inconsistent behavior |
| No clear component boundaries | Hard to extend |
---
## Proposed Architecture
### New Component Structure
```
src/components/Alerts/Thresholds/
├── index.ts # Public exports
├── ThresholdsPage.tsx # Main page layout (~200 lines)
├── ThresholdsContext.tsx # State management context
├── sections/
│ ├── CollapsibleSection.tsx # Reusable accordion section
│ ├── ProxmoxNodesSection.tsx # Nodes-specific logic
│ ├── GuestsSection.tsx # VMs/CTs with node grouping
│ ├── StorageSection.tsx # Storage devices
│ ├── PBSSection.tsx # PBS servers
│ ├── BackupsSection.tsx # Backup thresholds
│ └── SnapshotsSection.tsx # Snapshot thresholds
├── components/
│ ├── ResourceCard.tsx # Expandable resource card
│ ├── ThresholdBadge.tsx # Colored threshold pill
│ ├── ThresholdEditor.tsx # Inline/modal threshold editing
│ ├── GlobalDefaultsRow.tsx # Editable defaults row
│ ├── SearchBar.tsx # Enhanced search/filter
│ └── ViewToggle.tsx # List/Compact toggle
├── hooks/
│ ├── useThresholds.ts # Threshold state management
│ ├── useCollapsedSections.ts # Persist collapsed state
│ └── useResourceFilter.ts # Search/filter logic
└── types.ts # TypeScript interfaces
```
### Component Responsibilities
#### `ThresholdsPage.tsx` (~200 lines)
- Page layout and header
- Tab navigation (Proxmox/PBS, Mail Gateway, Hosts, Containers)
- Search bar and view toggle
- Renders appropriate section components based on active tab
#### `CollapsibleSection.tsx` (~150 lines)
- Reusable accordion wrapper
- Expand/collapse with animation
- Header with title, count, and actions
- Persists collapsed state to localStorage
#### `ResourceCard.tsx` (~200 lines)
- Compact collapsed view: Name, status, key thresholds as pills
- Expanded view: Full threshold editing grid
- Handles inline editing
- Shows "Custom" badge when overridden
#### `ThresholdBadge.tsx` (~50 lines)
- Colored pill showing threshold value
- Color indicates severity (green = conservative, red = aggressive, gray = disabled)
- Clickable to edit
---
## New Layout Design
### Page Structure
```
┌─────────────────────────────────────────────────────────────────┐
│ Alert Thresholds │
│ Tune resource thresholds and override rules │
├─────────────────────────────────────────────────────────────────┤
│ [🔍 Search resources...] [List ▼] [💡 Tips] │
├─────────────────────────────────────────────────────────────────┤
│ [Proxmox/PBS] [Mail Gateway] [Host Agents] [Containers] │
╞═════════════════════════════════════════════════════════════════╡
│ │
│ ▼ Proxmox Nodes 2 resources [Edit Defaults]│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Global Defaults ││
│ │ [CPU 80%] [Mem 85%] [Disk 90%] [Temp 80°C] ││
│ ├─────────────────────────────────────────────────────────────┤│
│ │ ✓ delly Online [CPU 80%] [Mem 85%] [▼] ││
│ │ ✓ minipc Online [CPU 80%] [Mem 85%] [▼] ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ▼ VMs & Containers 24 resources [Edit Defaults]│
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Global Defaults ││
│ │ [CPU 80%] [Mem 85%] [Disk 90%] [I/O: Off] ││
│ ├─────────────────────────────────────────────────────────────┤│
│ │ ▼ delly 12 guests ││
│ │ ✓ homeassistant Running [Custom] [CPU 70%] [▼] ││
│ │ ✓ frigate Running [CPU 80%] [▼] ││
│ │ ✓ mqtt Running [CPU 80%] [▼] ││
│ │ ... 9 more ││
│ ├─────────────────────────────────────────────────────────────┤│
│ │ ► minipc 12 guests ││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ ► Storage 4 resources [Edit Defaults]│
│ │
│ ► PBS Servers 0 resources [Edit Defaults]│
│ │
│ ► Backups [Edit Defaults]│
│ │
│ ► Snapshots [Edit Defaults]│
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Expanded Resource Card
When a resource is expanded:
```
┌─────────────────────────────────────────────────────────────────┐
│ homeassistant [Custom] [Alerts: ON] [▲ Close]│
│ VM 100 • 192.168.1.100 • delly │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Performance Thresholds │
│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │
│ │ CPU │ Memory │ Disk │ Temp │ │
│ │ [70 %] │ [85 %] │ [90 %] │ [80°C] │ │
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │
│ │
│ I/O Thresholds │
│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │
│ │ Disk Read │ Disk Write │ Net In │ Net Out │ │
│ │ [Off] │ [Off] │ [Off] │ [Off] │ │
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │
│ │
│ Offline Alerts: [Warning ▼] │
│ │
│ Note: [Production HA instance ] │
│ │
│ [Reset to Defaults] [Save] │
└─────────────────────────────────────────────────────────────────┘
```
---
## Key UX Improvements
### 1. Progressive Disclosure
- **Collapsed by default**: Sections show count and summary only
- **One-click expand**: Click anywhere on header to expand
- **Nested grouping**: VMs/Containers grouped by node, nodes collapsible
- **Remember state**: Collapsed/expanded state persisted in localStorage
### 2. Visual Hierarchy
- **Section headers**: Large, bold, with resource counts
- **Global defaults**: Always visible at top of each section
- **Custom indicators**: Blue "Custom" badge for overridden resources
- **Status colors**: Green checkmarks for healthy, warning/critical indicators
### 3. Threshold Badges
Color-coded pills that instantly communicate threshold severity:
- **Gray**: Disabled (Off)
- **Green**: Conservative (≥85%)
- **Yellow**: Moderate (70-84%)
- **Orange**: Aggressive (50-69%)
- **Red**: Very aggressive (<50%)
### 4. Search & Filter
Enhanced command bar supporting:
- Simple text search: `homeassistant`
- Property filters: `node:delly`, `type:vm`, `custom:true`
- Threshold filters: `cpu>80`, `memory<70`
- Combination: `node:delly custom:true`
### 5. Responsive Design
- **Wide screens**: Full grid layout with all columns
- **Medium screens**: Hide I/O thresholds, show on expand
- **Narrow screens**: Single column cards, full expand for editing
---
## Implementation Phases
### Phase 1: Component Decomposition (Foundation)
**Goal**: Break up `ThresholdsTable.tsx` without changing UI
1. Extract shared types to `types.ts`
2. Create `ThresholdsContext.tsx` for state management
3. Extract `ResourceCard.tsx` from table row rendering
4. Extract `ThresholdBadge.tsx` for threshold display
5. Create section components that wrap current logic
6. Update imports and ensure tests pass
**Deliverable**: Same UI, cleaner code, easier to modify
### Phase 2: Collapsible Sections
**Goal**: Add accordion behavior to sections
1. Create `CollapsibleSection.tsx` component
2. Add collapse/expand animation
3. Persist collapsed state to localStorage
4. Add resource counts to headers
5. Move "Edit Defaults" to section headers
**Deliverable**: Users can collapse sections they don't need
### Phase 3: Resource Cards
**Goal**: Replace table rows with expandable cards
1. Create compact card view (collapsed)
2. Create full editor view (expanded)
3. Add transition animation
4. Implement inline editing
5. Show threshold pills in collapsed view
**Deliverable**: Cleaner resource display, less horizontal scroll
### Phase 4: Enhanced Filtering
**Goal**: Powerful search and filter
1. Create `SearchBar.tsx` with command palette style
2. Implement filter parsers
3. Add quick filter buttons
4. Keyboard navigation support
5. Search highlighting in results
**Deliverable**: Users can quickly find specific resources
### Phase 5: Polish & Accessibility
**Goal**: Production-ready quality
1. Keyboard navigation throughout
2. Screen reader labels
3. Focus management
4. Loading states
5. Error handling
6. Empty states
7. Responsive testing
**Deliverable**: Accessible, polished experience
---
## Success Metrics
| Metric | Current | Target |
|--------|---------|--------|
| Lines in main component | ~3000 | <300 |
| Horizontal scroll needed | Often | Rarely |
| Clicks to find resource | 2-5 + scroll | 1-2 |
| Time to understand page | ~30s | <10s |
| Mobile usability | Poor | Good |
---
## Files to Create/Modify
### New Files
- `src/components/Alerts/Thresholds/index.ts`
- `src/components/Alerts/Thresholds/ThresholdsPage.tsx`
- `src/components/Alerts/Thresholds/ThresholdsContext.tsx`
- `src/components/Alerts/Thresholds/types.ts`
- `src/components/Alerts/Thresholds/sections/CollapsibleSection.tsx`
- `src/components/Alerts/Thresholds/sections/ProxmoxNodesSection.tsx`
- `src/components/Alerts/Thresholds/sections/GuestsSection.tsx`
- `src/components/Alerts/Thresholds/sections/StorageSection.tsx`
- `src/components/Alerts/Thresholds/components/ResourceCard.tsx`
- `src/components/Alerts/Thresholds/components/ThresholdBadge.tsx`
- `src/components/Alerts/Thresholds/components/ThresholdEditor.tsx`
- `src/components/Alerts/Thresholds/components/GlobalDefaultsRow.tsx`
- `src/components/Alerts/Thresholds/hooks/useThresholds.ts`
- `src/components/Alerts/Thresholds/hooks/useCollapsedSections.ts`
### Modified Files
- `src/components/Alerts/ThresholdsTable.tsx` → Eventually deprecated
- `src/pages/Alerts.tsx` → Use new ThresholdsPage
- `src/components/Alerts/ResourceTable.tsx` → Simplify or deprecate
---
## Risk Mitigation
1. **Incremental migration**: Keep old component working during transition
2. **Feature flags**: Can switch between old/new implementations
3. **Comprehensive tests**: Add tests for new components before replacing old
4. **User feedback**: Consider A/B testing or beta flag
---
## Next Steps
1. ✅ Create this implementation plan
2. ⬜ Generate visual mockup for approval
3. ⬜ Begin Phase 1: Component decomposition
4. ⬜ Add tests for extracted components
5. ⬜ Proceed through remaining phases

View file

@ -0,0 +1,290 @@
/**
* GlobalDefaultsRow Component
*
* Displays and allows editing of global default thresholds for a resource type.
* Shows at the top of each section with editable threshold badges.
*/
import { Component, Show, For, createSignal, createEffect, createMemo } from 'solid-js';
import Settings from 'lucide-solid/icons/settings';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import Check from 'lucide-solid/icons/check';
import X from 'lucide-solid/icons/x';
import { ThresholdBadge } from './ThresholdBadge';
import type { ThresholdColumn, ThresholdValues } from '../types';
export interface GlobalDefaultsRowProps {
/** Current default values */
defaults: ThresholdValues;
/** Factory defaults for reset */
factoryDefaults?: ThresholdValues;
/** Column definitions */
columns: ThresholdColumn[];
/** Called when defaults change */
onUpdateDefaults: (
value: ThresholdValues | ((prev: ThresholdValues) => ThresholdValues)
) => void;
/** Called when changes are made */
setHasUnsavedChanges: (value: boolean) => void;
/** Called to reset to factory defaults */
onResetDefaults?: () => void;
/** Whether to show connectivity/offline settings */
showOfflineSettings?: boolean;
/** Current offline alert state */
disableConnectivity?: boolean;
offlineSeverity?: 'warning' | 'critical';
onSetOfflineState?: (state: 'off' | 'warning' | 'critical') => void;
/** Whether all resources of this type are disabled */
globalDisabled?: boolean;
onToggleGlobalDisabled?: () => void;
/** Title override */
title?: string;
}
export const GlobalDefaultsRow: Component<GlobalDefaultsRowProps> = (props) => {
const [isEditing, setIsEditing] = createSignal(false);
const [editingValues, setEditingValues] = createSignal<ThresholdValues>({});
// Check if current defaults differ from factory defaults
const hasCustomDefaults = createMemo(() => {
if (!props.factoryDefaults) return false;
return props.columns.some((col) => {
const current = props.defaults[col.key];
const factory = props.factoryDefaults?.[col.key];
return current !== factory;
});
});
// Start editing
const startEditing = () => {
setEditingValues({ ...props.defaults });
setIsEditing(true);
};
// Save changes
const saveEdit = () => {
props.onUpdateDefaults(editingValues());
props.setHasUnsavedChanges(true);
setIsEditing(false);
};
// Cancel editing
const cancelEdit = () => {
setEditingValues({});
setIsEditing(false);
};
// Update a single threshold value
const updateValue = (metric: string, value: number | undefined) => {
setEditingValues((prev) => ({
...prev,
[metric]: value,
}));
};
// Handle input change
const handleInput = (metric: string, e: Event) => {
const input = e.target as HTMLInputElement;
const value = input.value === '' ? undefined : Number(input.value);
updateValue(metric, value);
};
// Get the display value for a metric
const getDisplayValue = (metric: string) => {
if (isEditing()) {
return editingValues()[metric];
}
return props.defaults[metric];
};
return (
<div
class={`
rounded-lg border-2 border-dashed transition-all duration-200
${isEditing()
? 'border-blue-400 bg-blue-50/50 dark:bg-blue-950/20'
: 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800/50'
}
`}
>
<div class="px-4 py-3">
<div class="flex items-center justify-between gap-4 mb-3">
{/* Title */}
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
{props.title || 'Global Defaults'}
</h4>
<Show when={hasCustomDefaults()}>
<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
Modified
</span>
</Show>
<Show when={props.globalDisabled}>
<span class="px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400">
All Disabled
</span>
</Show>
</div>
{/* Actions */}
<div class="flex items-center gap-2">
{/* Toggle all disabled */}
<Show when={props.onToggleGlobalDisabled}>
<button
type="button"
onClick={props.onToggleGlobalDisabled}
class={`
px-2 py-1 text-xs font-medium rounded transition-colors
${props.globalDisabled
? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:text-yellow-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400'
}
`}
title={props.globalDisabled ? 'Enable all alerts' : 'Disable all alerts'}
>
{props.globalDisabled ? 'Enable All' : 'Disable All'}
</button>
</Show>
{/* Reset to factory defaults */}
<Show when={props.onResetDefaults && hasCustomDefaults() && !isEditing()}>
<button
type="button"
onClick={props.onResetDefaults}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 rounded transition-colors"
title="Reset to factory defaults"
>
<RotateCcw class="w-3 h-3" />
Reset
</button>
</Show>
{/* Edit/Save/Cancel */}
<Show
when={isEditing()}
fallback={
<button
type="button"
onClick={startEditing}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 rounded transition-colors"
>
<Settings class="w-3 h-3" />
Edit Defaults
</button>
}
>
<button
type="button"
onClick={cancelEdit}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 rounded transition-colors"
>
<X class="w-3 h-3" />
Cancel
</button>
<button
type="button"
onClick={saveEdit}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded transition-colors"
>
<Check class="w-3 h-3" />
Save
</button>
</Show>
</div>
</div>
{/* Threshold Values */}
<div class="flex flex-wrap items-center gap-4">
{/* Threshold badges/inputs */}
<div class="flex flex-wrap items-center gap-2">
<For each={props.columns}>
{(column) => {
const metric = column.key;
const value = getDisplayValue(metric);
const factoryValue = props.factoryDefaults?.[metric];
const isModified = factoryValue !== undefined && value !== factoryValue;
return (
<Show
when={isEditing()}
fallback={
<ThresholdBadge
metric={metric}
value={value}
defaultValue={factoryValue}
isOverridden={isModified}
onClick={startEditing}
size="md"
showLabel={true}
label={column.label.replace(' %', '').replace(' °C', '').replace(' MB/s', '')}
/>
}
>
<div class="flex items-center gap-1.5">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">
{column.label.replace(' %', '').replace(' °C', '').replace(' MB/s', '')}
</label>
<div class="relative">
<input
type="number"
value={editingValues()[metric] ?? ''}
onInput={(e) => handleInput(metric, e)}
placeholder={factoryValue !== undefined ? String(factoryValue) : '—'}
class="
w-16 px-2 py-1 text-sm text-center rounded-md border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-900
focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20
placeholder:text-gray-400
"
/>
</div>
</div>
</Show>
);
}}
</For>
</div>
{/* Offline alerts settings */}
<Show when={props.showOfflineSettings && props.onSetOfflineState}>
<div class="flex items-center gap-2 pl-4 border-l border-gray-300 dark:border-gray-600">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
Offline:
</span>
<For each={['off' as const, 'warning' as const, 'critical' as const]}>
{(state) => {
const currentState = props.disableConnectivity
? 'off'
: props.offlineSeverity || 'warning';
const isActive = currentState === state;
return (
<button
type="button"
onClick={() => props.onSetOfflineState?.(state)}
class={`
px-2 py-0.5 rounded text-xs font-medium transition-all
${isActive
? state === 'off'
? 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200'
: state === 'warning'
? 'bg-yellow-200 text-yellow-800 dark:bg-yellow-800/50 dark:text-yellow-300'
: 'bg-red-200 text-red-800 dark:bg-red-800/50 dark:text-red-300'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
}
`}
>
{state === 'off' ? 'Off' : state === 'warning' ? 'Warn' : 'Crit'}
</button>
);
}}
</For>
</div>
</Show>
</div>
</div>
</div>
);
};
export default GlobalDefaultsRow;

View file

@ -0,0 +1,473 @@
/**
* ResourceCard Component
*
* An expandable card for displaying and editing resource thresholds.
* Replaces wide table rows with a compact, mobile-friendly design.
*/
import { Component, Show, For, createSignal, createMemo, JSX } from 'solid-js';
import ChevronDown from 'lucide-solid/icons/chevron-down';
import ChevronUp from 'lucide-solid/icons/chevron-up';
import Settings from 'lucide-solid/icons/settings';
import RotateCcw from 'lucide-solid/icons/rotate-ccw';
import Check from 'lucide-solid/icons/check';
import X from 'lucide-solid/icons/x';
import Bell from 'lucide-solid/icons/bell';
import BellOff from 'lucide-solid/icons/bell-off';
import Power from 'lucide-solid/icons/power';
import ExternalLink from 'lucide-solid/icons/external-link';
import StickyNote from 'lucide-solid/icons/sticky-note';
import { ThresholdBadge, ThresholdBadgeGroup } from './ThresholdBadge';
import type { ThresholdResource, ThresholdColumn } from '../types';
export interface ResourceCardProps {
/** The resource to display */
resource: ThresholdResource;
/** Column definitions for thresholds */
columns: ThresholdColumn[];
/** Whether this card is currently being edited */
isEditing: boolean;
/** Current editing threshold values */
editingThresholds: Record<string, number | undefined>;
/** Current editing note value */
editingNote: string;
/** Callbacks */
onStartEdit: () => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
onUpdateThreshold: (metric: string, value: number | undefined) => void;
onUpdateNote: (note: string) => void;
onToggleDisabled?: () => void;
onToggleConnectivity?: () => void;
onSetOfflineState?: (state: 'off' | 'warning' | 'critical') => void;
onRemoveOverride?: () => void;
/** Format a threshold value for display */
formatValue: (metric: string, value: number | undefined) => string;
/** Check if there's an active alert for this resource/metric */
hasActiveAlert: (resourceId: string, metric: string) => boolean;
/** Whether to show offline alerts column */
showOfflineAlerts?: boolean;
/** Global defaults for comparison */
globalDefaults?: Record<string, number | undefined>;
}
/**
* Get status indicator color
*/
const getStatusColor = (status?: string): string => {
switch (status?.toLowerCase()) {
case 'running':
case 'online':
return 'bg-green-500';
case 'stopped':
case 'offline':
return 'bg-red-500';
case 'paused':
return 'bg-yellow-500';
default:
return 'bg-gray-400';
}
};
/**
* Get human-readable status text
*/
const getStatusText = (status?: string): string => {
switch (status?.toLowerCase()) {
case 'running':
return 'Running';
case 'online':
return 'Online';
case 'stopped':
return 'Stopped';
case 'offline':
return 'Offline';
case 'paused':
return 'Paused';
default:
return status || 'Unknown';
}
};
export const ResourceCard: Component<ResourceCardProps> = (props) => {
const [isExpanded, setIsExpanded] = createSignal(false);
// When editing starts, expand the card
const expanded = createMemo(() => props.isEditing || isExpanded());
// Determine which metrics to show in collapsed view
const primaryMetrics = createMemo(() => {
// Show first 3-4 metrics in collapsed view
return props.columns.slice(0, 4).map((col) => col.key);
});
// Check if resource has any custom overrides
const hasCustomSettings = () => props.resource.hasOverride;
// Get the effective threshold value (editing or current)
const getEffectiveValue = (metric: string) => {
if (props.isEditing) {
return props.editingThresholds[metric];
}
return props.resource.thresholds[metric] ?? props.resource.defaults[metric];
};
// Check if a metric is overridden from defaults
const isOverridden = (metric: string) => {
const current = props.resource.thresholds[metric];
const defaultVal = props.resource.defaults[metric];
return current !== undefined && current !== defaultVal;
};
// Handle expanding/collapsing
const toggleExpand = () => {
if (!props.isEditing) {
setIsExpanded(!isExpanded());
}
};
// Handle input change for threshold
const handleThresholdInput = (metric: string, e: Event) => {
const input = e.target as HTMLInputElement;
const value = input.value === '' ? undefined : Number(input.value);
props.onUpdateThreshold(metric, value);
};
return (
<div
class={`
rounded-lg border transition-all duration-200
${props.isEditing
? 'border-blue-400 ring-2 ring-blue-400/20 dark:ring-blue-400/10 bg-blue-50/50 dark:bg-blue-950/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
}
${props.resource.disabled
? 'opacity-60'
: ''
}
`}
data-testid={`resource-card-${props.resource.id}`}
>
{/* Collapsed Header */}
<div
class={`
flex items-center justify-between gap-3 px-4 py-3
${!props.isEditing ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30' : ''}
transition-colors duration-150
`}
onClick={!props.isEditing ? toggleExpand : undefined}
>
{/* Left: Status + Name + Badges */}
<div class="flex items-center gap-3 min-w-0 flex-1">
{/* Status indicator */}
<span
class={`flex-shrink-0 w-2.5 h-2.5 rounded-full ${getStatusColor(props.resource.status)}`}
title={getStatusText(props.resource.status)}
/>
{/* Resource info */}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
{/* Name */}
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{props.resource.displayName || props.resource.name}
</span>
{/* Type badge */}
<Show when={props.resource.resourceType}>
<span class="flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{props.resource.resourceType}
</span>
</Show>
{/* Custom badge */}
<Show when={hasCustomSettings()}>
<span class="flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400">
Custom
</span>
</Show>
{/* Has note indicator */}
<Show when={props.resource.note}>
<StickyNote class="w-3.5 h-3.5 text-yellow-500" title={props.resource.note} />
</Show>
{/* Disabled badge */}
<Show when={props.resource.disabled}>
<span class="flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400">
Alerts Off
</span>
</Show>
</div>
{/* Subtitle with node/instance info */}
<Show when={props.resource.node || props.resource.vmid}>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
<Show when={props.resource.vmid}>
ID {props.resource.vmid}
</Show>
<Show when={props.resource.vmid && props.resource.node}> </Show>
<Show when={props.resource.node}>
{props.resource.node}
</Show>
</p>
</Show>
</div>
</div>
{/* Center: Threshold badges (collapsed view) */}
<Show when={!expanded()}>
<div class="hidden sm:flex items-center gap-1 flex-shrink-0">
<ThresholdBadgeGroup
thresholds={props.resource.thresholds}
defaults={props.resource.defaults}
metrics={primaryMetrics()}
hasActiveAlert={(metric) => props.hasActiveAlert(props.resource.id, metric)}
size="sm"
maxVisible={4}
/>
</div>
</Show>
{/* Right: Actions */}
<div class="flex items-center gap-2 flex-shrink-0">
{/* Alert toggle */}
<Show when={props.onToggleDisabled && !props.isEditing}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
props.onToggleDisabled?.();
}}
class={`
p-1.5 rounded-md transition-colors
${props.resource.disabled
? 'text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-green-600 hover:text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/30'
}
`}
title={props.resource.disabled ? 'Enable alerts' : 'Disable alerts'}
>
<Show when={props.resource.disabled} fallback={<Bell class="w-4 h-4" />}>
<BellOff class="w-4 h-4" />
</Show>
</button>
</Show>
{/* Expand/Collapse */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (!props.isEditing) {
setIsExpanded(!isExpanded());
}
}}
class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-700 transition-colors"
title={expanded() ? 'Collapse' : 'Expand to edit'}
>
<Show when={expanded()} fallback={<ChevronDown class="w-4 h-4" />}>
<ChevronUp class="w-4 h-4" />
</Show>
</button>
</div>
</div>
{/* Expanded Content */}
<Show when={expanded()}>
<div class="px-4 pb-4 pt-2 border-t border-gray-200 dark:border-gray-700 space-y-4">
{/* Threshold Grid */}
<div>
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
Thresholds
</h4>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<For each={props.columns}>
{(column) => {
const metric = column.key;
const value = getEffectiveValue(metric);
const defaultVal = props.resource.defaults[metric];
const hasAlert = props.hasActiveAlert(props.resource.id, metric);
return (
<div class="space-y-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">
{column.label}
</label>
<Show
when={props.isEditing}
fallback={
<ThresholdBadge
metric={metric}
value={value}
defaultValue={defaultVal}
isOverridden={isOverridden(metric)}
hasAlert={hasAlert}
onClick={props.onStartEdit}
size="md"
/>
}
>
<div class="relative">
<input
type="number"
value={props.editingThresholds[metric] ?? ''}
onInput={(e) => handleThresholdInput(metric, e)}
placeholder={defaultVal !== undefined ? String(defaultVal) : 'Off'}
class={`
w-full px-2.5 py-1.5 text-sm rounded-md border
bg-white dark:bg-gray-900
${hasAlert
? 'border-red-400 ring-1 ring-red-400'
: 'border-gray-300 dark:border-gray-600'
}
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
placeholder:text-gray-400 dark:placeholder:text-gray-500
`}
/>
<Show when={column.unit}>
<span class="absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-gray-400">
{column.unit}
</span>
</Show>
</div>
</Show>
</div>
);
}}
</For>
</div>
</div>
{/* Offline Alerts Section */}
<Show when={props.showOfflineAlerts && props.onSetOfflineState}>
<div>
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
Offline Alerts
</h4>
<div class="flex items-center gap-2">
<For each={['off' as const, 'warning' as const, 'critical' as const]}>
{(state) => {
const currentState = props.resource.disableConnectivity
? 'off'
: props.resource.poweredOffSeverity || 'warning';
const isActive = currentState === state;
const colors = {
off: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
warning: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400',
critical: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
};
return (
<button
type="button"
onClick={() => props.onSetOfflineState?.(state)}
class={`
px-3 py-1.5 rounded-md text-sm font-medium transition-all
${isActive
? `${colors[state]} ring-2 ring-offset-2 ring-blue-500 dark:ring-offset-gray-800`
: 'bg-gray-50 text-gray-500 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
}
`}
>
{state === 'off' ? 'Off' : state === 'warning' ? 'Warning' : 'Critical'}
</button>
);
}}
</For>
</div>
</div>
</Show>
{/* Note Field */}
<Show when={props.isEditing}>
<div>
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Note
</label>
<textarea
value={props.editingNote}
onInput={(e) => props.onUpdateNote(e.currentTarget.value)}
placeholder="Add a note about this resource..."
rows={2}
class="
w-full mt-1 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-900
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
placeholder:text-gray-400 dark:placeholder:text-gray-500
resize-none
"
/>
</div>
</Show>
{/* Action Buttons */}
<div class="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
{/* Host link */}
<Show when={props.resource.host}>
<a
href={props.resource.host}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
<ExternalLink class="w-3 h-3" />
Open
</a>
</Show>
{/* Reset to defaults */}
<Show when={hasCustomSettings() && props.onRemoveOverride}>
<button
type="button"
onClick={() => props.onRemoveOverride?.()}
class="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
<RotateCcw class="w-3 h-3" />
Reset
</button>
</Show>
</div>
<Show
when={props.isEditing}
fallback={
<button
type="button"
onClick={props.onStartEdit}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 rounded-md transition-colors"
>
<Settings class="w-4 h-4" />
Edit
</button>
}
>
<div class="flex items-center gap-2">
<button
type="button"
onClick={props.onCancelEdit}
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 rounded-md transition-colors"
>
<X class="w-4 h-4" />
Cancel
</button>
<button
type="button"
onClick={props.onSaveEdit}
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded-md transition-colors"
>
<Check class="w-4 h-4" />
Save
</button>
</div>
</Show>
</div>
</div>
</Show>
</div>
);
};
export default ResourceCard;

View file

@ -0,0 +1,204 @@
/**
* ThresholdBadge Component
*
* A pill-shaped badge that displays a threshold value with color coding
* based on severity. Used in resource cards for at-a-glance threshold visibility.
*/
import { Component, Show } from 'solid-js';
import { getThresholdSeverityColor, SEVERITY_COLORS } from '../types';
export interface ThresholdBadgeProps {
/** The metric key (cpu, memory, disk, etc.) */
metric: string;
/** The current threshold value */
value: number | undefined;
/** The default value (to detect overrides) */
defaultValue?: number;
/** Whether this value differs from the default */
isOverridden?: boolean;
/** Whether there's an active alert for this metric */
hasAlert?: boolean;
/** Click handler for editing */
onClick?: () => void;
/** Badge size variant */
size?: 'sm' | 'md' | 'lg';
/** Show the metric label */
showLabel?: boolean;
/** Custom label text */
label?: string;
}
/**
* Format the display value based on metric type
*/
const formatDisplayValue = (metric: string, value: number | undefined): string => {
if (value === undefined || value === null) return '—';
if (value <= 0) return 'Off';
// Percentage metrics
if (['cpu', 'memory', 'disk', 'usage', 'memoryWarnPct', 'memoryCriticalPct'].includes(metric)) {
return `${value}%`;
}
// Temperature
if (metric === 'temperature') {
return `${value}°C`;
}
// Time-based
if (metric === 'restartWindow') {
return `${value}s`;
}
// Size-based
if (metric === 'warningSizeGiB' || metric === 'criticalSizeGiB') {
return `${Math.round(value * 10) / 10} GiB`;
}
// MB/s metrics
if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) {
return `${value}`;
}
// Days for backup/snapshot age
if (metric.includes('days') || metric.includes('Days')) {
return `${value}d`;
}
return String(value);
};
/**
* Get the unit suffix for a metric
*/
const getUnitSuffix = (metric: string): string => {
if (['cpu', 'memory', 'disk', 'usage'].includes(metric)) return '%';
if (metric === 'temperature') return '°C';
if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) return ' MB/s';
return '';
};
/**
* Get readable label for a metric
*/
const getMetricLabel = (metric: string): string => {
const labels: Record<string, string> = {
cpu: 'CPU',
memory: 'Mem',
disk: 'Disk',
temperature: 'Temp',
diskRead: 'Read',
diskWrite: 'Write',
networkIn: 'In',
networkOut: 'Out',
usage: 'Usage',
};
return labels[metric] || metric;
};
export const ThresholdBadge: Component<ThresholdBadgeProps> = (props) => {
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
};
const size = () => props.size || 'sm';
const severity = () => getThresholdSeverityColor(props.value, props.metric);
const colorClass = () => SEVERITY_COLORS[severity()];
const isDisabled = () => props.value === undefined || props.value <= 0;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
props.onClick?.();
}}
class={`
inline-flex items-center gap-1 rounded-full font-medium
transition-all duration-150
${sizeClasses[size()]}
${colorClass()}
${props.onClick ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-blue-400 dark:hover:ring-offset-gray-900' : 'cursor-default'}
${props.isOverridden ? 'ring-1 ring-blue-400 dark:ring-blue-500' : ''}
${props.hasAlert ? 'animate-pulse ring-2 ring-red-400' : ''}
`}
title={
props.hasAlert
? `Active alert on ${props.metric}`
: props.isOverridden
? `Custom threshold (default: ${formatDisplayValue(props.metric, props.defaultValue)})`
: `${props.metric} threshold`
}
>
<Show when={props.showLabel}>
<span class="opacity-70">{props.label || getMetricLabel(props.metric)}</span>
</Show>
<span class={isDisabled() ? 'italic' : ''}>
{formatDisplayValue(props.metric, props.value)}
</span>
</button>
);
};
/**
* A group of threshold badges for a resource
*/
export interface ThresholdBadgeGroupProps {
thresholds: Record<string, number | undefined>;
defaults: Record<string, number | undefined>;
metrics: string[];
onClickMetric?: (metric: string) => void;
hasActiveAlert?: (metric: string) => boolean;
size?: 'sm' | 'md' | 'lg';
maxVisible?: number;
}
export const ThresholdBadgeGroup: Component<ThresholdBadgeGroupProps> = (props) => {
const maxVisible = () => props.maxVisible ?? 4;
const visibleMetrics = () => {
const metrics = props.metrics.filter((m) => {
const value = props.thresholds[m];
// Show if has a value or is overridden
return value !== undefined || (props.defaults[m] !== undefined);
});
return metrics.slice(0, maxVisible());
};
const hiddenCount = () => {
const total = props.metrics.filter((m) =>
props.thresholds[m] !== undefined || props.defaults[m] !== undefined
).length;
return Math.max(0, total - maxVisible());
};
return (
<div class="flex flex-wrap items-center gap-1">
{visibleMetrics().map((metric) => (
<ThresholdBadge
metric={metric}
value={props.thresholds[metric] ?? props.defaults[metric]}
defaultValue={props.defaults[metric]}
isOverridden={
props.thresholds[metric] !== undefined &&
props.thresholds[metric] !== props.defaults[metric]
}
hasAlert={props.hasActiveAlert?.(metric)}
onClick={props.onClickMetric ? () => props.onClickMetric!(metric) : undefined}
size={props.size}
showLabel={true}
/>
))}
<Show when={hiddenCount() > 0}>
<span class="text-xs text-gray-500 dark:text-gray-400">
+{hiddenCount()} more
</span>
</Show>
</div>
);
};
export default ThresholdBadge;

View file

@ -0,0 +1,181 @@
/**
* useCollapsedSections Hook
*
* Manages collapsed/expanded state for accordion sections with localStorage persistence.
* Provides a clean interface for toggling sections and remembering user preferences.
*/
import { createSignal, createEffect, onMount } from 'solid-js';
const STORAGE_KEY = 'pulse-thresholds-collapsed-sections';
interface CollapsedSectionsState {
[sectionId: string]: boolean;
}
/**
* Default collapsed state for sections
* Sections not listed here default to expanded (false)
*/
const DEFAULT_COLLAPSED: CollapsedSectionsState = {
// In Proxmox tab, collapse less-frequently-used sections by default
storage: true,
backups: true,
snapshots: true,
// PBS servers often empty, collapse by default
pbs: true,
};
/**
* Load collapsed state from localStorage
*/
const loadFromStorage = (): CollapsedSectionsState => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (typeof parsed === 'object' && parsed !== null) {
return { ...DEFAULT_COLLAPSED, ...parsed };
}
}
} catch {
// Ignore parse errors
}
return { ...DEFAULT_COLLAPSED };
};
/**
* Save collapsed state to localStorage
*/
const saveToStorage = (state: CollapsedSectionsState): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Ignore storage errors (e.g., quota exceeded)
}
};
export interface UseCollapsedSectionsResult {
/**
* Check if a section is collapsed
*/
isCollapsed: (sectionId: string) => boolean;
/**
* Toggle a section's collapsed state
*/
toggleSection: (sectionId: string) => void;
/**
* Set a section's collapsed state explicitly
*/
setCollapsed: (sectionId: string, collapsed: boolean) => void;
/**
* Expand all sections
*/
expandAll: () => void;
/**
* Collapse all sections
*/
collapseAll: () => void;
/**
* Reset to default collapsed state
*/
resetToDefaults: () => void;
}
/**
* Hook for managing accordion section collapsed state
*
* @example
* ```tsx
* const { isCollapsed, toggleSection } = useCollapsedSections();
*
* <CollapsibleSection
* id="nodes"
* collapsed={isCollapsed('nodes')}
* onToggle={() => toggleSection('nodes')}
* >
* {content}
* </CollapsibleSection>
* ```
*/
export function useCollapsedSections(): UseCollapsedSectionsResult {
const [collapsedState, setCollapsedState] = createSignal<CollapsedSectionsState>(
loadFromStorage()
);
// Persist to localStorage when state changes
createEffect(() => {
saveToStorage(collapsedState());
});
const isCollapsed = (sectionId: string): boolean => {
const state = collapsedState();
// If not explicitly set, check defaults
if (sectionId in state) {
return state[sectionId];
}
return DEFAULT_COLLAPSED[sectionId] ?? false;
};
const toggleSection = (sectionId: string): void => {
setCollapsedState((prev) => ({
...prev,
[sectionId]: !isCollapsed(sectionId),
}));
};
const setCollapsed = (sectionId: string, collapsed: boolean): void => {
setCollapsedState((prev) => ({
...prev,
[sectionId]: collapsed,
}));
};
const expandAll = (): void => {
setCollapsedState((prev) => {
const newState: CollapsedSectionsState = {};
Object.keys(prev).forEach((key) => {
newState[key] = false;
});
// Also expand defaults
Object.keys(DEFAULT_COLLAPSED).forEach((key) => {
newState[key] = false;
});
return newState;
});
};
const collapseAll = (): void => {
setCollapsedState((prev) => {
const newState: CollapsedSectionsState = {};
Object.keys(prev).forEach((key) => {
newState[key] = true;
});
// Also collapse defaults
Object.keys(DEFAULT_COLLAPSED).forEach((key) => {
newState[key] = true;
});
return newState;
});
};
const resetToDefaults = (): void => {
setCollapsedState({ ...DEFAULT_COLLAPSED });
};
return {
isCollapsed,
toggleSection,
setCollapsed,
expandAll,
collapseAll,
resetToDefaults,
};
}
export default useCollapsedSections;

View file

@ -0,0 +1,23 @@
/**
* Alert Thresholds Components
*
* Public exports for the redesigned thresholds components.
*/
// Types
export * from './types';
// Hooks
export { useCollapsedSections, type UseCollapsedSectionsResult } from './hooks/useCollapsedSections';
// Components
export { ThresholdBadge, ThresholdBadgeGroup, type ThresholdBadgeProps, type ThresholdBadgeGroupProps } from './components/ThresholdBadge';
export { ResourceCard, type ResourceCardProps } from './components/ResourceCard';
export { GlobalDefaultsRow, type GlobalDefaultsRowProps } from './components/GlobalDefaultsRow';
// Sections
export { CollapsibleSection, SectionActionButton, NestedGroupHeader, type CollapsibleSectionProps, type SectionActionButtonProps, type NestedGroupHeaderProps } from './sections/CollapsibleSection';
export { ProxmoxNodesSection, type ProxmoxNodesSectionProps } from './sections/ProxmoxNodesSection';
// Re-export existing components for compatibility during migration
export { ResourceTable } from '../ResourceTable';

View file

@ -0,0 +1,300 @@
/**
* CollapsibleSection Component
*
* A reusable accordion-style section with animated expand/collapse.
* Used to organize resource groups in the thresholds page.
*/
import { Component, Show, createSignal, createEffect, JSX } from 'solid-js';
import ChevronRight from 'lucide-solid/icons/chevron-right';
import ChevronDown from 'lucide-solid/icons/chevron-down';
import Settings from 'lucide-solid/icons/settings';
export interface CollapsibleSectionProps {
/** Unique identifier for the section */
id: string;
/** Section title */
title: string;
/** Number of resources in this section */
resourceCount?: number;
/** Whether the section is currently collapsed */
collapsed?: boolean;
/** Callback when collapse state changes */
onToggle?: (collapsed: boolean) => void;
/** Section content (children) */
children: JSX.Element;
/** Action buttons to show in the header (e.g., "Edit Defaults") */
headerActions?: JSX.Element;
/** Icon to show before the title */
icon?: JSX.Element;
/** Subtitle or description */
subtitle?: string;
/** Message to show when section is empty */
emptyMessage?: string;
/** Whether to show a visual indicator for global disable state */
isGloballyDisabled?: boolean;
/** Whether the section has unsaved changes */
hasChanges?: boolean;
/** Test ID for e2e testing */
testId?: string;
}
export const CollapsibleSection: Component<CollapsibleSectionProps> = (props) => {
// Local collapsed state if not controlled externally
const [localCollapsed, setLocalCollapsed] = createSignal(props.collapsed ?? false);
// Sync with external collapsed state
createEffect(() => {
if (props.collapsed !== undefined) {
setLocalCollapsed(props.collapsed);
}
});
const isCollapsed = () => {
return props.collapsed !== undefined ? props.collapsed : localCollapsed();
};
const handleToggle = () => {
const newState = !isCollapsed();
setLocalCollapsed(newState);
props.onToggle?.(newState);
};
const isEmpty = () => props.resourceCount === 0;
const showEmpty = () => isCollapsed() === false && isEmpty() && props.emptyMessage;
return (
<div
class={`
rounded-lg border transition-all duration-200
${props.isGloballyDisabled
? 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50 opacity-60'
: 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'
}
${props.hasChanges ? 'ring-2 ring-blue-400 ring-opacity-50' : ''}
`}
data-testid={props.testId || `section-${props.id}`}
>
{/* Section Header */}
<button
type="button"
onClick={handleToggle}
class={`
w-full flex items-center justify-between gap-3 px-4 py-3
text-left cursor-pointer select-none
hover:bg-gray-50 dark:hover:bg-gray-700/50
transition-colors duration-150
${isCollapsed() ? 'rounded-lg' : 'rounded-t-lg border-b border-gray-200 dark:border-gray-700'}
`}
aria-expanded={!isCollapsed()}
aria-controls={`section-content-${props.id}`}
>
{/* Left side: Chevron + Icon + Title + Count */}
<div class="flex items-center gap-3 min-w-0">
{/* Expand/Collapse chevron */}
<div class="flex-shrink-0 text-gray-400 dark:text-gray-500 transition-transform duration-200">
<Show when={isCollapsed()} fallback={<ChevronDown class="w-5 h-5" />}>
<ChevronRight class="w-5 h-5" />
</Show>
</div>
{/* Optional icon */}
<Show when={props.icon}>
<div class="flex-shrink-0 text-gray-500 dark:text-gray-400">
{props.icon}
</div>
</Show>
{/* Title and count */}
<div class="min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 truncate">
{props.title}
</h3>
<Show when={props.resourceCount !== undefined}>
<span class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{props.resourceCount}
</span>
</Show>
<Show when={props.isGloballyDisabled}>
<span class="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
Disabled
</span>
</Show>
<Show when={props.hasChanges}>
<span class="flex-shrink-0 w-2 h-2 rounded-full bg-blue-500" title="Unsaved changes" />
</Show>
</div>
<Show when={props.subtitle}>
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">
{props.subtitle}
</p>
</Show>
</div>
</div>
{/* Right side: Header actions */}
<div
class="flex items-center gap-2 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{props.headerActions}
</div>
</button>
{/* Section Content */}
<div
id={`section-content-${props.id}`}
class={`
overflow-hidden transition-all duration-200 ease-in-out
${isCollapsed() ? 'max-h-0 opacity-0' : 'max-h-[5000px] opacity-100'}
`}
>
<div class="p-4">
<Show when={showEmpty()}>
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>{props.emptyMessage}</p>
</div>
</Show>
<Show when={!isEmpty() || !props.emptyMessage}>
{props.children}
</Show>
</div>
</div>
</div>
);
};
/**
* A button for section header actions (e.g., "Edit Defaults")
*/
export interface SectionActionButtonProps {
label: string;
onClick: () => void;
icon?: JSX.Element;
variant?: 'default' | 'primary' | 'danger';
disabled?: boolean;
title?: string;
}
export const SectionActionButton: Component<SectionActionButtonProps> = (props) => {
const variantClasses = {
default: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700',
primary: 'text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-900/30',
danger: 'text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/30',
};
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
props.onClick();
}}
disabled={props.disabled}
title={props.title}
class={`
inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm font-medium
transition-colors duration-150
${variantClasses[props.variant || 'default']}
${props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<Show when={props.icon}>
{props.icon}
</Show>
{props.label}
</button>
);
};
/**
* Nested group header within a section (e.g., node name grouping VMs)
*/
export interface NestedGroupHeaderProps {
title: string;
subtitle?: string;
count?: number;
collapsed?: boolean;
onToggle?: () => void;
actions?: JSX.Element;
status?: 'online' | 'offline' | 'unknown';
href?: string;
}
export const NestedGroupHeader: Component<NestedGroupHeaderProps> = (props) => {
const statusColors = {
online: 'bg-green-500',
offline: 'bg-red-500',
unknown: 'bg-gray-400',
};
return (
<div
class={`
flex items-center justify-between gap-3 px-3 py-2 -mx-3 rounded-md
${props.onToggle ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50' : ''}
transition-colors duration-150
`}
onClick={props.onToggle}
>
<div class="flex items-center gap-2 min-w-0">
<Show when={props.onToggle}>
<div class="flex-shrink-0 text-gray-400">
<Show when={props.collapsed} fallback={<ChevronDown class="w-4 h-4" />}>
<ChevronRight class="w-4 h-4" />
</Show>
</div>
</Show>
<Show when={props.status}>
<span
class={`flex-shrink-0 w-2 h-2 rounded-full ${statusColors[props.status!]}`}
title={props.status}
/>
</Show>
<div class="min-w-0">
<div class="flex items-center gap-2">
<Show
when={props.href}
fallback={
<span class="font-medium text-gray-800 dark:text-gray-200 truncate">
{props.title}
</span>
}
>
<a
href={props.href}
target="_blank"
rel="noopener noreferrer"
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 truncate"
onClick={(e) => e.stopPropagation()}
>
{props.title}
</a>
</Show>
<Show when={props.count !== undefined}>
<span class="text-xs text-gray-500 dark:text-gray-400">
({props.count})
</span>
</Show>
</div>
<Show when={props.subtitle}>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
{props.subtitle}
</p>
</Show>
</div>
</div>
<Show when={props.actions}>
<div class="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{props.actions}
</div>
</Show>
</div>
);
};
export default CollapsibleSection;

View file

@ -0,0 +1,374 @@
/**
* ProxmoxNodesSection Component
*
* Displays Proxmox nodes in a collapsible section with the new card-based layout.
* This is the first section to be migrated to the new architecture.
*/
import { Component, For, createMemo, createSignal } from 'solid-js';
import Server from 'lucide-solid/icons/server';
import { CollapsibleSection } from './CollapsibleSection';
import { ResourceCard } from '../components/ResourceCard';
import { GlobalDefaultsRow } from '../components/GlobalDefaultsRow';
import type { ThresholdResource, ThresholdValues, ThresholdColumn } from '../types';
import type { Alert, Node } from '@/types/api';
import type { RawOverrideConfig } from '@/types/alerts';
/**
* Column definitions for Proxmox nodes
*/
const NODE_COLUMNS: ThresholdColumn[] = [
{ key: 'cpu', label: 'CPU %', unit: '%' },
{ key: 'memory', label: 'Memory %', unit: '%' },
{ key: 'disk', label: 'Disk %', unit: '%' },
{ key: 'temperature', label: 'Temp °C', unit: '°C' },
];
export interface ProxmoxNodesSectionProps {
/** Raw nodes from state */
nodes: Node[];
/** Current overrides */
overrides: Array<{
id: string;
name: string;
type: string;
thresholds: Record<string, number | undefined>;
disableConnectivity?: boolean;
note?: string;
}>;
/** Raw overrides config for saving */
rawOverridesConfig: Record<string, RawOverrideConfig>;
setRawOverridesConfig: (config: Record<string, RawOverrideConfig>) => void;
/** Default thresholds */
nodeDefaults: ThresholdValues;
setNodeDefaults: (
value: ThresholdValues | ((prev: ThresholdValues) => ThresholdValues)
) => void;
factoryNodeDefaults?: ThresholdValues;
resetNodeDefaults?: () => void;
/** Global disable flags */
disableAllNodes: boolean;
setDisableAllNodes: (value: boolean) => void;
disableAllNodesOffline: boolean;
setDisableAllNodesOffline: (value: boolean) => void;
/** Time thresholds */
globalDelaySeconds?: number;
metricDelaySeconds?: Record<string, number>;
/** Unsaved changes tracking */
setHasUnsavedChanges: (value: boolean) => void;
/** Active alerts */
activeAlerts?: Record<string, Alert>;
removeAlerts?: (predicate: (alert: Alert) => boolean) => void;
/** Section state */
isCollapsed?: boolean;
onToggleCollapse?: (collapsed: boolean) => void;
/** Overrides management */
setOverrides: (overrides: any[]) => void;
}
/**
* Transform a Node to a ThresholdResource
*/
const nodeToResource = (
node: Node,
override?: {
thresholds: Record<string, number | undefined>;
disableConnectivity?: boolean;
note?: string;
},
defaults?: ThresholdValues
): ThresholdResource => {
// Build a friendly display name
const displayName = node.displayName?.trim() || node.name;
// Check if there are any custom thresholds
const hasCustomThresholds = override?.thresholds &&
Object.keys(override.thresholds).some((key) => {
const value = override.thresholds[key];
const defaultValue = defaults?.[key];
return value !== undefined && value !== defaultValue;
});
const hasNote = Boolean(override?.note?.trim());
return {
id: node.id,
name: displayName,
displayName,
rawName: node.name,
type: 'node',
resourceType: 'Node',
status: node.status,
uptime: node.uptime,
cpu: node.cpu,
memory: node.memory?.usage,
hasOverride: hasCustomThresholds || hasNote || Boolean(override?.disableConnectivity),
disableConnectivity: override?.disableConnectivity || false,
thresholds: override?.thresholds || {},
defaults: defaults || {},
clusterName: node.isClusterMember ? node.clusterName?.trim() : undefined,
isClusterMember: node.isClusterMember ?? false,
instance: node.instance,
note: override?.note,
// Build host URL
host: buildNodeUrl(node),
};
};
/**
* Build management URL for a node
*/
const buildNodeUrl = (node: Node): string | undefined => {
const hostValue = node.host?.trim();
if (hostValue) {
return hostValue.startsWith('http')
? hostValue
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
}
if (node.name) {
return `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`;
}
return undefined;
};
export const ProxmoxNodesSection: Component<ProxmoxNodesSectionProps> = (props) => {
// Editing state
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, number | undefined>>({});
const [editingNote, setEditingNote] = createSignal('');
// Transform nodes to resources
const resources = createMemo(() => {
const overridesMap = new Map(
props.overrides
.filter((o) => o.type === 'node')
.map((o) => [o.id, o])
);
return props.nodes.map((node) => {
const override = overridesMap.get(node.id);
return nodeToResource(node, override, props.nodeDefaults);
});
});
// Check if there's an active alert for a resource/metric
const hasActiveAlert = (resourceId: string, metric: string): boolean => {
if (!props.activeAlerts) return false;
const alertKey = `${resourceId}-${metric}`;
return alertKey in props.activeAlerts;
};
// Format threshold value for display
const formatValue = (metric: string, value: number | undefined): string => {
if (value === undefined || value === null) return '—';
if (value <= 0) return 'Off';
if (metric === 'temperature') return `${value}°C`;
if (['cpu', 'memory', 'disk'].includes(metric)) return `${value}%`;
return String(value);
};
// Start editing a resource
const startEditing = (resource: ThresholdResource) => {
const mergedThresholds: Record<string, number | undefined> = {};
NODE_COLUMNS.forEach((col) => {
mergedThresholds[col.key] = resource.thresholds[col.key] ?? resource.defaults[col.key];
});
setEditingThresholds(mergedThresholds);
setEditingNote(resource.note || '');
setEditingId(resource.id);
};
// Save edit
const saveEdit = (resource: ThresholdResource) => {
const newThresholds: Record<string, number | undefined> = {};
const thresholds = editingThresholds();
// Only save values that differ from defaults
NODE_COLUMNS.forEach((col) => {
const value = thresholds[col.key];
const defaultValue = props.nodeDefaults[col.key];
if (value !== undefined && value !== defaultValue) {
newThresholds[col.key] = value;
}
});
const note = editingNote().trim();
const hasChanges = Object.keys(newThresholds).length > 0 || note;
// Update overrides
const existingIndex = props.overrides.findIndex((o) => o.id === resource.id);
const newOverride = {
id: resource.id,
name: resource.name,
type: 'node',
thresholds: newThresholds,
disableConnectivity: resource.disableConnectivity,
note: note || undefined,
};
const newOverrides = [...props.overrides];
if (hasChanges) {
if (existingIndex >= 0) {
newOverrides[existingIndex] = newOverride;
} else {
newOverrides.push(newOverride);
}
} else if (existingIndex >= 0) {
// Remove override if no changes
newOverrides.splice(existingIndex, 1);
}
props.setOverrides(newOverrides);
// Update raw config
const newRawConfig = { ...props.rawOverridesConfig };
if (hasChanges) {
const rawOverride: RawOverrideConfig = {};
Object.entries(newThresholds).forEach(([key, value]) => {
if (value !== undefined) {
rawOverride[key] = { trigger: value, clear: Math.max(0, value - 5) };
}
});
if (resource.disableConnectivity) {
rawOverride.disableConnectivity = true;
}
if (note) {
rawOverride.note = note;
}
newRawConfig[resource.id] = rawOverride;
} else {
delete newRawConfig[resource.id];
}
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
setEditingId(null);
setEditingThresholds({});
setEditingNote('');
};
// Cancel edit
const cancelEdit = () => {
setEditingId(null);
setEditingThresholds({});
setEditingNote('');
};
// Toggle connectivity alerts for a node
const toggleConnectivity = (resource: ThresholdResource) => {
const newDisableConnectivity = !resource.disableConnectivity;
const existingIndex = props.overrides.findIndex((o) => o.id === resource.id);
const existingOverride = existingIndex >= 0 ? props.overrides[existingIndex] : null;
const newOverride = {
id: resource.id,
name: resource.name,
type: 'node',
thresholds: existingOverride?.thresholds || {},
disableConnectivity: newDisableConnectivity,
note: existingOverride?.note,
};
const newOverrides = [...props.overrides];
if (existingIndex >= 0) {
newOverrides[existingIndex] = newOverride;
} else {
newOverrides.push(newOverride);
}
props.setOverrides(newOverrides);
// Update raw config
const newRawConfig = { ...props.rawOverridesConfig };
const existing = newRawConfig[resource.id] || {};
if (newDisableConnectivity) {
existing.disableConnectivity = true;
} else {
delete existing.disableConnectivity;
}
if (Object.keys(existing).length > 0) {
newRawConfig[resource.id] = existing;
} else {
delete newRawConfig[resource.id];
}
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
};
// Remove override for a resource
const removeOverride = (resourceId: string) => {
props.setOverrides(props.overrides.filter((o) => o.id !== resourceId));
const newRawConfig = { ...props.rawOverridesConfig };
delete newRawConfig[resourceId];
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
};
return (
<CollapsibleSection
id="nodes"
title="Proxmox Nodes"
resourceCount={props.nodes.length}
collapsed={props.isCollapsed}
onToggle={props.onToggleCollapse}
icon={<Server class="w-5 h-5" />}
isGloballyDisabled={props.disableAllNodes}
emptyMessage="No Proxmox nodes found."
>
<div class="space-y-4">
{/* Global Defaults */}
<GlobalDefaultsRow
defaults={props.nodeDefaults}
factoryDefaults={props.factoryNodeDefaults}
columns={NODE_COLUMNS}
onUpdateDefaults={props.setNodeDefaults}
setHasUnsavedChanges={props.setHasUnsavedChanges}
onResetDefaults={props.resetNodeDefaults}
globalDisabled={props.disableAllNodes}
onToggleGlobalDisabled={() => props.setDisableAllNodes(!props.disableAllNodes)}
showOfflineSettings={true}
disableConnectivity={props.disableAllNodesOffline}
onSetOfflineState={(state) => {
if (state === 'off') {
props.setDisableAllNodesOffline(true);
} else {
props.setDisableAllNodesOffline(false);
}
props.setHasUnsavedChanges(true);
}}
/>
{/* Resource Cards */}
<div class="space-y-2">
<For each={resources()}>
{(resource) => (
<ResourceCard
resource={resource}
columns={NODE_COLUMNS}
isEditing={editingId() === resource.id}
editingThresholds={editingThresholds()}
editingNote={editingNote()}
onStartEdit={() => startEditing(resource)}
onSaveEdit={() => saveEdit(resource)}
onCancelEdit={cancelEdit}
onUpdateThreshold={(metric, value) => {
setEditingThresholds((prev) => ({ ...prev, [metric]: value }));
}}
onUpdateNote={setEditingNote}
onToggleConnectivity={() => toggleConnectivity(resource)}
onRemoveOverride={() => removeOverride(resource.id)}
formatValue={formatValue}
hasActiveAlert={hasActiveAlert}
showOfflineAlerts={true}
globalDefaults={props.nodeDefaults}
/>
)}
</For>
</div>
</div>
</CollapsibleSection>
);
};
export default ProxmoxNodesSection;

View file

@ -0,0 +1,381 @@
/**
* Alert Thresholds - Shared Types
*
* This module defines the core types used across the thresholds components.
* Centralizing types here ensures consistency and makes refactoring easier.
*/
import type { Alert } from '@/types/api';
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
// ============================================================================
// Resource Types
// ============================================================================
/**
* The type of resource being configured
*/
export type ResourceType =
| 'node'
| 'guest'
| 'storage'
| 'pbs'
| 'pmg'
| 'hostAgent'
| 'dockerHost'
| 'dockerContainer';
/**
* Severity level for powered-off/offline alerts
*/
export type OfflineAlertSeverity = 'off' | 'warning' | 'critical';
/**
* A resource that can have threshold overrides
*/
export interface ThresholdResource {
id: string;
name: string;
displayName?: string;
rawName?: string;
type: ResourceType;
resourceType?: string; // Human-readable: "VM", "CT", "Node", etc.
// Hierarchy
node?: string;
instance?: string;
host?: string;
clusterName?: string;
isClusterMember?: boolean;
// Status
status?: string;
uptime?: number;
cpu?: number;
memory?: number;
// Override state
hasOverride: boolean;
disabled?: boolean;
disableConnectivity?: boolean;
poweredOffSeverity?: 'warning' | 'critical';
// Threshold values
thresholds: ThresholdValues;
defaults: ThresholdValues;
// Additional metadata
vmid?: number;
note?: string;
delaySeconds?: number;
// For special resources (snapshots, backups)
editable?: boolean;
editScope?: 'snapshot' | 'backup';
isEnabled?: boolean;
toggleEnabled?: () => void;
toggleTitleEnabled?: string;
toggleTitleDisabled?: string;
}
/**
* Threshold values for a resource
*/
export interface ThresholdValues {
cpu?: number;
memory?: number;
disk?: number;
diskRead?: number;
diskWrite?: number;
networkIn?: number;
networkOut?: number;
temperature?: number;
usage?: number;
[key: string]: number | undefined;
}
/**
* Metadata for a group header (e.g., node grouping VMs)
*/
export interface GroupHeaderMeta {
type?: 'node' | 'default';
displayName?: string;
rawName?: string;
host?: string;
status?: string;
clusterName?: string;
isClusterMember?: boolean;
}
// ============================================================================
// Section Types
// ============================================================================
/**
* Configuration for a collapsible section
*/
export interface SectionConfig {
id: string;
title: string;
resourceCount: number;
isCollapsed: boolean;
hasResources: boolean;
}
/**
* Available section IDs in the Proxmox tab
*/
export type ProxmoxSectionId =
| 'nodes'
| 'pbs'
| 'guests'
| 'storage'
| 'backups'
| 'snapshots';
/**
* Column definition for resource tables/grids
*/
export interface ThresholdColumn {
key: string;
label: string;
unit?: string;
tooltip?: string;
minWidth?: number;
hideOnMobile?: boolean;
}
// ============================================================================
// Editing State Types
// ============================================================================
/**
* State for the currently editing resource
*/
export interface EditingState {
resourceId: string | null;
thresholds: Record<string, number | undefined>;
note: string;
isDirty: boolean;
}
/**
* Actions for editing thresholds
*/
export interface EditingActions {
startEditing: (
resourceId: string,
currentThresholds: Record<string, number | undefined>,
defaults: Record<string, number | undefined>,
note?: string
) => void;
updateThreshold: (metric: string, value: number | undefined) => void;
updateNote: (note: string) => void;
saveEdit: () => void;
cancelEdit: () => void;
}
// ============================================================================
// Global Defaults Types
// ============================================================================
/**
* Default threshold configuration for guests
*/
export interface GuestDefaults extends ThresholdValues {
disableConnectivity?: boolean;
poweredOffSeverity?: 'warning' | 'critical';
}
/**
* Default threshold configuration for Docker containers
*/
export interface DockerDefaults {
cpu: number;
memory: number;
disk: number;
restartCount: number;
restartWindow: number;
memoryWarnPct: number;
memoryCriticalPct: number;
serviceWarnGapPercent: number;
serviceCriticalGapPercent: number;
}
/**
* Time-based thresholds (delay before alerting)
*/
export interface TimeThresholds {
guest: number;
node: number;
storage: number;
pbs: number;
}
// ============================================================================
// Global Disable Flags
// ============================================================================
export interface GlobalDisableFlags {
// Disable all alerts for resource type
disableAllNodes: boolean;
disableAllGuests: boolean;
disableAllHosts: boolean;
disableAllStorage: boolean;
disableAllPBS: boolean;
disableAllPMG: boolean;
disableAllDockerHosts: boolean;
disableAllDockerContainers: boolean;
disableAllDockerServices: boolean;
// Disable offline/connectivity alerts
disableAllNodesOffline: boolean;
disableAllGuestsOffline: boolean;
disableAllHostsOffline: boolean;
disableAllPBSOffline: boolean;
disableAllPMGOffline: boolean;
disableAllDockerHostsOffline: boolean;
}
// ============================================================================
// Props Types
// ============================================================================
/**
* Props passed to the main ThresholdsPage component
*/
export interface ThresholdsPageProps {
// Active alerts for visual indicators
activeAlerts?: Record<string, Alert>;
removeAlerts?: (predicate: (alert: Alert) => boolean) => void;
// Callback when changes are made
setHasUnsavedChanges: (value: boolean) => void;
}
/**
* Props for a collapsible section component
*/
export interface CollapsibleSectionProps {
id: string;
title: string;
resourceCount: number;
defaultCollapsed?: boolean;
onToggleCollapse?: (collapsed: boolean) => void;
children: any; // SolidJS children
actions?: any; // Header action buttons
emptyMessage?: string;
}
/**
* Props for a resource card component
*/
export interface ResourceCardProps {
resource: ThresholdResource;
columns: ThresholdColumn[];
isEditing: boolean;
editingThresholds: Record<string, number | undefined>;
onStartEdit: () => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
onUpdateThreshold: (metric: string, value: number | undefined) => void;
onUpdateNote: (note: string) => void;
onToggleDisabled?: () => void;
onToggleConnectivity?: () => void;
onRemoveOverride?: () => void;
formatValue: (metric: string, value: number | undefined) => string;
hasActiveAlert: (resourceId: string, metric: string) => boolean;
}
/**
* Props for the threshold badge component
*/
export interface ThresholdBadgeProps {
metric: string;
value: number | undefined;
defaultValue?: number;
isOverridden?: boolean;
hasAlert?: boolean;
onClick?: () => void;
size?: 'sm' | 'md' | 'lg';
}
// ============================================================================
// Constants
// ============================================================================
/**
* Standard columns for different resource types
*/
export const RESOURCE_COLUMNS: Record<ResourceType, ThresholdColumn[]> = {
node: [
{ key: 'cpu', label: 'CPU %', unit: '%' },
{ key: 'memory', label: 'Memory %', unit: '%' },
{ key: 'disk', label: 'Disk %', unit: '%' },
{ key: 'temperature', label: 'Temp °C', unit: '°C' },
],
guest: [
{ key: 'cpu', label: 'CPU %', unit: '%' },
{ key: 'memory', label: 'Memory %', unit: '%' },
{ key: 'disk', label: 'Disk %', unit: '%' },
{ key: 'diskRead', label: 'Disk R', unit: 'MB/s', hideOnMobile: true },
{ key: 'diskWrite', label: 'Disk W', unit: 'MB/s', hideOnMobile: true },
{ key: 'networkIn', label: 'Net In', unit: 'MB/s', hideOnMobile: true },
{ key: 'networkOut', label: 'Net Out', unit: 'MB/s', hideOnMobile: true },
],
storage: [
{ key: 'usage', label: 'Usage %', unit: '%' },
],
pbs: [
{ key: 'cpu', label: 'CPU %', unit: '%' },
{ key: 'memory', label: 'Memory %', unit: '%' },
],
pmg: [
// PMG has different columns - defined in PMG section
],
hostAgent: [
{ key: 'cpu', label: 'CPU %', unit: '%' },
{ key: 'memory', label: 'Memory %', unit: '%' },
{ key: 'disk', label: 'Disk %', unit: '%' },
],
dockerHost: [],
dockerContainer: [
{ key: 'cpu', label: 'CPU %', unit: '%' },
{ key: 'memory', label: 'Memory %', unit: '%' },
],
};
/**
* Get severity color for a threshold value
*/
export const getThresholdSeverityColor = (
value: number | undefined,
metric: string
): 'disabled' | 'conservative' | 'moderate' | 'aggressive' | 'critical' => {
if (value === undefined || value <= 0) return 'disabled';
// Temperature has different scale
if (metric === 'temperature') {
if (value >= 90) return 'critical';
if (value >= 80) return 'aggressive';
if (value >= 70) return 'moderate';
return 'conservative';
}
// Standard percentage metrics
if (value >= 95) return 'conservative'; // Very permissive
if (value >= 85) return 'moderate';
if (value >= 70) return 'aggressive';
return 'critical'; // Very strict
};
/**
* CSS classes for severity colors
*/
export const SEVERITY_COLORS = {
disabled: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
conservative: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
moderate: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
aggressive: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
critical: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
} as const;

File diff suppressed because it is too large Load diff

View file

@ -40,17 +40,92 @@ func ComputeTrend(points []MetricPoint, metricName string, period time.Duration)
regression := linearRegression(sorted)
trend.Confidence = regression.R2
// Calculate actual time span of the data
actualSpan := sorted[len(sorted)-1].Timestamp.Sub(sorted[0].Timestamp)
// Convert slope from "per second" to "per hour" and "per day"
// Slope is in units/second
trend.RatePerHour = regression.Slope * 3600
trend.RatePerDay = regression.Slope * 86400
// Apply sanity checks for short time spans and percentage metrics
trend = applyTrendSanityChecks(trend, actualSpan, metricName, stats.Mean)
// Classify the trend direction
trend.Direction = classifyTrend(regression.Slope, stats.Mean, stats.StdDev)
return trend
}
// applyTrendSanityChecks applies corrections to prevent unrealistic extrapolated rates.
// This addresses issues like a 1-minute data span being extrapolated to 700%/day.
func applyTrendSanityChecks(trend Trend, actualSpan time.Duration, metricName string, mean float64) Trend {
// Minimum time span required for meaningful extrapolation
// Without at least 1 hour of data, daily rate extrapolation is very unreliable
minSpanForDailyRate := time.Hour
minSpanForHourlyRate := 10 * time.Minute
// If we don't have enough time span for meaningful extrapolation,
// reduce confidence and cap the rates
if actualSpan < minSpanForHourlyRate {
// Very short time span - rates are unreliable
trend.Confidence *= 0.1 // Heavily penalize confidence
// Don't extrapolate short blips to large rates
trend.RatePerHour = 0
trend.RatePerDay = 0
} else if actualSpan < minSpanForDailyRate {
// Medium time span - hourly is somewhat reliable, daily is not
spanRatio := float64(actualSpan) / float64(minSpanForDailyRate)
trend.Confidence *= spanRatio // Scale confidence by how much data we have
// Cap daily rate extrapolation for short spans
// Scale down the daily rate by how much of the day we actually observed
dailyScaleFactor := float64(actualSpan) / float64(24*time.Hour)
if dailyScaleFactor < 0.1 {
dailyScaleFactor = 0.1
}
// Cap absurd rates - if extrapolated rate is much higher than observed change, it's noise
observedChange := math.Abs(trend.Max - trend.Min)
maxReasonableDaily := observedChange * 10 // Allow 10x the observed change as max daily
if math.Abs(trend.RatePerDay) > maxReasonableDaily && maxReasonableDaily > 0 {
if trend.RatePerDay > 0 {
trend.RatePerDay = maxReasonableDaily
} else {
trend.RatePerDay = -maxReasonableDaily
}
}
}
// For percentage metrics (0-100 range), apply physical limits
// A metric bounded 0-100 can't grow more than 100% per day
isPercentageMetric := metricName == "cpu" || metricName == "memory" || metricName == "disk" ||
metricName == "usage" || mean <= 100
if isPercentageMetric {
// Maximum physically possible rate for a 0-100% metric
// Even a runaway process can't grow more than 100 percentage points per day
maxRate := 100.0
if math.Abs(trend.RatePerDay) > maxRate {
if trend.RatePerDay > 0 {
trend.RatePerDay = maxRate
} else {
trend.RatePerDay = -maxRate
}
// Also cap hourly rate
if math.Abs(trend.RatePerHour) > maxRate/24 {
if trend.RatePerHour > 0 {
trend.RatePerHour = maxRate / 24
} else {
trend.RatePerHour = -maxRate / 24
}
}
// Reduce confidence as the raw calculation was clearly wrong
trend.Confidence *= 0.5
}
}
return trend
}
// computeStats calculates basic statistics for a set of metric points
func computeStats(points []MetricPoint) Stats {
if len(points) == 0 {

View file

@ -248,3 +248,110 @@ func TestComputeStats(t *testing.T) {
t.Errorf("Expected mean 30, got %.2f", stats.Mean)
}
}
// TestComputeTrend_ShortTimeSpanBlip tests that a small fluctuation
// over a very short time span (like 1 minute) doesn't get extrapolated
// to an absurd daily rate like 700%/day
func TestComputeTrend_ShortTimeSpanBlip(t *testing.T) {
// This simulates the exact bug: homepage-docker goes from 24.8% to 25.2%
// over 1 minute (3 data points), but was being reported as 708%/day growth
now := time.Now()
points := []MetricPoint{
{Value: 24.8, Timestamp: now.Add(-2 * time.Minute)},
{Value: 25.0, Timestamp: now.Add(-1 * time.Minute)},
{Value: 25.2, Timestamp: now}, // Only 0.4% change total
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
// With only 2 minutes of data, we should NOT extrapolate to crazy daily rates
// The observed change is only 0.4%, so a 700% daily rate is nonsense
if trend.RatePerDay > 50 {
t.Errorf("Short time span blip should not extrapolate to %f%%/day (expected < 50)", trend.RatePerDay)
}
// Confidence should be low for such short time spans
if trend.Confidence > 0.5 {
t.Errorf("Expected low confidence for 2-minute span, got %.2f", trend.Confidence)
}
}
// TestComputeTrend_PercentageCapping tests that percentage metrics (0-100)
// have their growth rates capped to physically possible limits
func TestComputeTrend_PercentageCapping(t *testing.T) {
// Even with a long time span, if the raw rate comes out absurdly high
// (which shouldn't happen with good data, but let's test the cap)
now := time.Now()
// Create data that would naively produce a >100%/day rate
// 5 points over 2 hours with aggressive growth
points := make([]MetricPoint, 5)
for i := 0; i < 5; i++ {
points[i] = MetricPoint{
Value: 20 + float64(i)*10, // 20, 30, 40, 50, 60
Timestamp: now.Add(time.Duration(-4+i) * 30 * time.Minute), // 30 min apart
}
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
// For a percentage metric, rate should be capped at 100%/day max
if trend.RatePerDay > 100 {
t.Errorf("Percentage metric should be capped at 100%%/day, got %.2f", trend.RatePerDay)
}
}
// TestComputeTrend_MediumTimeSpan tests that 10-60 minutes of data
// gets moderate rate capping but isn't completely zeroed out
func TestComputeTrend_MediumTimeSpan(t *testing.T) {
now := time.Now()
// 30 minutes of data with steady growth
points := make([]MetricPoint, 7)
for i := 0; i < 7; i++ {
points[i] = MetricPoint{
Value: 30 + float64(i)*1.5, // Growing ~10% over 30 min
Timestamp: now.Add(time.Duration(-30+i*5) * time.Minute),
}
}
trend := ComputeTrend(points, "cpu", 24*time.Hour)
// Rate should be present (not zeroed) but reasonable
if trend.RatePerHour == 0 {
t.Errorf("Medium time span should have non-zero hourly rate")
}
// But daily extrapolation should be constrained
observedChange := 1.5 * 6 // ~9% change
if trend.RatePerDay > observedChange*15 {
t.Errorf("Daily rate %.2f should not vastly exceed observed change %.2f",
trend.RatePerDay, observedChange)
}
}
// TestComputeTrend_LongTimeSpanNoChange tests that with 24h of data
// and minimal change, we get stable (not growing) trend
func TestComputeTrend_LongTimeSpanNoChange(t *testing.T) {
now := time.Now()
// 24 hours of stable data at ~25%
points := make([]MetricPoint, 24)
for i := 0; i < 24; i++ {
// Very small oscillation around 25%
points[i] = MetricPoint{
Value: 25.0 + float64(i%2)*0.2, // 25.0, 25.2, 25.0, 25.2...
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
}
}
trend := ComputeTrend(points, "memory", 24*time.Hour)
if trend.Direction == TrendGrowing {
t.Errorf("Stable oscillating data should not be classified as Growing")
}
// Rate should be tiny
if trend.RatePerDay > 1 || trend.RatePerDay < -1 {
t.Errorf("Stable data should have near-zero rate, got %.2f/day", trend.RatePerDay)
}
}