mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat: Add full-width mode toggle for wider views on large monitors. Related to #974
This commit is contained in:
@@ -23,6 +23,7 @@ import { Login } from './components/Login';
|
||||
import { logger } from './utils/logger';
|
||||
import { POLLING_INTERVALS } from './constants';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
import { UpdatesAPI } from './api/updates';
|
||||
import type { VersionInfo } from './api/updates';
|
||||
import { apiFetch } from './utils/apiClient';
|
||||
@@ -1186,7 +1187,7 @@ function AppLayout(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="pulse-shell">
|
||||
<div class={`pulse-shell ${layoutStore.isFullWidth() ? 'pulse-shell--full-width' : ''}`}>
|
||||
{/* Header */}
|
||||
<div class="header mb-3 flex items-center justify-between gap-2 sm:grid sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-0">
|
||||
<div class="flex items-center gap-2 sm:flex-initial sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
|
||||
|
||||
213
frontend-modern/src/components/Dashboard/MetricBar.test.tsx
Normal file
213
frontend-modern/src/components/Dashboard/MetricBar.test.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@solidjs/testing-library';
|
||||
import { MetricBar } from './MetricBar';
|
||||
|
||||
// Mock Stores
|
||||
const mockUseMetricsViewMode = vi.fn();
|
||||
vi.mock('@/stores/metricsViewMode', () => ({
|
||||
useMetricsViewMode: () => mockUseMetricsViewMode()
|
||||
}));
|
||||
|
||||
const mockGetMetricHistory = vi.fn();
|
||||
vi.mock('@/stores/metricsHistory', () => ({
|
||||
getMetricHistoryForRange: (...args: any[]) => mockGetMetricHistory(...args),
|
||||
getMetricsVersion: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock Sparkline
|
||||
vi.mock('@/components/shared/Sparkline', () => ({
|
||||
Sparkline: (props: any) => <div data-testid="sparkline" title={`Data count: ${props.data?.length ?? 0}`}>{props.metric}</div>
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
const mockObserve = vi.fn();
|
||||
const mockDisconnect = vi.fn();
|
||||
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
resizeCallback = cb;
|
||||
}
|
||||
observe = mockObserve;
|
||||
disconnect = mockDisconnect;
|
||||
unobserve = vi.fn();
|
||||
};
|
||||
|
||||
describe('MetricBar', () => {
|
||||
beforeEach(() => {
|
||||
mockUseMetricsViewMode.mockReturnValue({
|
||||
viewMode: () => 'bars',
|
||||
timeRange: () => '1h'
|
||||
});
|
||||
mockGetMetricHistory.mockReturnValue([]);
|
||||
|
||||
// Default Mock offsetWidth
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 100 });
|
||||
|
||||
resizeCallback = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders basic bar with label', () => {
|
||||
render(() => <MetricBar value={50} label="50%" type="cpu" />);
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
const textEl = screen.getByText('50%');
|
||||
const container = textEl.closest('.relative') as HTMLElement;
|
||||
const bar = container.firstElementChild as HTMLElement;
|
||||
expect(bar).toHaveStyle({ width: '50%' });
|
||||
});
|
||||
|
||||
it('renders correct color classes for CPU', () => {
|
||||
let result = render(() => <MetricBar value={50} label="val" type="cpu" />);
|
||||
let bar = result.container.querySelector('.bg-green-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={80} label="val" type="cpu" />);
|
||||
bar = result.container.querySelector('.bg-yellow-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={90} label="val" type="cpu" />);
|
||||
bar = result.container.querySelector('.bg-red-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
});
|
||||
|
||||
it('renders correct color classes for Memory', () => {
|
||||
let result = render(() => <MetricBar value={50} label="val" type="memory" />);
|
||||
let bar = result.container.querySelector('.bg-green-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={75} label="val" type="memory" />);
|
||||
bar = result.container.querySelector('.bg-yellow-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={85} label="val" type="memory" />);
|
||||
bar = result.container.querySelector('.bg-red-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
});
|
||||
|
||||
it('renders correct color classes for Disk', () => {
|
||||
let result = render(() => <MetricBar value={50} label="val" type="disk" />);
|
||||
let bar = result.container.querySelector('.bg-green-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={80} label="val" type="disk" />);
|
||||
bar = result.container.querySelector('.bg-yellow-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={90} label="val" type="disk" />);
|
||||
bar = result.container.querySelector('.bg-red-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
});
|
||||
|
||||
it('renders correct color classes for Generic/Default', () => {
|
||||
let result = render(() => <MetricBar value={50} label="val" />);
|
||||
let bar = result.container.querySelector('.bg-green-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={75} label="val" />);
|
||||
bar = result.container.querySelector('.bg-yellow-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
|
||||
result = render(() => <MetricBar value={90} label="val" />);
|
||||
bar = result.container.querySelector('.bg-red-500\\/60');
|
||||
expect(bar).toBeInTheDocument();
|
||||
result.unmount();
|
||||
});
|
||||
|
||||
it('toggles sparkline view mode', () => {
|
||||
mockUseMetricsViewMode.mockReturnValue({
|
||||
viewMode: () => 'sparklines',
|
||||
timeRange: () => '1h'
|
||||
});
|
||||
|
||||
render(() => <MetricBar value={50} label="val" resourceId="node1" type="cpu" />);
|
||||
expect(screen.getByTestId('sparkline')).toBeInTheDocument();
|
||||
expect(screen.queryByText('val')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to bars if resourceId missing in sparkline mode', () => {
|
||||
mockUseMetricsViewMode.mockReturnValue({
|
||||
viewMode: () => 'sparklines',
|
||||
timeRange: () => '1h'
|
||||
});
|
||||
render(() => <MetricBar value={50} label="val" />); // No resourceId
|
||||
expect(screen.queryByTestId('sparkline')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('val')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows sublabel when space permits', () => {
|
||||
render(() => <MetricBar value={50} label="val" sublabel="sub" />);
|
||||
const sub = screen.getByText('(sub)');
|
||||
expect(sub).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides sublabel when space constrained', () => {
|
||||
// Mock small width
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 20 });
|
||||
// Must use long text to ensure it definitely doesn't fit in 20px
|
||||
render(() => <MetricBar value={50} label="VeryLongLabel" sublabel="LongSublabel" />);
|
||||
expect(screen.queryByText('(LongSublabel)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates sublabel visibility on resize', async () => {
|
||||
// Start small -> Hidden
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 20 });
|
||||
|
||||
render(() => <MetricBar value={50} label="Label" sublabel="Sub" />);
|
||||
expect(screen.queryByText('(Sub)')).not.toBeInTheDocument();
|
||||
|
||||
// Resize to 200px
|
||||
resizeCallback?.([{ contentRect: { width: 200 } } as ResizeObserverEntry], {} as ResizeObserver);
|
||||
|
||||
expect(await screen.findByText('(Sub)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes metric history to Sparkline', () => {
|
||||
const dummyData = [{ value: 1, timestamp: 100 }];
|
||||
mockUseMetricsViewMode.mockReturnValue({
|
||||
viewMode: () => 'sparklines',
|
||||
timeRange: () => '1h'
|
||||
});
|
||||
mockGetMetricHistory.mockReturnValue(dummyData);
|
||||
|
||||
render(() => <MetricBar value={50} label="val" resourceId="r1" type="cpu" />);
|
||||
const spark = screen.getByTestId('sparkline');
|
||||
expect(spark).toHaveAttribute('title', 'Data count: 1');
|
||||
});
|
||||
|
||||
it('handles sparkline metric types correctly', () => {
|
||||
mockUseMetricsViewMode.mockReturnValue({
|
||||
viewMode: () => 'sparklines',
|
||||
timeRange: () => '1h'
|
||||
});
|
||||
|
||||
// Case: Generic -> cpu logic
|
||||
render(() => <MetricBar value={50} label="val" resourceId="r1" type="generic" />);
|
||||
expect(screen.getByText('cpu')).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
// Case: Undefined -> cpu logic
|
||||
render(() => <MetricBar value={50} label="val" resourceId="r2" />);
|
||||
expect(screen.getByText('cpu')).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
// Case: memory -> memory
|
||||
render(() => <MetricBar value={50} label="val" resourceId="r3" type="memory" />);
|
||||
expect(screen.getByText('memory')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@solidjs/testing-library';
|
||||
import { ThresholdSlider } from './ThresholdSlider';
|
||||
|
||||
// Mock Utils
|
||||
vi.mock('@/utils/temperature', () => ({
|
||||
formatTemperature: (val: number) => `${val}°C`,
|
||||
getTemperatureSymbol: () => '°C'
|
||||
}));
|
||||
|
||||
describe('ThresholdSlider', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders with correct value for percentage types', () => {
|
||||
const onChange = vi.fn();
|
||||
render(() => <ThresholdSlider value={50} onChange={onChange} type="cpu" />);
|
||||
|
||||
// Thumb text
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
// Input accessible via title or just implicit role
|
||||
// The input is opacity-0 but exists
|
||||
// It has a title
|
||||
const input = screen.getByTitle('CPU: 50%');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue('50');
|
||||
});
|
||||
|
||||
it('renders with correct value for temperature', () => {
|
||||
const onChange = vi.fn();
|
||||
render(() => <ThresholdSlider value={45} onChange={onChange} type="temperature" />);
|
||||
|
||||
expect(screen.getByText('45°C')).toBeInTheDocument();
|
||||
const input = screen.getByTitle('Temperature: 45°C');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers onChange when input changes', () => {
|
||||
const onChange = vi.fn();
|
||||
render(() => <ThresholdSlider value={50} onChange={onChange} type="memory" />);
|
||||
|
||||
const input = screen.getByTitle('MEMORY: 50%') as HTMLInputElement;
|
||||
fireEvent.input(input, { target: { value: '75' } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(75);
|
||||
});
|
||||
|
||||
it('applies correct position and styling', () => {
|
||||
// value 50 -> 50% left, translate -50%
|
||||
render(() => <ThresholdSlider value={50} onChange={vi.fn()} type="cpu" min={0} max={100} />);
|
||||
|
||||
const thumb = screen.getByText('50%').closest('.absolute.pointer-events-none');
|
||||
expect(thumb).toHaveStyle({ left: '50%' });
|
||||
expect(thumb).toHaveStyle({ transform: 'translateY(-50%) translateX(-50%)' });
|
||||
});
|
||||
|
||||
it('handles edge positions logic (left)', () => {
|
||||
// value 0 -> 0% left, translate 0%
|
||||
render(() => <ThresholdSlider value={0} onChange={vi.fn()} type="cpu" />);
|
||||
|
||||
const thumb = screen.getByText('0%').closest('.absolute.pointer-events-none');
|
||||
expect(thumb).toHaveStyle({ left: '0%' });
|
||||
expect(thumb).toHaveStyle({ transform: 'translateY(-50%) translateX(0%)' });
|
||||
});
|
||||
|
||||
it('handles edge positions logic (right)', () => {
|
||||
// value 100 -> 100% left, translate -100%
|
||||
render(() => <ThresholdSlider value={100} onChange={vi.fn()} type="cpu" />);
|
||||
|
||||
const thumb = screen.getByText('100%').closest('.absolute.pointer-events-none');
|
||||
expect(thumb).toHaveStyle({ left: '100%' });
|
||||
expect(thumb).toHaveStyle({ transform: 'translateY(-50%) translateX(-100%)' });
|
||||
});
|
||||
|
||||
it('handles mouse drag events for scrolling lock', () => {
|
||||
// Asserting scroll lock logic via mocks on window/document is complex.
|
||||
// We can verify event listeners are added/removed if we spy on them.
|
||||
const addSpy = vi.spyOn(document, 'addEventListener');
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
||||
|
||||
render(() => <ThresholdSlider value={50} onChange={vi.fn()} type="disk" />);
|
||||
const input = screen.getByTitle('DISK: 50%');
|
||||
|
||||
// Mouse Down
|
||||
fireEvent.mouseDown(input);
|
||||
expect(addSpy).toHaveBeenCalledWith('mouseup', expect.any(Function));
|
||||
|
||||
// Mouse Up
|
||||
const mouseUpHandler = addSpy.mock.calls.find(c => c[0] === 'mouseup')![1] as EventListener;
|
||||
mouseUpHandler({} as Event); // Simulate up
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('mouseup', expect.any(Function));
|
||||
});
|
||||
|
||||
it('prevents default wheel event on input always', () => {
|
||||
render(() => <ThresholdSlider value={50} onChange={vi.fn()} type="disk" />);
|
||||
const input = screen.getByTitle('DISK: 50%');
|
||||
|
||||
const evt = new WheelEvent('wheel', { bubbles: true, cancelable: true });
|
||||
const spy = vi.spyOn(evt, 'preventDefault');
|
||||
|
||||
input.dispatchEvent(evt);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents default wheel event on container when dragging', () => {
|
||||
render(() => <ThresholdSlider value={50} onChange={vi.fn()} type="disk" />);
|
||||
const input = screen.getByTitle('DISK: 50%');
|
||||
// The container happens to be the parent of the input (which is absolute inset-0)
|
||||
// Input is child of div (container).
|
||||
const container = input.parentElement!;
|
||||
|
||||
// Start dragging
|
||||
fireEvent.mouseDown(input);
|
||||
|
||||
const evt = new WheelEvent('wheel', { bubbles: true, cancelable: true });
|
||||
const spy = vi.spyOn(evt, 'preventDefault');
|
||||
|
||||
container.dispatchEvent(evt);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes scroll lock handler while dragging', () => {
|
||||
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => { });
|
||||
render(() => <ThresholdSlider value={50} onChange={vi.fn()} type="disk" />);
|
||||
const input = screen.getByTitle('DISK: 50%');
|
||||
|
||||
// Start dragging
|
||||
fireEvent.mouseDown(input);
|
||||
|
||||
// Trigger scroll
|
||||
fireEvent.scroll(window);
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies correct color classes', () => {
|
||||
const { unmount } = render(() => <ThresholdSlider value={50} onChange={vi.fn()} type="cpu" />);
|
||||
// Blue
|
||||
expect(screen.getByText('50%').closest('.text-blue-500')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(() => <ThresholdSlider value={50} onChange={vi.fn()} type="temperature" />);
|
||||
// Rose
|
||||
expect(screen.getByText('50°C').closest('.text-rose-500')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import Activity from 'lucide-solid/icons/activity';
|
||||
import Sun from 'lucide-solid/icons/sun';
|
||||
import Moon from 'lucide-solid/icons/moon';
|
||||
import Thermometer from 'lucide-solid/icons/thermometer';
|
||||
import Maximize2 from 'lucide-solid/icons/maximize-2';
|
||||
import { temperatureStore } from '@/utils/temperature';
|
||||
import { layoutStore } from '@/utils/layout';
|
||||
|
||||
const PVE_POLLING_MIN_SECONDS = 10;
|
||||
const PVE_POLLING_MAX_SECONDS = 3600;
|
||||
@@ -126,6 +128,30 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-width Mode Toggle */}
|
||||
<div class="flex items-center justify-between gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class={`p-2.5 rounded-xl transition-all duration-300 ${layoutStore.isFullWidth()
|
||||
? 'bg-gradient-to-br from-blue-500 to-indigo-600 shadow-lg shadow-blue-500/25'
|
||||
: 'bg-gradient-to-br from-gray-400 to-gray-500 shadow-lg shadow-gray-500/25'
|
||||
}`}>
|
||||
<Maximize2 class="w-5 h-5 text-white" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
Full-width mode
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Expand content to use all available screen width on large monitors
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={layoutStore.isFullWidth()}
|
||||
onChange={() => layoutStore.toggle()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
text: 'LOW LIFE',
|
||||
};
|
||||
}
|
||||
const label = normalizedHealth === 'PASSED' ? 'HEALTHY' : normalizedHealth || 'HEALTHY';
|
||||
const label = normalizedHealth === 'PASSED' ? 'HEALTHY' : normalizedHealth;
|
||||
return {
|
||||
color: 'text-green-700 dark:text-green-400',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
@@ -120,7 +120,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
{selectedNodeName() && <p class="text-xs mt-1">for node {selectedNodeName()}</p>}
|
||||
{props.searchTerm && <p class="text-xs mt-1">matching "{props.searchTerm}"</p>}
|
||||
</div>
|
||||
<Show when={!props.searchTerm && props.disks.length === 0}>
|
||||
<Show when={!props.searchTerm && (props.disks || []).length === 0}>
|
||||
<Show
|
||||
when={hasPVENodes()}
|
||||
fallback={
|
||||
|
||||
@@ -138,6 +138,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Full-width mode - removes max-width constraint for large monitors */
|
||||
.pulse-shell--full-width {
|
||||
max-width: 100%;
|
||||
padding-inline: clamp(1rem, 2vw, 3rem);
|
||||
}
|
||||
|
||||
.pulse-panel {
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
|
||||
38
frontend-modern/src/utils/layout.ts
Normal file
38
frontend-modern/src/utils/layout.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Layout utilities for managing full-width mode preference
|
||||
*/
|
||||
import { createSignal } from 'solid-js';
|
||||
import { STORAGE_KEYS } from './localStorage';
|
||||
|
||||
export type LayoutMode = 'default' | 'full-width';
|
||||
|
||||
/**
|
||||
* Creates a reactive store for layout mode preference
|
||||
*/
|
||||
function createLayoutStore() {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.FULL_WIDTH_MODE);
|
||||
const initialMode: LayoutMode = stored === 'full-width' ? 'full-width' : 'default';
|
||||
|
||||
const [mode, setModeInternal] = createSignal<LayoutMode>(initialMode);
|
||||
|
||||
const setMode = (newMode: LayoutMode) => {
|
||||
localStorage.setItem(STORAGE_KEYS.FULL_WIDTH_MODE, newMode);
|
||||
setModeInternal(newMode);
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
const newMode = mode() === 'default' ? 'full-width' : 'default';
|
||||
setMode(newMode);
|
||||
};
|
||||
|
||||
const isFullWidth = () => mode() === 'full-width';
|
||||
|
||||
return {
|
||||
mode,
|
||||
setMode,
|
||||
toggle,
|
||||
isFullWidth,
|
||||
};
|
||||
}
|
||||
|
||||
export const layoutStore = createLayoutStore();
|
||||
@@ -64,6 +64,7 @@ export const STORAGE_KEYS = {
|
||||
// UI preferences
|
||||
DARK_MODE: 'darkMode',
|
||||
SIDEBAR_COLLAPSED: 'sidebarCollapsed',
|
||||
FULL_WIDTH_MODE: 'fullWidthMode',
|
||||
|
||||
// Metadata
|
||||
GUEST_METADATA: 'pulseGuestMetadata',
|
||||
|
||||
@@ -2701,6 +2701,7 @@ func (m *Manager) CheckHost(host models.Host) {
|
||||
// Clear any existing host alerts when all host alerts are disabled
|
||||
m.clearHostMetricAlerts(host.ID)
|
||||
m.clearHostDiskAlerts(host.ID)
|
||||
m.clearHostRAIDAlerts(host.ID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2709,6 +2710,7 @@ func (m *Manager) CheckHost(host models.Host) {
|
||||
if thresholds.Disabled {
|
||||
m.clearHostMetricAlerts(host.ID)
|
||||
m.clearHostDiskAlerts(host.ID)
|
||||
m.clearHostRAIDAlerts(host.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2788,6 +2790,16 @@ func (m *Manager) CheckHost(host models.Host) {
|
||||
// Check RAID arrays for degraded or failed state
|
||||
if len(host.RAID) > 0 {
|
||||
for _, array := range host.RAID {
|
||||
// Skip Synology internal system arrays (md0/md1) which often report false positives.
|
||||
// DSM handles these differently and they're not user-facing storage arrays.
|
||||
deviceLower := strings.ToLower(strings.TrimPrefix(array.Device, "/dev/"))
|
||||
if deviceLower == "md0" || deviceLower == "md1" {
|
||||
// Still clear any existing alerts for these devices
|
||||
alertID := fmt.Sprintf("host-%s-raid-%s", host.ID, sanitizeRAIDDevice(array.Device))
|
||||
m.clearAlert(alertID)
|
||||
continue
|
||||
}
|
||||
|
||||
raidResourceID := fmt.Sprintf("host-%s-raid-%s", host.ID, sanitizeRAIDDevice(array.Device))
|
||||
raidName := fmt.Sprintf("%s - %s (%s)", resourceName, array.Device, array.Level)
|
||||
|
||||
@@ -2939,6 +2951,7 @@ func (m *Manager) HandleHostRemoved(host models.Host) {
|
||||
m.HandleHostOnline(host)
|
||||
m.clearHostMetricAlerts(host.ID)
|
||||
m.clearHostDiskAlerts(host.ID)
|
||||
m.clearHostRAIDAlerts(host.ID)
|
||||
}
|
||||
|
||||
// HandleHostOffline raises an alert when a host agent stops reporting.
|
||||
@@ -3107,6 +3120,23 @@ func (m *Manager) cleanupHostDiskAlerts(host models.Host, seen map[string]struct
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) clearHostRAIDAlerts(hostID string) {
|
||||
if hostID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := fmt.Sprintf("host-%s-raid-", hostID)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for alertID := range m.activeAlerts {
|
||||
if strings.HasPrefix(alertID, prefix) {
|
||||
m.clearAlertNoLock(alertID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckPBS checks PBS instance metrics against thresholds
|
||||
func (m *Manager) CheckPBS(pbs models.PBSInstance) {
|
||||
m.mu.RLock()
|
||||
|
||||
180
internal/alerts/synology_test.go
Normal file
180
internal/alerts/synology_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
)
|
||||
|
||||
func TestSynologyRAIDSuppression(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
m.ClearActiveAlerts()
|
||||
m.mu.Lock()
|
||||
m.config.TimeThreshold = 0
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.mu.Unlock()
|
||||
|
||||
host := models.Host{
|
||||
ID: "syno-1",
|
||||
DisplayName: "Synology NAS",
|
||||
Hostname: "synology",
|
||||
Status: "online",
|
||||
LastSeen: time.Now(),
|
||||
RAID: []models.HostRAIDArray{
|
||||
{
|
||||
Device: "/dev/md0", // Suppressed
|
||||
Level: "raid1",
|
||||
State: "degraded", // Should NOT alert
|
||||
FailedDevices: 1,
|
||||
},
|
||||
{
|
||||
Device: "/dev/md1", // Suppressed
|
||||
Level: "raid1",
|
||||
State: "resyncing", // Should NOT alert
|
||||
RebuildPercent: 50.0,
|
||||
},
|
||||
{
|
||||
Device: "/dev/md2", // Not suppressed
|
||||
Level: "raid5",
|
||||
State: "degraded", // SHOULD alert
|
||||
FailedDevices: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m.CheckHost(host)
|
||||
|
||||
alerts := m.GetActiveAlerts()
|
||||
var md0Found, md1Found, md2Found bool
|
||||
|
||||
for _, a := range alerts {
|
||||
if strings.Contains(a.ID, "md0") {
|
||||
md0Found = true
|
||||
}
|
||||
if strings.Contains(a.ID, "md1") {
|
||||
md1Found = true
|
||||
}
|
||||
if strings.Contains(a.ID, "md2") {
|
||||
md2Found = true
|
||||
}
|
||||
}
|
||||
|
||||
if md0Found {
|
||||
t.Error("expected md0 alert to be suppressed")
|
||||
}
|
||||
if md1Found {
|
||||
t.Error("expected md1 alert to be suppressed")
|
||||
}
|
||||
if !md2Found {
|
||||
t.Error("expected md2 alert to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynologyRAIDClearing(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
m.ClearActiveAlerts()
|
||||
m.mu.Lock()
|
||||
m.config.TimeThreshold = 0
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Manually inject an alert for md0
|
||||
alertID := "host-syno-1-raid-md0"
|
||||
m.mu.Lock()
|
||||
m.activeAlerts[alertID] = &Alert{
|
||||
ID: alertID,
|
||||
ResourceID: "host-syno-1-raid-md0",
|
||||
ResourceName: "Synology NAS - /dev/md0 (raid1)",
|
||||
Message: "RAID array degraded",
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
host := models.Host{
|
||||
ID: "syno-1",
|
||||
DisplayName: "Synology NAS",
|
||||
Hostname: "synology",
|
||||
Status: "online",
|
||||
LastSeen: time.Now(),
|
||||
RAID: []models.HostRAIDArray{
|
||||
{
|
||||
Device: "/dev/md0", // Suppressed
|
||||
Level: "raid1",
|
||||
State: "degraded", // Should trigger clearing logic
|
||||
FailedDevices: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m.CheckHost(host)
|
||||
|
||||
m.mu.RLock()
|
||||
_, exists := m.activeAlerts[alertID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
t.Error("expected md0 alert to be filtered and cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDisableClearsRAID(t *testing.T) {
|
||||
m := newTestManager(t)
|
||||
m.ClearActiveAlerts()
|
||||
m.mu.Lock()
|
||||
m.config.TimeThreshold = 0
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.mu.Unlock()
|
||||
|
||||
host := models.Host{
|
||||
ID: "host-raid",
|
||||
DisplayName: "RAID Host",
|
||||
Hostname: "raid-host",
|
||||
Status: "online",
|
||||
LastSeen: time.Now(),
|
||||
RAID: []models.HostRAIDArray{
|
||||
{
|
||||
Device: "/dev/md2",
|
||||
Level: "raid5",
|
||||
State: "degraded",
|
||||
FailedDevices: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Initial check - creates alert
|
||||
m.CheckHost(host)
|
||||
|
||||
alertID := "host-host-raid-raid-md2"
|
||||
m.mu.RLock()
|
||||
_, exists := m.activeAlerts[alertID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
t.Fatal("expected RAID alert to be created")
|
||||
}
|
||||
|
||||
// 2. Disable alerts for this host
|
||||
cfg := m.GetConfig()
|
||||
cfg.Overrides = map[string]ThresholdConfig{
|
||||
host.ID: {
|
||||
Disabled: true,
|
||||
},
|
||||
}
|
||||
m.UpdateConfig(cfg)
|
||||
m.mu.Lock()
|
||||
m.config.TimeThreshold = 0
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.mu.Unlock()
|
||||
|
||||
// 3. Re-check - should clear alerts
|
||||
m.CheckHost(host)
|
||||
|
||||
m.mu.RLock()
|
||||
_, exists = m.activeAlerts[alertID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
t.Error("expected RAID alert to be cleared when host alerts are disabled")
|
||||
}
|
||||
}
|
||||
164
scripts/dev-deploy-agent.sh
Executable file
164
scripts/dev-deploy-agent.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
# Dev deployment script for Pulse agents
|
||||
# Usage: ./scripts/dev-deploy-agent.sh [host1] [host2] ...
|
||||
# Example: ./scripts/dev-deploy-agent.sh tower pimox minipc
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# Default hosts if none specified
|
||||
DEFAULT_HOSTS=("tower")
|
||||
|
||||
# Target architecture (most home servers are amd64)
|
||||
GOARCH="${GOARCH:-amd64}"
|
||||
GOOS="${GOOS:-linux}"
|
||||
|
||||
# SSH options
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
|
||||
# Remote paths
|
||||
REMOTE_AGENT_PATH="/usr/local/bin/pulse-agent"
|
||||
REMOTE_SERVICE="pulse-agent"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[OK]${NC} $1"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Get list of hosts
|
||||
if [ $# -eq 0 ]; then
|
||||
HOSTS=("${DEFAULT_HOSTS[@]}")
|
||||
log_info "No hosts specified, using defaults: ${HOSTS[*]}"
|
||||
else
|
||||
HOSTS=("$@")
|
||||
fi
|
||||
|
||||
# Build the agent
|
||||
log_info "Building pulse-agent for ${GOOS}/${GOARCH}..."
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
BINARY_PATH="$PROJECT_ROOT/bin/pulse-agent-${GOOS}-${GOARCH}"
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -ldflags="-s -w" -o "$BINARY_PATH" ./cmd/pulse-agent
|
||||
|
||||
if [ ! -f "$BINARY_PATH" ]; then
|
||||
log_error "Build failed - binary not found at $BINARY_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Built $(du -h "$BINARY_PATH" | cut -f1) binary"
|
||||
|
||||
# Deploy to each host
|
||||
FAILED_HOSTS=()
|
||||
SUCCESS_HOSTS=()
|
||||
|
||||
for host in "${HOSTS[@]}"; do
|
||||
echo ""
|
||||
log_info "Deploying to $host..."
|
||||
|
||||
# Check if host is reachable and get architecture
|
||||
HOST_ARCH=$(ssh $SSH_OPTS "$host" "uname -m" 2>/dev/null || echo "unknown")
|
||||
if [ "$HOST_ARCH" == "unknown" ]; then
|
||||
log_error "Cannot connect to $host or determine architecture - skipping"
|
||||
FAILED_HOSTS+=("$host")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Map uname -m to GOARCH
|
||||
case $HOST_ARCH in
|
||||
x86_64) TARGET_GOARCH="amd64" ;;
|
||||
aarch64) TARGET_GOARCH="arm64" ;;
|
||||
armv7l) TARGET_GOARCH="arm" ;;
|
||||
*) log_error "Unsupported architecture: $HOST_ARCH"; FAILED_HOSTS+=("$host"); continue ;;
|
||||
esac
|
||||
|
||||
log_info " Host architecture: $HOST_ARCH (building for $TARGET_GOARCH)..."
|
||||
|
||||
# Build specifically for this host's arch
|
||||
BINARY_PATH="$PROJECT_ROOT/bin/pulse-agent-${GOOS}-${TARGET_GOARCH}"
|
||||
if [ ! -f "$BINARY_PATH" ] || [ $(( $(date +%s) - $(stat -c %Y "$BINARY_PATH" 2>/dev/null || echo 0) )) -gt 60 ]; then
|
||||
log_info " Building pulse-agent for $TARGET_GOARCH..."
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$TARGET_GOARCH" go build -ldflags="-s -w" -o "$BINARY_PATH" ./cmd/pulse-agent
|
||||
fi
|
||||
|
||||
# Stop the service
|
||||
log_info " Stopping pulse-agent..."
|
||||
ssh $SSH_OPTS "$host" "sudo systemctl stop $REMOTE_SERVICE 2>/dev/null || pkill -f pulse-agent || true"
|
||||
|
||||
# Copy the binary
|
||||
log_info " Copying binary..."
|
||||
if ! scp $SSH_OPTS "$BINARY_PATH" "$host:/tmp/pulse-agent-new"; then
|
||||
log_error " Failed to copy binary to $host"
|
||||
FAILED_HOSTS+=("$host")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Install the binary
|
||||
log_info " Installing binary..."
|
||||
if ! ssh $SSH_OPTS "$host" "sudo mv /tmp/pulse-agent-new $REMOTE_AGENT_PATH && sudo chmod +x $REMOTE_AGENT_PATH"; then
|
||||
log_error " Failed to install binary on $host"
|
||||
FAILED_HOSTS+=("$host")
|
||||
continue
|
||||
fi
|
||||
|
||||
# Start the service
|
||||
log_info " Starting pulse-agent..."
|
||||
# Try systemd first, then Unraid go.d script, then manual start via existing scripts
|
||||
if ! ssh $SSH_OPTS "$host" "sudo systemctl start $REMOTE_SERVICE 2>/dev/null"; then
|
||||
if ssh $SSH_OPTS "$host" "test -f /boot/config/go.d/pulse-agent.sh" 2>/dev/null; then
|
||||
log_info " Using Unraid startup script..."
|
||||
ssh $SSH_OPTS "$host" "bash /boot/config/go.d/pulse-agent.sh" >/dev/null 2>&1
|
||||
elif ssh $SSH_OPTS "$host" "test -f /etc/init.d/pulse-agent" 2>/dev/null; then
|
||||
log_info " Using init.d script..."
|
||||
ssh $SSH_OPTS "$host" "sudo /etc/init.d/pulse-agent start" >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify it's running
|
||||
sleep 2
|
||||
if ssh $SSH_OPTS "$host" "pgrep -x pulse-agent >/dev/null 2>&1"; then
|
||||
log_success " Agent deployed and running on $host"
|
||||
SUCCESS_HOSTS+=("$host")
|
||||
else
|
||||
# Try one last ditch effort: run it via the background helper if we can find it
|
||||
log_warn " Agent not running, checking logs..."
|
||||
ssh $SSH_OPTS "$host" "tail -n 5 /var/log/pulse-agent.log /boot/logs/pulse-agent.log 2>/dev/null" | log_warn
|
||||
FAILED_HOSTS+=("$host")
|
||||
fi
|
||||
done
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "========================================"
|
||||
log_info "Deployment Summary"
|
||||
echo "========================================"
|
||||
|
||||
if [ ${#SUCCESS_HOSTS[@]} -gt 0 ]; then
|
||||
log_success "Successfully deployed to: ${SUCCESS_HOSTS[*]}"
|
||||
fi
|
||||
|
||||
if [ ${#FAILED_HOSTS[@]} -gt 0 ]; then
|
||||
log_error "Failed to deploy to: ${FAILED_HOSTS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "All deployments complete!"
|
||||
61
scripts/watch-agents.sh
Executable file
61
scripts/watch-agents.sh
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Automatic Agent Hot-Reload Watcher
|
||||
# Watches for local code changes and pushes to all agents
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
DEPLOY_SCRIPT="$SCRIPT_DIR/dev-deploy-agent.sh"
|
||||
|
||||
# Hosts to sync to (edit this list as needed)
|
||||
HOSTS=("verdeclose" "minipc" "delly" "pimox")
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[WATCHER]${NC} $1"
|
||||
}
|
||||
|
||||
if ! command -v inotifywait &> /dev/null; then
|
||||
echo "Error: inotifywait not found. Install with: sudo apt install inotify-tools"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "${GREEN}Starting Agent Hot-Reload Watcher...${NC}"
|
||||
log_info "Target hosts: ${HOSTS[*]}"
|
||||
log_info "Watching internal/, pkg/, and cmd/pulse-agent/ for changes..."
|
||||
|
||||
# Debounce deployment to prevent multiple builds for rapid saves
|
||||
LAST_DEPLOY=0
|
||||
DEBOUNCE_SEC=2
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Use inotifywait to watch relevant directories
|
||||
inotifywait -m -r -e modify,create,delete,move \
|
||||
--exclude ".*\.test\.go" \
|
||||
"$PROJECT_ROOT/internal" \
|
||||
"$PROJECT_ROOT/pkg" \
|
||||
"$PROJECT_ROOT/cmd/pulse-agent" |
|
||||
while read -r path action file; do
|
||||
# Only trigger for .go files
|
||||
if [[ "$file" == *.go ]]; then
|
||||
NOW=$(date +%s)
|
||||
if (( NOW - LAST_DEPLOY > DEBOUNCE_SEC )); then
|
||||
echo ""
|
||||
log_info "${YELLOW}Change detected in $path$file. Deploying...${NC}"
|
||||
|
||||
# Execute deployment in background so we don't miss other events
|
||||
# but wait for it so we don't overlap builds
|
||||
"$DEPLOY_SCRIPT" "${HOSTS[@]}" || true
|
||||
|
||||
LAST_DEPLOY=$(date +%s)
|
||||
log_info "${GREEN}Ready for next change.${NC}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user