From 165b809c7ccb831cfbc3aa07bd6f1633cdd5397f Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Tue, 29 Jul 2025 20:44:04 +0000 Subject: [PATCH] Replace any types with proper TypeScript interfaces - Added typed API classes for alerts and notifications - Replaced direct fetch calls with typed API methods - Fixed any types in Storage, NodeModal, and Settings components - Created NotificationsAPI with proper types for email/webhook config - Enhanced error handling to show actual error messages This prevents issues like the polling interval bug by ensuring type safety across API boundaries. --- frontend-modern/src/App.tsx | 14 +- frontend-modern/src/api/alerts.ts | 50 ++++- frontend-modern/src/api/notifications.ts | 184 ++++++++++++++++++ frontend-modern/src/api/settings.ts | 51 ++++- .../src/components/Settings/NodeModal.tsx | 4 +- .../src/components/Settings/Settings.tsx | 147 +++++--------- .../src/components/Storage/Storage.tsx | 2 +- frontend-modern/src/pages/Alerts.tsx | 44 ++--- frontend-modern/src/stores/websocket.ts | 7 + 9 files changed, 365 insertions(+), 138 deletions(-) create mode 100644 frontend-modern/src/api/notifications.ts diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index e26d94157..bfb3da8c5 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -28,7 +28,7 @@ export const useWebSocket = () => { function App() { // Get singleton WebSocket store const wsStore = getGlobalWebSocketStore(); - const { state, connected } = wsStore; + const { state, connected, reconnecting } = wsStore; // Data update indicator const [dataUpdated, setDataUpdated] = createSignal(false); @@ -119,12 +119,20 @@ function App() {
-
- {connected() ? 'Connected' : 'Disconnected'} + + + + + + + {connected() ? 'Connected' : reconnecting() ? 'Reconnecting...' : 'Disconnected'}
diff --git a/frontend-modern/src/api/alerts.ts b/frontend-modern/src/api/alerts.ts index aba9947ef..d8ad0421f 100644 --- a/frontend-modern/src/api/alerts.ts +++ b/frontend-modern/src/api/alerts.ts @@ -1,4 +1,5 @@ import type { Alert } from '@/types/api'; +import type { AlertConfig } from '@/types/alerts'; export class AlertsAPI { private static baseUrl = '/api/alerts'; @@ -53,5 +54,52 @@ export class AlertsAPI { return response.json(); } - // Removed unused notification test methods - not implemented in backend + // Alert configuration methods + static async getConfig(): Promise { + const response = await fetch(`${this.baseUrl}/config`); + if (!response.ok) { + throw new Error('Failed to fetch alert configuration'); + } + return response.json(); + } + + static async updateConfig(config: AlertConfig): Promise<{ success: boolean }> { + const response = await fetch(`${this.baseUrl}/config`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + + if (!response.ok) { + throw new Error('Failed to update alert configuration'); + } + + return response.json(); + } + + static async clearAlert(alertId: string): Promise<{ success: boolean }> { + const response = await fetch(`${this.baseUrl}/${alertId}/clear`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('Failed to clear alert'); + } + + return response.json(); + } + + static async clearHistory(): Promise<{ success: boolean }> { + const response = await fetch(`${this.baseUrl}/history`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to clear alert history'); + } + + return response.json(); + } } \ No newline at end of file diff --git a/frontend-modern/src/api/notifications.ts b/frontend-modern/src/api/notifications.ts new file mode 100644 index 000000000..a943bd685 --- /dev/null +++ b/frontend-modern/src/api/notifications.ts @@ -0,0 +1,184 @@ +import type { AlertConfig } from '@/types/alerts'; + +export interface EmailProvider { + id: string; + name: string; + server: string; + port: number; + security: 'none' | 'tls' | 'starttls'; +} + +export interface WebhookTemplate { + id: string; + name: string; + description: string; + template: { + url?: string; + method?: string; + headers?: Record; + body?: string; + }; +} + +export interface EmailConfig { + enabled: boolean; + provider: string; + server: string; + port: number; + username: string; + password?: string; + from: string; + to: string[]; + tls: boolean; + starttls: boolean; +} + +export interface Webhook { + id: string; + name: string; + url: string; + method: string; + headers: Record; + template?: string; + enabled: boolean; +} + +export interface NotificationTestRequest { + type: 'email' | 'webhook'; + config?: EmailConfig | Webhook; + webhookId?: string; +} + +export class NotificationsAPI { + private static baseUrl = '/api/notifications'; + + // Email configuration + static async getEmailConfig(): Promise { + const response = await fetch(`${this.baseUrl}/email`); + if (!response.ok) { + throw new Error('Failed to fetch email configuration'); + } + return response.json(); + } + + static async updateEmailConfig(config: EmailConfig): Promise<{ success: boolean }> { + const response = await fetch(`${this.baseUrl}/email`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + + if (!response.ok) { + throw new Error('Failed to update email configuration'); + } + + return response.json(); + } + + // Webhook management + static async getWebhooks(): Promise { + const response = await fetch(`${this.baseUrl}/webhooks`); + if (!response.ok) { + throw new Error('Failed to fetch webhooks'); + } + return response.json(); + } + + static async createWebhook(webhook: Omit): Promise { + const response = await fetch(`${this.baseUrl}/webhooks`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(webhook), + }); + + if (!response.ok) { + throw new Error('Failed to create webhook'); + } + + return response.json(); + } + + static async updateWebhook(id: string, webhook: Partial): Promise { + const response = await fetch(`${this.baseUrl}/webhooks/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(webhook), + }); + + if (!response.ok) { + throw new Error('Failed to update webhook'); + } + + return response.json(); + } + + static async deleteWebhook(id: string): Promise<{ success: boolean }> { + const response = await fetch(`${this.baseUrl}/webhooks/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete webhook'); + } + + return response.json(); + } + + // Templates and providers + static async getEmailProviders(): Promise { + const response = await fetch(`${this.baseUrl}/email-providers`); + if (!response.ok) { + throw new Error('Failed to fetch email providers'); + } + return response.json(); + } + + static async getWebhookTemplates(): Promise { + const response = await fetch(`${this.baseUrl}/webhook-templates`); + if (!response.ok) { + throw new Error('Failed to fetch webhook templates'); + } + return response.json(); + } + + // Testing + static async testNotification(request: NotificationTestRequest): Promise<{ success: boolean; message?: string }> { + const response = await fetch(`${this.baseUrl}/test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to test notification'); + } + + return response.json(); + } + + static async testWebhook(webhook: Webhook): Promise<{ success: boolean; message?: string }> { + const response = await fetch(`${this.baseUrl}/webhooks/test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(webhook), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to test webhook'); + } + + return response.json(); + } +} \ No newline at end of file diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index 129ada012..3b252df4a 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -1,9 +1,27 @@ -// Simple types - no complex validation needed +import type { + Settings, + SettingsResponse, + SettingsUpdateRequest, + MonitoringSettings +} from '@/types/settings'; + +// System settings type matching Go backend +export interface SystemSettingsUpdate { + pollingInterval: number; // in seconds +} + +// Response types +export interface ApiResponse { + success?: boolean; + status?: string; + message?: string; + data?: T; +} export class SettingsAPI { private static baseUrl = '/api'; - static async getSettings() { + static async getSettings(): Promise { const response = await fetch(`${this.baseUrl}/settings`); if (!response.ok) { @@ -11,10 +29,11 @@ export class SettingsAPI { throw new Error(errorText || 'Failed to fetch settings'); } - return response.json(); + return response.json() as Promise; } - static async updateSettings(settings: any) { + // Full settings update (legacy - avoid using) + static async updateSettings(settings: SettingsUpdateRequest): Promise { const response = await fetch(`${this.baseUrl}/settings/update`, { method: 'POST', headers: { @@ -27,10 +46,28 @@ export class SettingsAPI { throw new Error('Failed to update settings'); } - return response.json(); + return response.json() as Promise; + } + + // System settings update (preferred) + static async updateSystemSettings(settings: SystemSettingsUpdate): Promise { + const response = await fetch(`${this.baseUrl}/config/system`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(settings), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to update system settings'); + } + + return response.json() as Promise; } - static async validateSettings(settings: any) { + static async validateSettings(settings: SettingsUpdateRequest): Promise { const response = await fetch(`${this.baseUrl}/settings/validate`, { method: 'POST', headers: { @@ -43,6 +80,6 @@ export class SettingsAPI { throw new Error('Failed to validate settings'); } - return response.json(); + return response.json() as Promise; } } \ No newline at end of file diff --git a/frontend-modern/src/components/Settings/NodeModal.tsx b/frontend-modern/src/components/Settings/NodeModal.tsx index 5b6b69452..427c7d75c 100644 --- a/frontend-modern/src/components/Settings/NodeModal.tsx +++ b/frontend-modern/src/components/Settings/NodeModal.tsx @@ -86,7 +86,7 @@ export const NodeModal: Component = (props) => { const data = formData(); // Prepare data based on auth type - const nodeData: any = { + const nodeData: Partial = { type: props.nodeType, name: data.name, host: data.host, @@ -95,7 +95,7 @@ export const NodeModal: Component = (props) => { }; if (data.authType === 'password') { - nodeData.user = data.user; + nodeData.username = data.user; if (data.password) { nodeData.password = data.password; } diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 368a7f739..46d5bfd8d 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -3,39 +3,25 @@ import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; import { NodeModal } from './NodeModal'; import { SettingsAPI } from '@/api/settings'; +import { NodesAPI } from '@/api/nodes'; +import type { NodeConfig } from '@/types/nodes'; type SettingsTab = 'pve' | 'pbs' | 'system' | 'diagnostics'; -interface NodeConfig { - id: string; - type: 'pve' | 'pbs'; - name: string; - host: string; - user?: string; - hasPassword: boolean; - tokenName?: string; - hasToken: boolean; - fingerprint?: string; - verifySSL: boolean; - monitorVMs?: boolean; - monitorContainers?: boolean; - monitorStorage?: boolean; - monitorBackups?: boolean; - monitorDatastores?: boolean; - monitorSyncJobs?: boolean; - monitorVerifyJobs?: boolean; - monitorPruneJobs?: boolean; - monitorGarbageJobs?: boolean; +// Node with UI-specific fields +type NodeConfigWithStatus = NodeConfig & { + hasPassword?: boolean; + hasToken?: boolean; status: 'connected' | 'disconnected' | 'error'; -} +}; const Settings: Component = () => { const { state, connected } = useWebSocket(); const [activeTab, setActiveTab] = createSignal('pve'); const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false); - const [nodes, setNodes] = createSignal([]); + const [nodes, setNodes] = createSignal([]); const [showNodeModal, setShowNodeModal] = createSignal(false); - const [editingNode, setEditingNode] = createSignal(null); + const [editingNode, setEditingNode] = createSignal(null); // System settings const [pollingInterval, setPollingInterval] = createSignal(5); @@ -67,11 +53,15 @@ const Settings: Component = () => { onMount(async () => { try { // Load nodes - const nodesResponse = await fetch('/api/config/nodes'); - if (nodesResponse.ok) { - const data = await nodesResponse.json(); - setNodes(data); - } + const nodesList = await NodesAPI.getNodes(); + // Add status and other UI fields + const nodesWithStatus = nodesList.map(node => ({ + ...node, + hasPassword: !!node.password, + hasToken: !!node.tokenValue, + status: 'disconnected' as const + })); + setNodes(nodesWithStatus); // Load system settings try { @@ -89,26 +79,16 @@ const Settings: Component = () => { const saveSettings = async () => { try { if (activeTab() === 'system') { - // Save system settings - const response = await fetch('/api/settings/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - monitoring: { - pollingInterval: pollingInterval() * 1000 - } - }) + // Save system settings using typed API + await SettingsAPI.updateSystemSettings({ + pollingInterval: pollingInterval() }); - - if (!response.ok) { - throw new Error('Failed to save system settings'); - } } showSuccess('Settings saved successfully'); setHasUnsavedChanges(false); } catch (error) { - showError('Failed to save settings'); + showError(error instanceof Error ? error.message : 'Failed to save settings'); } }; @@ -116,35 +96,29 @@ const Settings: Component = () => { if (!confirm('Are you sure you want to delete this node?')) return; try { - const response = await fetch(`/api/config/nodes/${nodeId}`, { - method: 'DELETE' - }); - - if (response.ok) { - setNodes(nodes().filter(n => n.id !== nodeId)); - showSuccess('Node deleted successfully'); - } else { - throw new Error('Failed to delete node'); - } + await NodesAPI.deleteNode(nodeId); + setNodes(nodes().filter(n => n.id !== nodeId)); + showSuccess('Node deleted successfully'); } catch (error) { - showError('Failed to delete node'); + showError(error instanceof Error ? error.message : 'Failed to delete node'); } }; const testNodeConnection = async (nodeId: string) => { try { - const response = await fetch(`/api/config/nodes/${nodeId}/test`, { - method: 'POST' - }); + const node = nodes().find(n => n.id === nodeId); + if (!node) { + throw new Error('Node not found'); + } - if (response.ok) { - const result = await response.json(); - showSuccess(`Connection successful (${result.latency}ms)`); + const result = await NodesAPI.testConnection(node); + if (result.success && result.details) { + showSuccess(`Connection successful`); } else { - throw new Error('Connection failed'); + throw new Error(result.message || 'Connection failed'); } } catch (error) { - showError('Connection test failed'); + showError(error instanceof Error ? error.message : 'Connection test failed'); } }; @@ -580,42 +554,29 @@ const Settings: Component = () => { try { if (editingNode()) { // Update existing node - const response = await fetch(`/api/config/nodes/${editingNode()!.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(nodeData) - }); + await NodesAPI.updateNode(editingNode()!.id, nodeData as NodeConfig); - if (response.ok) { - // Update local state - setNodes(nodes().map(n => - n.id === editingNode()!.id - ? { ...n, ...nodeData, hasPassword: !!nodeData.password, hasToken: !!nodeData.tokenValue } - : n - )); - showSuccess('Node updated successfully'); - } else { - throw new Error('Failed to update node'); - } + // Update local state + setNodes(nodes().map(n => + n.id === editingNode()!.id + ? { ...n, ...nodeData, hasPassword: !!nodeData.password, hasToken: !!nodeData.tokenValue } + : n + )); + showSuccess('Node updated successfully'); } else { // Add new node - const response = await fetch('/api/config/nodes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(nodeData) - }); + await NodesAPI.addNode(nodeData as NodeConfig); - if (response.ok) { - // Reload nodes to get the new ID - const nodesResponse = await fetch('/api/config/nodes'); - if (nodesResponse.ok) { - const updatedNodes = await nodesResponse.json(); - setNodes(updatedNodes); - } - showSuccess('Node added successfully'); - } else { - throw new Error('Failed to add node'); - } + // Reload nodes to get the new ID + const nodesList = await NodesAPI.getNodes(); + const nodesWithStatus = nodesList.map(node => ({ + ...node, + hasPassword: !!node.password, + hasToken: !!node.tokenValue, + status: 'disconnected' as const + })); + setNodes(nodesWithStatus); + showSuccess('Node added successfully'); } setShowNodeModal(false); diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 9848470a5..72d08afd0 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -102,7 +102,7 @@ const Storage: Component = () => { const filteredStorage = createMemo(() => { const storage = state.storage || []; if (viewMode() === 'storage') { - return storage.filter((s: any) => s.total > 0); + return storage.filter((s) => s.total > 0); } return storage; }); diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 145dbc9f8..83b3cb788 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -324,11 +324,7 @@ export function Alerts() { // Save email config if on destinations tab if (activeTab() === 'destinations' && destinationsRef.emailConfig) { - await fetch('/api/notifications/email', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(destinationsRef.emailConfig()) - }); + await NotificationsAPI.updateEmailConfig(destinationsRef.emailConfig()); } setHasUnsavedChanges(false); @@ -558,7 +554,7 @@ function OverviewTab(props: { overrides: any[]; activeAlerts: Record { // API call to acknowledge alert - fetch(`/api/alerts/${alert.id}/acknowledge`, { method: 'POST' }) + AlertsAPI.acknowledge(alert.id) .catch(err => console.error('Failed to acknowledge alert:', err)); }} > @@ -569,7 +565,7 @@ function OverviewTab(props: { overrides: any[]; activeAlerts: Record { // API call to clear alert - fetch(`/api/alerts/${alert.id}/clear`, { method: 'POST' }) + AlertsAPI.clearAlert(alert.id) .catch(err => console.error('Failed to clear alert:', err)); }} > @@ -1195,22 +1191,16 @@ function DestinationsTab(props: any) { // Load email config on mount onMount(async () => { try { - const res = await fetch('/api/notifications/email'); - if (res.ok) { - const config = await res.json(); - setEmailConfig(config); - } + const config = await NotificationsAPI.getEmailConfig(); + setEmailConfig(config); } catch (err) { console.error('Failed to load email config:', err); } // Load webhooks try { - const res = await fetch('/api/notifications/webhooks'); - if (res.ok) { - const hooks = await res.json(); - setWebhooks(hooks); - } + const hooks = await NotificationsAPI.getWebhooks(); + setWebhooks(hooks); } catch (err) { console.error('Failed to load webhooks:', err); } @@ -1254,7 +1244,7 @@ function DestinationsTab(props: any) { alert(`Failed to send test webhook: ${error}`); } } catch (err) { - alert('Failed to send test webhook'); + alert(`Failed to send test webhook: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { setTestingWebhook(null); } @@ -1941,11 +1931,8 @@ function HistoryTab() { // Load alert history on mount onMount(async () => { try { - const res = await fetch('/api/alerts/history?limit=1000'); - if (res.ok) { - const history = await res.json(); - setAlertHistory(history); - } + const history = await AlertsAPI.getHistory({ limit: 1000 }); + setAlertHistory(history); } catch (err) { console.error('Failed to load alert history:', err); } finally { @@ -2463,14 +2450,9 @@ function HistoryTab() { onClick={async () => { if (confirm('Are you sure you want to clear all alert history?\n\nThis will permanently delete all historical alert data and cannot be undone.\n\nThis is typically only used for system maintenance or when starting fresh with a new monitoring setup.')) { try { - const res = await fetch('/api/alerts/history', { method: 'DELETE' }); - if (res.ok) { - setAlertHistory([]); - console.log('Alert history cleared successfully'); - } else { - console.error('Failed to clear alert history'); - alert('Failed to clear alert history. Please try again.'); - } + await AlertsAPI.clearHistory(); + setAlertHistory([]); + console.log('Alert history cleared successfully'); } catch (err) { console.error('Error clearing alert history:', err); alert('Error clearing alert history. Please check your connection and try again.'); diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index 0e14d48e5..30e48a10d 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -7,6 +7,7 @@ import { POLLING_INTERVALS, WEBSOCKET } from '@/constants'; // Type-safe WebSocket store export function createWebSocketStore(url: string) { const [connected, setConnected] = createSignal(false); + const [reconnecting, setReconnecting] = createSignal(false); const [state, setState] = createStore({ nodes: [], vms: [], @@ -45,6 +46,7 @@ export function createWebSocketStore(url: string) { ws.onopen = () => { logger.debug('connect'); setConnected(true); + setReconnecting(false); // Clear reconnecting state reconnectAttempt = 0; // Reset reconnect attempts on successful connection // Alerts will come with the initial state broadcast @@ -149,6 +151,7 @@ export function createWebSocketStore(url: string) { } isReconnecting = true; + setReconnecting(true); // Calculate exponential backoff delay const delay = Math.min( @@ -161,6 +164,7 @@ export function createWebSocketStore(url: string) { reconnectTimeout = window.setTimeout(() => { isReconnecting = false; + setReconnecting(false); connect(); }, delay); }; @@ -180,6 +184,7 @@ export function createWebSocketStore(url: string) { if (isReconnecting) return; isReconnecting = true; + setReconnecting(true); // Use exponential backoff for connection errors too const delay = Math.min( @@ -190,6 +195,7 @@ export function createWebSocketStore(url: string) { reconnectAttempt++; reconnectTimeout = window.setTimeout(() => { isReconnecting = false; + setReconnecting(false); connect(); }, delay); } @@ -209,6 +215,7 @@ export function createWebSocketStore(url: string) { activeAlerts, recentlyResolved, connected, + reconnecting, reconnect: () => { ws?.close(); window.clearTimeout(reconnectTimeout);