mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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
c91307be94
commit
0182cc8310
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)
|
||||
trend.Confidence = regression.R2
|
||||
|
||||
// Calculate actual time span of the data
|
||||
actualSpan := sorted[len(sorted)-1].Timestamp.Sub(sorted[0].Timestamp)
|
||||
|
||||
// Convert slope from "per second" to "per hour" and "per day"
|
||||
// Slope is in units/second
|
||||
trend.RatePerHour = regression.Slope * 3600
|
||||
trend.RatePerDay = regression.Slope * 86400
|
||||
|
||||
// Apply sanity checks for short time spans and percentage metrics
|
||||
trend = applyTrendSanityChecks(trend, actualSpan, metricName, stats.Mean)
|
||||
|
||||
// Classify the trend direction
|
||||
trend.Direction = classifyTrend(regression.Slope, stats.Mean, stats.StdDev)
|
||||
|
||||
return trend
|
||||
}
|
||||
|
||||
// applyTrendSanityChecks applies corrections to prevent unrealistic extrapolated rates.
|
||||
// This addresses issues like a 1-minute data span being extrapolated to 700%/day.
|
||||
func applyTrendSanityChecks(trend Trend, actualSpan time.Duration, metricName string, mean float64) Trend {
|
||||
// Minimum time span required for meaningful extrapolation
|
||||
// Without at least 1 hour of data, daily rate extrapolation is very unreliable
|
||||
minSpanForDailyRate := time.Hour
|
||||
minSpanForHourlyRate := 10 * time.Minute
|
||||
|
||||
// If we don't have enough time span for meaningful extrapolation,
|
||||
// reduce confidence and cap the rates
|
||||
if actualSpan < minSpanForHourlyRate {
|
||||
// Very short time span - rates are unreliable
|
||||
trend.Confidence *= 0.1 // Heavily penalize confidence
|
||||
// Don't extrapolate short blips to large rates
|
||||
trend.RatePerHour = 0
|
||||
trend.RatePerDay = 0
|
||||
} else if actualSpan < minSpanForDailyRate {
|
||||
// Medium time span - hourly is somewhat reliable, daily is not
|
||||
spanRatio := float64(actualSpan) / float64(minSpanForDailyRate)
|
||||
trend.Confidence *= spanRatio // Scale confidence by how much data we have
|
||||
// Cap daily rate extrapolation for short spans
|
||||
// Scale down the daily rate by how much of the day we actually observed
|
||||
dailyScaleFactor := float64(actualSpan) / float64(24*time.Hour)
|
||||
if dailyScaleFactor < 0.1 {
|
||||
dailyScaleFactor = 0.1
|
||||
}
|
||||
// Cap absurd rates - if extrapolated rate is much higher than observed change, it's noise
|
||||
observedChange := math.Abs(trend.Max - trend.Min)
|
||||
maxReasonableDaily := observedChange * 10 // Allow 10x the observed change as max daily
|
||||
if math.Abs(trend.RatePerDay) > maxReasonableDaily && maxReasonableDaily > 0 {
|
||||
if trend.RatePerDay > 0 {
|
||||
trend.RatePerDay = maxReasonableDaily
|
||||
} else {
|
||||
trend.RatePerDay = -maxReasonableDaily
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For percentage metrics (0-100 range), apply physical limits
|
||||
// A metric bounded 0-100 can't grow more than 100% per day
|
||||
isPercentageMetric := metricName == "cpu" || metricName == "memory" || metricName == "disk" ||
|
||||
metricName == "usage" || mean <= 100
|
||||
|
||||
if isPercentageMetric {
|
||||
// Maximum physically possible rate for a 0-100% metric
|
||||
// Even a runaway process can't grow more than 100 percentage points per day
|
||||
maxRate := 100.0
|
||||
if math.Abs(trend.RatePerDay) > maxRate {
|
||||
if trend.RatePerDay > 0 {
|
||||
trend.RatePerDay = maxRate
|
||||
} else {
|
||||
trend.RatePerDay = -maxRate
|
||||
}
|
||||
// Also cap hourly rate
|
||||
if math.Abs(trend.RatePerHour) > maxRate/24 {
|
||||
if trend.RatePerHour > 0 {
|
||||
trend.RatePerHour = maxRate / 24
|
||||
} else {
|
||||
trend.RatePerHour = -maxRate / 24
|
||||
}
|
||||
}
|
||||
// Reduce confidence as the raw calculation was clearly wrong
|
||||
trend.Confidence *= 0.5
|
||||
}
|
||||
}
|
||||
|
||||
return trend
|
||||
}
|
||||
|
||||
// computeStats calculates basic statistics for a set of metric points
|
||||
func computeStats(points []MetricPoint) Stats {
|
||||
if len(points) == 0 {
|
||||
|
|
|
|||
|
|
@ -248,3 +248,110 @@ func TestComputeStats(t *testing.T) {
|
|||
t.Errorf("Expected mean 30, got %.2f", stats.Mean)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeTrend_ShortTimeSpanBlip tests that a small fluctuation
|
||||
// over a very short time span (like 1 minute) doesn't get extrapolated
|
||||
// to an absurd daily rate like 700%/day
|
||||
func TestComputeTrend_ShortTimeSpanBlip(t *testing.T) {
|
||||
// This simulates the exact bug: homepage-docker goes from 24.8% to 25.2%
|
||||
// over 1 minute (3 data points), but was being reported as 708%/day growth
|
||||
now := time.Now()
|
||||
points := []MetricPoint{
|
||||
{Value: 24.8, Timestamp: now.Add(-2 * time.Minute)},
|
||||
{Value: 25.0, Timestamp: now.Add(-1 * time.Minute)},
|
||||
{Value: 25.2, Timestamp: now}, // Only 0.4% change total
|
||||
}
|
||||
|
||||
trend := ComputeTrend(points, "memory", 24*time.Hour)
|
||||
|
||||
// With only 2 minutes of data, we should NOT extrapolate to crazy daily rates
|
||||
// The observed change is only 0.4%, so a 700% daily rate is nonsense
|
||||
if trend.RatePerDay > 50 {
|
||||
t.Errorf("Short time span blip should not extrapolate to %f%%/day (expected < 50)", trend.RatePerDay)
|
||||
}
|
||||
|
||||
// Confidence should be low for such short time spans
|
||||
if trend.Confidence > 0.5 {
|
||||
t.Errorf("Expected low confidence for 2-minute span, got %.2f", trend.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeTrend_PercentageCapping tests that percentage metrics (0-100)
|
||||
// have their growth rates capped to physically possible limits
|
||||
func TestComputeTrend_PercentageCapping(t *testing.T) {
|
||||
// Even with a long time span, if the raw rate comes out absurdly high
|
||||
// (which shouldn't happen with good data, but let's test the cap)
|
||||
now := time.Now()
|
||||
|
||||
// Create data that would naively produce a >100%/day rate
|
||||
// 5 points over 2 hours with aggressive growth
|
||||
points := make([]MetricPoint, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
points[i] = MetricPoint{
|
||||
Value: 20 + float64(i)*10, // 20, 30, 40, 50, 60
|
||||
Timestamp: now.Add(time.Duration(-4+i) * 30 * time.Minute), // 30 min apart
|
||||
}
|
||||
}
|
||||
|
||||
trend := ComputeTrend(points, "memory", 24*time.Hour)
|
||||
|
||||
// For a percentage metric, rate should be capped at 100%/day max
|
||||
if trend.RatePerDay > 100 {
|
||||
t.Errorf("Percentage metric should be capped at 100%%/day, got %.2f", trend.RatePerDay)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeTrend_MediumTimeSpan tests that 10-60 minutes of data
|
||||
// gets moderate rate capping but isn't completely zeroed out
|
||||
func TestComputeTrend_MediumTimeSpan(t *testing.T) {
|
||||
now := time.Now()
|
||||
// 30 minutes of data with steady growth
|
||||
points := make([]MetricPoint, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
points[i] = MetricPoint{
|
||||
Value: 30 + float64(i)*1.5, // Growing ~10% over 30 min
|
||||
Timestamp: now.Add(time.Duration(-30+i*5) * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
trend := ComputeTrend(points, "cpu", 24*time.Hour)
|
||||
|
||||
// Rate should be present (not zeroed) but reasonable
|
||||
if trend.RatePerHour == 0 {
|
||||
t.Errorf("Medium time span should have non-zero hourly rate")
|
||||
}
|
||||
|
||||
// But daily extrapolation should be constrained
|
||||
observedChange := 1.5 * 6 // ~9% change
|
||||
if trend.RatePerDay > observedChange*15 {
|
||||
t.Errorf("Daily rate %.2f should not vastly exceed observed change %.2f",
|
||||
trend.RatePerDay, observedChange)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputeTrend_LongTimeSpanNoChange tests that with 24h of data
|
||||
// and minimal change, we get stable (not growing) trend
|
||||
func TestComputeTrend_LongTimeSpanNoChange(t *testing.T) {
|
||||
now := time.Now()
|
||||
// 24 hours of stable data at ~25%
|
||||
points := make([]MetricPoint, 24)
|
||||
for i := 0; i < 24; i++ {
|
||||
// Very small oscillation around 25%
|
||||
points[i] = MetricPoint{
|
||||
Value: 25.0 + float64(i%2)*0.2, // 25.0, 25.2, 25.0, 25.2...
|
||||
Timestamp: now.Add(time.Duration(-24+i) * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
trend := ComputeTrend(points, "memory", 24*time.Hour)
|
||||
|
||||
if trend.Direction == TrendGrowing {
|
||||
t.Errorf("Stable oscillating data should not be classified as Growing")
|
||||
}
|
||||
|
||||
// Rate should be tiny
|
||||
if trend.RatePerDay > 1 || trend.RatePerDay < -1 {
|
||||
t.Errorf("Stable data should have near-zero rate, got %.2f/day", trend.RatePerDay)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue