mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 19:23:31 +00:00
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:
@@ -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 />
|
||||
|
||||
@@ -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*
|
||||
@@ -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 |
@@ -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, '&') // Encode ampersands
|
||||
.replace(/</g, '<') // Encode less than
|
||||
.replace(/>/g, '>') // Encode greater than
|
||||
.replace(/"/g, '"') // Encode quotes
|
||||
.replace(/'/g, '''); // 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;
|
||||
}
|
||||
@@ -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 +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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user