feat: Add full-width mode toggle for wider views on large monitors. Related to #974

This commit is contained in:
rcourtman
2025-12-30 12:19:53 +00:00
parent a62d7dc78d
commit f855625f65
12 changed files with 871 additions and 3 deletions

View File

@@ -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">

View 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();
});
});

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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={

View File

@@ -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);
}

View 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();

View File

@@ -64,6 +64,7 @@ export const STORAGE_KEYS = {
// UI preferences
DARK_MODE: 'darkMode',
SIDEBAR_COLLAPSED: 'sidebarCollapsed',
FULL_WIDTH_MODE: 'fullWidthMode',
// Metadata
GUEST_METADATA: 'pulseGuestMetadata',

View File

@@ -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()

View 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
View 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
View 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