From f855625f6542fcba3a899a7ce1b6aa2aee92d71b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 30 Dec 2025 12:19:53 +0000 Subject: [PATCH] feat: Add full-width mode toggle for wider views on large monitors. Related to #974 --- frontend-modern/src/App.tsx | 3 +- .../components/Dashboard/MetricBar.test.tsx | 213 ++++++++++++++++++ .../Dashboard/ThresholdSlider.test.tsx | 148 ++++++++++++ .../Settings/GeneralSettingsPanel.tsx | 26 +++ .../src/components/Storage/DiskList.tsx | 4 +- frontend-modern/src/index.css | 6 + frontend-modern/src/utils/layout.ts | 38 ++++ frontend-modern/src/utils/localStorage.ts | 1 + internal/alerts/alerts.go | 30 +++ internal/alerts/synology_test.go | 180 +++++++++++++++ scripts/dev-deploy-agent.sh | 164 ++++++++++++++ scripts/watch-agents.sh | 61 +++++ 12 files changed, 871 insertions(+), 3 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/MetricBar.test.tsx create mode 100644 frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx create mode 100644 frontend-modern/src/utils/layout.ts create mode 100644 internal/alerts/synology_test.go create mode 100755 scripts/dev-deploy-agent.sh create mode 100755 scripts/watch-agents.sh diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 2041cd194..a41eb137c 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -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 ( -
+
{/* Header */}
diff --git a/frontend-modern/src/components/Dashboard/MetricBar.test.tsx b/frontend-modern/src/components/Dashboard/MetricBar.test.tsx new file mode 100644 index 000000000..9f0339285 --- /dev/null +++ b/frontend-modern/src/components/Dashboard/MetricBar.test.tsx @@ -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) =>
{props.metric}
+})); + +// 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(() => ); + 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(() => ); + let bar = result.container.querySelector('.bg-green-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + bar = result.container.querySelector('.bg-yellow-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + bar = result.container.querySelector('.bg-red-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + }); + + it('renders correct color classes for Memory', () => { + let result = render(() => ); + let bar = result.container.querySelector('.bg-green-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + bar = result.container.querySelector('.bg-yellow-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + bar = result.container.querySelector('.bg-red-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + }); + + it('renders correct color classes for Disk', () => { + let result = render(() => ); + let bar = result.container.querySelector('.bg-green-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + bar = result.container.querySelector('.bg-yellow-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + bar = result.container.querySelector('.bg-red-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + }); + + it('renders correct color classes for Generic/Default', () => { + let result = render(() => ); + let bar = result.container.querySelector('.bg-green-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + bar = result.container.querySelector('.bg-yellow-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ); + 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(() => ); + 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(() => ); // No resourceId + expect(screen.queryByTestId('sparkline')).not.toBeInTheDocument(); + expect(screen.getByText('val')).toBeInTheDocument(); + }); + + it('shows sublabel when space permits', () => { + render(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + expect(screen.getByText('cpu')).toBeInTheDocument(); + cleanup(); + + // Case: Undefined -> cpu logic + render(() => ); + expect(screen.getByText('cpu')).toBeInTheDocument(); + cleanup(); + + // Case: memory -> memory + render(() => ); + expect(screen.getByText('memory')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx b/frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx new file mode 100644 index 000000000..b68d04ccb --- /dev/null +++ b/frontend-modern/src/components/Dashboard/ThresholdSlider.test.tsx @@ -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(() => ); + + // 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(() => ); + + 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(() => ); + + 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(() => ); + + 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(() => ); + + 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(() => ); + + 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(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + 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(() => ); + // Blue + expect(screen.getByText('50%').closest('.text-blue-500')).toBeInTheDocument(); + unmount(); + + render(() => ); + // Rose + expect(screen.getByText('50°C').closest('.text-rose-500')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index 8f1e1b805..cccbcd0e8 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -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 = (props
+ + {/* Full-width Mode Toggle */} +
+
+
+ +
+
+

+ Full-width mode +

+

+ Expand content to use all available screen width on large monitors +

+
+
+ layoutStore.toggle()} + /> +
diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index 62cc9b115..4f00e4042 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -70,7 +70,7 @@ export const DiskList: Component = (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 = (props) => { {selectedNodeName() &&

for node {selectedNodeName()}

} {props.searchTerm &&

matching "{props.searchTerm}"

}
- + (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(); diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index fc8c1d66b..fa1be703e 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -64,6 +64,7 @@ export const STORAGE_KEYS = { // UI preferences DARK_MODE: 'darkMode', SIDEBAR_COLLAPSED: 'sidebarCollapsed', + FULL_WIDTH_MODE: 'fullWidthMode', // Metadata GUEST_METADATA: 'pulseGuestMetadata', diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index e727c2d87..4ec4c1275 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -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() diff --git a/internal/alerts/synology_test.go b/internal/alerts/synology_test.go new file mode 100644 index 000000000..36fc2393c --- /dev/null +++ b/internal/alerts/synology_test.go @@ -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") + } +} diff --git a/scripts/dev-deploy-agent.sh b/scripts/dev-deploy-agent.sh new file mode 100755 index 000000000..896223bfa --- /dev/null +++ b/scripts/dev-deploy-agent.sh @@ -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!" diff --git a/scripts/watch-agents.sh b/scripts/watch-agents.sh new file mode 100755 index 000000000..0e7fa7dff --- /dev/null +++ b/scripts/watch-agents.sh @@ -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