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.
This commit is contained in:
Pulse Monitor
2025-07-29 20:44:04 +00:00
parent ff6ecc9872
commit 165b809c7c
9 changed files with 365 additions and 138 deletions
+11 -3
View File
@@ -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() {
</Show>
</button>
<div class="flex items-center gap-2">
<div class={`status text-xs px-2 py-1 rounded-full ${
<div class={`status text-xs px-2 py-1 rounded-full flex items-center gap-1 ${
connected()
? 'connected bg-green-200 dark:bg-green-700 text-green-700 dark:text-green-300'
: reconnecting()
? 'reconnecting bg-yellow-200 dark:bg-yellow-700 text-yellow-700 dark:text-yellow-300'
: 'disconnected bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}>
{connected() ? 'Connected' : 'Disconnected'}
<Show when={reconnecting()}>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</Show>
{connected() ? 'Connected' : reconnecting() ? 'Reconnecting...' : 'Disconnected'}
</div>
</div>
</div>
+49 -1
View File
@@ -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<AlertConfig> {
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();
}
}
+184
View File
@@ -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<string, string>;
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<string, string>;
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<EmailConfig> {
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<Webhook[]> {
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<Webhook, 'id'>): Promise<Webhook> {
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<Webhook>): Promise<Webhook> {
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<EmailProvider[]> {
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<WebhookTemplate[]> {
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();
}
}
+44 -7
View File
@@ -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<T = any> {
success?: boolean;
status?: string;
message?: string;
data?: T;
}
export class SettingsAPI {
private static baseUrl = '/api';
static async getSettings() {
static async getSettings(): Promise<SettingsResponse> {
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<SettingsResponse>;
}
static async updateSettings(settings: any) {
// Full settings update (legacy - avoid using)
static async updateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
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<ApiResponse>;
}
// System settings update (preferred)
static async updateSystemSettings(settings: SystemSettingsUpdate): Promise<ApiResponse> {
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<ApiResponse>;
}
static async validateSettings(settings: any) {
static async validateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
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<ApiResponse>;
}
}
@@ -86,7 +86,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const data = formData();
// Prepare data based on auth type
const nodeData: any = {
const nodeData: Partial<any> = {
type: props.nodeType,
name: data.name,
host: data.host,
@@ -95,7 +95,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
};
if (data.authType === 'password') {
nodeData.user = data.user;
nodeData.username = data.user;
if (data.password) {
nodeData.password = data.password;
}
@@ -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<SettingsTab>('pve');
const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false);
const [nodes, setNodes] = createSignal<NodeConfig[]>([]);
const [nodes, setNodes] = createSignal<NodeConfigWithStatus[]>([]);
const [showNodeModal, setShowNodeModal] = createSignal(false);
const [editingNode, setEditingNode] = createSignal<NodeConfig | null>(null);
const [editingNode, setEditingNode] = createSignal<NodeConfigWithStatus | null>(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);
@@ -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;
});
+13 -31
View File
@@ -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<string, any
class="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
onClick={() => {
// 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<string, any
class="px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
onClick={() => {
// 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.');
+7
View File
@@ -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<State>({
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);