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:
parent
558b5a6ccd
commit
41004ebcea
12 changed files with 3278 additions and 401 deletions
325
.gemini/artifacts/alert-thresholds-redesign-plan.md
Normal file
325
.gemini/artifacts/alert-thresholds-redesign-plan.md
Normal 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
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
23
frontend-modern/src/components/Alerts/Thresholds/index.ts
Normal file
23
frontend-modern/src/components/Alerts/Thresholds/index.ts
Normal 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';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
381
frontend-modern/src/components/Alerts/Thresholds/types.ts
Normal file
381
frontend-modern/src/components/Alerts/Thresholds/types.ts
Normal 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
|
|
@ -40,17 +40,92 @@ func ComputeTrend(points []MetricPoint, metricName string, period time.Duration)
|
||||||
regression := linearRegression(sorted)
|
regression := linearRegression(sorted)
|
||||||
trend.Confidence = regression.R2
|
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"
|
// Convert slope from "per second" to "per hour" and "per day"
|
||||||
// Slope is in units/second
|
// Slope is in units/second
|
||||||
trend.RatePerHour = regression.Slope * 3600
|
trend.RatePerHour = regression.Slope * 3600
|
||||||
trend.RatePerDay = regression.Slope * 86400
|
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
|
// Classify the trend direction
|
||||||
trend.Direction = classifyTrend(regression.Slope, stats.Mean, stats.StdDev)
|
trend.Direction = classifyTrend(regression.Slope, stats.Mean, stats.StdDev)
|
||||||
|
|
||||||
return trend
|
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
|
// computeStats calculates basic statistics for a set of metric points
|
||||||
func computeStats(points []MetricPoint) Stats {
|
func computeStats(points []MetricPoint) Stats {
|
||||||
if len(points) == 0 {
|
if len(points) == 0 {
|
||||||
|
|
|
||||||
|
|
@ -248,3 +248,110 @@ func TestComputeStats(t *testing.T) {
|
||||||
t.Errorf("Expected mean 30, got %.2f", stats.Mean)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue