fix: permanently solve frontend embed sync issue

- Add build.sh script that ensures frontend is copied before Go build
- Update backend-watch.sh to sync frontend on every restart
- Remove duplicate frontend files from internal/api/
- Frontend embed directory already in .gitignore
- No more manual copying needed - just run ./build.sh
This commit is contained in:
Pulse Monitor
2025-08-12 20:56:43 +00:00
parent 01dd7120be
commit b1b74b2bd8
66 changed files with 27 additions and 15785 deletions
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Build script for Pulse that ensures frontend is in the right place for embedding
set -e
echo "Building Pulse..."
# Build frontend
echo "Building frontend..."
cd frontend-modern
npm run build
# Ensure the embed directory exists and is up to date
echo "Preparing frontend for embedding..."
mkdir -p ../internal/api/frontend-modern
rm -rf ../internal/api/frontend-modern/dist
cp -r dist ../internal/api/frontend-modern/
# Build backend with embedded frontend
echo "Building backend..."
cd ..
go build -o pulse ./cmd/pulse
echo "Build complete!"
echo "To run: ./pulse or sudo systemctl restart pulse-backend"
@@ -1476,7 +1476,7 @@ const Settings: Component = () => {
{/* Security Tab */}
<Show when={activeTab() === 'security'}>
<div class="space-y-6">
<Show when={!securityStatus()?.hasAuthentication}>
<Show when={securityStatus() && !securityStatus()!.hasAuthentication}>
<div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Quick Security Setup</h3>
<QuickSecuritySetup />
-32
View File
@@ -1,32 +0,0 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Production
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>Pulse</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -1,16 +0,0 @@
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<title>Pulse Logo</title>
<style>
.pulse-bg { fill: #2563eb; }
.pulse-ring { fill: none; stroke: #ffffff; stroke-width: 14; opacity: 0.92; }
.pulse-center { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.pulse-bg { fill: #3b82f6; }
.pulse-ring { stroke: #dbeafe; }
.pulse-center { fill: #dbeafe; }
}
</style>
<circle class="pulse-bg" cx="128" cy="128" r="122"/>
<circle class="pulse-ring" cx="128" cy="128" r="84"/>
<circle class="pulse-center" cx="128" cy="128" r="26"/>
</svg>

Before

Width:  |  Height:  |  Size: 625 B

-295
View File
@@ -1,295 +0,0 @@
import { Show, createSignal, createContext, useContext, createEffect, onMount } from 'solid-js';
import { getGlobalWebSocketStore } from './stores/websocket-global';
import { Dashboard } from './components/Dashboard/Dashboard';
import StorageComponent from './components/Storage/Storage';
import Backups from './components/Backups/Backups';
import Settings from './components/Settings/Settings';
import { Alerts } from './pages/Alerts';
import { ToastContainer } from './components/Toast/Toast';
import { ErrorBoundary } from './components/ErrorBoundary';
import NotificationContainer from './components/NotificationContainer';
import { logger } from './utils/logger';
import { POLLING_INTERVALS, STORAGE_KEYS } from './constants';
import { UpdatesAPI } from './api/updates';
import type { VersionInfo } from './api/updates';
type TabType = 'main' | 'storage' | 'backups' | 'alerts' | 'settings';
// Enhanced store type with proper typing
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
// Export WebSocket context for other components
export const WebSocketContext = createContext<EnhancedStore>();
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within WebSocketContext.Provider');
}
return context;
};
function App() {
// Get singleton WebSocket store
const wsStore = getGlobalWebSocketStore();
const { state, connected, reconnecting } = wsStore;
// Data update indicator
const [dataUpdated, setDataUpdated] = createSignal(false);
let updateTimeout: number;
// Flash indicator when data updates
createEffect(() => {
// Watch for state changes
const updateTime = state.lastUpdate;
if (updateTime && updateTime !== '') {
setDataUpdated(true);
window.clearTimeout(updateTimeout);
updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH);
}
});
// Tab management
const [activeTab, setActiveTab] = createSignal<TabType>('main');
// Version info
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
// Dark mode
const [darkMode, setDarkMode] = createSignal(
localStorage.getItem(STORAGE_KEYS.DARK_MODE) === 'true' ||
window.matchMedia('(prefers-color-scheme: dark)').matches
);
// Toggle dark mode
const toggleDarkMode = () => {
const newMode = !darkMode();
setDarkMode(newMode);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(newMode));
if (newMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
logger.info('Theme changed', { mode: newMode ? 'dark' : 'light' });
};
// Initialize dark mode
if (darkMode()) {
document.documentElement.classList.add('dark');
}
// Load version info on mount
onMount(async () => {
try {
const version = await UpdatesAPI.getVersion();
setVersionInfo(version);
} catch (error) {
console.error('Failed to load version:', error);
}
});
// Pass through the store directly
const enhancedStore: EnhancedStore = wsStore;
return (
<ErrorBoundary>
<WebSocketContext.Provider value={enhancedStore}>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 p-2 font-sans">
<div class="container w-[95%] max-w-screen-xl mx-auto">
{/* Header */}
<div class="header flex flex-row justify-between items-center mb-2">
<div class="hidden md:block md:flex-1"></div>
<div class="flex items-center gap-1 flex-none">
<svg
width="20"
height="20"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class={`pulse-logo ${connected() && dataUpdated() ? 'animate-pulse-logo' : ''}`}
>
<title>Pulse Logo</title>
<circle class="pulse-bg fill-blue-600 dark:fill-blue-500" cx="128" cy="128" r="122"/>
<circle class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]" cx="128" cy="128" r="84"/>
<circle class="pulse-center fill-white dark:fill-[#dbeafe]" cx="128" cy="128" r="26"/>
</svg>
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
<Show when={versionInfo()?.channel === 'rc'}>
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">RC</span>
</Show>
</div>
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
<button
onClick={toggleDarkMode}
class="p-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title={darkMode() ? "Switch to light mode" : "Switch to dark mode"}
>
<Show
when={darkMode()}
fallback={
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</Show>
</button>
<div class="flex items-center gap-2">
<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'
}`}>
<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>
</div>
{/* Tabs */}
<div class="tabs flex mb-2 border-b border-gray-300 dark:border-gray-700 overflow-x-auto overflow-y-hidden whitespace-nowrap scrollbar-hide" role="tablist">
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'main'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('main')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span>Main</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'storage'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('storage')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
<span>Storage</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'backups'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('backups')}
role="tab"
title="PVE backups, PBS backups, and VM/CT snapshots"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
<span>Backups</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'alerts'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('alerts')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>Alerts</span>
</div>
<div
class={`tab px-2 sm:px-3 py-1.5 cursor-pointer text-xs sm:text-sm rounded-t flex items-center gap-1 sm:gap-1.5 transition-colors ${
activeTab() === 'settings'
? 'active bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 border-b-0 -mb-px text-blue-600 dark:text-blue-500'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent'
}`}
onClick={() => setActiveTab('settings')}
role="tab"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span>Settings</span>
</div>
</div>
{/* Main Content */}
<main id="main" class="tab-content block bg-white dark:bg-gray-800 rounded-b rounded-tr shadow mb-2">
<div class="p-3">
<Show when={activeTab() === 'main'}>
<Dashboard
vms={state.vms}
containers={state.containers}
nodes={state.nodes}
pbs={state.pbs}
/>
</Show>
<Show when={activeTab() === 'storage'}>
<StorageComponent />
</Show>
<Show when={activeTab() === 'backups'}>
<Backups />
</Show>
<Show when={activeTab() === 'alerts'}>
<Alerts />
</Show>
<Show when={activeTab() === 'settings'}>
<Settings />
</Show>
</div>
</main>
{/* Footer */}
<footer class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">
Pulse | Version: {' '}
<a
href="https://github.com/rcourtman/Pulse/releases"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline"
>
{versionInfo()?.version || 'loading...'}
</a>
{versionInfo()?.isDevelopment && ' (Development)'}
{versionInfo()?.isDocker && ' - Docker'}
</footer>
</div>
</div>
<ToastContainer />
<NotificationContainer />
</WebSocketContext.Provider>
</ErrorBoundary>
);
}
export default App;
@@ -1,107 +0,0 @@
import type { Alert } from '@/types/api';
import type { AlertConfig } from '@/types/alerts';
// Error handling utilities available for future use
// import { handleError, createErrorBoundary } from '@/utils/errorHandler';
export class AlertsAPI {
private static baseUrl = '/api/alerts';
static async getActive(): Promise<Alert[]> {
const response = await fetch(`${this.baseUrl}/active`);
if (!response.ok) {
throw new Error('Failed to fetch active alerts');
}
return response.json();
}
static async getHistory(params?: {
limit?: number;
offset?: number;
startTime?: string;
endTime?: string;
severity?: 'warning' | 'critical' | 'all';
resourceId?: string;
}): Promise<Alert[]> {
const queryParams = new URLSearchParams();
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
queryParams.append(key, value.toString());
}
});
}
const response = await fetch(`${this.baseUrl}/history?${queryParams}`);
if (!response.ok) {
throw new Error('Failed to fetch alert history');
}
return response.json();
}
// Removed unused config methods - not implemented in backend
static async acknowledge(alertId: string, user?: string): Promise<{ success: boolean }> {
const response = await fetch(`${this.baseUrl}/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user }),
});
if (!response.ok) {
throw new Error('Failed to acknowledge alert');
}
return response.json();
}
// 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();
}
}
@@ -1,37 +0,0 @@
import type { State, Performance, Stats } from '@/types/api';
export class MonitoringAPI {
private static baseUrl = '/api';
static async getState(): Promise<State> {
const response = await fetch(`${this.baseUrl}/state`);
if (!response.ok) {
throw new Error('Failed to fetch monitoring state');
}
return response.json();
}
static async getPerformance(): Promise<Performance> {
const response = await fetch(`${this.baseUrl}/performance`);
if (!response.ok) {
throw new Error('Failed to fetch performance metrics');
}
return response.json();
}
static async getStats(): Promise<Stats> {
const response = await fetch(`${this.baseUrl}/stats`);
if (!response.ok) {
throw new Error('Failed to fetch system stats');
}
return response.json();
}
static async exportDiagnostics(): Promise<Blob> {
const response = await fetch(`${this.baseUrl}/diagnostics/export`);
if (!response.ok) {
throw new Error('Failed to export diagnostics');
}
return response.blob();
}
}
@@ -1,103 +0,0 @@
import { NodeConfig } from '../types/nodes';
export class NodesAPI {
private static readonly baseUrl = '/api/config/nodes';
static async getNodes(): Promise<NodeConfig[]> {
const response = await fetch(this.baseUrl);
if (!response.ok) {
throw new Error('Failed to fetch nodes');
}
// The API returns an array of nodes directly
const nodes: NodeConfig[] = await response.json();
return nodes;
}
static async addNode(node: NodeConfig): Promise<{ success: boolean; message?: string }> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to add node: ${error}`);
}
return response.json();
}
static async updateNode(nodeId: string, node: NodeConfig): Promise<{ success: boolean; message?: string }> {
const response = await fetch(`${this.baseUrl}/${nodeId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to update node: ${error}`);
}
return response.json();
}
static async deleteNode(nodeId: string): Promise<{ success: boolean; message?: string }> {
const response = await fetch(`${this.baseUrl}/${nodeId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to delete node: ${error}`);
}
return response.json();
}
static async testConnection(node: NodeConfig): Promise<{
status: string;
message?: string;
isCluster?: boolean;
nodeCount?: number;
clusterNodeCount?: number;
datastoreCount?: number;
}> {
const response = await fetch(`${this.baseUrl}/test-connection`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
return response.json();
}
static async testExistingNode(nodeId: string): Promise<{
status: string;
message?: string;
latency?: number;
}> {
const response = await fetch(`${this.baseUrl}/${nodeId}/test`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.text();
throw new Error(error);
}
return response.json();
}
}
@@ -1,231 +0,0 @@
export interface EmailProvider {
id?: string;
name: string;
smtpHost: string;
smtpPort: number;
tls: boolean;
startTLS: boolean;
authRequired: boolean;
instructions: string;
server?: string;
port?: number;
security?: 'none' | 'tls' | 'starttls';
}
export interface WebhookTemplate {
id?: string;
service: string;
name: string;
urlPattern: string;
method: string;
headers: Record<string, string>;
payloadTemplate: string;
instructions: 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;
service?: string; // Added to support Discord, Slack, etc.
}
export interface NotificationTestRequest {
type: 'email' | 'webhook';
config?: any; // Backend expects different format than frontend types
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');
}
const backendConfig = await response.json();
// Backend already returns fields with correct names (server, port)
return {
enabled: backendConfig.enabled || false,
provider: backendConfig.provider || '',
server: backendConfig.server || '',
port: backendConfig.port || 587,
username: backendConfig.username || '',
password: backendConfig.password || '',
from: backendConfig.from || '',
to: backendConfig.to || [],
tls: backendConfig.tls || false,
startTLS: backendConfig.startTLS || false
};
}
static async updateEmailConfig(config: EmailConfig): Promise<{ success: boolean }> {
// Backend expects fields with these names (server, port)
const backendConfig = {
enabled: config.enabled,
server: config.server,
port: config.port,
username: config.username,
password: config.password,
from: config.from,
to: config.to,
tls: config.tls || false,
startTLS: config.startTLS || false,
provider: config.provider || ''
};
const response = await fetch(`${this.baseUrl}/email`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(backendConfig),
});
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 body: { method: string; config?: any } = { method: request.type };
// Include config if provided for testing without saving
if (request.config) {
body.config = request.config;
}
const response = await fetch(`${this.baseUrl}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
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();
}
}
@@ -1,91 +0,0 @@
import type {
SettingsResponse,
SettingsUpdateRequest
} from '@/types/settings';
// System settings type matching Go backend
export interface SystemSettingsUpdate {
pollingInterval: number; // in seconds
backendPort?: number;
frontendPort?: number;
allowedOrigins?: string;
connectionTimeout?: number; // in seconds
updateChannel?: string;
autoUpdateEnabled?: boolean;
autoUpdateCheckInterval?: number; // in hours
autoUpdateTime?: string; // HH:MM format
}
// Response types
export interface ApiResponse<T = unknown> {
success?: boolean;
status?: string;
message?: string;
data?: T;
}
export class SettingsAPI {
private static baseUrl = '/api';
static async getSettings(): Promise<SettingsResponse> {
const response = await fetch(`${this.baseUrl}/settings`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to fetch settings');
}
return response.json() as Promise<SettingsResponse>;
}
// Full settings update (legacy - avoid using)
static async updateSettings(settings: SettingsUpdateRequest): Promise<ApiResponse> {
const response = await fetch(`${this.baseUrl}/settings/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error('Failed to update settings');
}
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: SettingsUpdateRequest): Promise<ApiResponse> {
const response = await fetch(`${this.baseUrl}/settings/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error('Failed to validate settings');
}
return response.json() as Promise<ApiResponse>;
}
}
@@ -1,69 +0,0 @@
// Remove apiRequest import - use fetch directly
export interface UpdateInfo {
available: boolean;
currentVersion: string;
latestVersion: string;
releaseNotes: string;
releaseDate: string;
downloadUrl: string;
isPrerelease: boolean;
}
export interface UpdateStatus {
status: string;
progress: number;
message: string;
error?: string;
updatedAt: string;
}
export interface VersionInfo {
version: string;
build: string;
runtime: string;
channel?: string;
isDocker: boolean;
isDevelopment: boolean;
}
export class UpdatesAPI {
static async checkForUpdates(channel?: string): Promise<UpdateInfo> {
const url = channel ? `/api/updates/check?channel=${channel}` : '/api/updates/check';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to check for updates');
}
return response.json();
}
static async applyUpdate(downloadUrl: string): Promise<{ status: string; message: string }> {
const response = await fetch('/api/updates/apply', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ downloadUrl }),
});
if (!response.ok) {
throw new Error('Failed to apply update');
}
return response.json();
}
static async getUpdateStatus(): Promise<UpdateStatus> {
const response = await fetch('/api/updates/status');
if (!response.ok) {
throw new Error('Failed to get update status');
}
return response.json();
}
static async getVersion(): Promise<VersionInfo> {
const response = await fetch('/api/version');
if (!response.ok) {
throw new Error('Failed to get version');
}
return response.json();
}
}
@@ -1,178 +0,0 @@
import { For, Show } from 'solid-js';
import type { CustomAlertRule } from '@/types/alerts';
interface CustomRulesTabProps {
rules: CustomAlertRule[];
onUpdateRules: (rules: CustomAlertRule[]) => void;
onHasChanges: (hasChanges: boolean) => void;
}
export function CustomRulesTab(props: CustomRulesTabProps) {
const deleteRule = (ruleId: string) => {
const updatedRules = props.rules.filter(r => r.id !== ruleId);
props.onUpdateRules(updatedRules);
props.onHasChanges(true);
};
const toggleRule = (ruleId: string) => {
const updatedRules = props.rules.map(r =>
r.id === ruleId ? { ...r, enabled: !r.enabled } : r
);
props.onUpdateRules(updatedRules);
props.onHasChanges(true);
};
const getFilterDescription = (rule: CustomAlertRule): string => {
return rule.filterConditions.filters.map(filter => {
if (filter.type === 'metric' && filter.field && filter.operator && filter.value !== undefined) {
return `${filter.field} ${filter.operator} ${filter.value}%`;
} else if (filter.type === 'text' && filter.field && filter.value) {
return `${filter.field}: ${filter.value}`;
} else {
return filter.rawText || '';
}
}).join(` ${rule.filterConditions.logicalOperator} `);
};
const getThresholdsSummary = (rule: CustomAlertRule): string => {
const parts: string[] = [];
if (rule.thresholds.cpu !== undefined) parts.push(`CPU: ${rule.thresholds.cpu}%`);
if (rule.thresholds.memory !== undefined) parts.push(`Memory: ${rule.thresholds.memory}%`);
if (rule.thresholds.disk !== undefined) parts.push(`Disk: ${rule.thresholds.disk}%`);
return parts.length > 0 ? parts.join(', ') : 'Using global defaults';
};
return (
<div class="space-y-4">
{/* Header */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-medium text-gray-800 dark:text-gray-200 mb-2">Custom Alert Rules</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Custom rules apply specific thresholds to guests matching filter conditions.
Rules are evaluated in priority order (higher number = higher priority).
</p>
</div>
{/* Priority Order Explanation */}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Alert Priority Order:</p>
<ol class="list-decimal list-inside space-y-0.5 text-xs">
<li>Guest-specific overrides (highest priority)</li>
<li>Custom rules (evaluated by priority number)</li>
<li>Global default thresholds (lowest priority)</li>
</ol>
</div>
</div>
</div>
{/* Rules List */}
<Show when={props.rules.length > 0} fallback={
<div class="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<p class="text-sm text-gray-600 dark:text-gray-400">
No custom alert rules defined yet. Create rules from the Dashboard by applying filters and clicking "Create Alert".
</p>
</div>
}>
<div class="space-y-3">
<For each={props.rules.sort((a, b) => b.priority - a.priority)}>
{(rule) => (
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div class="p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h4 class="text-sm font-medium text-gray-800 dark:text-gray-200">{rule.name}</h4>
<span class={`px-2 py-0.5 text-xs font-medium rounded-full ${
rule.enabled
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{rule.enabled ? 'Active' : 'Disabled'}
</span>
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
Priority: {rule.priority}
</span>
</div>
<Show when={rule.description}>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">{rule.description}</p>
</Show>
</div>
<div class="flex items-center gap-2">
<button
onClick={() => toggleRule(rule.id)}
class="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
title={rule.enabled ? "Disable rule" : "Enable rule"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<Show when={rule.enabled} fallback={
<path d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
}>
<path d="M18.364 5.636a9 9 0 010 12.728m0 0a9 9 0 01-12.728 0m12.728 0L5.636 5.636m12.728 0L5.636 18.364" />
</Show>
</svg>
</button>
<button
onClick={() => deleteRule(rule.id)}
class="p-1.5 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Delete rule"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-20">Filters:</span>
<div class="flex-1">
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{getFilterDescription(rule)}
</code>
</div>
</div>
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-20">Thresholds:</span>
<span class="text-xs text-gray-700 dark:text-gray-300">
{getThresholdsSummary(rule)}
</span>
</div>
<Show when={rule.notifications.email?.enabled || rule.notifications.webhook?.enabled}>
<div class="flex items-start gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-20">Notify:</span>
<div class="flex gap-2">
<Show when={rule.notifications.email?.enabled}>
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
Email
</span>
</Show>
<Show when={rule.notifications.webhook?.enabled}>
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
Webhook
</span>
</Show>
</div>
</div>
</Show>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
);
}
@@ -1,332 +0,0 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
interface EmailProvider {
name: string;
smtpHost: string;
smtpPort: number;
tls: boolean;
startTLS: boolean;
authRequired: boolean;
instructions: string;
}
interface EmailConfig {
enabled: boolean;
provider: string;
server: string; // Fixed: use 'server' not 'smtpHost'
port: number; // Fixed: use 'port' not 'smtpPort'
from: string;
username: string;
password: string;
to: string[];
tls: boolean;
startTLS: boolean;
replyTo: string;
maxRetries: number;
retryDelay: number;
rateLimit: number;
}
interface EmailProviderSelectProps {
config: EmailConfig;
onChange: (config: EmailConfig) => void;
onTest: () => void;
testing?: boolean;
}
export function EmailProviderSelect(props: EmailProviderSelectProps) {
const [providers, setProviders] = createSignal<EmailProvider[]>([]);
const [showProviders, setShowProviders] = createSignal(false);
const [showAdvanced, setShowAdvanced] = createSignal(false);
// Load email providers
createEffect(async () => {
try {
const data = await NotificationsAPI.getEmailProviders();
setProviders(data);
} catch (err) {
console.error('Failed to load email providers:', err);
}
});
const selectProvider = (provider: EmailProvider) => {
props.onChange({
...props.config,
provider: provider.name,
server: provider.smtpHost, // Fixed: use 'server' not 'smtpHost'
port: provider.smtpPort, // Fixed: use 'port' not 'smtpPort'
tls: provider.tls,
startTLS: provider.startTLS,
username: provider.name === 'SendGrid' ? 'apikey' : props.config.username,
});
setShowProviders(false);
};
const currentProvider = () => providers().find(p => p.name === props.config.provider);
return (
<div class="space-y-6">
{/* Provider Selection */}
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Email Provider
</label>
<button
onClick={() => setShowProviders(!showProviders())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{props.config.provider || 'Select Provider'}
</button>
</div>
<Show when={showProviders()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<For each={providers()}>
{(provider) => (
<button
onClick={() => selectProvider(provider)}
class={`p-3 text-left rounded-lg border transition-all ${
props.config.provider === provider.name
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
{provider.name}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{provider.smtpHost}:{provider.smtpPort}
</div>
</button>
)}
</For>
</div>
</Show>
<Show when={currentProvider()}>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Setup Instructions
</h4>
<pre class="text-xs text-blue-800 dark:text-blue-200 whitespace-pre-wrap">
{currentProvider()!.instructions}
</pre>
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SMTP Server
</label>
<input
type="text"
value={props.config.server}
onInput={(e) => props.onChange({ ...props.config, server: e.currentTarget.value })}
placeholder="smtp.example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
SMTP Port
</label>
<input
type="number"
value={props.config.port}
onInput={(e) => props.onChange({ ...props.config, port: parseInt(e.currentTarget.value) || 587 })}
placeholder="587"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
From Address
</label>
<input
type="email"
value={props.config.from}
onInput={(e) => props.onChange({ ...props.config, from: e.currentTarget.value })}
placeholder="noreply@example.com"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reply-To Address
</label>
<input
type="email"
value={props.config.replyTo || ''}
onInput={(e) => props.onChange({ ...props.config, replyTo: e.currentTarget.value })}
placeholder="admin@example.com (optional)"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<input
type="text"
value={props.config.username}
onInput={(e) => props.onChange({ ...props.config, username: e.currentTarget.value })}
placeholder={props.config.provider === 'SendGrid' ? 'apikey' : 'username@example.com'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password / API Key
</label>
<input
type="password"
value={props.config.password}
onInput={(e) => props.onChange({ ...props.config, password: e.currentTarget.value })}
placeholder="••••••••"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
{/* Recipients */}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Recipients (one per line)
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">
Leave empty to send to the From address
</span>
</label>
<textarea
value={props.config.to.join('\n')}
onInput={(e) => {
// Parse recipients - split by newlines and keep all non-empty lines
const rawValue = e.currentTarget.value;
const recipients = rawValue
.split('\n')
.map(r => r.trim())
.filter(r => r.length > 0); // Keep all non-empty lines, validation happens on save
props.onChange({ ...props.config, to: recipients });
}}
onKeyDown={(e) => {
// Allow Enter key in textarea without triggering form submission
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
placeholder={`Leave empty to use ${props.config.from || 'From address'}\nOr add additional recipients:\nadmin@company.com\nops-team@company.com`}
rows="3"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
{/* Advanced Settings */}
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 flex items-center gap-1"
>
<svg class={`w-4 h-4 transition-transform ${showAdvanced() ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Advanced Settings
</button>
<Show when={showAdvanced()}>
<div class="mt-4 space-y-4 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.tls}
onChange={(e) => props.onChange({ ...props.config, tls: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use TLS</span>
</label>
</div>
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={props.config.startTLS}
onChange={(e) => props.onChange({ ...props.config, startTLS: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rate Limit
</label>
<div class="flex items-center gap-2">
<input
type="number"
value={props.config.rateLimit || 60}
onInput={(e) => props.onChange({ ...props.config, rateLimit: parseInt(e.currentTarget.value) })}
class="w-20 px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
/>
<span class="text-sm text-gray-600 dark:text-gray-400">/min</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Retries
</label>
<input
type="number"
value={props.config.maxRetries || 3}
min="0"
max="5"
onInput={(e) => props.onChange({ ...props.config, maxRetries: parseInt(e.currentTarget.value) })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Retry Delay (seconds)
</label>
<input
type="number"
value={props.config.retryDelay || 5}
min="1"
max="60"
onInput={(e) => props.onChange({ ...props.config, retryDelay: parseInt(e.currentTarget.value) })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
</div>
</div>
</Show>
</div>
{/* Test Button */}
<div class="flex justify-end">
<button
onClick={props.onTest}
disabled={props.testing || !props.config.enabled}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{props.testing ? 'Sending Test Email...' : 'Send Test Email'}
</button>
</div>
</div>
);
}
@@ -1,340 +0,0 @@
import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
interface Override {
id?: string; // Full guest ID (e.g. "Main-node1-105")
guestName: string;
vmid: number;
type: string;
node: string;
instance?: string;
thresholds: {
cpu?: number;
memory?: number;
disk?: number;
diskRead?: number;
diskWrite?: number;
networkIn?: number;
networkOut?: number;
};
}
interface OverrideModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (override: Override) => void;
existingOverride?: Override;
guests: Array<{ id: string; name: string; vmid: number; type: string; node: string; instance: string }>;
}
export function OverrideModal(props: OverrideModalProps) {
// Initialize state only when modal opens, not on every render
const [selectedGuest, setSelectedGuest] = createSignal<string>('');
// Store the select element ref
let selectRef: HTMLSelectElement | undefined;
const [thresholds, setThresholds] = createSignal({
cpu: 80,
memory: 80,
disk: 80,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0
});
const [enabledMetrics, setEnabledMetrics] = createSignal({
cpu: false,
memory: false,
disk: false,
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false
});
// Maintain select value when guests change
createEffect(() => {
if (selectRef && selectedGuest()) {
const currentValue = selectedGuest();
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
if (selectRef) {
selectRef.value = currentValue;
}
});
}
});
// Reset state when modal opens
createEffect(() => {
if (props.isOpen) {
if (props.existingOverride) {
setSelectedGuest(`${props.existingOverride.vmid}`);
setThresholds({
cpu: props.existingOverride.thresholds.cpu || 80,
memory: props.existingOverride.thresholds.memory || 80,
disk: props.existingOverride.thresholds.disk || 80,
diskRead: props.existingOverride.thresholds.diskRead || 0,
diskWrite: props.existingOverride.thresholds.diskWrite || 0,
networkIn: props.existingOverride.thresholds.networkIn || 0,
networkOut: props.existingOverride.thresholds.networkOut || 0
});
setEnabledMetrics({
cpu: props.existingOverride.thresholds.cpu !== undefined,
memory: props.existingOverride.thresholds.memory !== undefined,
disk: props.existingOverride.thresholds.disk !== undefined,
diskRead: props.existingOverride.thresholds.diskRead !== undefined,
diskWrite: props.existingOverride.thresholds.diskWrite !== undefined,
networkIn: props.existingOverride.thresholds.networkIn !== undefined,
networkOut: props.existingOverride.thresholds.networkOut !== undefined
});
} else {
// Reset to defaults for new override
setSelectedGuest('');
setThresholds({
cpu: 80,
memory: 80,
disk: 80,
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0
});
setEnabledMetrics({
cpu: false,
memory: false,
disk: false,
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false
});
}
}
});
const handleSave = () => {
const guest = props.guests.find(g => g.vmid.toString() === selectedGuest());
if (!guest) return;
const enabledThresholds: Override['thresholds'] = {};
const enabled = enabledMetrics();
const thresh = thresholds();
if (enabled.cpu && thresh.cpu !== undefined) enabledThresholds.cpu = thresh.cpu;
if (enabled.memory && thresh.memory !== undefined) enabledThresholds.memory = thresh.memory;
if (enabled.disk && thresh.disk !== undefined) enabledThresholds.disk = thresh.disk;
if (enabled.diskRead && thresh.diskRead) enabledThresholds.diskRead = thresh.diskRead;
if (enabled.diskWrite && thresh.diskWrite) enabledThresholds.diskWrite = thresh.diskWrite;
if (enabled.networkIn && thresh.networkIn) enabledThresholds.networkIn = thresh.networkIn;
if (enabled.networkOut && thresh.networkOut) enabledThresholds.networkOut = thresh.networkOut;
props.onSave({
id: guest.id, // Pass the full guest ID
guestName: guest.name,
vmid: guest.vmid,
type: guest.type,
node: guest.node,
instance: guest.instance,
thresholds: enabledThresholds
});
};
return (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden">
{/* Header */}
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
{props.existingOverride ? 'Edit Guest Override' : 'Add Guest Override'}
</h2>
</div>
{/* Content */}
<div class="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-8rem)]">
{/* Guest Selection */}
<Show when={!props.existingOverride}>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Guest
</label>
<select
class="w-full px-3 py-2 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
onChange={(e) => {
const value = e.currentTarget.value;
setSelectedGuest(value);
}}
ref={(el) => {
selectRef = el;
}}
>
<option value="">Choose a guest...</option>
<For each={props.guests}>
{(guest) => (
<option value={guest.vmid.toString()}>
{guest.name} ({guest.vmid}) - {guest.type} on {guest.node}
</option>
)}
</For>
</select>
</div>
</Show>
{/* Threshold Overrides */}
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
Threshold Overrides
</h3>
{/* CPU */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().cpu}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), cpu: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">CPU Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().cpu || 80}
onChange={(v) => setThresholds({...thresholds(), cpu: v})}
type="cpu"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().cpu || 80}%
</span>
</div>
</div>
</div>
{/* Memory */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().memory}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), memory: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Memory Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().memory || 85}
onChange={(v) => setThresholds({...thresholds(), memory: v})}
type="memory"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().memory || 85}%
</span>
</div>
</div>
</div>
{/* Disk */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().disk}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), disk: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Usage</label>
<div class="flex items-center gap-2">
<div class="flex-1">
<ThresholdSlider
value={thresholds().disk || 90}
onChange={(v) => setThresholds({...thresholds(), disk: v})}
type="disk"
/>
</div>
<span class="text-xs text-gray-500 w-10 text-right">
{thresholds().disk || 90}%
</span>
</div>
</div>
</div>
{/* I/O Metrics */}
<div class="grid grid-cols-2 gap-4">
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().diskRead}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), diskRead: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Read</label>
<select
value={thresholds().diskRead}
onChange={(e) => setThresholds({...thresholds(), diskRead: parseInt(e.currentTarget.value)})}
class="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="0">Off</option>
<option value="10">10 MB/s</option>
<option value="50">50 MB/s</option>
<option value="100">100 MB/s</option>
<option value="500">500 MB/s</option>
</select>
</div>
</div>
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().diskWrite}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), diskWrite: e.currentTarget.checked})}
class="mt-1 rounded border-gray-300 dark:border-gray-600"
/>
<div class="flex-1 space-y-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Disk Write</label>
<select
value={thresholds().diskWrite}
onChange={(e) => setThresholds({...thresholds(), diskWrite: parseInt(e.currentTarget.value)})}
class="w-full px-2 py-1 text-sm border rounded dark:bg-gray-700 dark:border-gray-600"
>
<option value="0">Off</option>
<option value="10">10 MB/s</option>
<option value="50">50 MB/s</option>
<option value="100">100 MB/s</option>
<option value="500">500 MB/s</option>
</select>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
<button
onClick={props.onClose}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!selectedGuest() && !props.existingOverride}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Save Override
</button>
</div>
</div>
</div>
</Portal>
</Show>
);
}
@@ -1,362 +0,0 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI, Webhook } from '@/api/notifications';
interface WebhookTemplate {
service: string;
name: string;
urlPattern: string;
method: string;
headers: Record<string, string>;
payloadTemplate: string;
instructions: string;
}
interface WebhookConfigProps {
webhooks: Webhook[];
onAdd: (webhook: Omit<Webhook, 'id'>) => void;
onUpdate: (webhook: Webhook) => void;
onDelete: (id: string) => void;
onTest: (id: string) => void;
testing?: string | null;
}
export function WebhookConfig(props: WebhookConfigProps) {
const [adding, setAdding] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<Omit<Webhook, 'id'> & { service: string; payloadTemplate?: string }>({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: ''
});
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
// Load webhook templates
createEffect(async () => {
try {
const data = await NotificationsAPI.getWebhookTemplates();
setTemplates(data);
} catch (err) {
console.error('Failed to load webhook templates:', err);
}
});
const saveWebhook = () => {
const data = formData();
if (!data.name || !data.url) return;
if (editingId()) {
props.onUpdate({ ...data, id: editingId()!, service: data.service });
setEditingId(null);
setAdding(false);
} else {
// onAdd expects a webhook without id, but with service
const newWebhook: Omit<Webhook, 'id'> = {
name: data.name,
url: data.url,
method: data.method,
headers: data.headers,
enabled: data.enabled,
service: data.service,
template: data.payloadTemplate
};
props.onAdd(newWebhook);
// Reset form but keep adding state true
setFormData({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: ''
});
}
};
const cancelForm = () => {
setAdding(false);
setEditingId(null);
setFormData({
name: '',
url: '',
method: 'POST',
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: ''
});
};
const editWebhook = (webhook: Webhook) => {
setEditingId(webhook.id!);
setFormData({
...webhook,
service: webhook.service || 'generic',
payloadTemplate: webhook.template || ''
});
setAdding(true);
};
const selectService = (service: string) => {
const template = templates().find(t => t.service === service);
if (template) {
setFormData({
...formData(),
service: template.service,
method: template.method,
headers: { ...template.headers },
name: formData().name || template.name
});
}
setShowServiceDropdown(false);
};
const currentTemplate = () => templates().find(t => t.service === formData().service);
const serviceName = (service: string) => {
const names: Record<string, string> = {
generic: 'Generic',
discord: 'Discord',
slack: 'Slack',
telegram: 'Telegram',
teams: 'Microsoft Teams',
'teams-adaptive': 'Teams (Adaptive)',
pagerduty: 'PagerDuty'
};
return names[service] || service;
};
return (
<div class="space-y-6">
{/* Existing Webhooks List */}
<Show when={props.webhooks.length > 0}>
<div class="space-y-3">
<For each={props.webhooks}>
{(webhook) => (
<div class="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<span class="font-medium text-sm text-gray-800 dark:text-gray-200">
{webhook.name}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{serviceName(webhook.service || 'generic')}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{webhook.method}
</span>
{!webhook.enabled && (
<span class="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
Disabled
</span>
)}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
{webhook.url}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<button
onClick={() => props.onTest(webhook.id!)}
disabled={props.testing === webhook.id}
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
>
{props.testing === webhook.id ? 'Testing...' : 'Test'}
</button>
<button
onClick={() => editWebhook(webhook)}
class="px-3 py-1 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
onClick={() => props.onDelete(webhook.id!)}
class="px-3 py-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
{/* Add/Edit Form */}
<Show when={adding()}>
<div class="space-y-4">
{/* Service Selection */}
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Service Type
</label>
<button
onClick={() => setShowServiceDropdown(!showServiceDropdown())}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{serviceName(formData().service)}
</button>
</div>
<Show when={showServiceDropdown()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg mb-4">
<For each={['generic', 'discord', 'slack', 'telegram', 'teams', 'teams-adaptive', 'pagerduty']}>
{(service) => (
<button
onClick={() => selectService(service)}
class={`p-3 text-left rounded-lg border transition-all ${
formData().service === service
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
{serviceName(service)}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1">
{service === 'generic' ? 'Custom webhook endpoint' :
service === 'discord' ? 'Discord server webhook' :
service === 'slack' ? 'Slack incoming webhook' :
service === 'telegram' ? 'Telegram bot notifications' :
service === 'teams' ? 'Microsoft Teams webhook' :
service === 'teams-adaptive' ? 'Teams with Adaptive Cards' :
'PagerDuty Events API v2'}
</div>
</button>
)}
</For>
</div>
</Show>
<Show when={currentTemplate()?.instructions}>
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Setup Instructions
</h4>
<pre class="text-xs text-blue-800 dark:text-blue-200 whitespace-pre-wrap">
{currentTemplate()!.instructions}
</pre>
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name
</label>
<input
type="text"
value={formData().name}
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
placeholder={currentTemplate()?.name || 'My Webhook'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
HTTP Method
</label>
<select
value={formData().method}
onChange={(e) => setFormData({ ...formData(), method: e.currentTarget.value })}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webhook URL
</label>
<input
type="url"
value={formData().url}
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
placeholder={currentTemplate()?.urlPattern || 'https://example.com/webhook'}
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono"
/>
</div>
{/* Custom Payload Template - only show for generic service */}
<Show when={formData().service === 'generic'}>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Custom Payload Template (JSON)
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
Optional - Leave empty to use default
</span>
</label>
<textarea
value={formData().payloadTemplate || ''}
onInput={(e) => setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })}
placeholder={`{
"text": "Alert: {{.Level}} - {{.Message}}",
"resource": "{{.ResourceName}}",
"value": {{.Value}},
"threshold": {{.Threshold}}
}`}
rows={8}
class="w-full px-3 py-2 text-xs font-mono border rounded-lg dark:bg-gray-700 dark:border-gray-600"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Available variables: {"{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}"}
</p>
</div>
</Show>
<div>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={formData().enabled}
onChange={(e) => setFormData({ ...formData(), enabled: e.currentTarget.checked })}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Enable this webhook</span>
</label>
</div>
<div class="flex justify-end gap-2">
<button
onClick={cancelForm}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancel
</button>
<button
onClick={saveWebhook}
disabled={!formData().name || !formData().url}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId() ? 'Update' : 'Add'} Webhook
</button>
</div>
</div>
</Show>
{/* Add Webhook Button */}
<Show when={!adding()}>
<button
onClick={() => setAdding(true)}
class="w-full py-2 text-sm border border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400"
>
+ Add Webhook
</button>
</Show>
</div>
);
}
@@ -1,46 +0,0 @@
import { Component, Show } from 'solid-js';
import { useWebSocket } from '@/App';
import UnifiedBackups from './UnifiedBackups';
const Backups: Component = () => {
const { state, connected } = useWebSocket();
return (
<div>
{/* Loading State */}
<Show when={connected() && !state.pveBackups && !state.pbs}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<div class="inline-flex items-center justify-center w-12 h-12 mb-4">
<svg class="animate-spin h-8 w-8 text-blue-600 dark:text-blue-400" xmlns="http://www.w3.org/2000/svg" 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>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Loading backup information...</p>
</div>
</div>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-600 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-2">Connection Lost</h3>
<p class="text-xs text-red-700 dark:text-red-300">Unable to connect to the backend server. Attempting to reconnect...</p>
</div>
</div>
</Show>
{/* Main Content - Unified Backups View */}
<Show when={connected() && (state.pveBackups || state.pbs)}>
<UnifiedBackups />
</Show>
</div>
);
};
export default Backups;
File diff suppressed because it is too large Load Diff
@@ -1,729 +0,0 @@
import { createSignal, createMemo, createEffect, For, Show } from 'solid-js';
import type { VM, Container, Node, PBSInstance } from '@/types/api';
import { GuestRow } from './GuestRow';
import NodeCard from './NodeCard';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { createTooltipSystem, showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
interface DashboardProps {
vms: VM[];
containers: Container[];
nodes: Node[];
pbs?: PBSInstance[];
}
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'stopped';
export function Dashboard(props: DashboardProps) {
const { connected, activeAlerts, initialDataReceived } = useWebSocket();
const [search, setSearch] = createSignal('');
// Initialize from localStorage with proper type checking
const storedViewMode = localStorage.getItem('dashboardViewMode');
const [viewMode, setViewMode] = createSignal<ViewMode>(
(storedViewMode === 'all' || storedViewMode === 'vm' || storedViewMode === 'lxc') ? storedViewMode : 'all'
);
const storedStatusMode = localStorage.getItem('dashboardStatusMode');
const [statusMode, setStatusMode] = createSignal<StatusMode>(
(storedStatusMode === 'all' || storedStatusMode === 'running' || storedStatusMode === 'stopped') ? storedStatusMode : 'all'
);
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('dashboardShowFilters') !== null
? localStorage.getItem('dashboardShowFilters') === 'true'
: false // Default to collapsed
);
// Sorting state - default to VMID ascending (matches Proxmox order)
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Create tooltip system
const TooltipComponent = createTooltipSystem();
// Create a mapping from node name to host URL
const nodeHostMap = createMemo(() => {
const map: Record<string, string> = {};
props.nodes.forEach(node => {
if (node.host) {
map[node.name] = node.host;
}
});
return map;
});
// Persist filter states to localStorage
createEffect(() => {
localStorage.setItem('dashboardViewMode', viewMode());
});
createEffect(() => {
localStorage.setItem('dashboardStatusMode', statusMode());
});
createEffect(() => {
localStorage.setItem('dashboardShowFilters', showFilters().toString());
});
// Sort handler
const handleSort = (key: keyof (VM | Container)) => {
if (sortKey() === key) {
// Toggle direction for the same column
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
// New column - set key and default direction
setSortKey(key);
// Set default sort direction based on column type
if (key === 'cpu' || key === 'memory' || key === 'disk' || key === 'diskRead' ||
key === 'diskWrite' || key === 'networkIn' || key === 'networkOut' || key === 'uptime') {
setSortDirection('desc');
} else {
setSortDirection('asc');
}
}
};
// Handle keyboard shortcuts
let searchInputRef: HTMLInputElement | undefined;
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = e.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear
if (search().trim() || sortKey() !== 'vmid' || sortDirection() !== 'asc') {
// Clear search and reset filters
setSearch('');
setSortKey('vmid');
setSortDirection('asc');
// Blur the search input if it's focused
if (searchInputRef && document.activeElement === searchInputRef) {
searchInputRef.blur();
}
} else if (showFilters()) {
// No search/filters active, so collapse the filters section
setShowFilters(false);
}
// If filters are already collapsed, do nothing
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
}
// Focus the search input and let the character be typed
if (searchInputRef) {
searchInputRef.focus();
// Don't prevent default - let the character be typed
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Combine VMs and containers into a single list
const allGuests = createMemo(() => {
const vms = props.vms || [];
const containers = props.containers || [];
const guests: (VM | Container)[] = [...vms, ...containers];
return guests;
});
// Filter guests based on current settings
const filteredGuests = createMemo(() => {
let guests = allGuests();
// Filter by type
if (viewMode() === 'vm') {
guests = guests.filter(g => g.type === 'qemu');
} else if (viewMode() === 'lxc') {
guests = guests.filter(g => g.type === 'lxc');
}
// Filter by status
if (statusMode() === 'running') {
guests = guests.filter(g => g.status === 'running');
} else if (statusMode() === 'stopped') {
guests = guests.filter(g => g.status !== 'running');
}
// Apply search/filter
const searchTerm = search().trim();
if (searchTerm) {
// Split by commas first
const searchParts = searchTerm.split(',').map(t => t.trim()).filter(t => t);
// Separate filters from text searches
const filters: string[] = [];
const textSearches: string[] = [];
searchParts.forEach(part => {
if (part.includes('>') || part.includes('<') || part.includes(':')) {
filters.push(part);
} else {
textSearches.push(part.toLowerCase());
}
});
// Apply filters if any
if (filters.length > 0) {
// Join filters with AND operator
const filterString = filters.join(' AND ');
const stack = parseFilterStack(filterString);
if (stack.filters.length > 0) {
guests = guests.filter(g => evaluateFilterStack(g, stack));
}
}
// Apply text search if any
if (textSearches.length > 0) {
guests = guests.filter(g =>
textSearches.some(term =>
g.name.toLowerCase().includes(term) ||
g.vmid.toString().includes(term) ||
g.node.toLowerCase().includes(term) ||
g.status.toLowerCase().includes(term)
)
);
}
}
// Don't filter by thresholds anymore - dimming is handled in GuestRow component
return guests;
});
// Group by node
const groupedGuests = createMemo(() => {
const guests = filteredGuests();
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach(guest => {
if (!groups[guest.node]) {
groups[guest.node] = [];
}
groups[guest.node].push(guest);
});
// Sort within each node group
const key = sortKey();
const dir = sortDirection();
if (key) {
Object.keys(groups).forEach(node => {
groups[node] = groups[node].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[key] as string | number | boolean | null | undefined;
let bVal: string | number | boolean | null | undefined = b[key] as string | number | boolean | null | undefined;
// Special handling for percentage-based columns
if (key === 'cpu') {
// CPU is displayed as percentage
aVal = a.cpu * 100;
bVal = b.cpu * 100;
} else if (key === 'memory') {
// Memory is displayed as percentage (use pre-calculated usage)
aVal = a.memory ? (a.memory.usage || 0) : 0;
bVal = b.memory ? (b.memory.usage || 0) : 0;
} else if (key === 'disk') {
// Disk is displayed as percentage
aVal = a.disk.total > 0 ? (a.disk.used / a.disk.total) * 100 : 0;
bVal = b.disk.total > 0 ? (b.disk.used / b.disk.total) * 100 : 0;
}
// Handle null/undefined/empty values - put at end for both asc and desc
const aIsEmpty = aVal === null || aVal === undefined || aVal === '';
const bIsEmpty = bVal === null || bVal === undefined || bVal === '';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1;
if (bIsEmpty) return -1;
// Type-specific value preparation
if (typeof aVal === 'number' && typeof bVal === 'number') {
// Numeric comparison
const comparison = aVal < bVal ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
} else {
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return 0;
const comparison = aStr < bStr ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
}
});
});
}
return groups;
});
const totalStats = createMemo(() => {
const guests = filteredGuests();
const running = guests.filter(g => g.status === 'running').length;
const vms = guests.filter(g => g.type === 'qemu').length;
const containers = guests.filter(g => g.type === 'lxc').length;
return {
total: guests.length,
running,
stopped: guests.length - running,
vms,
containers
};
});
return (
<div>
{/* Node Summary Cards */}
<div id="node-summary-cards-container" class="mb-3">
<Show when={props.nodes.length > 0}>
<div class="flex flex-wrap gap-2">
<For each={props.nodes}>
{(node) => (
<div class="flex-1 min-w-[250px]">
<ComponentErrorBoundary name="NodeCard">
<NodeCard node={node} />
</ComponentErrorBoundary>
</div>
)}
</For>
</div>
</Show>
</div>
{/* Dashboard Filter */}
<div class="dashboard-filter mb-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
{/* Filter toggle - now visible on all screen sizes */}
<button
onClick={() => setShowFilters(!showFilters())}
class="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded-lg transition-colors"
>
<span class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
Filters & Search
<Show when={search() || viewMode() !== 'all' || statusMode() !== 'all'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
</Show>
</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class={`transform transition-transform ${showFilters() ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class={`filter-controls-wrapper ${showFilters() ? 'block' : 'hidden'} p-3 lg:p-4 border-t border-gray-200 dark:border-gray-700`}>
<div class="flex flex-col gap-3">
{/* Search Bar Row */}
<div class="flex gap-2">
<div class="relative flex-1">
<input
ref={searchInputRef}
type="text"
placeholder="Search: name, jellyfin,plex, or cpu>80"
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search guests or use filters like cpu>80"
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<button
class="absolute right-3 top-2.5 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltipContent = `
<div class="space-y-2 p-1">
<div class="font-semibold mb-2">Search Examples:</div>
<div class="space-y-1">
<div><span class="font-mono bg-gray-700 px-1 rounded">jellyfin</span> - Find guests with "jellyfin" in name</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">plex,media</span> - Find guests with "plex" OR "media"</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">cpu>80</span> - Guests using >80% CPU</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">memory<20</span> - Guests using <20% memory</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">disk>90</span> - Guests using >90% disk</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">node:pve1</span> - Guests on specific node</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">vmid:104</span> - Find specific VM/container</div>
</div>
<div class="mt-2 pt-2 border-t border-gray-600">
<div class="font-semibold mb-1">Combine searches:</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">media,cpu>50</span> - "media" in name AND >50% CPU</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">plex,jellyfin,disk>80</span> - Multiple names AND disk filter</div>
</div>
</div>
`;
showTooltip(tooltipContent, rect.left, rect.top);
}}
onMouseLeave={() => hideTooltip()}
onClick={(e) => e.preventDefault()}
type="button"
aria-label="Search help"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
{/* Reset Button */}
<button
onClick={() => {
setSearch('');
setSortKey('vmid');
setSortDirection('asc');
setViewMode('all');
setStatusMode('all');
}}
title="Reset all filters (Esc)"
class="flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
<span class="ml-1.5 hidden sm:inline">Reset</span>
</button>
</div>
{/* Filters Row */}
<div class="flex flex-col sm:flex-row gap-2">
{/* Type Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setViewMode('all')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
onClick={() => setViewMode('vm')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
VMs
</button>
<button
onClick={() => setViewMode('lxc')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
LXCs
</button>
</div>
<div class="h-6 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setStatusMode('all')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
statusMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
onClick={() => setStatusMode('running')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
statusMode() === 'running'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Running
</button>
<button
onClick={() => setStatusMode('stopped')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
statusMode() === 'stopped'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Stopped
</button>
</div>
</div>
</div>
</div>
</div>
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="animate-spin mx-auto h-12 w-12 text-gray-400 mb-4" 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>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Loading dashboard data...</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Connecting to monitoring service</p>
</div>
</div>
</Show>
{/* Empty State - No PVE Nodes Configured */}
<Show when={connected() && initialDataReceived() && props.nodes.filter(n => n.type === 'pve').length === 0 && props.vms.length === 0 && props.containers.length === 0}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No Proxmox VE nodes configured</h3>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-4">Add a Proxmox VE node in the Settings tab to start monitoring your infrastructure.</p>
<button
onClick={() => {
const settingsTab = document.querySelector('[role="tab"]:last-child') as HTMLElement;
settingsTab?.click();
}}
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Settings
</button>
</div>
</div>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-600 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-2">Connection Lost</h3>
<p class="text-xs text-red-700 dark:text-red-300">Unable to connect to the backend server. Attempting to reconnect...</p>
</div>
</div>
</Show>
{/* Table View */}
<Show when={connected() && initialDataReceived() && (props.nodes.length > 0 || props.vms.length > 0 || props.containers.length > 0)}>
<ComponentErrorBoundary name="Guest Table">
<ScrollableTable
class="mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden"
minWidth="900px"
>
<table class="w-full min-w-[900px] text-xs sm:text-sm table-fixed">
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[200px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
role="button"
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[60px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[70px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[100px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[140px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskRead')}
>
Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('diskWrite')}
>
Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[90px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('networkOut')}
>
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={Object.entries(groupedGuests()).sort(([a], [b]) => a.localeCompare(b))} fallback={<></>}>
{([node, guests]) => (
<>
<Show when={node}>
<tr class="node-header bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[200px]">
<a
href={nodeHostMap()[node] || (node.includes(':') ? `https://${node}` : `https://${node}:8006`)}
target="_blank"
rel="noopener noreferrer"
class="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${node} web interface`}
>
{node}
</a>
</td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[60px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[70px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[100px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[140px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[140px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[140px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 w-[90px]"></td>
</tr>
</Show>
<For each={guests} fallback={<></>}>
{(guest) => (
<ComponentErrorBoundary name="GuestRow">
<GuestRow
guest={guest}
showNode={false}
alertStyles={getAlertStyles(guest.id || `${guest.instance}-${guest.name}-${guest.vmid}`, activeAlerts)}
/>
</ComponentErrorBoundary>
)}
</For>
</>
)}
</For>
</tbody>
</table>
</ScrollableTable>
</ComponentErrorBoundary>
</Show>
<Show when={connected() && initialDataReceived() && filteredGuests().length === 0 && props.nodes.filter(n => n.type === 'pve').length > 0}>
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="mt-2">No guests found matching your filters</p>
</div>
</Show>
{/* Stats */}
<Show when={connected() && initialDataReceived()}>
<div class="mb-4">
<div class="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-700 rounded">
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-green-500 rounded-full"></span>
{totalStats().running} running
</span>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-gray-400 rounded-full"></span>
{totalStats().stopped} stopped
</span>
</div>
</div>
</Show>
{/* Tooltip System */}
<TooltipComponent />
</div>
);
}
@@ -1,176 +0,0 @@
import { Show, createMemo } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { formatBytes, formatUptime } from '@/utils/format';
import { MetricBar } from './MetricBar';
import { IOMetric } from './IOMetric';
import { getResourceAlerts } from '@/utils/alerts';
import { useWebSocket } from '@/App';
type Guest = VM | Container;
// Type guard for VM vs Container
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
interface GuestRowProps {
guest: Guest;
showNode?: boolean;
alertStyles?: {
rowClass: string;
indicatorClass: string;
badgeClass: string;
hasAlert: boolean;
alertCount: number;
severity: 'critical' | 'warning' | null;
};
}
export function GuestRow(props: GuestRowProps) {
const { activeAlerts } = useWebSocket();
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
const memPercent = createMemo(() => {
if (!props.guest.memory) return 0;
// Use the pre-calculated usage percentage from the backend
return props.guest.memory.usage || 0;
});
const diskPercent = createMemo(() => {
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
return (props.guest.disk.used / props.guest.disk.total) * 100;
});
const isRunning = createMemo(() => props.guest.status === 'running');
// Get alerts for this guest
const guestAlerts = createMemo(() => {
const guestId = props.guest.id || `${props.guest.instance}-${props.guest.name}-${props.guest.vmid}`;
return getResourceAlerts(guestId, activeAlerts);
});
// Get row styling - include alert styles if present
const rowClass = createMemo(() => {
const base = 'transition-all duration-200';
const hover = 'hover:shadow-sm';
const alertClass = props.alertStyles?.rowClass || '';
const defaultHover = alertClass ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700';
return `${base} ${hover} ${defaultHover} ${alertClass}`;
});
return (
<tr class={rowClass()}>
{/* Name - Sticky column */}
<td class="p-1 px-2 whitespace-nowrap">
<div class="flex items-center gap-2">
{/* Status indicator */}
<span class={`h-2 w-2 rounded-full flex-shrink-0 ${
isRunning() ? 'bg-green-500' : 'bg-gray-400'
}`} title={props.guest.status}></span>
{/* Name */}
<span class="font-medium text-gray-900 dark:text-gray-100 truncate" title={props.guest.name}>
{props.guest.name}
</span>
{/* Alert indicators */}
<Show when={props.alertStyles?.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={props.alertStyles?.severity || null} alerts={guestAlerts()} />
<Show when={props.alertStyles?.alertCount && props.alertStyles.alertCount > 1}>
<AlertCountBadge count={props.alertStyles!.alertCount} severity={props.alertStyles!.severity || 'warning'} alerts={guestAlerts()} />
</Show>
</div>
</Show>
</div>
</td>
{/* Type */}
<td class="p-1 px-2 whitespace-nowrap">
<span class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}>
{isVM(props.guest) ? 'VM' : 'LXC'}
</span>
</td>
{/* VMID */}
<td class="p-1 px-2 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{props.guest.vmid}
</td>
{/* Node (optional) */}
<Show when={props.showNode}>
<td class="p-1 px-2 text-sm text-gray-600 dark:text-gray-400">
{props.guest.node}
</td>
</Show>
{/* Uptime */}
<td class={`p-1 px-2 text-sm whitespace-nowrap ${
props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
}`}>
<Show when={isRunning()} fallback="-">
{formatUptime(props.guest.uptime)}
</Show>
</td>
{/* CPU */}
<td class="p-1 px-2 w-[140px]">
<MetricBar
value={cpuPercent()}
label={`${cpuPercent().toFixed(0)}%`}
sublabel={props.guest.cpus ? `${((props.guest.cpu || 0) * props.guest.cpus).toFixed(1)}/${props.guest.cpus} cores` : undefined}
type="cpu"
/>
</td>
{/* Memory */}
<td class="p-1 px-2 w-[140px]">
<MetricBar
value={memPercent()}
label={`${memPercent().toFixed(0)}%`}
sublabel={props.guest.memory ? `${formatBytes(props.guest.memory.used)}/${formatBytes(props.guest.memory.total)}` : undefined}
type="memory"
/>
</td>
{/* Disk */}
<td class="p-1 px-2 w-[140px]">
<Show
when={props.guest.disk && props.guest.disk.total > 0}
fallback={<span class="text-gray-400 text-sm">-</span>}
>
<MetricBar
value={diskPercent()}
label={`${diskPercent().toFixed(0)}%`}
sublabel={props.guest.disk ? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}` : undefined}
type="disk"
/>
</Show>
</td>
{/* Disk I/O */}
<td class="p-1 px-2">
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
</td>
<td class="p-1 px-2">
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
</td>
{/* Network I/O */}
<td class="p-1 px-2">
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
</td>
<td class="p-1 px-2">
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
</td>
</tr>
);
}
@@ -1,30 +0,0 @@
import { createMemo, Show } from 'solid-js';
import { formatSpeed } from '@/utils/format';
interface IOMetricProps {
value: number;
disabled?: boolean;
}
export function IOMetric(props: IOMetricProps) {
const formatted = createMemo(() => formatSpeed(props.value, 0));
// Color based on speed (MB/s) - matching current dashboard
const colorClass = createMemo(() => {
if (props.disabled) return 'text-gray-400 dark:text-gray-500';
const mbps = props.value / (1024 * 1024);
if (mbps < 1) return 'text-gray-300 dark:text-gray-400';
if (mbps < 10) return 'text-green-600 dark:text-green-400';
if (mbps < 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
});
return (
<Show when={!props.disabled} fallback={<span class="text-sm text-gray-400">-</span>}>
<span class={`text-sm font-mono ${colorClass()}`}>
{formatted()}
</span>
</Show>
);
}
@@ -1,68 +0,0 @@
import { createMemo } from 'solid-js';
interface MetricBarProps {
value: number;
label: string;
sublabel?: string;
type?: 'cpu' | 'memory' | 'disk' | 'generic';
}
export function MetricBar(props: MetricBarProps) {
const width = createMemo(() => Math.min(props.value, 100));
// Get color based on percentage and metric type (matching original)
const getColor = createMemo(() => {
const percentage = props.value;
const metric = props.type || 'generic';
if (metric === 'cpu') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else if (metric === 'memory') {
if (percentage >= 85) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
} else if (metric === 'disk') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else {
if (percentage >= 90) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
}
});
// Map color to CSS classes
const progressColorClass = createMemo(() => {
const colorMap = {
'red': 'bg-red-500/60 dark:bg-red-500/50',
'yellow': 'bg-yellow-500/60 dark:bg-yellow-500/50',
'green': 'bg-green-500/60 dark:bg-green-500/50'
};
return colorMap[getColor()] || 'bg-gray-500/60 dark:bg-gray-500/50';
});
// Combine label and sublabel for display text
const displayText = createMemo(() => {
if (props.sublabel) {
return `${props.label} (${props.sublabel})`;
}
return props.label;
});
return (
<div class="metric-text">
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${progressColorClass()}`}
style={{ width: `${width()}%` }}
/>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">{displayText()}</span>
</span>
</div>
</div>
);
}
@@ -1,164 +0,0 @@
import { Component, Show, createMemo } from 'solid-js';
import type { Node } from '@/types/api';
import { formatUptime, formatBytes } from '@/utils/format';
import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { useWebSocket } from '@/App';
interface NodeCardProps {
node: Node;
}
const NodeCard: Component<NodeCardProps> = (props) => {
const { activeAlerts } = useWebSocket();
// Early return if node data is incomplete
if (!props.node || !props.node.memory || !props.node.disk) {
return (
<div class="bg-white dark:bg-gray-800 shadow-md rounded-lg p-2 border border-gray-200 dark:border-gray-700 flex flex-col gap-1 min-w-[250px]">
<div class="text-sm text-gray-500">Loading node data...</div>
</div>
);
}
const isOnline = () => props.node.status === 'online' && props.node.uptime > 0 && props.node.connectionHealth !== 'error';
const cpuPercent = () => Math.round(props.node.cpu * 100);
const memPercent = () => {
if (!props.node.memory) return 0;
// Use the pre-calculated usage percentage from the backend
return Math.round(props.node.memory.usage || 0);
};
const diskPercent = () => {
if (!props.node.disk || props.node.disk.total === 0) return 0;
return Math.round((props.node.disk.used / props.node.disk.total) * 100);
};
// Calculate normalized load (load average / cpu count)
const normalizedLoad = () => {
if (props.node.loadAverage && props.node.loadAverage.length > 0) {
const load1m = props.node.loadAverage[0];
if (typeof load1m === 'number' && !isNaN(load1m)) {
// Use CPU cores from cpuInfo if available, otherwise assume 4
const cpuCount = props.node.cpuInfo?.cores || 4;
return (load1m / cpuCount).toFixed(2);
}
}
return 'N/A';
};
// Helper function to create progress bar with text overlay (matching original)
const createProgressBar = (percentage: number, text: string, colorClass: string) => {
const bgColorClass = 'bg-gray-200 dark:bg-gray-600';
const progressColorClass = {
'red': 'bg-red-500/60 dark:bg-red-500/50',
'yellow': 'bg-yellow-500/60 dark:bg-yellow-500/50',
'green': 'bg-green-500/60 dark:bg-green-500/50'
}[colorClass] || 'bg-gray-500/60 dark:bg-gray-500/50';
return (
<div class={`relative w-full h-3.5 rounded overflow-hidden ${bgColorClass}`}>
<div class={`absolute top-0 left-0 h-full ${progressColorClass}`} style={{ width: `${percentage}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">{text}</span>
</span>
</div>
);
};
// Get color based on percentage and metric type
const getColor = (percentage: number, metric: 'cpu' | 'memory' | 'disk') => {
if (metric === 'cpu') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
} else if (metric === 'memory') {
if (percentage >= 85) return 'red';
if (percentage >= 75) return 'yellow';
return 'green';
} else if (metric === 'disk') {
if (percentage >= 90) return 'red';
if (percentage >= 80) return 'yellow';
return 'green';
}
return 'green';
};
// Format CPU text with cores info
const cpuText = () => {
const cores = props.node.cpuInfo?.cores;
const cpuUsed = cores ? (props.node.cpu * cores).toFixed(1) : '0';
return cores && cores > 0
? `${cpuPercent()}% (${cpuUsed}/${cores} cores)`
: `${cpuPercent()}%`;
};
// Format memory text with size info
const memoryText = () => {
if (!props.node.memory) return '0%';
return `${memPercent()}% (${formatBytes(props.node.memory.used)}/${formatBytes(props.node.memory.total)})`;
};
// Format disk text with size info
const diskText = () => {
if (!props.node.disk) return '0%';
return `${diskPercent()}% (${formatBytes(props.node.disk.used)}/${formatBytes(props.node.disk.total)})`;
};
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
const nodeAlerts = createMemo(() => getResourceAlerts(props.node.id || props.node.name, activeAlerts));
const borderClass = alertStyles.hasAlert
? (alertStyles.severity === 'critical' ? 'border-red-500 border-2' : 'border-orange-500 border-2')
: 'border-gray-200 dark:border-gray-700';
return (
<div class={`bg-white dark:bg-gray-800 shadow-md rounded-lg p-2 border flex flex-col gap-1 min-w-[250px] ${borderClass} ${alertStyles.rowClass}`}>
{/* Header */}
<div class="flex justify-between items-center">
<h3 class="text-sm font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-2">
<span>{props.node.name}</span>
<Show when={alertStyles.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={alertStyles.severity} alerts={nodeAlerts()} />
<Show when={alertStyles.alertCount > 1}>
<AlertCountBadge count={alertStyles.alertCount} severity={alertStyles.severity!} alerts={nodeAlerts()} />
</Show>
</div>
</Show>
</h3>
<div class="flex items-center">
<span class={`h-2.5 w-2.5 rounded-full mr-1.5 flex-shrink-0 ${
isOnline() ? 'bg-green-500' : 'bg-red-500'
}`} />
<span class="text-xs capitalize text-gray-600 dark:text-gray-400">
{isOnline() ? 'online' : props.node.status || 'unknown'}
</span>
</div>
</div>
{/* CPU */}
<div class="text-[11px] text-gray-600 dark:text-gray-400">
<span class="font-medium">CPU:</span>
{createProgressBar(cpuPercent(), cpuText(), getColor(cpuPercent(), 'cpu'))}
</div>
{/* Memory */}
<div class="text-[11px] text-gray-600 dark:text-gray-400">
<span class="font-medium">Mem:</span>
{createProgressBar(memPercent(), memoryText(), getColor(memPercent(), 'memory'))}
</div>
{/* Disk */}
<div class="text-[11px] text-gray-600 dark:text-gray-400">
<span class="font-medium">Disk:</span>
{createProgressBar(diskPercent(), diskText(), getColor(diskPercent(), 'disk'))}
</div>
{/* Footer Info */}
<div class="flex justify-between text-[11px] text-gray-500 dark:text-gray-400 pt-0.5">
<span>Uptime: {formatUptime(props.node.uptime)}</span>
<span>Load: {normalizedLoad()}</span>
</div>
</div>
);
};
export default NodeCard;
@@ -1,123 +0,0 @@
import { createSignal, createEffect, onMount } from 'solid-js';
interface ThresholdSliderProps {
value: number;
onChange: (value: number) => void;
type: 'cpu' | 'memory' | 'disk';
min?: number;
max?: number;
}
export function ThresholdSlider(props: ThresholdSliderProps) {
let sliderRef: HTMLInputElement | undefined;
let thumbRef: HTMLDivElement | undefined;
const [thumbPosition, setThumbPosition] = createSignal(0);
const [isDragging, setIsDragging] = createSignal(false);
// Color mapping
const colorMap = {
cpu: 'text-blue-500',
memory: 'text-green-500',
disk: 'text-amber-500'
};
// Calculate visual position - allow full range 0-100%
const calculateVisualPosition = (value: number) => {
const min = props.min || 0;
const max = props.max || 100;
const percent = ((value - min) / (max - min)) * 100;
// Use full range, handle edge cases with CSS
return Math.max(0, Math.min(100, percent));
};
// Update thumb position when value changes
createEffect(() => {
if (sliderRef) {
setThumbPosition(calculateVisualPosition(props.value));
}
});
onMount(() => {
// Initialize thumb position
setThumbPosition(calculateVisualPosition(props.value));
});
// Prevent scrolling while dragging
const handleMouseDown = () => {
setIsDragging(true);
// Store the current scroll position
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const handleScroll = () => {
window.scrollTo(scrollX, scrollY);
};
const handleMouseUp = () => {
setIsDragging(false);
window.removeEventListener('scroll', handleScroll, { capture: true });
document.removeEventListener('mouseup', handleMouseUp);
};
// Lock scroll position while dragging
window.addEventListener('scroll', handleScroll, { capture: true });
document.addEventListener('mouseup', handleMouseUp);
};
return (
<div
class="relative w-full h-3.5 overflow-visible"
onWheel={(e) => isDragging() && e.preventDefault()}
style={{ "touch-action": isDragging() ? "none" : "auto" }}
>
{/* Track background */}
<div class="absolute inset-0 h-3.5 rounded bg-gray-200 dark:bg-gray-600"></div>
{/* Colored fill */}
<div
class={`absolute left-0 h-3.5 rounded ${
props.type === 'cpu' ? 'bg-blue-500/30' :
props.type === 'memory' ? 'bg-green-500/30' :
'bg-amber-500/30'
}`}
style={{ width: `${calculateVisualPosition(props.value)}%` }}
></div>
{/* Native range input (invisible but functional) */}
<input
ref={sliderRef}
type="range"
min={props.min || 0}
max={props.max || 100}
value={props.value}
onInput={(e) => props.onChange(parseInt(e.currentTarget.value))}
onMouseDown={handleMouseDown}
onWheel={(e) => e.preventDefault()}
class="absolute inset-0 w-full h-3.5 opacity-0 cursor-pointer z-20"
style={{ "touch-action": "none" }}
title={`${props.type.toUpperCase()}: ${props.value}%`}
/>
{/* Custom thumb with value */}
<div
ref={thumbRef}
class={`absolute top-1/2 pointer-events-none z-10 ${colorMap[props.type]}`}
style={{
left: `${thumbPosition()}%`,
transform: `translateY(-50%) translateX(${
thumbPosition() <= 1 ? '0%' : // At 0-1%, keep at left edge
thumbPosition() >= 99 ? '-100%' : // At 99-100%, keep at right edge
'-50%' // Otherwise center
})`
}}
>
<div class="relative">
<div class="w-9 h-4 bg-white dark:bg-gray-800 rounded-full shadow-md border-2 border-current flex items-center justify-center">
<span class="text-[9px] font-semibold">{props.value}%</span>
</div>
</div>
</div>
</div>
);
}
@@ -1,132 +0,0 @@
import { Component, JSX, createSignal, ErrorBoundary as SolidErrorBoundary } from 'solid-js';
import { logError } from '@/utils/logger';
interface ErrorBoundaryProps {
children: JSX.Element;
fallback?: (error: Error, reset: () => void) => JSX.Element;
onError?: (error: Error) => void;
}
const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (props) => {
const [details, setDetails] = createSignal(false);
return (
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
<div class="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div class="flex items-center mb-4">
<svg class="w-12 h-12 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Something went wrong
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
An unexpected error occurred
</p>
</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3 mb-4">
<p class="text-sm text-red-800 dark:text-red-200 font-mono">
{props.error.message}
</p>
</div>
<div class="flex gap-2">
<button
onClick={props.reset}
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
class="flex-1 px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
Reload Page
</button>
</div>
<button
onClick={() => setDetails(!details())}
class="mt-4 text-sm text-gray-500 dark:text-gray-400 underline hover:text-gray-700 dark:hover:text-gray-300"
>
{details() ? 'Hide' : 'Show'} error details
</button>
{details() && (
<div class="mt-4 p-3 bg-gray-100 dark:bg-gray-700 rounded overflow-x-auto">
<pre class="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{props.error.stack}
</pre>
</div>
)}
</div>
</div>
);
};
export const ErrorBoundary: Component<ErrorBoundaryProps> = (props) => {
return (
<SolidErrorBoundary
fallback={(error, reset) => {
// Log the error
logError('Error boundary caught error', error);
// Call custom error handler if provided
if (props.onError) {
props.onError(error);
}
// Render custom or default fallback
if (props.fallback) {
return props.fallback(error, reset);
}
return <DefaultErrorFallback error={error} reset={reset} />;
}}
>
{props.children}
</SolidErrorBoundary>
);
};
// Component-specific error boundary with more context
export const ComponentErrorBoundary: Component<{
name: string;
children: JSX.Element;
}> = (props) => {
return (
<ErrorBoundary
fallback={(error, reset) => (
<div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">
Error in {props.name}
</h3>
</div>
<p class="text-xs text-red-700 dark:text-red-300 mb-2">
{error.message}
</p>
<button
onClick={reset}
class="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
onError={(error) => {
logError(`Error in component ${props.name}`, error);
}}
>
{props.children}
</ErrorBoundary>
);
};
@@ -1,25 +0,0 @@
import { Component, For } from 'solid-js';
import { Portal } from 'solid-js/web';
import NotificationToast from './NotificationToast';
import { notificationStore } from '../stores/notifications';
const NotificationContainer: Component = () => {
return (
<Portal>
<div class="fixed top-4 right-4 z-50 space-y-2">
<For each={notificationStore.notifications}>
{(notification) => (
<NotificationToast
message={notification.message}
type={notification.type}
duration={notification.duration}
onClose={() => notificationStore.remove(notification.id)}
/>
)}
</For>
</div>
</Portal>
);
};
export default NotificationContainer;
@@ -1,110 +0,0 @@
import { Component, createSignal, onCleanup, onMount, Show } from 'solid-js';
interface NotificationToastProps {
message: string;
type: 'success' | 'error' | 'info';
duration?: number;
onClose?: () => void;
}
const NotificationToast: Component<NotificationToastProps> = (props) => {
const [isVisible, setIsVisible] = createSignal(true);
const [isLeaving, setIsLeaving] = createSignal(false);
const handleClose = () => {
setIsLeaving(true);
setTimeout(() => {
setIsVisible(false);
props.onClose?.();
}, 300);
};
onMount(() => {
if (props.duration && props.duration > 0) {
const timer = setTimeout(() => {
handleClose();
}, props.duration);
onCleanup(() => clearTimeout(timer));
}
});
const iconColor = () => {
switch (props.type) {
case 'success': return 'text-green-400';
case 'error': return 'text-red-400';
default: return 'text-blue-400';
}
};
const icon = () => {
switch (props.type) {
case 'success':
return (
<div class="relative">
<div class="absolute inset-0 bg-green-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
);
case 'error':
return (
<div class="relative">
<div class="absolute inset-0 bg-red-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
);
default:
return (
<div class="relative">
<div class="absolute inset-0 bg-blue-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
);
}
};
return (
<Show when={isVisible()}>
<div
class={`
fixed top-4 right-4 z-50
backdrop-blur-xl bg-white/10 dark:bg-gray-900/30
border border-white/20 dark:border-gray-700/30
px-5 py-4 rounded-2xl shadow-2xl
flex items-center gap-4
min-w-[320px] max-w-[500px]
transition-all duration-500 ease-out
${isLeaving() ? 'opacity-0 translate-x-full scale-95' : 'opacity-100 translate-x-0 scale-100'}
animate-slide-in-glass
`}
style={{
"background": "linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)",
"box-shadow": "0 8px 32px 0 rgba(31, 38, 135, 0.37), inset 0 0 0 1px rgba(255,255,255,0.1)"
}}
>
<div class={`${iconColor()} flex-shrink-0`}>
{icon()}
</div>
<div class="flex-1">
<p class="text-gray-800 dark:text-gray-100 font-medium text-sm leading-relaxed">{props.message}</p>
</div>
<button
onClick={handleClose}
class="flex-shrink-0 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white/10 rounded-lg p-1.5 transition-all duration-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Show>
);
};
export default NotificationToast;
@@ -1,319 +0,0 @@
import { Component, Show, createSignal, For } from 'solid-js';
import { Portal } from 'solid-js/web';
import { showSuccess, showError } from '@/utils/toast';
import { NodesAPI } from '@/api/nodes';
import type { NodeConfig } from '@/types/nodes';
interface DiscoveredServer {
ip: string;
port: number;
type: 'pve' | 'pbs';
version: string;
hostname?: string;
release?: string;
}
interface BatchCredentialModalProps {
isOpen: boolean;
onClose: () => void;
servers: DiscoveredServer[];
onComplete: () => void;
}
export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props) => {
const [authType, setAuthType] = createSignal<'password' | 'token'>('token');
const [username, setUsername] = createSignal('');
const [password, setPassword] = createSignal('');
const [tokenName, setTokenName] = createSignal('');
const [tokenValue, setTokenValue] = createSignal('');
const [isAdding, setIsAdding] = createSignal(false);
const [progress, setProgress] = createSignal<{ current: number; total: number; currentServer?: string }>({ current: 0, total: 0 });
const handleBatchAdd = async () => {
// Validate inputs
if (authType() === 'password') {
if (!username() || !password()) {
showError('Username and password are required');
return;
}
} else {
if (!tokenName() || !tokenValue()) {
showError('Token ID and value are required');
return;
}
}
setIsAdding(true);
const total = props.servers.length;
setProgress({ current: 0, total });
let successCount = 0;
let failedServers: string[] = [];
for (let i = 0; i < props.servers.length; i++) {
const server = props.servers[i];
setProgress({ current: i + 1, total, currentServer: server.hostname || server.ip });
try {
const nodeData: NodeConfig = {
id: '', // Will be generated by backend
type: server.type,
name: server.hostname || server.ip,
host: `https://${server.ip}:${server.port}`,
verifySSL: false,
...(authType() === 'password'
? { user: username(), password: password() }
: { tokenName: tokenName(), tokenValue: tokenValue() }),
// Add default monitoring options based on type
...(server.type === 'pve'
? {
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true
}
: {
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: false
})
} as NodeConfig;
await NodesAPI.addNode(nodeData);
successCount++;
} catch (error) {
console.error(`Failed to add ${server.hostname || server.ip}:`, error);
failedServers.push(server.hostname || server.ip);
}
}
setIsAdding(false);
if (successCount === total) {
showSuccess(`Successfully added all ${total} servers`);
props.onComplete();
props.onClose();
} else if (successCount > 0) {
showError(`Added ${successCount} of ${total} servers. Failed: ${failedServers.join(', ')}`);
props.onComplete();
} else {
showError('Failed to add any servers. Check your credentials.');
}
};
return (
<Portal>
<Show when={props.isOpen}>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
{/* Backdrop */}
<div
class="fixed inset-0 bg-black/50 transition-opacity"
onClick={!isAdding() ? props.onClose : undefined}
/>
{/* Modal */}
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Add {props.servers.length} Discovered Servers
</h3>
<Show when={!isAdding()}>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</Show>
</div>
{/* Body */}
<div class="p-6 space-y-6">
{/* Server List */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Servers to Add</h4>
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 max-h-32 overflow-y-auto">
<div class="flex flex-wrap gap-2">
<For each={props.servers}>
{(server) => (
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded">
<span class={`w-2 h-2 rounded-full ${server.type === 'pve' ? 'bg-orange-500' : 'bg-green-500'}`}></span>
{server.hostname || server.ip}
</span>
)}
</For>
</div>
</div>
</div>
{/* Shared Credentials */}
<div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Shared Credentials
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">
(Same credentials will be used for all servers)
</span>
</h4>
{/* Auth Type Selector */}
<div class="mb-4">
<div class="flex gap-4">
<label class="flex items-center">
<input
type="radio"
name="batchAuthType"
value="token"
checked={authType() === 'token'}
onChange={() => setAuthType('token')}
disabled={isAdding()}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">API Token (Recommended)</span>
</label>
<label class="flex items-center">
<input
type="radio"
name="batchAuthType"
value="password"
checked={authType() === 'password'}
onChange={() => setAuthType('password')}
disabled={isAdding()}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Username & Password</span>
</label>
</div>
</div>
{/* Token Auth Fields */}
<Show when={authType() === 'token'}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Token ID <span class="text-red-500">*</span>
</label>
<input
type="text"
value={tokenName()}
onInput={(e) => setTokenName(e.currentTarget.value)}
placeholder="user@realm!tokenname"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Example: pulse-monitor@pam!pulse-token or pulse-monitor@pbs!pulse-token
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Token Value <span class="text-red-500">*</span>
</label>
<input
type="password"
value={tokenValue()}
onInput={(e) => setTokenValue(e.currentTarget.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono"
/>
</div>
</div>
</Show>
{/* Password Auth Fields */}
<Show when={authType() === 'password'}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username <span class="text-red-500">*</span>
</label>
<input
type="text"
value={username()}
onInput={(e) => setUsername(e.currentTarget.value)}
placeholder="root@pam or admin@pbs"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
placeholder="Password"
disabled={isAdding()}
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</Show>
</div>
{/* Progress */}
<Show when={isAdding()}>
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-center gap-3 mb-2">
<svg class="animate-spin h-5 w-5 text-blue-600 dark:text-blue-400" viewBox="0 0 24 24" fill="none">
<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>
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-200">
Adding servers... ({progress().current}/{progress().total})
</p>
<Show when={progress().currentServer}>
<p class="text-xs text-blue-700 dark:text-blue-300">
Currently adding: {progress().currentServer}
</p>
</Show>
</div>
</div>
<div class="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
<div
class="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all"
style={`width: ${(progress().current / progress().total) * 100}%`}
></div>
</div>
</div>
</Show>
</div>
{/* Footer */}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={props.onClose}
disabled={isAdding()}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={handleBatchAdd}
disabled={isAdding()}
class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAdding() ? 'Adding...' : `Add ${props.servers.length} Servers`}
</button>
</div>
</div>
</div>
</div>
</Show>
</Portal>
);
};
@@ -1,371 +0,0 @@
import { Component, Show, createSignal, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web';
import { showSuccess, showError } from '@/utils/toast';
interface DiscoveredServer {
ip: string;
port: number;
type: 'pve' | 'pbs';
version: string;
hostname?: string;
release?: string;
}
interface DiscoveryResult {
servers: DiscoveredServer[];
errors?: string[];
}
interface DiscoveryModalProps {
isOpen: boolean;
onClose: () => void;
onAddServers: (servers: DiscoveredServer[]) => void;
}
export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
const [isScanning, setIsScanning] = createSignal(false);
const [subnet, setSubnet] = createSignal('auto');
const [discoveryResult, setDiscoveryResult] = createSignal<DiscoveryResult | null>(null);
const [hasScanned, setHasScanned] = createSignal(false);
// Load cached results when modal opens
createEffect(() => {
if (props.isOpen && !hasScanned()) {
setHasScanned(true);
loadCachedResults();
}
});
// Reset scan state when modal closes
createEffect(() => {
if (!props.isOpen) {
setHasScanned(false);
// Keep cached results, don't clear them
}
});
// Note: WebSocket updates for discovery will be handled separately
// The background service will update the cache and we'll fetch it when needed
const loadCachedResults = async () => {
try {
// Fetch cached results with GET request
const response = await fetch('/api/discover', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
// If we have cached results, show them immediately
if (data.servers && data.servers.length > 0) {
setDiscoveryResult({
servers: data.servers,
errors: data.errors || []
});
} else {
// No cached results, start a background scan
handleScan();
}
} else {
// Fallback to scanning
handleScan();
}
} catch (error) {
console.error('Failed to load cached discovery results:', error);
// Fallback to scanning
handleScan();
}
};
const handleScan = async () => {
setIsScanning(true);
setDiscoveryResult(null);
// Set a timeout for the scan (30 seconds)
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 30000);
try {
const response = await fetch('/api/discover', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subnet: subnet() }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(await response.text());
}
const result: DiscoveryResult = await response.json();
setDiscoveryResult(result);
if (result.servers.length === 0) {
showError('No Proxmox/PBS servers found on the network');
} else {
showSuccess(`Found ${result.servers.length} server(s)`);
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
showError('Scan timeout - try a smaller subnet range');
} else {
showError(`Discovery failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
} finally {
setIsScanning(false);
}
};
const handleAddServer = (server: DiscoveredServer) => {
props.onAddServers([server]);
};
const getServerIcon = (type: string) => {
if (type === 'pve') {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
<rect x="9" y="9" width="6" height="6"></rect>
<line x1="9" y1="1" x2="9" y2="4"></line>
<line x1="15" y1="1" x2="15" y2="4"></line>
<line x1="9" y1="20" x2="9" y2="23"></line>
<line x1="15" y1="20" x2="15" y2="23"></line>
<line x1="20" y1="9" x2="23" y2="9"></line>
<line x1="20" y1="14" x2="23" y2="14"></line>
<line x1="1" y1="9" x2="4" y2="9"></line>
<line x1="1" y1="14" x2="4" y2="14"></line>
</svg>
);
}
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 01-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 011-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 011.52 0C14.51 3.81 17 5 19 5a1 1 0 011 1v7z"></path>
<path d="M9 12l2 2 4-4"></path>
</svg>
);
};
return (
<Portal>
<Show when={props.isOpen}>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
{/* Backdrop */}
<div
class="fixed inset-0 bg-black/50 transition-opacity"
onClick={props.onClose}
/>
{/* Modal */}
<div class="relative w-full max-w-3xl bg-white dark:bg-gray-800 rounded-lg shadow-xl">
{/* Header */}
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Network Discovery
</h3>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/* Body */}
<div class="p-6">
{/* Quick Actions Bar */}
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-3">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
<Show when={isScanning()} fallback="Network Servers">
Scanning Network...
</Show>
</h4>
<Show when={discoveryResult() && !isScanning()}>
<span class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-1 rounded-full">
{discoveryResult()!.servers.length} found
</span>
</Show>
</div>
<div class="flex items-center gap-2">
{/* Subnet selector */}
<select
value={subnet()}
onChange={(e) => {
setSubnet(e.currentTarget.value);
setHasScanned(false);
handleScan();
}}
class="px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isScanning()}
>
<option value="auto">Auto-detect</option>
<option value="192.168.1.0/24">192.168.1.x</option>
<option value="192.168.0.0/24">192.168.0.x</option>
<option value="10.0.0.0/24">10.0.0.x</option>
<option value="172.16.0.0/24">172.16.0.x</option>
</select>
{/* Refresh button */}
<button
onClick={handleScan}
disabled={isScanning()}
title="Refresh scan"
class="p-1.5 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Show when={isScanning()} fallback={
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
}>
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<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>
</button>
</div>
</div>
{/* Loading skeleton cards */}
<Show when={isScanning() && !discoveryResult()}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Show 4 skeleton cards */}
<For each={[1, 2, 3, 4]}>
{() => (
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
<div class="w-5 h-5 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div class="flex-1">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-24 mb-2"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
</div>
</div>
<div class="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
)}
</For>
</div>
<div class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Scanning network... This may take up to 30 seconds.
</div>
</Show>
{/* Discovery Results */}
<Show when={discoveryResult() && !isScanning()}>
<div class="space-y-4">
{/* Server Cards */}
<Show when={discoveryResult()!.servers.length > 0} fallback={
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="mx-auto mb-3 opacity-50">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
<path d="M11 8v3m0 4h.01"></path>
</svg>
<p>No Proxmox servers found on this network</p>
<p class="text-xs mt-2">Try a different subnet or check if servers are online</p>
</div>
}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<For each={discoveryResult()!.servers}>
{(server) => (
<div
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 cursor-pointer transition-all hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 group"
onClick={() => handleAddServer(server)}
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
{/* Server Icon */}
<div class={`mt-0.5 ${server.type === 'pve' ? 'text-orange-500' : 'text-green-500'}`}>
{getServerIcon(server.type)}
</div>
{/* Server Details */}
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-gray-100">
{server.hostname || server.ip}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{server.ip}:{server.port}
</div>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{server.type === 'pve' ? 'Proxmox VE' : 'PBS'}
</span>
<Show when={server.version !== 'Unknown'}>
<span class="text-xs text-gray-500 dark:text-gray-400">
v{server.version}
</span>
</Show>
</div>
</div>
</div>
{/* Add Arrow */}
<div class="text-gray-400 group-hover:text-blue-500 transition-colors">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
{/* Errors */}
<Show when={discoveryResult()!.errors && discoveryResult()!.errors!.length > 0}>
<div class="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<h5 class="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">Discovery Warnings</h5>
<ul class="text-xs text-yellow-700 dark:text-yellow-300 space-y-1">
<For each={discoveryResult()!.errors}>
{(error) => <li> {error}</li>}
</For>
</ul>
</div>
</Show>
</div>
</Show>
</div>
{/* Footer */}
<div class="flex items-center justify-center px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={props.onClose}
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Close
</button>
</div>
</div>
</div>
</div>
</Show>
</Portal>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,411 +0,0 @@
import { Component, createSignal, onMount, For, Show } from 'solid-js';
import { showSuccess, showError } from '@/utils/toast';
interface Token {
token: string;
expires: string;
maxUses: number;
usedCount: number;
description?: string;
}
interface GenerateTokenRequest {
validityMinutes: number;
maxUses: number;
allowedTypes: string[];
description: string;
}
const RegistrationTokens: Component = () => {
const [tokens, setTokens] = createSignal<Token[]>([]);
const [loading, setLoading] = createSignal(false);
const [showGenerator, setShowGenerator] = createSignal(false);
const [newToken, setNewToken] = createSignal<Token | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [copiedToken, setCopiedToken] = createSignal<string | null>(null);
const [validityOption, setValidityOption] = createSignal('15');
const [maxUses, setMaxUses] = createSignal('1');
const [allowedTypesPVE, setAllowedTypesPVE] = createSignal(true);
const [allowedTypesPBS, setAllowedTypesPBS] = createSignal(true);
const [description, setDescription] = createSignal('');
onMount(() => {
fetchTokens();
});
const fetchTokens = async () => {
setLoading(true);
try {
const apiToken = localStorage.getItem('apiToken');
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
const response = await fetch('/api/tokens/list', {
headers
});
if (!response.ok) {
throw new Error('Failed to fetch tokens');
}
const data = await response.json();
setTokens(data || []);
} catch (err) {
console.error('Failed to load registration tokens:', err);
// Keep setError here since we're not showing a toast
setError('Failed to load registration tokens');
} finally {
setLoading(false);
}
};
const generateToken = async () => {
setError(null);
try {
const apiToken = localStorage.getItem('apiToken');
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
const validityMinutes = parseInt(validityOption());
const allowedTypes: string[] = [];
if (allowedTypesPVE()) allowedTypes.push('pve');
if (allowedTypesPBS()) allowedTypes.push('pbs');
const request: GenerateTokenRequest = {
validityMinutes,
maxUses: parseInt(maxUses()),
allowedTypes,
description: description()
};
const response = await fetch('/api/tokens/generate', {
method: 'POST',
headers,
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error('Failed to generate token');
}
const token = await response.json();
setNewToken(token);
setShowGenerator(false);
fetchTokens();
showSuccess('Registration token generated successfully');
// Reset form
setValidityOption('15');
setMaxUses('1');
setAllowedTypesPVE(true);
setAllowedTypesPBS(true);
setDescription('');
} catch (err) {
showError('Failed to generate token');
console.error(err);
}
};
const revokeToken = async (token: string) => {
if (!confirm('Are you sure you want to revoke this token?')) {
return;
}
try {
const apiToken = localStorage.getItem('apiToken');
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
if (apiToken) {
headers['X-API-Token'] = apiToken;
}
const response = await fetch(`/api/tokens/revoke?token=${encodeURIComponent(token)}`, {
method: 'DELETE',
headers
});
if (!response.ok) {
throw new Error('Failed to revoke token');
}
fetchTokens();
showSuccess('Token revoked successfully');
} catch (err) {
showError('Failed to revoke token');
console.error(err);
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedToken(text);
setTimeout(() => setCopiedToken(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const formatExpiry = (expires: string) => {
const expiryDate = new Date(expires);
const now = new Date();
const diff = expiryDate.getTime() - now.getTime();
if (diff < 0) {
return 'Expired';
}
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `${days}d ${hours % 24}h`;
} else if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else {
return `${minutes}m`;
}
};
return (
<div class="space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Registration Tokens
</h3>
<button
onClick={() => setShowGenerator(true)}
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Generate Token
</button>
</div>
<Show when={error()}>
<div class="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
<div class="flex">
<svg class="h-5 w-5 text-red-400 dark:text-red-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="ml-3">
<p class="text-sm text-red-800 dark:text-red-300">{error()}</p>
</div>
</div>
</div>
</Show>
<Show when={newToken()}>
<div class="rounded-md bg-green-50 dark:bg-green-900/20 p-4 border border-green-200 dark:border-green-800">
<div class="flex items-start">
<svg class="h-5 w-5 text-green-400 dark:text-green-300 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div class="ml-3 flex-1">
<h4 class="text-sm font-medium text-green-800 dark:text-green-300">
New Token Generated
</h4>
<div class="mt-2 space-y-2">
<div class="flex items-center space-x-2">
<code class="flex-1 text-sm bg-white dark:bg-gray-800 px-2 py-1 rounded border border-green-300 dark:border-green-700">
{newToken()!.token}
</code>
<button
onClick={() => copyToClipboard(newToken()!.token)}
class="p-1 hover:bg-green-100 dark:hover:bg-green-800 rounded"
>
<svg class="h-4 w-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<p class="text-xs text-green-700 dark:text-green-400">
Save this token securely. It won't be shown again.
</p>
</div>
</div>
</div>
</div>
</Show>
<Show when={showGenerator()}>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-4">
<h4 class="font-medium text-gray-900 dark:text-gray-100">Generate Registration Token</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Validity
</label>
<select
value={validityOption()}
onChange={(e) => setValidityOption(e.currentTarget.value)}
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="15">15 minutes</option>
<option value="60">1 hour</option>
<option value="1440">24 hours</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Max Uses
</label>
<input
type="number"
min="1"
max="100"
value={maxUses()}
onInput={(e) => setMaxUses(e.currentTarget.value)}
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Allowed Node Types
</label>
<div class="mt-2 space-x-4">
<label class="inline-flex items-center">
<input
type="checkbox"
checked={allowedTypesPVE()}
onChange={(e) => setAllowedTypesPVE(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Proxmox VE</span>
</label>
<label class="inline-flex items-center">
<input
type="checkbox"
checked={allowedTypesPBS()}
onChange={(e) => setAllowedTypesPBS(e.currentTarget.checked)}
class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">PBS</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description (optional)
</label>
<input
type="text"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="e.g., Production cluster setup"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
</div>
<div class="flex justify-end space-x-2">
<button
onClick={() => setShowGenerator(false)}
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={generateToken}
disabled={!allowedTypesPVE() && !allowedTypesPBS()}
class="px-3 py-2 border border-transparent rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Generate
</button>
</div>
</div>
</Show>
<Show
when={!loading()}
fallback={
<div class="text-center py-4">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
}
>
<Show
when={tokens().length > 0}
fallback={
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="h-12 w-12 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<p>No registration tokens</p>
<p class="text-sm mt-1">Generate a token to allow secure node registration</p>
</div>
}
>
<div class="space-y-2">
<For each={tokens()}>
{(token) => (
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2">
<code class="text-sm font-mono bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{token.token}
</code>
<button
onClick={() => copyToClipboard(token.token)}
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
<Show
when={copiedToken() === token.token}
fallback={
<svg class="h-3 w-3 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
}
>
<span class="text-xs text-green-600 dark:text-green-400">Copied!</span>
</Show>
</button>
</div>
<Show when={token.description}>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{token.description}
</p>
</Show>
<div class="flex items-center space-x-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span>Expires: {formatExpiry(token.expires)}</span>
<span>Uses: {token.usedCount}/{token.maxUses === 0 ? '' : token.maxUses}</span>
</div>
</div>
<button
onClick={() => revokeToken(token.token)}
class="ml-4 p-1 hover:bg-red-100 dark:hover:bg-red-900/20 rounded text-red-600 dark:text-red-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
);
};
export default RegistrationTokens;
File diff suppressed because it is too large Load Diff
@@ -1,460 +0,0 @@
import { Component, For, Show, createSignal, createMemo, createEffect } from 'solid-js';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { formatBytes } from '@/utils/format';
import { createTooltipSystem } from '@/components/shared/Tooltip';
import type { Storage as StorageType } from '@/types/api';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
const Storage: Component = () => {
const { state, connected, activeAlerts, initialDataReceived } = useWebSocket();
const [viewMode, setViewMode] = createSignal<'node' | 'storage'>('node');
const [searchTerm, setSearchTerm] = createSignal('');
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('storageShowFilters') !== null
? localStorage.getItem('storageShowFilters') === 'true'
: false // Default to collapsed
);
// Create tooltip system
const TooltipComponent = createTooltipSystem();
// Create a mapping from node name to host URL
const nodeHostMap = createMemo(() => {
const map: Record<string, string> = {};
(state.nodes || []).forEach(node => {
if (node.host) {
map[node.name] = node.host;
}
});
return map;
});
// Load preferences from localStorage
createEffect(() => {
const savedViewMode = localStorage.getItem('storageViewMode');
if (savedViewMode === 'storage') setViewMode('storage');
});
// Save preferences to localStorage
createEffect(() => {
localStorage.setItem('storageViewMode', viewMode());
});
createEffect(() => {
localStorage.setItem('storageShowFilters', showFilters().toString());
});
// Filter storage - in storage view, filter out 0 capacity
const filteredStorage = createMemo(() => {
const storage = state.storage || [];
if (viewMode() === 'storage') {
return storage.filter((s) => s.total > 0);
}
return storage;
});
// Sort and filter storage
const sortedStorage = createMemo(() => {
let storage = [...filteredStorage()];
// Apply search filter
const search = searchTerm().toLowerCase();
if (search) {
storage = storage.filter(s =>
s.name.toLowerCase().includes(search) ||
s.node.toLowerCase().includes(search) ||
s.type.toLowerCase().includes(search) ||
s.content?.toLowerCase().includes(search) ||
(s.status && s.status.toLowerCase().includes(search))
);
}
// Always sort by name alphabetically for consistent order
return storage.sort((a, b) => a.name.localeCompare(b.name));
});
// Group storage by node or storage
const groupedStorage = createMemo(() => {
const storage = sortedStorage();
const mode = viewMode();
if (mode === 'node') {
const groups: Record<string, StorageType[]> = {};
storage.forEach(s => {
if (!groups[s.node]) groups[s.node] = [];
groups[s.node].push(s);
});
return groups;
} else {
// Group by storage name - show all storage as-is for maximum compatibility
const groups: Record<string, StorageType[]> = {};
storage.forEach(s => {
if (!groups[s.name]) groups[s.name] = [];
groups[s.name].push(s);
});
return groups;
}
});
const getProgressBarColor = (usage: number) => {
// Match MetricBar component styling - use the same disk/generic logic
if (usage >= 90) return 'bg-red-500/60 dark:bg-red-500/50';
if (usage >= 80) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
if (usage >= 70) return 'bg-amber-500/60 dark:bg-amber-500/50';
if (usage >= 60) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
return 'bg-emerald-500/60 dark:bg-emerald-500/50';
};
const resetFilters = () => {
setSearchTerm('');
setViewMode('node');
};
const getTotalByNode = (storages: StorageType[]) => {
const totals = { used: 0, total: 0, free: 0 };
storages.forEach(s => {
totals.used += s.used || 0;
totals.total += s.total || 0;
totals.free += s.free || 0;
});
return totals;
};
const calculateOverallUsage = (storages: StorageType[]) => {
const totals = getTotalByNode(storages);
return totals.total > 0 ? (totals.used / totals.total * 100) : 0;
};
// Handle keyboard shortcuts
let searchInputRef: HTMLInputElement | undefined;
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input
const target = e.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.contentEditable === 'true';
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear
if (searchTerm().trim() || viewMode() !== 'node') {
// Clear search and reset filters
resetFilters();
// Blur the search input if it's focused
if (searchInputRef && document.activeElement === searchInputRef) {
searchInputRef.blur();
}
} else if (showFilters()) {
// No search/filters active, so collapse the filters section
setShowFilters(false);
}
// If filters are already collapsed, do nothing
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
}
// Focus the search input and let the character be typed
if (searchInputRef) {
searchInputRef.focus();
// Don't prevent default - let the character be typed
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
});
return (
<div>
{/* Filters and Search */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg mb-4 overflow-hidden">
<button
onClick={() => setShowFilters(!showFilters())}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
>
<span class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
Filters & Search
<Show when={searchTerm() || viewMode() !== 'node'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
</Show>
</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class={`transform transition-transform ${showFilters() ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class={`filter-controls-wrapper ${showFilters() ? 'block' : 'hidden'} p-3 lg:p-4 border-t border-gray-200 dark:border-gray-700`}>
<div class="flex flex-col gap-3">
{/* Search Bar Row */}
<div class="flex gap-2">
<div class="relative flex-1">
<input
ref={searchInputRef}
type="text"
placeholder="Search by name, node, type, content, or status..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* Reset Button */}
<button
onClick={resetFilters}
title="Reset all filters"
class="flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
<span class="ml-1.5 hidden sm:inline">Reset</span>
</button>
</div>
{/* Filters Row */}
<div class="flex flex-col sm:flex-row gap-2">
{/* View Mode Toggle */}
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Group by:</span>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setViewMode('node')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'node'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Node
</button>
<button
onClick={() => setViewMode('storage')}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
viewMode() === 'storage'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Storage
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Loading State */}
<Show when={connected() && !initialDataReceived()}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="animate-spin mx-auto h-12 w-12 text-gray-400 mb-4" 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>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Loading storage data...</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">Connecting to monitoring service</p>
</div>
</div>
</Show>
{/* Empty State - No Storage Configured */}
<Show when={connected() && initialDataReceived() && sortedStorage().length === 0}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No storage found</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">No storage repositories are configured or visible.</p>
</div>
</div>
</Show>
{/* Storage Table */}
<Show when={connected() && initialDataReceived() && sortedStorage().length > 0}>
<ComponentErrorBoundary name="Storage Table">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Storage</th>
<Show when={viewMode() === 'node'}>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">Node</th>
</Show>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden md:table-cell">Type</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden lg:table-cell">Content</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">Status</th>
<Show when={viewMode() === 'node'}>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden lg:table-cell">Shared</th>
</Show>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider min-w-[100px] sm:min-w-[150px] md:min-w-[200px]">Usage</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">Free</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Total</th>
</tr>
</thead>
<tbody>
<For each={Object.entries(groupedStorage()).sort(([a], [b]) => a.localeCompare(b))}>
{([groupName, storages]) => (
<>
{/* Group Header */}
<Show when={viewMode() === 'node'}>
<tr class="bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
<a
href={nodeHostMap()[groupName] || (groupName.includes(':') ? `https://${groupName}` : `https://${groupName}:8006`)}
target="_blank"
rel="noopener noreferrer"
class="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${groupName} web interface`}
>
{groupName}
</a>
</td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400" colspan="8">
<span class="text-[10px]">
{getTotalByNode(storages).total > 0 && (
<span>
{formatBytes(getTotalByNode(storages).used)} / {formatBytes(getTotalByNode(storages).total)} ({calculateOverallUsage(storages).toFixed(1)}%)
</span>
)}
</span>
</td>
</tr>
</Show>
{/* Storage Rows */}
<For each={storages} fallback={<></>}>
{(storage) => {
const usagePercent = storage.total > 0 ? (storage.used / storage.total * 100) : 0;
const isDisabled = storage.status !== 'available';
const alertStyles = getAlertStyles(storage.id || `${storage.instance}-${storage.name}`, activeAlerts);
const rowClass = `${isDisabled ? 'opacity-60' : ''} ${alertStyles.rowClass} hover:shadow-sm transition-all duration-200`;
return (
<tr class={rowClass}>
<td class="p-1 px-2">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-gray-100">
{storage.name}
</span>
<Show when={alertStyles.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={alertStyles.severity} alerts={[]} />
<Show when={alertStyles.alertCount > 1}>
<AlertCountBadge count={alertStyles.alertCount} severity={alertStyles.severity!} alerts={[]} />
</Show>
</div>
</Show>
</div>
</td>
<Show when={viewMode() === 'node'}>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400 hidden sm:table-cell">{storage.node}</td>
</Show>
<td class="p-1 px-2 hidden md:table-cell">
<span class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${
storage.type === 'dir' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' :
storage.type === 'pbs' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' :
'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
}`}>
{storage.type}
</span>
</td>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400 hidden lg:table-cell">
{storage.content || '-'}
</td>
<td class="p-1 px-2 text-xs hidden sm:table-cell">
<span class={`${
storage.status === 'available' ? 'text-green-600 dark:text-green-400' :
'text-red-600 dark:text-red-400'
}`}>
{storage.status || 'unknown'}
</span>
</td>
<Show when={viewMode() === 'node'}>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400 hidden lg:table-cell">
{storage.shared ? '✓' : '-'}
</td>
</Show>
<td class="p-1 px-2">
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${getProgressBarColor(usagePercent)}`}
style={{ width: `${usagePercent}%` }}
/>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">
<span class="sm:hidden">{usagePercent.toFixed(0)}%</span>
<span class="hidden sm:inline">{formatBytes(storage.used || 0)} / {formatBytes(storage.total || 0)} ({usagePercent.toFixed(1)}%)</span>
</span>
</span>
</div>
</td>
<td class="p-1 px-2 text-xs hidden sm:table-cell">{formatBytes(storage.free || 0)}</td>
<td class="p-1 px-2 text-xs">{formatBytes(storage.total || 0)}</td>
</tr>
);
}}
</For>
</>
)}
</For>
</tbody>
</table>
</div>
</ComponentErrorBoundary>
</Show>
{/* Tooltip System */}
<TooltipComponent />
</div>
);
};
export default Storage;
@@ -1,146 +0,0 @@
import { Component, createSignal, For, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import { POLLING_INTERVALS } from '@/constants';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface ToastMessage {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
}
interface ToastProps {
toast: ToastMessage;
onRemove: (id: string) => void;
}
const Toast: Component<ToastProps> = (props) => {
const [show, setShow] = createSignal(true);
const icons = {
success: (
<div class="relative">
<div class="absolute inset-0 bg-green-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
),
error: (
<div class="relative">
<div class="absolute inset-0 bg-red-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
),
warning: (
<div class="relative">
<div class="absolute inset-0 bg-yellow-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
),
info: (
<div class="relative">
<div class="absolute inset-0 bg-blue-400 rounded-full blur-xl opacity-50 animate-pulse"></div>
<svg class="w-6 h-6 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
)
};
const iconColors = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-blue-400'
};
const handleClose = () => {
setShow(false);
window.setTimeout(() => props.onRemove(props.toast.id), 300);
};
// Auto-remove after duration
window.setTimeout(() => {
handleClose();
}, props.toast.duration || POLLING_INTERVALS.TOAST_DURATION);
return (
<div
class={`transform transition-all duration-500 ease-out ${
show() ? 'translate-x-0 opacity-100 scale-100' : 'translate-x-full opacity-0 scale-95'
} animate-slide-in-glass`}
>
<div
class={`
backdrop-blur-xl bg-white/10 dark:bg-gray-900/30
border border-white/20 dark:border-gray-700/30
px-5 py-4 rounded-2xl shadow-2xl
flex items-center gap-4
min-w-[320px] max-w-[500px]
`}
style={{
"background": "linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)",
"box-shadow": "0 8px 32px 0 rgba(31, 38, 135, 0.37), inset 0 0 0 1px rgba(255,255,255,0.1)"
}}
>
<div class={`flex-shrink-0 ${iconColors[props.toast.type]}`}>
{icons[props.toast.type]}
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-gray-800 dark:text-gray-100">{props.toast.title}</h3>
<Show when={props.toast.message}>
<p class="mt-1 text-xs text-gray-700 dark:text-gray-300 opacity-90">{props.toast.message}</p>
</Show>
</div>
<button
onClick={handleClose}
class="flex-shrink-0 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-white/10 rounded-lg p-1.5 transition-all duration-200"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
};
// Toast Container Component
// Declare global interface extension
declare global {
interface Window {
showToast: (type: ToastType, title: string, message?: string, duration?: number) => void;
}
}
export const ToastContainer: Component = () => {
const [toasts, setToasts] = createSignal<ToastMessage[]>([]);
const removeToast = (id: string) => {
setToasts(toasts().filter(t => t.id !== id));
};
// Expose global toast function
window.showToast = (type: ToastType, title: string, message?: string, duration?: number) => {
const id = Date.now().toString();
setToasts([...toasts(), { id, type, title, message, duration }]);
};
return (
<Portal>
<div class="fixed top-4 right-4 z-50 space-y-2 max-w-sm">
<For each={toasts()}>
{(toast) => <Toast toast={toast} onRemove={removeToast} />}
</For>
</div>
</Portal>
);
};
@@ -1,115 +0,0 @@
import { Component, Show, createSignal } from 'solid-js';
import type { Alert } from '@/types/api';
import { Portal } from 'solid-js/web';
interface AlertIndicatorProps {
severity: 'critical' | 'warning' | null;
alerts?: Alert[];
}
export const AlertIndicator: Component<AlertIndicatorProps> = (props) => {
if (!props.severity) return null;
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPosition, setTooltipPosition] = createSignal({ x: 0, y: 0 });
const dotClass = props.severity === 'critical'
? 'bg-red-500 animate-pulse'
: 'bg-orange-500';
const handleMouseEnter = (e: MouseEvent) => {
if (!props.alerts || props.alerts.length === 0) return;
const rect = (e.target as HTMLElement).getBoundingClientRect();
setTooltipPosition({ x: rect.left + rect.width / 2, y: rect.top - 5 });
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<>
<span
class={`inline-block w-2 h-2 rounded-full ${dotClass}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
<Show when={showTooltip() && props.alerts && props.alerts.length > 0}>
<Portal>
<div
class="fixed z-50 bg-gray-900 text-white text-xs rounded px-2 py-1 pointer-events-none transform -translate-x-1/2 -translate-y-full"
style={{
left: `${tooltipPosition().x}px`,
top: `${tooltipPosition().y}px`,
}}
>
{props.alerts!.map((alert, i) => (
<div class={i > 0 ? 'mt-1' : ''}>
{alert.type}: {alert.value.toFixed(1)}% (threshold: {alert.threshold}%)
</div>
))}
<div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900" />
</div>
</Portal>
</Show>
</>
);
};
interface AlertCountBadgeProps {
count: number;
severity: 'critical' | 'warning';
alerts?: Alert[];
}
export const AlertCountBadge: Component<AlertCountBadgeProps> = (props) => {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPosition, setTooltipPosition] = createSignal({ x: 0, y: 0 });
const badgeClass = props.severity === 'critical'
? 'bg-red-500 text-white'
: 'bg-orange-500 text-white';
const handleMouseEnter = (e: MouseEvent) => {
if (!props.alerts || props.alerts.length === 0) return;
const rect = (e.target as HTMLElement).getBoundingClientRect();
setTooltipPosition({ x: rect.left + rect.width / 2, y: rect.top - 5 });
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<>
<span
class={`inline-flex items-center justify-center min-w-[20px] h-5 px-1 text-xs font-medium rounded-full ${badgeClass}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{props.count}
</span>
<Show when={showTooltip() && props.alerts && props.alerts.length > 0}>
<Portal>
<div
class="fixed z-50 bg-gray-900 text-white text-xs rounded px-2 py-1 pointer-events-none transform -translate-x-1/2 -translate-y-full max-w-xs"
style={{
left: `${tooltipPosition().x}px`,
top: `${tooltipPosition().y}px`,
}}
>
<div class="font-semibold mb-1">{props.count} Active Alerts:</div>
{props.alerts!.map((alert, i) => (
<div class={i > 0 ? 'mt-1' : ''}>
{i + 1}. {alert.type}: {alert.value.toFixed(1)}% (threshold: {alert.threshold}%)
</div>
))}
<div class="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900" />
</div>
</Portal>
</Show>
</>
);
};
@@ -1,58 +0,0 @@
import { Component, JSX, createSignal, createEffect, onMount } from 'solid-js';
import { Show } from 'solid-js';
interface ScrollableTableProps {
children: JSX.Element;
class?: string;
minWidth?: string;
}
export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
const [showLeftFade, setShowLeftFade] = createSignal(false);
const [showRightFade, setShowRightFade] = createSignal(false);
let scrollContainer: HTMLDivElement | undefined;
const checkScroll = () => {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
setShowLeftFade(scrollLeft > 0);
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
};
onMount(() => {
checkScroll();
window.addEventListener('resize', checkScroll);
});
createEffect(() => {
if (scrollContainer) {
scrollContainer.addEventListener('scroll', checkScroll);
return () => scrollContainer?.removeEventListener('scroll', checkScroll);
}
});
return (
<div class={`relative ${props.class || ''}`}>
{/* Left fade */}
<Show when={showLeftFade()}>
<div class="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-white dark:from-gray-800 to-transparent z-10 pointer-events-none" />
</Show>
{/* Scrollable container */}
<div
ref={scrollContainer}
class="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-400 dark:scrollbar-thumb-gray-600"
>
<div style={{ "min-width": props.minWidth || 'auto' }}>
{props.children}
</div>
</div>
{/* Right fade */}
<Show when={showRightFade()}>
<div class="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-white dark:from-gray-800 to-transparent z-10 pointer-events-none" />
</Show>
</div>
);
};
@@ -1,121 +0,0 @@
import { Component, createSignal, createEffect, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
interface TooltipProps {
content: string;
x: number;
y: number;
visible: boolean;
}
// Sanitize tooltip content to prevent XSS
function sanitizeContent(content: string): string {
// Remove any HTML tags and encode special characters
return content
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/&/g, '&amp;') // Encode ampersands
.replace(/</g, '&lt;') // Encode less than
.replace(/>/g, '&gt;') // Encode greater than
.replace(/"/g, '&quot;') // Encode quotes
.replace(/'/g, '&#x27;'); // Encode apostrophes
}
const Tooltip: Component<TooltipProps> = (props) => {
let tooltipRef: HTMLDivElement | undefined;
const [position, setPosition] = createSignal({ x: 0, y: 0 });
createEffect(() => {
if (!props.visible) {
setPosition({ x: props.x, y: props.y });
return;
}
// Use requestAnimationFrame to ensure DOM is updated
requestAnimationFrame(() => {
if (!tooltipRef) return;
// Calculate position to keep tooltip on screen
const rect = tooltipRef.getBoundingClientRect();
const padding = 10;
let x = props.x + padding;
let y = props.y - rect.height - padding;
// Keep within viewport
if (x + rect.width > window.innerWidth) {
x = props.x - rect.width - padding;
}
if (y < 0) {
y = props.y + padding;
}
// Ensure x and y are not negative
x = Math.max(0, x);
y = Math.max(0, y);
setPosition({ x, y });
});
});
return (
<Show when={props.visible}>
<Portal mount={document.body}>
<div
ref={tooltipRef}
class="fixed z-50 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-800 rounded shadow-lg pointer-events-none whitespace-nowrap"
style={{
left: '0',
top: '0',
transform: `translate(${position().x}px, ${position().y}px)`,
opacity: props.visible ? '1' : '0',
transition: 'opacity 200ms ease-out'
}}
textContent={sanitizeContent(props.content)}
/>
</Portal>
</Show>
);
};
// Global tooltip singleton
let tooltipInstance: {
show: (content: string, x: number, y: number) => void;
hide: () => void;
} | null = null;
export function createTooltipSystem() {
const [visible, setVisible] = createSignal(false);
const [content, setContent] = createSignal('');
const [position, setPosition] = createSignal({ x: 0, y: 0 });
tooltipInstance = {
show: (content: string, x: number, y: number) => {
setContent(content);
setPosition({ x, y });
setVisible(true);
},
hide: () => {
setVisible(false);
}
};
return () => (
<Tooltip
content={content()}
x={position().x}
y={position().y}
visible={visible()}
/>
);
}
export function showTooltip(content: string, x: number, y: number) {
tooltipInstance?.show(content, x, y);
}
export function hideTooltip() {
tooltipInstance?.hide();
}
export default Tooltip;
@@ -1,69 +0,0 @@
// Constants used throughout the application
// Polling and update intervals (in milliseconds)
export const POLLING_INTERVALS = {
DEFAULT: 5000, // 5 seconds - default polling interval
RECONNECT_BASE: 1000, // 1 second - base reconnect delay
RECONNECT_MAX: 30000, // 30 seconds - max reconnect delay
DATA_FLASH: 1000, // 1 second - data update indicator flash duration
TOAST_DURATION: 5000, // 5 seconds - default toast notification duration
} as const;
// Display thresholds (percentages)
export const THRESHOLDS = {
WARNING: 60, // Yellow warning threshold
CRITICAL: 80, // Orange critical threshold
DANGER: 90, // Red danger threshold
} as const;
// Network and I/O metrics thresholds (MB/s)
export const IO_THRESHOLDS = {
LOW: 1,
MEDIUM: 10,
HIGH: 50,
VERY_HIGH: 100,
} as const;
// Animation durations (in milliseconds)
export const ANIMATIONS = {
TOAST_SLIDE: 300, // Toast slide in/out animation
} as const;
// UI configuration
export const UI = {
DEBOUNCE_DELAY: 300, // 300ms - input debounce delay
} as const;
// WebSocket configuration
export const WEBSOCKET = {
PING_INTERVAL: 25000, // 25 seconds - WebSocket ping interval
MESSAGE_TYPES: {
INITIAL_STATE: 'initialState',
RAW_DATA: 'rawData',
ERROR: 'error',
} as const,
} as const;
// Storage keys for localStorage
export const STORAGE_KEYS = {
DARK_MODE: 'darkMode',
VIEW_MODE: 'viewMode',
DISPLAY_MODE: 'displayMode',
SORT_KEY: 'sortKey',
SORT_DIRECTION: 'sortDirection',
ALERT_THRESHOLDS: 'alertThresholds',
} as const;
// File size units
export const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const;
// Log levels for the logger
export const LOG_LEVELS = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
} as const;
export type LogLevel = keyof typeof LOG_LEVELS;
@@ -1,45 +0,0 @@
import { onMount, onCleanup, createSignal } from 'solid-js';
interface UseIntersectionObserverOptions {
threshold?: number;
rootMargin?: string;
root?: Element | null;
}
export function useIntersectionObserver(
ref: () => HTMLElement | undefined,
options: UseIntersectionObserverOptions = {}
) {
const [isIntersecting, setIsIntersecting] = createSignal(false);
let observer: IntersectionObserver | undefined;
onMount(() => {
const element = ref();
if (!element || typeof window === 'undefined' || !window.IntersectionObserver) {
// If IntersectionObserver is not supported, assume element is visible
setIsIntersecting(true);
return;
}
observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
},
{
threshold: options.threshold || 0,
rootMargin: options.rootMargin || '50px',
root: options.root || null
}
);
observer.observe(element);
});
onCleanup(() => {
if (observer) {
observer.disconnect();
}
});
return isIntersecting;
}
-325
View File
@@ -1,325 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Notification animations */
@keyframes slide-in-glass {
from {
opacity: 0;
transform: translateX(100%) scale(0.95);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
filter: blur(0);
}
}
.animate-slide-in-glass {
animation: slide-in-glass 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Support for backdrop blur in browsers */
@supports (backdrop-filter: blur(0px)) {
.backdrop-blur-xl {
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
}
/* Custom styles */
@layer base {
body {
@apply bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
/* Ensure body scrollbar follows our styling */
overflow-y: auto;
overflow-x: hidden;
}
/* Hide scrollbars during transitions to prevent flicker */
html {
overflow-y: scroll;
scroll-behavior: smooth;
}
/* Threshold slider styles */
.threshold-slider-container {
@apply relative w-full;
}
.threshold-slider {
@apply w-full h-3.5 appearance-none bg-transparent relative z-10;
}
/* Slider track styles - match progress bar height */
.threshold-slider::-webkit-slider-runnable-track {
@apply h-3.5 rounded bg-gray-200 dark:bg-gray-600;
}
.threshold-slider::-moz-range-track {
@apply h-3.5 rounded bg-gray-200 dark:bg-gray-600;
}
/* Slider thumb styles with value display */
.threshold-slider::-webkit-slider-thumb {
@apply appearance-none w-12 h-5 rounded-full cursor-pointer relative;
margin-top: -2.5px; /* Center thumb on track */
background: white;
border: 2px solid currentColor;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.threshold-slider::-moz-range-thumb {
@apply appearance-none w-12 h-5 rounded-full cursor-pointer relative border-0;
background: white;
border: 2px solid currentColor;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
/* Color coding for different metrics */
.threshold-slider.slider-cpu {
color: #3b82f6; /* blue-500 */
}
.threshold-slider.slider-memory {
color: #10b981; /* green-500 */
}
.threshold-slider.slider-disk {
color: #f59e0b; /* amber-500 */
}
/* Value display inside thumb */
.threshold-value {
@apply absolute inset-0 flex items-center justify-center text-[10px] font-semibold pointer-events-none;
color: currentColor;
}
}
@layer components {
/* Card styles */
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
}
/* Table styles */
.table-header {
@apply text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider;
}
.table-row {
@apply border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750;
}
/* Status indicators */
.status-running {
@apply text-green-600 dark:text-green-400;
}
.status-stopped {
@apply text-gray-600 dark:text-gray-400;
}
.status-error {
@apply text-red-600 dark:text-red-400;
}
/* Progress bars */
.progress-bar {
@apply bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden;
}
.progress-fill {
@apply h-full;
transition: width 300ms ease-out;
}
/* Stable chart containers */
.chart-stable-container {
contain: layout style;
will-change: contents;
}
/* Prevent chart SVG flicker */
.sparkline {
backface-visibility: hidden;
transform: translateZ(0);
}
/* Disable animations in charts mode to prevent blinking */
.charts-mode .sparkline path {
transition: none !important;
}
/* Custom scrollbar - subtle styling */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: theme('colors.gray.300') theme('colors.gray.100');
}
.dark .custom-scrollbar {
scrollbar-color: theme('colors.gray.700') theme('colors.gray.800');
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-800;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-600;
}
}
/* Global scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; /* gray-400 with transparency */
}
.dark * {
scrollbar-color: rgba(55, 65, 81, 0.5) transparent; /* gray-700 with transparency */
}
/* Make scrollbars even more subtle on hover */
*:hover {
scrollbar-color: rgba(156, 163, 175, 0.7) rgba(243, 244, 246, 0.5); /* Slightly more visible on hover */
}
.dark *:hover {
scrollbar-color: rgba(75, 85, 99, 0.7) rgba(31, 41, 55, 0.3); /* dark mode hover */
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 10px;
}
.dark *::-webkit-scrollbar-thumb {
background-color: rgba(55, 65, 81, 0.5);
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.8);
}
.dark *::-webkit-scrollbar-thumb:hover {
background-color: rgba(75, 85, 99, 0.8);
}
/* Dark mode transitions - Only apply to elements that actually change colors */
body,
.card,
.table-row,
.table-header,
.bg-white,
.bg-gray-50,
.bg-gray-100,
.bg-gray-200,
.bg-gray-300,
.bg-gray-700,
.bg-gray-800,
.bg-gray-900,
.text-gray-500,
.text-gray-600,
.text-gray-700,
.text-gray-800,
.text-gray-900,
.border-gray-200,
.border-gray-300,
.border-gray-600,
.border-gray-700,
.dark\:bg-gray-700,
.dark\:bg-gray-800,
.dark\:bg-gray-900,
.dark\:text-gray-100,
.dark\:text-gray-200,
.dark\:text-gray-300,
.dark\:text-gray-400,
.dark\:border-gray-600,
.dark\:border-gray-700 {
transition: background-color 150ms ease-in-out, color 150ms ease-in-out, border-color 150ms ease-in-out;
}
/* Pulse logo animation - subtle ripple effect */
@keyframes pulse-logo {
0% {
transform: scale(1);
}
25% {
transform: scale(1.05);
}
50% {
transform: scale(1);
}
}
@keyframes pulse-ring {
0%, 100% {
opacity: 0.92;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
.animate-pulse-logo {
animation: pulse-logo 0.8s cubic-bezier(0.4, 0, 0.6, 1);
transform-origin: center;
}
.animate-pulse-logo .pulse-ring {
animation: pulse-ring 0.8s cubic-bezier(0.4, 0, 0.6, 1);
transform-origin: center;
transform-box: fill-box;
}
/* Stable table layout */
.table-fixed {
table-layout: fixed;
}
/* Prevent text from affecting column widths */
.table-fixed td,
.table-fixed th {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep name column slightly flexible while maintaining stability */
.table-fixed td:first-child {
white-space: normal;
word-break: break-word;
}
/* Hide scrollbars but keep functionality */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
@@ -1,19 +0,0 @@
/* @refresh reload */
import { render } from 'solid-js/web';
import './index.css';
import App from './App';
import { logger } from './utils/logger';
const root = document.getElementById('root');
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?',
);
}
// Initialize app with logging
logger.info('Pulse monitoring dashboard starting');
render(() => <App />, root!);
File diff suppressed because it is too large Load Diff
@@ -1,32 +0,0 @@
// Event bus for cross-component communication
// Event types
export type EventType = 'node_auto_registered' | 'refresh_nodes' | 'discovery_updated';
// Event handlers
type EventHandler = (data?: any) => void;
class EventBus {
private handlers: Map<EventType, Set<EventHandler>> = new Map();
on(event: EventType, handler: EventHandler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
// Return unsubscribe function
return () => {
this.handlers.get(event)?.delete(handler);
};
}
emit(event: EventType, data?: any) {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach(handler => handler(data));
}
}
}
export const eventBus = new EventBus();
@@ -1,44 +0,0 @@
import { createStore } from 'solid-js/store';
export interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
duration?: number;
}
const [notifications, setNotifications] = createStore<Notification[]>([]);
export const notificationStore = {
notifications,
add: (notification: Omit<Notification, 'id'>) => {
const id = Date.now().toString();
setNotifications(prev => [...prev, { ...notification, id }]);
// Auto-remove after duration
if (notification.duration && notification.duration > 0) {
setTimeout(() => {
notificationStore.remove(id);
}, notification.duration);
}
return id;
},
remove: (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
},
success: (message: string, duration = 5000) => {
return notificationStore.add({ message, type: 'success', duration });
},
error: (message: string, duration = 10000) => {
return notificationStore.add({ message, type: 'error', duration });
},
info: (message: string, duration = 5000) => {
return notificationStore.add({ message, type: 'info', duration });
}
};
@@ -1,20 +0,0 @@
import { createWebSocketStore } from './websocket';
// Store the instance on window to survive hot reloads
declare global {
interface Window {
__pulseWsStore?: ReturnType<typeof createWebSocketStore>;
}
}
export function getGlobalWebSocketStore() {
if (!window.__pulseWsStore) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Use relative URL that works behind proxies
const wsUrl = `${protocol}//${window.location.host}/ws`;
window.__pulseWsStore = createWebSocketStore(wsUrl);
}
return window.__pulseWsStore;
}
@@ -1,318 +0,0 @@
import { createSignal, onCleanup } from 'solid-js';
import { createStore } from 'solid-js/store';
import type { State, WSMessage, Alert, ResolvedAlert, PVEBackups } from '@/types/api';
import { logger } from '@/utils/logger';
import { POLLING_INTERVALS, WEBSOCKET } from '@/constants';
import { notificationStore } from './notifications';
import { eventBus } from './events';
// Type-safe WebSocket store
export function createWebSocketStore(url: string) {
const [connected, setConnected] = createSignal(false);
const [reconnecting, setReconnecting] = createSignal(false);
const [initialDataReceived, setInitialDataReceived] = createSignal(false);
const [state, setState] = createStore<State>({
nodes: [],
vms: [],
containers: [],
storage: [],
pbs: [],
metrics: [],
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: []
} as PVEBackups,
pbsBackups: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0
},
connectionHealth: {},
stats: {
startTime: new Date().toISOString(),
uptime: 0,
pollingCycles: 0,
webSocketClients: 0,
version: '2.0.0'
},
activeAlerts: [],
recentlyResolved: [],
lastUpdate: ''
});
const [activeAlerts, setActiveAlerts] = createStore<Record<string, Alert>>({});
const [recentlyResolved, setRecentlyResolved] = createStore<Record<string, ResolvedAlert>>({});
const [updateProgress, setUpdateProgress] = createSignal<any>(null);
let ws: WebSocket | null = null;
let reconnectTimeout: number;
let reconnectAttempt = 0;
let isReconnecting = false;
const maxReconnectDelay = POLLING_INTERVALS.RECONNECT_MAX;
const initialReconnectDelay = POLLING_INTERVALS.RECONNECT_BASE;
const connect = () => {
try {
// Close existing connection if any
if (ws && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
ws = new WebSocket(url);
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
};
ws.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (parseError) {
logger.error('Failed to parse WebSocket message', parseError);
return;
}
try {
const message: WSMessage = data;
if (message.type === WEBSOCKET.MESSAGE_TYPES.INITIAL_STATE || message.type === WEBSOCKET.MESSAGE_TYPES.RAW_DATA) {
// Update state properties individually to ensure reactivity
if (message.data) {
// Mark that we've received initial data
if (message.type === WEBSOCKET.MESSAGE_TYPES.INITIAL_STATE) {
setInitialDataReceived(true);
}
// Only update if we have actual data, don't overwrite with empty arrays
if (message.data.nodes !== undefined) setState('nodes', message.data.nodes);
if (message.data.vms !== undefined) setState('vms', message.data.vms);
if (message.data.containers !== undefined) setState('containers', message.data.containers);
if (message.data.storage !== undefined) setState('storage', message.data.storage);
if (message.data.pbs !== undefined) setState('pbs', message.data.pbs);
if (message.data.pbsBackups !== undefined) setState('pbsBackups', message.data.pbsBackups);
if (message.data.metrics !== undefined) setState('metrics', message.data.metrics);
if (message.data.pveBackups !== undefined) setState('pveBackups', message.data.pveBackups);
if (message.data.performance !== undefined) setState('performance', message.data.performance);
if (message.data.connectionHealth !== undefined) setState('connectionHealth', message.data.connectionHealth);
if (message.data.stats !== undefined) setState('stats', message.data.stats);
// Sync active alerts from state
if (message.data.activeAlerts !== undefined) {
// Received activeAlerts update
// Update alerts atomically to prevent race conditions
const newAlerts: Record<string, Alert> = {};
message.data.activeAlerts.forEach((alert: Alert) => {
newAlerts[alert.id] = alert;
});
// Clear existing alerts and set new ones
const currentAlertIds = Object.keys(activeAlerts);
currentAlertIds.forEach(id => {
if (!newAlerts[id]) {
setActiveAlerts(id, undefined!);
}
});
// Add new alerts
Object.entries(newAlerts).forEach(([id, alert]) => {
setActiveAlerts(id, alert);
});
// Updated activeAlerts
}
// Sync recently resolved alerts
if (message.data.recentlyResolved !== undefined) {
// Received recentlyResolved update
// Update resolved alerts atomically to prevent race conditions
const newResolvedAlerts: Record<string, ResolvedAlert> = {};
message.data.recentlyResolved.forEach((alert: ResolvedAlert) => {
newResolvedAlerts[alert.id] = alert;
});
// Clear existing resolved alerts and set new ones
const currentResolvedIds = Object.keys(recentlyResolved);
currentResolvedIds.forEach(id => {
if (!newResolvedAlerts[id]) {
setRecentlyResolved(id, undefined!);
}
});
// Add new resolved alerts
Object.entries(newResolvedAlerts).forEach(([id, alert]) => {
setRecentlyResolved(id, alert);
});
// Updated recentlyResolved
}
setState('lastUpdate', message.data.lastUpdate || new Date().toISOString());
}
logger.debug('message', {
type: message.type,
hasData: !!message.data,
nodeCount: message.data?.nodes?.length || 0,
vmCount: message.data?.vms?.length || 0,
containerCount: message.data?.containers?.length || 0
});
} else if (message.type === WEBSOCKET.MESSAGE_TYPES.ERROR) {
logger.debug('error', message.error);
} else if (message.type === 'ping') {
// Respond to ping with pong
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'pong', data: { timestamp: Date.now() } }));
}
} else if (message.type === 'pong') {
// Server acknowledged our ping
logger.debug('Received pong from server');
} else if (message.type === 'welcome') {
// Welcome message from server
logger.info('WebSocket connection established');
} else if (message.type === 'alert') {
// Individual alerts now handled via state sync
logger.warn('New alert received (will sync with next state update)', message.data);
} else if (message.type === 'alertResolved') {
// Individual alert resolution now handled via state sync
logger.info('Alert resolved (will sync with next state update)', { alertId: message.data.alertId });
} else if (message.type === 'update:progress') {
// Update progress event
setUpdateProgress(message.data);
logger.info('Update progress:', message.data);
} else if (message.type === 'node_auto_registered') {
// Node was successfully auto-registered
// Received node_auto_registered message
const node = message.data;
const nodeName = node.name || node.host;
const nodeType = node.type === 'pve' ? 'Proxmox VE' : 'Proxmox Backup Server';
console.log('[WebSocket] Showing notification for node:', nodeName);
notificationStore.success(
`🎉 ${nodeType} node "${nodeName}" was successfully auto-registered and is now being monitored!`,
8000
);
logger.info('Node auto-registered:', node);
// Emit event to trigger UI updates
eventBus.emit('node_auto_registered', node);
// Trigger a refresh of nodes
eventBus.emit('refresh_nodes');
} else if (message.type === 'node_deleted' || message.type === 'nodes_changed') {
// Nodes configuration has changed, refresh the list
console.log('[WebSocket] Nodes configuration changed, refreshing...');
eventBus.emit('refresh_nodes');
} else if (message.type === 'discovery_update') {
// Discovery scan completed with new results
console.log('[WebSocket] Discovery update received:', message.data);
eventBus.emit('discovery_updated', message.data);
} else {
// Log any unhandled message types
console.log('[WebSocket] Unhandled message type:', (message as any).type);
}
} catch (err) {
logger.error('Failed to process WebSocket message', err);
}
};
ws.onclose = (event) => {
logger.debug('disconnect', { code: event.code, reason: event.reason });
setConnected(false);
setInitialDataReceived(false);
// Don't reconnect if we're already trying
if (isReconnecting) {
return;
}
// Clear any existing timeout to prevent multiple reconnections
if (reconnectTimeout) {
window.clearTimeout(reconnectTimeout);
reconnectTimeout = 0;
}
isReconnecting = true;
setReconnecting(true);
// Calculate exponential backoff delay
const delay = Math.min(
initialReconnectDelay * Math.pow(2, reconnectAttempt),
maxReconnectDelay
);
logger.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`);
reconnectAttempt++;
reconnectTimeout = window.setTimeout(() => {
isReconnecting = false;
setReconnecting(false);
connect();
}, delay);
};
ws.onerror = (error) => {
// Don't log connection errors if we're already connected
// Browser may show errors for initial connection attempts even after success
if (!connected()) {
logger.debug('error', error);
}
};
} catch (err) {
logger.error('Failed to connect', err);
setConnected(false);
// Don't reconnect if we're already trying
if (isReconnecting) return;
isReconnecting = true;
setReconnecting(true);
// Use exponential backoff for connection errors too
const delay = Math.min(
initialReconnectDelay * Math.pow(2, reconnectAttempt),
maxReconnectDelay
);
reconnectAttempt++;
reconnectTimeout = window.setTimeout(() => {
isReconnecting = false;
setReconnecting(false);
connect();
}, delay);
}
};
// Connect immediately
connect();
// Cleanup on unmount
onCleanup(() => {
window.clearTimeout(reconnectTimeout);
ws?.close();
});
return {
state,
activeAlerts,
recentlyResolved,
connected,
reconnecting,
initialDataReceived,
updateProgress,
reconnect: () => {
ws?.close();
window.clearTimeout(reconnectTimeout);
reconnectAttempt = 0; // Reset attempts for manual reconnect
connect();
}
};
}
@@ -1,94 +0,0 @@
import type { FilterStack } from '@/utils/searchQuery';
export interface HysteresisThreshold {
trigger: number;
clear: number;
}
export interface AlertThresholds {
cpu?: HysteresisThreshold;
memory?: HysteresisThreshold;
disk?: HysteresisThreshold;
diskRead?: HysteresisThreshold;
diskWrite?: HysteresisThreshold;
networkIn?: HysteresisThreshold;
networkOut?: HysteresisThreshold;
// Legacy support for backward compatibility
cpuLegacy?: number;
memoryLegacy?: number;
diskLegacy?: number;
diskReadLegacy?: number;
diskWriteLegacy?: number;
networkInLegacy?: number;
networkOutLegacy?: number;
// Allow indexing with string
[key: string]: HysteresisThreshold | number | undefined;
}
export interface CustomAlertRule {
id: string;
name: string;
description?: string;
filterConditions: FilterStack;
thresholds: AlertThresholds;
priority: number;
enabled: boolean;
notifications: {
email?: {
enabled: boolean;
recipients: string[];
};
webhook?: {
enabled: boolean;
url: string;
};
};
createdAt: string;
updatedAt: string;
}
export interface AlertConfig {
enabled: boolean;
guestDefaults: AlertThresholds;
nodeDefaults: AlertThresholds;
storageDefault: HysteresisThreshold;
customRules?: CustomAlertRule[];
overrides: Record<string, AlertThresholds>; // key: resource ID
minimumDelta?: number;
suppressionWindow?: number;
hysteresisMargin?: number;
timeThreshold?: number;
notifications?: {
email?: {
server: string;
port: number;
username: string;
password: string;
from: string;
tls: boolean;
};
webhooks?: Array<{
id: string;
name: string;
url: string;
enabled: boolean;
}>;
};
schedule?: {
enabled?: boolean;
quietHours?: {
enabled: boolean;
start: string;
end: string;
days: number[] | Record<string, boolean>;
};
cooldown?: number;
groupingWindow?: number;
};
}
// Priority levels:
// 0: Global defaults
// 1-99: Reserved for system rules
// 100+: Custom user rules
// 1000+: Guest-specific overrides
@@ -1,361 +0,0 @@
// Properly typed TypeScript interfaces for Pulse API
export interface State {
nodes: Node[];
vms: VM[];
containers: Container[];
storage: Storage[];
pbs: PBSInstance[];
pbsBackups: PBSBackup[];
metrics: Metric[];
pveBackups: PVEBackups;
performance: Performance;
connectionHealth: Record<string, boolean>;
stats: Stats;
activeAlerts: Alert[];
recentlyResolved: ResolvedAlert[];
lastUpdate: string;
}
export interface Node {
id: string;
name: string;
instance: string;
host: string;
status: string;
type: string;
cpu: number;
memory: Memory;
disk: Disk;
uptime: number;
loadAverage: number[];
kernelVersion: string;
pveVersion: string;
cpuInfo: CPUInfo;
lastSeen: string;
connectionHealth: string;
}
export interface VM {
id: string;
vmid: number;
name: string;
node: string;
instance: string;
status: string;
type: string;
cpu: number;
cpus: number;
memory: Memory;
disk: Disk;
networkIn: number;
networkOut: number;
diskRead: number;
diskWrite: number;
uptime: number;
template: boolean;
lastBackup: string;
tags: string[];
lock: string;
lastSeen: string;
}
export interface Container {
id: string;
vmid: number;
name: string;
node: string;
instance: string;
status: string;
type: string;
cpu: number;
cpus: number;
memory: Memory;
disk: Disk;
networkIn: number;
networkOut: number;
diskRead: number;
diskWrite: number;
uptime: number;
template: boolean;
lastBackup: string;
tags: string[];
lock: string;
lastSeen: string;
}
export interface Storage {
id: string;
name: string;
node: string;
instance: string;
type: string;
status: string;
total: number;
used: number;
free: number;
usage: number;
content: string;
shared: boolean;
enabled: boolean;
active: boolean;
}
export interface PBSInstance {
id: string;
name: string;
host: string;
status: string;
version: string;
cpu: number;
memory: number;
memoryUsed: number;
memoryTotal: number;
uptime: number;
datastores: PBSDatastore[];
backupJobs: PBSBackupJob[];
syncJobs: PBSSyncJob[];
verifyJobs: PBSVerifyJob[];
pruneJobs: PBSPruneJob[];
garbageJobs: PBSGarbageJob[];
connectionHealth: string;
lastSeen: string;
}
export interface PBSDatastore {
name: string;
total: number;
used: number;
free: number;
usage: number;
status: string;
error: string;
namespaces: PBSNamespace[];
}
export interface PBSNamespace {
path: string;
parent: string;
depth: number;
}
export interface PBSBackup {
id: string;
instance: string;
datastore: string;
namespace: string;
backupType: string;
vmid: string;
backupTime: string;
size: number;
protected: boolean;
verified: boolean;
comment: string;
files: string[];
}
export interface PBSBackupJob {
id: string;
store: string;
type: string;
vmid: string;
lastBackup: string;
nextRun: string;
status: string;
error: string;
}
export interface PBSSyncJob {
id: string;
store: string;
remote: string;
status: string;
lastSync: string;
nextRun: string;
error: string;
}
export interface PBSVerifyJob {
id: string;
store: string;
status: string;
lastVerify: string;
nextRun: string;
error: string;
}
export interface PBSPruneJob {
id: string;
store: string;
status: string;
lastPrune: string;
nextRun: string;
error: string;
}
export interface PBSGarbageJob {
id: string;
store: string;
status: string;
lastGarbage: string;
nextRun: string;
removedBytes: number;
error: string;
}
export interface Memory {
total: number;
used: number;
free: number;
usage: number;
}
export interface Disk {
total: number;
used: number;
free: number;
usage: number;
}
export interface CPUInfo {
model: string;
cores: number;
sockets: number;
mhz: string;
}
export interface Metric {
timestamp: string;
type: string;
id: string;
values: Record<string, number | string | boolean>;
}
export interface BackupTask {
id: string;
node: string;
type: string;
vmid: number;
status: string;
startTime: string;
endTime?: string;
size?: number;
error?: string;
}
export interface StorageBackup {
id: string;
storage: string;
node: string;
type: string;
vmid: number;
time: string;
ctime: number;
size: number;
format: string;
notes?: string;
protected: boolean;
volid: string;
isPBS: boolean;
verified: boolean;
verification?: string;
}
export interface PVEBackups {
backupTasks: BackupTask[];
storageBackups: StorageBackup[];
guestSnapshots: GuestSnapshot[];
}
export interface GuestSnapshot {
id: string;
name: string;
node: string;
type: string;
vmid: number;
time: string;
description: string;
parent: string;
vmstate: boolean;
}
export interface Performance {
apiCallDuration: Record<string, number>;
lastPollDuration: number;
pollingStartTime: string;
totalApiCalls: number;
failedApiCalls: number;
cacheHits?: number;
cacheMisses?: number;
}
export interface Stats {
startTime: string;
uptime: number;
pollingCycles: number;
webSocketClients: number;
version: string;
}
// Alert types
export interface Alert {
id: string;
type: string;
level: 'warning' | 'critical';
resourceId: string;
resourceName: string;
node: string;
instance: string;
message: string;
value: number;
threshold: number;
startTime: string;
lastSeen?: string;
acknowledged: boolean;
ackTime?: string;
ackUser?: string;
metadata?: Record<string, unknown>;
}
export interface ResolvedAlert extends Alert {
resolvedTime: string;
}
// WebSocket message types
export type WSMessage =
| { type: 'initialState'; data: State }
| { type: 'rawData'; data: State }
| { type: 'error'; error: string }
| { type: 'ping'; data?: unknown }
| { type: 'pong'; data?: unknown }
| { type: 'welcome'; data?: unknown }
| { type: 'alert'; data: Alert }
| { type: 'alertResolved'; data: { alertId: string } }
| { type: 'update:progress'; data: any }
| { type: 'node_auto_registered'; data: {
type: string;
host: string;
name: string;
tokenId: string;
hasToken: boolean;
verifySSL?: boolean;
status?: string;
}}
| { type: 'node_deleted'; data: { nodeType: string } }
| { type: 'nodes_changed'; data?: unknown }
| { type: 'discovery_update'; data: {
servers: Array<{
ip: string;
port: number;
type: string;
version: string;
hostname?: string;
release?: string;
}>;
errors?: string[];
timestamp?: number;
}};
// Utility types
export type Status = 'running' | 'stopped' | 'paused' | 'unknown';
export type GuestType = 'qemu' | 'lxc';
@@ -1,94 +0,0 @@
// Unified backup types for the backup view
export interface UnifiedBackup {
// Common fields
backupType: 'backup' | 'snapshot' | 'pbs';
vmid: number;
name: string;
type: 'VM' | 'LXC' | 'CT';
node: string;
backupTime: number; // Unix timestamp in seconds
backupName: string;
description: string;
status: string;
size: number | null;
storage: string | null;
// PBS specific
datastore: string | null;
namespace: string | null;
verified: boolean | null;
// Common flags
protected: boolean;
// UI specific
instance?: string;
isPBS?: boolean;
}
// PBS-specific backup file info
export interface PBSBackupFile {
filename: string;
size: number;
crypt?: string;
}
// Extended PBS backup with file details
export interface PBSBackupWithFiles {
id: string;
instance: string;
datastore: string;
namespace?: string;
backupType: string;
vmid: number;
backupTime: string;
size: number;
protected: boolean;
verified: boolean;
comment?: string;
files: PBSBackupFile[];
}
// PBS datastore with snapshots
export interface PBSDatastoreSnapshot {
id: string;
backupTime: string;
size: number;
owner?: string;
verified?: boolean;
protected?: boolean;
files?: PBSBackupFile[];
}
export interface PBSDatastore {
name: string;
total: number;
used: number;
free: number;
snapshots: PBSDatastoreSnapshot[];
}
export interface PBSInstanceData {
id: string;
name: string;
host: string;
backups: PBSBackupWithFiles[];
datastores: PBSDatastore[];
}
// Filter options for the backup view
export interface BackupFilters {
instance: string;
type: 'all' | 'VM' | 'LXC';
node: string;
storage: string;
protected: 'all' | 'protected' | 'unprotected';
verified: 'all' | 'verified' | 'unverified';
}
// Sorting options
export interface BackupSort {
key: keyof UnifiedBackup;
order: 'asc' | 'desc';
}
@@ -1,141 +0,0 @@
// Monitoring data types
export interface CPUInfo {
usage: number;
cores: number;
sockets: number;
model: string;
}
export interface MemoryInfo {
used: number;
total: number;
free: number;
usage: number;
}
export interface DiskInfo {
used: number;
total: number;
free: number;
usage: number;
device?: string;
filesystem?: string;
}
export interface NetworkInfo {
in: number;
out: number;
device?: string;
}
export interface Node {
id: string;
name: string;
type: string;
status: 'online' | 'offline' | 'unknown';
instance: string;
uptime: number;
cpu: CPUInfo;
memory: MemoryInfo;
disk?: DiskInfo;
network?: NetworkInfo;
loadAverage?: [number, number, number];
kernelVersion?: string;
}
export interface Guest {
id: string;
vmid: number;
name: string;
node: string;
type: 'qemu' | 'lxc';
status: 'running' | 'stopped' | 'paused' | 'suspended';
instance: string;
uptime?: number;
cpu: {
usage: number;
cores: number;
limit?: number;
};
memory: {
used: number;
total: number;
usage: number;
};
disk: {
read: number;
write: number;
used?: number;
total?: number;
};
network: {
in: number;
out: number;
};
template?: boolean;
haState?: string;
protection?: boolean;
tags?: string[];
}
export type VM = Guest & { type: 'qemu' };
export type Container = Guest & { type: 'lxc' };
export interface Storage {
id: string;
name: string;
type: string;
status: 'online' | 'offline' | 'unknown';
node: string;
instance: string;
used: number;
total: number;
available: number;
usage: number;
content: string[];
shared?: boolean;
enabled?: boolean;
}
export interface Backup {
id: string;
vmid: number;
name: string;
type: 'VM' | 'CT';
size: number;
backupTime: number;
node: string;
storage?: string;
instance: string;
notes?: string;
protected?: boolean;
encrypted?: boolean;
verified?: boolean;
format?: string;
}
export interface PBSBackup extends Backup {
datastore: string;
snapshotTime: number;
owner?: string;
fingerprint?: string;
}
export interface Alert {
id: string;
resourceId: string;
resourceName: string;
node: string;
instance: string;
type: string;
level: 'warning' | 'critical';
message: string;
value: number;
threshold: number;
startTime: string;
lastSeen?: string;
acknowledged: boolean;
acknowledgedBy?: string;
acknowledgedAt?: string;
}
@@ -1,69 +0,0 @@
// Node configuration types
export interface ClusterEndpoint {
NodeID: string;
NodeName: string;
Host: string;
IP: string;
Online: boolean;
LastSeen: string;
}
export interface PVENodeConfig {
id: string;
name: string;
host: string;
user: string;
hasPassword?: boolean;
hasToken?: boolean;
tokenName?: string;
tokenValue?: string;
password?: string;
verifySSL: boolean;
monitorVMs: boolean;
monitorContainers: boolean;
monitorStorage: boolean;
monitorBackups: boolean;
// Cluster information
isCluster?: boolean;
clusterName?: string;
clusterEndpoints?: ClusterEndpoint[];
}
export interface PBSNodeConfig {
id: string;
name: string;
host: string;
user: string;
hasPassword?: boolean;
hasToken?: boolean;
tokenName?: string;
tokenValue?: string;
password?: string;
fingerprint?: string;
verifySSL: boolean;
monitorDatastores: boolean;
monitorSyncJobs: boolean;
monitorVerifyJobs: boolean;
monitorPruneJobs: boolean;
monitorGarbageJobs: boolean;
}
export type NodeConfig = (PVENodeConfig | PBSNodeConfig) & {
type: 'pve' | 'pbs';
status?: 'connected' | 'disconnected' | 'error';
};
export interface NodesResponse {
pve_instances: PVENodeConfig[];
pbs_instances: PBSNodeConfig[];
}
export interface NodeUpdateRequest {
node: NodeConfig;
}
export interface NodeDeleteResponse {
success: boolean;
message: string;
}
@@ -1,67 +0,0 @@
// Settings API Types - Keep in sync with Go backend
export interface ServerSettings {
backend: {
port: number;
host: string;
};
frontend: {
port: number;
host: string;
};
}
export interface MonitoringSettings {
pollingInterval: number;
concurrentPolling: boolean;
backupPollingCycles: number;
metricsRetentionDays: number;
}
export interface LoggingSettings {
level: string;
file: string;
maxSize: number;
maxBackups: number;
maxAge: number;
compress: boolean;
}
export interface SecuritySettings {
apiToken: string;
allowedOrigins: string[];
iframeEmbedding: string;
enableAuthentication: boolean;
}
export interface Settings {
server: ServerSettings;
monitoring: MonitoringSettings;
logging: LoggingSettings;
security: SecuritySettings;
}
export interface SettingsCapabilities {
canRestart: boolean;
canValidatePorts: boolean;
requiresRestart: boolean;
}
export interface SettingsResponse {
current: Settings;
defaults: Settings;
capabilities: SettingsCapabilities;
}
export interface SettingsUpdateRequest {
server?: Partial<ServerSettings>;
monitoring?: Partial<MonitoringSettings>;
logging?: Partial<LoggingSettings>;
security?: Partial<SecuritySettings>;
}
export interface SettingsUpdateResponse {
success: boolean;
requiresRestart: boolean;
message?: string;
}
@@ -1,60 +0,0 @@
import type { Alert } from '@/types/api';
// Get alert highlighting styles based on active alerts for a resource
export const getAlertStyles = (
resourceId: string,
activeAlerts: Record<string, Alert>
) => {
// Find the highest severity alert for this resource
let highestSeverity: 'critical' | 'warning' | null = null;
let alertCount = 0;
Object.values(activeAlerts).forEach(alert => {
if (alert.resourceId === resourceId) {
alertCount++;
if (alert.level === 'critical' || (alert.level === 'warning' && highestSeverity !== 'critical')) {
highestSeverity = alert.level;
}
}
});
// Return appropriate styling based on alert severity
if (highestSeverity === 'critical') {
return {
rowClass: 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500',
indicatorClass: 'bg-red-500',
badgeClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
hasAlert: true,
alertCount,
severity: 'critical' as const
};
}
if (highestSeverity === 'warning') {
return {
rowClass: 'bg-orange-50 dark:bg-orange-900/20 border-l-4 border-orange-500',
indicatorClass: 'bg-orange-500',
badgeClass: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
hasAlert: true,
alertCount,
severity: 'warning' as const
};
}
return {
rowClass: '',
indicatorClass: '',
badgeClass: '',
hasAlert: false,
alertCount: 0,
severity: null
};
};
// Get alert messages for a specific resource
export const getResourceAlerts = (
resourceId: string,
activeAlerts: Record<string, Alert>
): Alert[] => {
return Object.values(activeAlerts).filter(alert => alert.resourceId === resourceId);
};
@@ -1,24 +0,0 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
}
} catch (err) {
console.error('Failed to copy to clipboard:', err);
return false;
}
}
@@ -1,55 +0,0 @@
import { logger } from './logger';
export interface ErrorContext {
component?: string;
action?: string;
data?: any;
}
export class AppError extends Error {
public readonly context: ErrorContext;
public readonly isOperational: boolean;
constructor(message: string, context: ErrorContext = {}, isOperational = true) {
super(message);
this.name = 'AppError';
this.context = context;
this.isOperational = isOperational;
}
}
export function handleError(error: unknown, context: ErrorContext = {}): void {
if (error instanceof AppError) {
logger.error(`[${context.component || 'Unknown'}] ${error.message}`, {
...error.context,
...context,
error: error.stack
});
} else if (error instanceof Error) {
logger.error(`[${context.component || 'Unknown'}] ${error.message}`, {
...context,
error: error.stack
});
} else {
logger.error(`[${context.component || 'Unknown'}] Unknown error`, {
...context,
error: String(error)
});
}
}
export function handleAsyncError<T>(
promise: Promise<T>,
context: ErrorContext = {}
): Promise<T> {
return promise.catch((error) => {
handleError(error, context);
throw error;
});
}
export function createErrorBoundary(component: string) {
return (action: string) => (error: unknown) => {
handleError(error, { component, action });
};
}
@@ -1,75 +0,0 @@
// Type-safe formatting utilities
export function formatBytes(bytes: number, decimals = 0): string {
if (!bytes || bytes < 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(decimals)} ${sizes[i]}`;
}
export function formatSpeed(bytesPerSecond: number, decimals = 0): string {
if (!bytesPerSecond || bytesPerSecond < 0) return '0 B/s';
return `${formatBytes(bytesPerSecond, decimals)}/s`;
}
export function formatUptime(seconds: number): string {
if (!seconds || seconds < 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
}
export function formatAbsoluteTime(timestamp: number): string {
if (!timestamp) return '';
const date = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getMonth()];
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day} ${month} ${hours}:${minutes}`;
}
export function formatRelativeTime(timestamp: number): string {
if (!timestamp) return '';
const now = Date.now();
const diffMs = now - timestamp;
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffSeconds < 60) {
return diffSeconds <= 1 ? 'just now' : `${diffSeconds}s ago`;
} else if (diffMinutes < 60) {
return diffMinutes === 1 ? '1 min ago' : `${diffMinutes} mins ago`;
} else if (diffHours < 24) {
return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`;
} else if (diffDays < 30) {
return diffDays === 1 ? '1 day ago' : `${diffDays} days ago`;
} else if (diffMonths < 12) {
return diffMonths === 1 ? '1 month ago' : `${diffMonths} months ago`;
} else {
return diffYears === 1 ? '1 year ago' : `${diffYears} years ago`;
}
}
@@ -1,98 +0,0 @@
import { createSignal, createEffect, Signal } from 'solid-js';
/**
* Creates a signal that syncs with localStorage
* @param key - The localStorage key
* @param defaultValue - Default value if nothing in localStorage
* @param parse - Optional parser function for complex types
* @param stringify - Optional stringify function for complex types
*/
export function createLocalStorageSignal<T>(
key: string,
defaultValue: T,
parse?: (value: string) => T,
stringify?: (value: T) => string
): Signal<T> {
// Get initial value from localStorage
const stored = localStorage.getItem(key);
const initialValue = stored !== null
? (parse ? parse(stored) : stored as unknown as T)
: defaultValue;
const [value, setValue] = createSignal<T>(initialValue);
// Sync to localStorage on changes
createEffect(() => {
const val = value();
if (val === null || val === undefined) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, stringify ? stringify(val) : String(val));
}
});
return [value, setValue];
}
/**
* Creates a boolean signal that syncs with localStorage
* @param key - The localStorage key
* @param defaultValue - Default value if nothing in localStorage
*/
export function createLocalStorageBooleanSignal(
key: string,
defaultValue: boolean = false
): Signal<boolean> {
return createLocalStorageSignal(
key,
defaultValue,
(val) => val === 'true',
(val) => String(val)
);
}
/**
* Creates a number signal that syncs with localStorage
* @param key - The localStorage key
* @param defaultValue - Default value if nothing in localStorage
*/
export function createLocalStorageNumberSignal(
key: string,
defaultValue: number = 0
): Signal<number> {
return createLocalStorageSignal(
key,
defaultValue,
(val) => Number(val),
(val) => String(val)
);
}
/**
* Storage keys used throughout the application
*/
export const STORAGE_KEYS = {
// UI preferences
DARK_MODE: 'darkMode',
SIDEBAR_COLLAPSED: 'sidebarCollapsed',
// Alert settings
ALERT_HISTORY_TIME_FILTER: 'alertHistoryTimeFilter',
ALERT_HISTORY_SEVERITY_FILTER: 'alertHistorySeverityFilter',
// Storage settings
STORAGE_SHOW_FILTERS: 'storageShowFilters',
STORAGE_VIEW_MODE: 'storageViewMode',
// Backup settings
BACKUPS_SHOW_FILTERS: 'backupsShowFilters',
BACKUPS_USE_RELATIVE_TIME: 'backupsUseRelativeTime',
// Dashboard settings
DASHBOARD_SHOW_FILTERS: 'dashboardShowFilters',
DASHBOARD_CARD_VIEW: 'dashboardCardView',
DASHBOARD_AUTO_REFRESH: 'dashboardAutoRefresh',
// API token
API_TOKEN: 'apiToken',
} as const;
@@ -1,22 +0,0 @@
// Simple logger - just console.log with timestamps
const isDev = import.meta.env.DEV;
export const logger = {
debug: (message: string, data?: unknown) => {
if (isDev) console.log(`[DEBUG] ${message}`, data || '');
},
info: (message: string, data?: unknown) => {
console.log(`[INFO] ${message}`, data || '');
},
warn: (message: string, data?: unknown) => {
console.warn(`[WARN] ${message}`, data || '');
},
error: (message: string, error?: unknown) => {
console.error(`[ERROR] ${message}`, error || '');
}
};
export const logError = logger.error;
@@ -1,322 +0,0 @@
import type { VM, Container } from '@/types/api';
export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
export type LogicalOperator = 'AND' | 'OR';
export interface MetricCondition {
field: 'cpu' | 'memory' | 'disk' | 'diskRead' | 'diskWrite' | 'networkIn' | 'networkOut';
operator: ComparisonOperator;
value: number;
}
export interface TextCondition {
field: 'name' | 'node' | 'vmid';
value: string;
}
export type Condition = MetricCondition | TextCondition;
export interface ParsedQuery {
conditions: Condition[];
logicalOperator: LogicalOperator;
rawText?: string; // Fallback for simple text search
}
// New interfaces for stackable filters
export interface ParsedFilter {
type: 'metric' | 'text' | 'raw';
rawText?: string;
field?: string;
operator?: ComparisonOperator;
value?: string | number;
}
export interface FilterStack {
filters: ParsedFilter[];
operators: LogicalOperator[]; // Operators between filters (length = filters.length - 1)
logicalOperator?: LogicalOperator; // Deprecated, kept for compatibility
}
// Parse a single filter from a search term
export function parseFilter(term: string): ParsedFilter {
term = term.trim();
// Try to parse metric condition (e.g., "cpu>80")
const metricMatch = term.match(/^(cpu|memory|disk|diskRead|diskWrite|networkIn|networkOut)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i);
if (metricMatch) {
const [, field, operator, value] = metricMatch;
const parsedValue = parseFloat(value);
if (isNaN(parsedValue)) {
return {
type: 'raw',
rawText: term
};
}
return {
type: 'metric',
field: field.toLowerCase(),
operator: operator as ComparisonOperator,
value: parsedValue
};
}
// Try to parse text condition (e.g., "name:prod")
const textMatch = term.match(/^(name|node|vmid)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
return {
type: 'text',
field: field.toLowerCase(),
value: value.trim()
};
}
// Otherwise treat as raw text search
return {
type: 'raw',
rawText: term
};
}
// Parse multiple filters from a search string
export function parseFilterStack(searchString: string): FilterStack {
const trimmed = searchString.trim();
if (!trimmed) {
return { filters: [], operators: [] };
}
// Split by AND/OR operators while preserving them
const regex = /\s+(AND|OR)\s+/gi;
const parts = trimmed.split(regex);
const filters: ParsedFilter[] = [];
const operators: LogicalOperator[] = [];
// Process the parts
for (let i = 0; i < parts.length; i++) {
const part = parts[i].trim();
if (!part) continue;
if (i % 2 === 0) {
// Even indices are filter expressions
const filter = parseFilter(part);
filters.push(filter);
} else {
// Odd indices are operators
operators.push(part.toUpperCase() as LogicalOperator);
}
}
// For backward compatibility, include logicalOperator as the first operator
const logicalOperator = operators.length > 0 ? operators[0] : 'AND';
return { filters, operators, logicalOperator };
}
function parseCondition(conditionStr: string): Condition | null {
// Try to parse metric condition (e.g., "cpu>80")
const metricMatch = conditionStr.match(/^(cpu|memory|disk|diskRead|diskWrite|networkIn|networkOut)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i);
if (metricMatch) {
const [, field, operator, value] = metricMatch;
return {
field: field.toLowerCase() as MetricCondition['field'],
operator: operator as ComparisonOperator,
value: (() => {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
})()
} as MetricCondition;
}
// Try to parse text condition (e.g., "name:prod")
const textMatch = conditionStr.match(/^(name|node|vmid)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
return {
field: field.toLowerCase() as 'name' | 'node' | 'vmid',
value: value.trim()
} as TextCondition;
}
return null;
}
export function parseSearchQuery(query: string): ParsedQuery {
query = query.trim();
// Check for logical operators
const hasAnd = /\bAND\b/i.test(query);
const hasOr = /\bOR\b/i.test(query);
// If no operators or invalid query, treat as simple text search
if (!hasAnd && !hasOr && !query.match(/[><=:]/)) {
return {
conditions: [],
logicalOperator: 'AND',
rawText: query
};
}
// Split by logical operator
const logicalOperator: LogicalOperator = hasAnd ? 'AND' : 'OR';
const parts = query.split(hasAnd ? /\bAND\b/i : /\bOR\b/i);
const conditions: Condition[] = [];
for (const part of parts) {
const condition = parseCondition(part.trim());
if (condition) {
conditions.push(condition);
}
}
// If no valid conditions parsed, fall back to text search
if (conditions.length === 0) {
return {
conditions: [],
logicalOperator: 'AND',
rawText: query
};
}
return {
conditions,
logicalOperator
};
}
function evaluateMetricCondition(guest: VM | Container, condition: MetricCondition): boolean {
let value: number;
switch (condition.field) {
case 'cpu':
// CPU is stored as decimal (0-1), convert to percentage
value = (guest.cpu || 0) * 100;
break;
case 'memory':
value = guest.memory ? guest.memory.usage : 0;
break;
case 'disk':
value = guest.disk ? guest.disk.usage : 0;
break;
default:
return false;
}
switch (condition.operator) {
case '>':
return value > condition.value;
case '<':
return value < condition.value;
case '>=':
return value >= condition.value;
case '<=':
return value <= condition.value;
case '=':
case '==':
return Math.abs(value - condition.value) < 0.01;
default:
return false;
}
}
function evaluateTextCondition(guest: VM | Container, condition: TextCondition): boolean {
const searchValue = condition.value.toLowerCase();
switch (condition.field) {
case 'name':
return guest.name.toLowerCase().includes(searchValue);
case 'node':
return guest.node.toLowerCase().includes(searchValue);
case 'vmid':
return guest.vmid.toString().includes(searchValue);
default:
return false;
}
}
export function evaluateSearchQuery(guest: VM | Container, query: ParsedQuery): boolean {
// If it's a simple text search
if (query.rawText) {
const searchTerms = query.rawText.toLowerCase().split(',').map(term => term.trim()).filter(term => term.length > 0);
return searchTerms.some(term =>
guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term)
);
}
// If no conditions, match all
if (query.conditions.length === 0) {
return true;
}
// Evaluate conditions
const results = query.conditions.map(condition => {
if ('operator' in condition) {
return evaluateMetricCondition(guest, condition);
} else {
return evaluateTextCondition(guest, condition);
}
});
// Apply logical operator
if (query.logicalOperator === 'AND') {
return results.every(result => result);
} else {
return results.some(result => result);
}
}
// Evaluate a filter stack against a guest
export function evaluateFilterStack(guest: VM | Container, stack: FilterStack): boolean {
if (stack.filters.length === 0) {
return true;
}
const results = stack.filters.map(filter => {
if (filter.type === 'metric' && filter.field && filter.operator && filter.value !== undefined) {
const condition: MetricCondition = {
field: filter.field as MetricCondition['field'],
operator: filter.operator,
value: filter.value as number
};
return evaluateMetricCondition(guest, condition);
} else if (filter.type === 'text' && filter.field && filter.value) {
const condition: TextCondition = {
field: filter.field as TextCondition['field'],
value: filter.value as string
};
return evaluateTextCondition(guest, condition);
} else if (filter.type === 'raw' && filter.rawText) {
const term = filter.rawText.toLowerCase();
return guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term);
}
return true;
});
// If only one filter, return its result
if (results.length === 1) {
return results[0];
}
// Apply operators between filters
let result = results[0];
for (let i = 0; i < stack.operators.length && i < results.length - 1; i++) {
const operator = stack.operators[i];
const nextResult = results[i + 1];
if (operator === 'AND') {
result = result && nextResult;
} else {
result = result || nextResult;
}
}
return result;
}
@@ -1,18 +0,0 @@
import type { ToastType } from '@/components/Toast/Toast';
// Global declaration is in Toast.tsx
export const showToast = (type: ToastType, title: string, message?: string, duration?: number) => {
// Use the global toast function exposed by ToastContainer
if (typeof window !== 'undefined' && window.showToast) {
window.showToast(type, title, message, duration);
} else {
// Fallback to console if toast system not ready
console.log(`[${type.toUpperCase()}] ${title}${message ? ': ' + message : ''}`);
}
};
// Convenience functions - only export what's used
export const showSuccess = (title: string, message?: string) => showToast('success', title, message);
export const showError = (title: string, message?: string) => showToast('error', title, message);
export const showInfo = (title: string, message?: string) => showToast('info', title, message);
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
@@ -1,20 +0,0 @@
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
gray: {
750: '#2d3748',
}
},
animation: {
'spin-slow': 'spin 2s linear infinite',
}
},
},
plugins: [],
}
@@ -1,30 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
@@ -1,35 +0,0 @@
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
import path from 'path';
export default defineConfig({
plugins: [solid()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 7655,
host: '0.0.0.0', // Listen on all interfaces for remote access
proxy: {
'/ws': {
target: 'ws://127.0.0.1:3000',
ws: true,
changeOrigin: true,
},
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
build: {
target: 'esnext',
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});