chore: tidy repo formatting and linting

This commit is contained in:
rcourtman
2025-09-29 20:19:18 +00:00
parent 657fa02142
commit eb82745638
171 changed files with 16834 additions and 11083 deletions
+57
View File
@@ -0,0 +1,57 @@
name: CI
on:
push:
branches:
- main
- master
pull_request:
jobs:
backend:
name: Backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify gofmt
run: |
fmt_out=$(gofmt -l ./cmd ./internal ./pkg)
if [ -n "$fmt_out" ]; then
echo "The following files are not gofmt formatted:"
echo "$fmt_out"
exit 1
fi
- name: Go vet
run: go vet ./...
- name: Go test
run: go test ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: ./...
frontend:
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend-modern
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend-modern/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Format check
run: npm run format:check
+20
View File
@@ -0,0 +1,20 @@
run:
timeout: 5m
tests: true
linters:
disable-all: true
enable:
- govet
- gofmt
- goimports
- errcheck
issues:
max-same-issues: 0
max-issues-per-linter: 0
linters-settings:
gofmt:
simplify: true
errcheck:
exclude-functions:
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
+19 -1
View File
@@ -1,6 +1,6 @@
# Pulse Makefile for development
.PHONY: build run dev frontend backend all clean dev-hot
.PHONY: build run dev frontend backend all clean dev-hot lint lint-backend lint-frontend format format-backend format-frontend
# Build everything
all: frontend backend
@@ -43,3 +43,21 @@ clean:
# Quick rebuild and restart for development
restart: frontend backend
sudo systemctl restart pulse-backend
# Run linters for both backend and frontend
lint: lint-backend lint-frontend
lint-backend:
golangci-lint run ./...
lint-frontend:
cd frontend-modern && npm run lint
# Apply formatters
format: format-backend format-frontend
format-backend:
gofmt -w cmd internal pkg
format-frontend:
cd frontend-modern && npm run format
+5 -5
View File
@@ -15,10 +15,10 @@ import (
)
var (
exportFile string
importFile string
passphrase string
forceImport bool
exportFile string
importFile string
passphrase string
forceImport bool
)
var configCmd = &cobra.Command{
@@ -267,4 +267,4 @@ func init() {
configImportCmd.Flags().StringVarP(&importFile, "input", "i", "", "Input file with encrypted configuration")
configImportCmd.Flags().StringVarP(&passphrase, "passphrase", "p", "", "Passphrase for decryption (or use PULSE_PASSPHRASE env var)")
configImportCmd.Flags().BoolVarP(&forceImport, "force", "f", false, "Force import without confirmation")
}
}
+24 -24
View File
@@ -30,9 +30,9 @@ var (
)
var rootCmd = &cobra.Command{
Use: "pulse",
Short: "Pulse - Proxmox VE and PBS monitoring system",
Long: `Pulse is a real-time monitoring system for Proxmox Virtual Environment (PVE) and Proxmox Backup Server (PBS)`,
Use: "pulse",
Short: "Pulse - Proxmox VE and PBS monitoring system",
Long: `Pulse is a real-time monitoring system for Proxmox Virtual Environment (PVE) and Proxmox Backup Server (PBS)`,
Version: Version,
Run: func(cmd *cobra.Command, args []string) {
runServer()
@@ -151,7 +151,7 @@ func runServer() {
}
defer configWatcher.Stop()
}
// Start server
go func() {
if cfg.HTTPSEnabled && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
@@ -181,23 +181,23 @@ func runServer() {
// Setup signal handlers
sigChan := make(chan os.Signal, 1)
reloadChan := make(chan os.Signal, 1)
// SIGTERM and SIGINT for shutdown
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// SIGHUP for config reload
signal.Notify(reloadChan, syscall.SIGHUP)
// Handle signals
for {
select {
case <-reloadChan:
log.Info().Msg("Received SIGHUP, reloading configuration...")
// Reload .env manually (watcher will also pick it up)
if configWatcher != nil {
configWatcher.ReloadConfig()
}
// Reload system.json
persistence := config.NewConfigPersistence(cfg.DataPath)
if persistence != nil {
@@ -210,17 +210,17 @@ func runServer() {
log.Error().Err(err).Msg("Failed to reload system.json")
}
}
// Could reload other configs here (alerts.json, webhooks.json, etc.)
log.Info().Msg("Configuration reload complete")
case <-sigChan:
log.Info().Msg("Shutting down server...")
goto shutdown
}
}
shutdown:
// Graceful shutdown
@@ -234,7 +234,7 @@ shutdown:
// Stop monitoring
cancel()
reloadableMonitor.Stop()
// Stop config watcher
if configWatcher != nil {
configWatcher.Stop()
@@ -250,15 +250,15 @@ func shouldAutoImport() bool {
if configPath == "" {
configPath = "/etc/pulse"
}
// If nodes.enc already exists, skip auto-import
if _, err := os.Stat(filepath.Join(configPath, "nodes.enc")); err == nil {
return false
}
// Check for auto-import environment variables
return os.Getenv("PULSE_INIT_CONFIG_DATA") != "" ||
os.Getenv("PULSE_INIT_CONFIG_FILE") != ""
return os.Getenv("PULSE_INIT_CONFIG_DATA") != "" ||
os.Getenv("PULSE_INIT_CONFIG_FILE") != ""
}
// performAutoImport imports configuration from environment variables
@@ -266,13 +266,13 @@ func performAutoImport() error {
configData := os.Getenv("PULSE_INIT_CONFIG_DATA")
configFile := os.Getenv("PULSE_INIT_CONFIG_FILE")
configPass := os.Getenv("PULSE_INIT_CONFIG_PASSPHRASE")
if configPass == "" {
return fmt.Errorf("PULSE_INIT_CONFIG_PASSPHRASE is required for auto-import")
}
var encryptedData string
// Get data from file or direct data
if configFile != "" {
data, err := os.ReadFile(configFile)
@@ -290,21 +290,21 @@ func performAutoImport() error {
} else {
return fmt.Errorf("no config data provided")
}
// Load configuration path
configPath := os.Getenv("PULSE_DATA_DIR")
if configPath == "" {
configPath = "/etc/pulse"
}
// Create persistence manager
persistence := config.NewConfigPersistence(configPath)
// Import configuration
if err := persistence.ImportConfig(encryptedData, configPass); err != nil {
return fmt.Errorf("failed to import configuration: %w", err)
}
log.Info().Msg("Configuration auto-imported successfully")
return nil
}
+3
View File
@@ -0,0 +1,3 @@
dist
node_modules
public
+41
View File
@@ -0,0 +1,41 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:solid/typescript',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'solid'],
ignorePatterns: ['dist', 'node_modules'],
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-case-declarations': 'off',
'no-useless-escape': 'off',
'prefer-const': 'off',
'solid/reactivity': 'off',
'solid/prefer-for': 'off',
'solid/style-prop': 'off',
'solid/components-return-once': 'off',
'solid/self-closing-comp': 'off',
},
};
+3
View File
@@ -0,0 +1,3 @@
dist
node_modules
public
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100
}
+1431
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -18,7 +18,11 @@
"build": "vite build",
"preview": "vite preview",
"generate-types": "cd ../scripts && go run generate-types.go",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\""
},
"dependencies": {
"@solidjs/router": "^0.10.0",
@@ -27,8 +31,14 @@
},
"devDependencies": {
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-solid": "^0.14.0",
"postcss": "^8.4.0",
"prettier": "^3.3.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^6.3.5",
+418 -285
View File
@@ -1,4 +1,14 @@
import { Show, createSignal, createContext, useContext, createEffect, onMount, onCleanup, getOwner, runWithOwner } from 'solid-js';
import {
Show,
createSignal,
createContext,
useContext,
createEffect,
onMount,
onCleanup,
getOwner,
runWithOwner,
} from 'solid-js';
import { getGlobalWebSocketStore } from './stores/websocket-global';
import { Dashboard } from './components/Dashboard/Dashboard';
import StorageComponent from './components/Storage/Storage';
@@ -48,7 +58,9 @@ export const useDarkMode = () => {
function App() {
const owner = getOwner();
const acquireWsStore = (): EnhancedStore => {
const store = owner ? runWithOwner(owner, () => getGlobalWebSocketStore()) : getGlobalWebSocketStore();
const store = owner
? runWithOwner(owner, () => getGlobalWebSocketStore())
: getGlobalWebSocketStore();
return store || getGlobalWebSocketStore();
};
@@ -56,18 +68,22 @@ function App() {
const [isLoading, setIsLoading] = createSignal(true);
const [needsAuth, setNeedsAuth] = createSignal(false);
const [hasAuth, setHasAuth] = createSignal(false);
const [proxyAuthInfo, setProxyAuthInfo] = createSignal<{ username?: string; logoutURL?: string } | null>(null);
const [proxyAuthInfo, setProxyAuthInfo] = createSignal<{
username?: string;
logoutURL?: string;
} | null>(null);
// Don't initialize WebSocket until after auth check
const [wsStore, setWsStore] = createSignal<EnhancedStore | null>(null);
const state = () => wsStore()?.state || { vms: [], containers: [], nodes: [], pbs: [], lastUpdate: '' };
const state = () =>
wsStore()?.state || { vms: [], containers: [], nodes: [], pbs: [], lastUpdate: '' };
const connected = () => wsStore()?.connected() || false;
const reconnecting = () => wsStore()?.reconnecting() || false;
// Data update indicator
const [dataUpdated, setDataUpdated] = createSignal(false);
let updateTimeout: number;
// Flash indicator when data updates
createEffect(() => {
// Watch for state changes
@@ -78,22 +94,24 @@ function App() {
updateTimeout = window.setTimeout(() => setDataUpdated(false), POLLING_INTERVALS.DATA_FLASH);
}
});
// Tab management with localStorage persistence
const savedTab = localStorage.getItem(STORAGE_KEYS.ACTIVE_TAB) as TabType;
const [activeTab, setActiveTab] = createSignal<TabType>(
savedTab && ['main', 'storage', 'backups', 'alerts', 'settings'].includes(savedTab) ? savedTab : 'main'
savedTab && ['main', 'storage', 'backups', 'alerts', 'settings'].includes(savedTab)
? savedTab
: 'main',
);
// Persist tab selection
const changeTab = (tab: TabType) => {
setActiveTab(tab);
localStorage.setItem(STORAGE_KEYS.ACTIVE_TAB, tab);
};
// Version info
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
// Dark mode - initialize immediately from localStorage to prevent flash
// This addresses issue #443 where dark mode wasn't persisting
// Priority: 1. localStorage (user's last choice on this device)
@@ -106,14 +124,14 @@ function App() {
: window.matchMedia('(prefers-color-scheme: dark)').matches;
const [darkMode, setDarkMode] = createSignal(initialDarkMode);
const [, setHasLoadedServerTheme] = createSignal(false);
// Apply dark mode immediately on initialization
if (initialDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Toggle dark mode
const toggleDarkMode = async () => {
const newMode = !darkMode();
@@ -125,7 +143,7 @@ function App() {
document.documentElement.classList.remove('dark');
}
logger.info('Theme changed', { mode: newMode ? 'dark' : 'light' });
// Save theme preference to server if authenticated
if (!needsAuth()) {
try {
@@ -137,20 +155,20 @@ function App() {
}
}
};
// Don't initialize dark mode here - will be handled based on auth state
// Listen for theme changes from other browser instances
onMount(() => {
const handleThemeChange = (theme?: string) => {
if (!theme) return;
logger.info('Received theme change from another browser instance', { theme });
const isDark = theme === 'dark';
// Update local state
setDarkMode(isDark);
localStorage.setItem(STORAGE_KEYS.DARK_MODE, String(isDark));
// Update DOM
if (isDark) {
document.documentElement.classList.add('dark');
@@ -158,20 +176,20 @@ function App() {
document.documentElement.classList.remove('dark');
}
};
// Subscribe to theme change events
eventBus.on('theme_changed', handleThemeChange);
// Cleanup on unmount
onCleanup(() => {
eventBus.off('theme_changed', handleThemeChange);
});
});
// Check auth on mount
onMount(async () => {
console.log('[App] Starting auth check...');
// Check if we just logged out - if so, always show login page
const justLoggedOut = localStorage.getItem('just_logged_out');
if (justLoggedOut) {
@@ -182,13 +200,13 @@ function App() {
setIsLoading(false);
return;
}
// First check security status to see if auth is configured
try {
const securityRes = await apiFetch('/api/security/status');
const securityData = await securityRes.json();
console.log('[App] Security status:', securityData);
// Check if auth is disabled via DISABLE_AUTH
if (securityData.disabled === true) {
console.log('[App] Auth is disabled via DISABLE_AUTH, skipping authentication');
@@ -196,7 +214,7 @@ function App() {
setNeedsAuth(false);
// Initialize WebSocket immediately since no auth needed
setWsStore(acquireWsStore());
// Load theme preference from server for cross-device sync
// Only use server preference if no local preference exists
if (!hasLocalPreference) {
@@ -217,34 +235,34 @@ function App() {
console.error('Failed to load theme from server:', error);
}
}
// Load version info even when auth is disabled
UpdatesAPI.getVersion()
.then(version => {
.then((version) => {
setVersionInfo(version);
// Check for updates after loading version info (non-blocking)
updateStore.checkForUpdates();
})
.catch(error => console.error('Failed to load version:', error));
.catch((error) => console.error('Failed to load version:', error));
setIsLoading(false);
return;
}
const authConfigured = securityData.hasAuthentication || false;
setHasAuth(authConfigured);
// Check for proxy auth
if (securityData.hasProxyAuth && securityData.proxyAuthUsername) {
console.log('[App] Proxy auth detected, user:', securityData.proxyAuthUsername);
setProxyAuthInfo({
username: securityData.proxyAuthUsername,
logoutURL: securityData.proxyAuthLogoutURL
logoutURL: securityData.proxyAuthLogoutURL,
});
setNeedsAuth(false);
// Initialize WebSocket for proxy auth users
setWsStore(acquireWsStore());
// Load theme preference from server for cross-device sync
// Only use server preference if no local preference exists
if (!hasLocalPreference) {
@@ -265,20 +283,20 @@ function App() {
console.error('Failed to load theme from server:', error);
}
}
// Load version info
UpdatesAPI.getVersion()
.then(version => {
.then((version) => {
setVersionInfo(version);
// Check for updates after loading version info (non-blocking)
updateStore.checkForUpdates();
})
.catch(error => console.error('Failed to load version:', error));
.catch((error) => console.error('Failed to load version:', error));
setIsLoading(false);
return;
}
// If no auth is configured, show FirstRunSetup
if (!authConfigured) {
console.log('[App] No auth configured, showing Login/FirstRunSetup');
@@ -286,22 +304,22 @@ function App() {
setIsLoading(false);
return;
}
// If auth is configured, check if we're authenticated
const stateRes = await apiFetch('/api/state', {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
Accept: 'application/json',
},
});
if (stateRes.status === 401) {
setNeedsAuth(true);
} else {
setNeedsAuth(false);
// Only initialize WebSocket after successful auth check
setWsStore(acquireWsStore());
// Load theme preference from server for cross-device sync
// Only use server preference if no local preference exists
if (!hasLocalPreference) {
@@ -331,26 +349,26 @@ function App() {
// On error, try to proceed without auth
setNeedsAuth(false);
setWsStore(acquireWsStore());
// Theme is already applied on initialization, no need to reapply
} finally {
setIsLoading(false);
}
// Load version info
UpdatesAPI.getVersion()
.then(version => {
.then((version) => {
setVersionInfo(version);
// Check for updates after loading version info (non-blocking)
updateStore.checkForUpdates();
})
.catch(error => console.error('Failed to load version:', error));
.catch((error) => console.error('Failed to load version:', error));
});
const handleLogin = () => {
window.location.reload();
};
const handleLogout = async () => {
// Check if we're using proxy auth with a logout URL
const proxyAuth = proxyAuthInfo();
@@ -359,26 +377,26 @@ function App() {
window.location.href = proxyAuth.logoutURL;
return;
}
try {
// Import the apiClient to get CSRF token support
const { apiFetch, clearAuth } = await import('./utils/apiClient');
// Clear any session data - this will include CSRF token
const response = await apiFetch('/api/logout', {
method: 'POST'
method: 'POST',
});
if (!response.ok) {
console.error('Logout failed:', response.status);
}
// Clear auth from apiClient
clearAuth();
} catch (error) {
console.error('Logout error:', error);
}
// Clear all local storage EXCEPT theme preference and logout flag
const currentTheme = localStorage.getItem(STORAGE_KEYS.DARK_MODE);
localStorage.clear();
@@ -388,12 +406,12 @@ function App() {
if (currentTheme) {
localStorage.setItem(STORAGE_KEYS.DARK_MODE, currentTheme);
}
// Clear WebSocket connection
if (wsStore()) {
setWsStore(null);
}
// Force reload to login page
window.location.href = '/';
};
@@ -403,243 +421,358 @@ function App() {
// Use Show for reactive rendering
return (
<Show
when={!isLoading()}
<Show
when={!isLoading()}
fallback={
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="text-gray-600 dark:text-gray-400">Loading...</div>
</div>
}
>
<Show
when={!needsAuth()}
fallback={<Login onLogin={handleLogin} />}
>
<Show when={!needsAuth()} fallback={<Login onLogin={handleLogin} />}>
<ErrorBoundary>
<Show when={enhancedStore()} fallback={<div>Initializing...</div>}>
<WebSocketContext.Provider value={enhancedStore()!}>
<DarkModeContext.Provider value={darkMode}>
<SecurityWarning />
<UpdateBanner />
<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'}
<Show when={enhancedStore()} fallback={<div>Initializing...</div>}>
<WebSocketContext.Provider value={enhancedStore()!}>
<DarkModeContext.Provider value={darkMode}>
<SecurityWarning />
<UpdateBanner />
<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>
<Show when={hasAuth() && !needsAuth()}>
<Show when={proxyAuthInfo()?.username}>
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
{proxyAuthInfo()?.username}
</span>
</Show>
<button
type="button"
onClick={handleLogout}
class="text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors flex items-center gap-1"
title="Logout"
>
<svg
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
<span>Logout</span>
</button>
</Show>
</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={() => changeTab('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={() => changeTab('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={() => changeTab('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={() => changeTab('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 relative ${
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={() => changeTab('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>
<Show when={updateStore.isUpdateVisible()}>
<span class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
</Show>
</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}
/>
</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>
<Show when={hasAuth() && !needsAuth()}>
<Show when={proxyAuthInfo()?.username}>
<span class="text-xs px-2 py-1 text-gray-600 dark:text-gray-400">
{proxyAuthInfo()?.username}
</span>
</Show>
<button type="button"
onClick={handleLogout}
class="text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors flex items-center gap-1"
title="Logout"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
</button>
</Show>
</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={() => changeTab('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={() => changeTab('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={() => changeTab('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={() => changeTab('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 relative ${
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={() => changeTab('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>
<Show when={updateStore.isUpdateVisible()}>
<span class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>
</Show>
</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}
/>
</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 />
</DarkModeContext.Provider>
</WebSocketContext.Provider>
</Show>
</ErrorBoundary>
<ToastContainer />
<NotificationContainer />
</DarkModeContext.Provider>
</WebSocketContext.Provider>
</Show>
</ErrorBoundary>
</Show>
</Show>
);
}
export default App;// Test hot-reload comment $(date)
export default App; // Test hot-reload comment $(date)
+17 -13
View File
@@ -7,45 +7,45 @@ export default function SimpleApp() {
onMount(() => {
setStatus('Testing API connection...');
// Test API
fetch('/api/health')
.then(res => {
.then((res) => {
setStatus(`API Status: ${res.status}`);
return res.json();
})
.then(d => {
.then((d) => {
setData(d);
setStatus('API Connected! Testing WebSocket...');
// Test WebSocket
const ws = new WebSocket(`ws://${window.location.host}/ws`);
ws.onopen = () => {
setWsStatus('WebSocket CONNECTED');
setStatus('Everything working!');
};
ws.onerror = (e) => {
setWsStatus('WebSocket ERROR');
console.error('WS Error:', e);
};
ws.onclose = (e) => {
setWsStatus(`WebSocket CLOSED: ${e.code} - ${e.reason}`);
};
ws.onmessage = (e) => {
setWsStatus('WebSocket receiving data!');
try {
const msg = JSON.parse(e.data);
setData(prev => ({ ...prev, lastMessage: msg.type }));
setData((prev) => ({ ...prev, lastMessage: msg.type }));
} catch (err) {
console.error('Parse error:', err);
}
};
})
.catch(err => {
.catch((err) => {
setStatus(`API Error: ${err}`);
console.error(err);
});
@@ -55,11 +55,15 @@ export default function SimpleApp() {
<div style={{ padding: '20px', 'font-family': 'monospace' }}>
<h1>Pulse System Test</h1>
<hr />
<p><strong>Status:</strong> {status()}</p>
<p><strong>WebSocket:</strong> {wsStatus()}</p>
<p>
<strong>Status:</strong> {status()}
</p>
<p>
<strong>WebSocket:</strong> {wsStatus()}
</p>
<hr />
<h3>API Data:</h3>
<pre>{JSON.stringify(data(), null, 2)}</pre>
</div>
);
}
}
+6 -4
View File
@@ -1,6 +1,8 @@
export default function Test() {
return <div style={{ padding: '20px', 'font-size': '24px' }}>
<h1>TEST - APP IS WORKING!</h1>
<p>If you see this, the basic app infrastructure works.</p>
</div>;
return (
<div style={{ padding: '20px', 'font-size': '24px' }}>
<h1>TEST - APP IS WORKING!</h1>
<p>If you see this, the basic app infrastructure works.</p>
</div>
);
}
+9 -4
View File
@@ -27,7 +27,7 @@ export class AlertsAPI {
}
});
}
return apiFetchJSON(`${this.baseUrl}/history?${queryParams}`);
}
@@ -70,17 +70,22 @@ export class AlertsAPI {
});
}
static async bulkAcknowledge(alertIds: string[], user?: string): Promise<{ results: Array<{ alertId: string; success: boolean; error?: string }> }> {
static async bulkAcknowledge(
alertIds: string[],
user?: string,
): Promise<{ results: Array<{ alertId: string; success: boolean; error?: string }> }> {
return apiFetchJSON(`${this.baseUrl}/bulk/acknowledge`, {
method: 'POST',
body: JSON.stringify({ alertIds, user }),
});
}
static async bulkClear(alertIds: string[]): Promise<{ results: Array<{ alertId: string; success: boolean; error?: string }> }> {
static async bulkClear(
alertIds: string[],
): Promise<{ results: Array<{ alertId: string; success: boolean; error?: string }> }> {
return apiFetchJSON(`${this.baseUrl}/bulk/clear`, {
method: 'POST',
body: JSON.stringify({ alertIds }),
});
}
}
}
+5 -3
View File
@@ -11,7 +11,6 @@ export interface GuestMetadata {
export class GuestMetadataAPI {
private static baseUrl = '/api/guests/metadata';
// Get metadata for a specific guest
static async getMetadata(guestId: string): Promise<GuestMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(guestId)}`);
@@ -23,7 +22,10 @@ export class GuestMetadataAPI {
}
// Update metadata for a guest
static async updateMetadata(guestId: string, metadata: Partial<GuestMetadata>): Promise<GuestMetadata> {
static async updateMetadata(
guestId: string,
metadata: Partial<GuestMetadata>,
): Promise<GuestMetadata> {
return apiFetchJSON(`${this.baseUrl}/${encodeURIComponent(guestId)}`, {
method: 'PUT',
body: JSON.stringify(metadata),
@@ -36,4 +38,4 @@ export class GuestMetadataAPI {
method: 'DELETE',
});
}
}
}
+1 -1
View File
@@ -20,4 +20,4 @@ export class MonitoringAPI {
const response = await apiFetch(`${this.baseUrl}/diagnostics/export`);
return response.blob();
}
}
}
+9 -6
View File
@@ -17,7 +17,10 @@ export class NodesAPI {
});
}
static async updateNode(nodeId: string, node: NodeConfig): Promise<{ success: boolean; message?: string }> {
static async updateNode(
nodeId: string,
node: NodeConfig,
): Promise<{ success: boolean; message?: string }> {
return apiFetchJSON(`${this.baseUrl}/${nodeId}`, {
method: 'PUT',
body: JSON.stringify(node),
@@ -30,9 +33,9 @@ export class NodesAPI {
});
}
static async testConnection(node: NodeConfig): Promise<{
static async testConnection(node: NodeConfig): Promise<{
status: string;
message?: string;
message?: string;
isCluster?: boolean;
nodeCount?: number;
clusterNodeCount?: number;
@@ -44,13 +47,13 @@ export class NodesAPI {
});
}
static async testExistingNode(nodeId: string): Promise<{
static async testExistingNode(nodeId: string): Promise<{
status: string;
message?: string;
message?: string;
latency?: number;
}> {
return apiFetchJSON(`${this.baseUrl}/${nodeId}/test`, {
method: 'POST',
});
}
}
}
+18 -12
View File
@@ -58,7 +58,7 @@ export interface Webhook {
export interface NotificationTestRequest {
type: 'email' | 'webhook';
config?: Record<string, unknown>; // Backend expects different format than frontend types
config?: Record<string, unknown>; // Backend expects different format than frontend types
webhookId?: string;
}
@@ -68,7 +68,7 @@ export class NotificationsAPI {
// Email configuration
static async getEmailConfig(): Promise<EmailConfig> {
const backendConfig = await apiFetchJSON<Record<string, unknown>>(`${this.baseUrl}/email`);
// Backend already returns fields with correct names (server, port)
return {
enabled: (backendConfig.enabled as boolean) || false,
@@ -80,7 +80,7 @@ export class NotificationsAPI {
from: (backendConfig.from as string) || '',
to: (backendConfig.to as string[]) || [],
tls: (backendConfig.tls as boolean) || false,
startTLS: (backendConfig.startTLS as boolean) || false
startTLS: (backendConfig.startTLS as boolean) || false,
};
}
@@ -96,9 +96,9 @@ export class NotificationsAPI {
to: config.to,
tls: config.tls || false,
startTLS: config.startTLS || false,
provider: config.provider || ''
provider: config.provider || '',
};
return apiFetchJSON(`${this.baseUrl}/email`, {
method: 'PUT',
body: JSON.stringify(backendConfig),
@@ -141,29 +141,35 @@ export class NotificationsAPI {
}
// Testing
static async testNotification(request: NotificationTestRequest): Promise<{ success: boolean; message?: string }> {
const body: { method: string; config?: Record<string, unknown>; webhookId?: string } = { method: request.type };
static async testNotification(
request: NotificationTestRequest,
): Promise<{ success: boolean; message?: string }> {
const body: { method: string; config?: Record<string, unknown>; webhookId?: string } = {
method: request.type,
};
// Include config if provided for testing without saving
if (request.config) {
body.config = request.config;
}
// Include webhookId for webhook testing
if (request.webhookId) {
body.webhookId = request.webhookId;
}
return apiFetchJSON(`${this.baseUrl}/test`, {
method: 'POST',
body: JSON.stringify(body),
});
}
static async testWebhook(webhook: Omit<Webhook, 'id'>): Promise<{ success: boolean; message?: string }> {
static async testWebhook(
webhook: Omit<Webhook, 'id'>,
): Promise<{ success: boolean; message?: string }> {
return apiFetchJSON(`${this.baseUrl}/webhooks/test`, {
method: 'POST',
body: JSON.stringify(webhook),
});
}
}
}
+4 -7
View File
@@ -1,7 +1,4 @@
import type {
SettingsResponse,
SettingsUpdateRequest
} from '@/types/settings';
import type { SettingsResponse, SettingsUpdateRequest } from '@/types/settings';
import type { SystemConfig } from '@/types/config';
import { apiFetchJSON } from '@/utils/apiClient';
@@ -27,7 +24,7 @@ export class SettingsAPI {
body: JSON.stringify(settings),
}) as Promise<ApiResponse>;
}
// System settings update (preferred) - uses SystemConfig type from config.ts
static async updateSystemSettings(settings: Partial<SystemConfig>): Promise<ApiResponse> {
return apiFetchJSON(`${this.baseUrl}/system/settings/update`, {
@@ -35,7 +32,7 @@ export class SettingsAPI {
body: JSON.stringify(settings),
}) as Promise<ApiResponse>;
}
// Get system settings - returns SystemConfig
static async getSystemSettings(): Promise<SystemConfig> {
return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise<SystemConfig>;
@@ -47,4 +44,4 @@ export class SettingsAPI {
body: JSON.stringify(settings),
}) as Promise<ApiResponse>;
}
}
}
+2 -2
View File
@@ -15,11 +15,11 @@ export class SystemAPI {
static async getSystemSettings(): Promise<SystemSettings> {
return apiFetchJSON('/api/system/settings');
}
static async updateSystemSettings(settings: Partial<SystemSettings>): Promise<void> {
await apiFetchJSON('/api/system/settings/update', {
method: 'POST',
body: JSON.stringify(settings),
});
}
}
}
+1 -1
View File
@@ -49,4 +49,4 @@ export class UpdatesAPI {
static async getVersion(): Promise<VersionInfo> {
return apiFetchJSON('/api/version');
}
}
}
@@ -11,31 +11,37 @@ interface CustomRulesTabProps {
}
export function CustomRulesTab(props: CustomRulesTabProps) {
const deleteRule = (ruleId: string) => {
const updatedRules = props.rules.filter(r => r.id !== ruleId);
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
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} `);
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 => {
@@ -62,8 +68,18 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
{/* 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
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 Control Methods:</p>
@@ -77,103 +93,149 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
</div>
{/* Rules List */}
<Show when={props.rules.length > 0} fallback={
<Card padding="lg">
<EmptyState
icon={(
<svg class="h-12 w-12 text-gray-400" 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>
)}
title="No custom alert rules"
description="Create rules from the Dashboard by applying filters and choosing Create Alert."
/>
</Card>
}>
<Show
when={props.rules.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 text-gray-400"
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>
}
title="No custom alert rules"
description="Create rules from the Dashboard by applying filters and choosing Create Alert."
/>
</Card>
}
>
<div class="space-y-3">
<For each={props.rules.sort((a, b) => b.priority - a.priority)}>
{(rule) => (
<Card padding="md" class="overflow-hidden">
<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
<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 type="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 type="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)}
{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.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 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
type="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
type="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>
</Card>
)}
</For>
@@ -1,6 +1,12 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form';
import {
formField,
labelClass,
controlClass,
formHelpText,
formCheckbox,
} from '@/components/shared/Form';
interface EmailProvider {
name: string;
@@ -152,7 +158,9 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<input
type="number"
value={props.config.port}
onInput={(e) => props.onChange({ ...props.config, port: parseInt(e.currentTarget.value) || 587 })}
onInput={(e) =>
props.onChange({ ...props.config, port: parseInt(e.currentTarget.value) || 587 })
}
placeholder="587"
class={controlClass('px-2 py-1.5')}
/>
@@ -241,7 +249,9 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<input
type="checkbox"
checked={props.config.tls}
onChange={(e) => props.onChange({ ...props.config, tls: e.currentTarget.checked })}
onChange={(e) =>
props.onChange({ ...props.config, tls: e.currentTarget.checked })
}
class={`${formCheckbox} h-4 w-4`}
/>
<span>Use TLS</span>
@@ -250,7 +260,9 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<input
type="checkbox"
checked={props.config.startTLS}
onChange={(e) => props.onChange({ ...props.config, startTLS: e.currentTarget.checked })}
onChange={(e) =>
props.onChange({ ...props.config, startTLS: e.currentTarget.checked })
}
class={`${formCheckbox} h-4 w-4`}
/>
<span>Use STARTTLS</span>
@@ -260,7 +272,9 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<input
type="number"
value={props.config.rateLimit || 60}
onInput={(e) => props.onChange({ ...props.config, rateLimit: parseInt(e.currentTarget.value) })}
onInput={(e) =>
props.onChange({ ...props.config, rateLimit: parseInt(e.currentTarget.value) })
}
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
/>
<span class={formHelpText}>/min</span>
@@ -275,18 +289,24 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
value={props.config.maxRetries || 3}
min={0}
max={5}
onInput={(e) => props.onChange({ ...props.config, maxRetries: parseInt(e.currentTarget.value) })}
onInput={(e) =>
props.onChange({ ...props.config, maxRetries: parseInt(e.currentTarget.value) })
}
class={controlClass('px-2 py-1 text-sm')}
/>
</div>
<div class={formField}>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>Retry delay (seconds)</label>
<label class={labelClass('text-xs uppercase tracking-[0.08em]')}>
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) })}
onInput={(e) =>
props.onChange({ ...props.config, retryDelay: parseInt(e.currentTarget.value) })
}
class={controlClass('px-2 py-1 text-sm')}
/>
</div>
@@ -4,13 +4,13 @@ import { ThresholdSlider } from '@/components/Dashboard/ThresholdSlider';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface Override {
id?: string; // Full guest ID (e.g. "Main-node1-105")
id?: string; // Full guest ID (e.g. "Main-node1-105")
guestName: string;
vmid: number;
type: string;
node: string;
instance?: string;
disabled?: boolean; // Completely disable alerts for this guest
disabled?: boolean; // Completely disable alerts for this guest
thresholds: {
cpu?: number;
memory?: number;
@@ -27,14 +27,21 @@ interface OverrideModalProps {
onClose: () => void;
onSave: (override: Override) => void;
existingOverride?: Override;
guests: Array<{ id: string; name: string; vmid: number; type: string; node: string; instance: string }>;
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>('');
const [alertsDisabled, setAlertsDisabled] = createSignal(false);
// Store the select element ref
let selectRef: HTMLSelectElement | undefined;
const [thresholds, setThresholds] = createSignal({
@@ -44,9 +51,9 @@ export function OverrideModal(props: OverrideModalProps) {
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0
networkOut: 0,
});
const [enabledMetrics, setEnabledMetrics] = createSignal({
cpu: false,
memory: false,
@@ -54,9 +61,9 @@ export function OverrideModal(props: OverrideModalProps) {
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false
networkOut: false,
});
// Maintain select value when guests change
createEffect(() => {
if (selectRef && selectedGuest()) {
@@ -69,7 +76,7 @@ export function OverrideModal(props: OverrideModalProps) {
});
}
});
// Reset state when modal opens
createEffect(() => {
if (props.isOpen) {
@@ -83,7 +90,7 @@ export function OverrideModal(props: OverrideModalProps) {
diskRead: props.existingOverride.thresholds.diskRead || 0,
diskWrite: props.existingOverride.thresholds.diskWrite || 0,
networkIn: props.existingOverride.thresholds.networkIn || 0,
networkOut: props.existingOverride.thresholds.networkOut || 0
networkOut: props.existingOverride.thresholds.networkOut || 0,
});
setEnabledMetrics({
cpu: props.existingOverride.thresholds.cpu !== undefined,
@@ -92,7 +99,7 @@ export function OverrideModal(props: OverrideModalProps) {
diskRead: props.existingOverride.thresholds.diskRead !== undefined,
diskWrite: props.existingOverride.thresholds.diskWrite !== undefined,
networkIn: props.existingOverride.thresholds.networkIn !== undefined,
networkOut: props.existingOverride.thresholds.networkOut !== undefined
networkOut: props.existingOverride.thresholds.networkOut !== undefined,
});
} else {
// Reset to defaults for new override
@@ -105,7 +112,7 @@ export function OverrideModal(props: OverrideModalProps) {
diskRead: 0,
diskWrite: 0,
networkIn: 0,
networkOut: 0
networkOut: 0,
});
setEnabledMetrics({
cpu: false,
@@ -114,20 +121,20 @@ export function OverrideModal(props: OverrideModalProps) {
diskRead: false,
diskWrite: false,
networkIn: false,
networkOut: false
networkOut: false,
});
}
}
});
const handleSave = () => {
const guest = props.guests.find(g => g.vmid.toString() === selectedGuest());
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;
@@ -135,19 +142,19 @@ export function OverrideModal(props: OverrideModalProps) {
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
id: guest.id, // Pass the full guest ID
guestName: guest.name,
vmid: guest.vmid,
type: guest.type,
node: guest.node,
instance: guest.instance,
disabled: alertsDisabled(),
thresholds: enabledThresholds
thresholds: enabledThresholds,
});
};
return (
<Show when={props.isOpen}>
<Portal>
@@ -161,7 +168,7 @@ export function OverrideModal(props: OverrideModalProps) {
titleClass="text-gray-800 dark:text-gray-200"
/>
</div>
{/* Content */}
<div class="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-8rem)]">
{/* Guest Selection */}
@@ -191,7 +198,7 @@ export function OverrideModal(props: OverrideModalProps) {
</select>
</div>
</Show>
{/* Disable Alerts Option */}
<div class="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<input
@@ -210,7 +217,7 @@ export function OverrideModal(props: OverrideModalProps) {
</p>
</label>
</div>
{/* Threshold Overrides */}
<div class={`space-y-4 ${alertsDisabled() ? 'opacity-50 pointer-events-none' : ''}`}>
<SectionHeader
@@ -218,13 +225,15 @@ export function OverrideModal(props: OverrideModalProps) {
size="sm"
titleClass="text-gray-700 dark:text-gray-300"
/>
{/* CPU */}
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().cpu}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), cpu: e.currentTarget.checked})}
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">
@@ -233,7 +242,7 @@ export function OverrideModal(props: OverrideModalProps) {
<div class="flex-1">
<ThresholdSlider
value={thresholds().cpu || 80}
onChange={(v) => setThresholds({...thresholds(), cpu: v})}
onChange={(v) => setThresholds({ ...thresholds(), cpu: v })}
type="cpu"
/>
</div>
@@ -243,13 +252,15 @@ export function OverrideModal(props: OverrideModalProps) {
</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})}
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">
@@ -258,7 +269,7 @@ export function OverrideModal(props: OverrideModalProps) {
<div class="flex-1">
<ThresholdSlider
value={thresholds().memory || 85}
onChange={(v) => setThresholds({...thresholds(), memory: v})}
onChange={(v) => setThresholds({ ...thresholds(), memory: v })}
type="memory"
/>
</div>
@@ -268,13 +279,15 @@ export function OverrideModal(props: OverrideModalProps) {
</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})}
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">
@@ -283,7 +296,7 @@ export function OverrideModal(props: OverrideModalProps) {
<div class="flex-1">
<ThresholdSlider
value={thresholds().disk || 90}
onChange={(v) => setThresholds({...thresholds(), disk: v})}
onChange={(v) => setThresholds({ ...thresholds(), disk: v })}
type="disk"
/>
</div>
@@ -293,21 +306,31 @@ export function OverrideModal(props: OverrideModalProps) {
</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})}
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)})}
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>
@@ -318,19 +341,29 @@ export function OverrideModal(props: OverrideModalProps) {
</select>
</div>
</div>
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={enabledMetrics().diskWrite}
onChange={(e) => setEnabledMetrics({...enabledMetrics(), diskWrite: e.currentTarget.checked})}
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)})}
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>
@@ -344,16 +377,18 @@ export function OverrideModal(props: OverrideModalProps) {
</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 type="button"
<button
type="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 type="button"
<button
type="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"
@@ -366,4 +401,4 @@ export function OverrideModal(props: OverrideModalProps) {
</Portal>
</Show>
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,12 @@
import { createSignal, createEffect, Show, For, Index } from 'solid-js';
import { NotificationsAPI, Webhook } from '@/api/notifications';
import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form';
import {
formField,
labelClass,
controlClass,
formHelpText,
formCheckbox,
} from '@/components/shared/Form';
interface WebhookTemplate {
service: string;
@@ -24,21 +30,25 @@ interface WebhookConfigProps {
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 }>({
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: ''
payloadTemplate: '',
});
const [templates, setTemplates] = createSignal<WebhookTemplate[]>([]);
const [showServiceDropdown, setShowServiceDropdown] = createSignal(false);
// Track header inputs separately to avoid focus loss
const [headerInputs, setHeaderInputs] = createSignal<Array<{id: string; key: string; value: string}>>([]);
const [headerInputs, setHeaderInputs] = createSignal<
Array<{ id: string; key: string; value: string }>
>([]);
// Load webhook templates
createEffect(async () => {
try {
@@ -48,26 +58,26 @@ export function WebhookConfig(props: WebhookConfigProps) {
console.error('Failed to load webhook templates:', err);
}
});
const saveWebhook = () => {
const data = formData();
if (!data.name || !data.url) return;
// Build headers from headerInputs
const headers: Record<string, string> = {};
headerInputs().forEach(input => {
headerInputs().forEach((input) => {
if (input.key) {
headers[input.key] = input.value;
}
});
if (editingId()) {
props.onUpdate({
...data,
id: editingId()!,
props.onUpdate({
...data,
id: editingId()!,
headers,
service: data.service,
template: data.payloadTemplate
template: data.payloadTemplate,
});
setEditingId(null);
setAdding(false);
@@ -81,7 +91,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
headers,
enabled: data.enabled,
service: data.service,
template: data.payloadTemplate
template: data.payloadTemplate,
};
props.onAdd(newWebhook);
// Reset form and close the adding panel
@@ -92,13 +102,13 @@ export function WebhookConfig(props: WebhookConfigProps) {
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: ''
payloadTemplate: '',
});
setHeaderInputs([]);
setAdding(false);
}
};
const cancelForm = () => {
setAdding(false);
setEditingId(null);
@@ -109,11 +119,11 @@ export function WebhookConfig(props: WebhookConfigProps) {
service: 'generic',
headers: { 'Content-Type': 'application/json' },
enabled: true,
payloadTemplate: ''
payloadTemplate: '',
});
setHeaderInputs([]);
};
const editWebhook = (webhook: Webhook) => {
if (webhook.id) {
setEditingId(webhook.id);
@@ -121,7 +131,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
setFormData({
...webhook,
service: webhook.service || 'generic',
payloadTemplate: webhook.template || ''
payloadTemplate: webhook.template || '',
});
// Set up header inputs for editing
const headers = webhook.headers || {};
@@ -129,14 +139,14 @@ export function WebhookConfig(props: WebhookConfigProps) {
Object.entries(headers).map(([key, value], index) => ({
id: `header-${Date.now()}-${index}`,
key,
value
}))
value,
})),
);
setAdding(true);
};
const selectService = (service: string) => {
const template = templates().find(t => t.service === service);
const template = templates().find((t) => t.service === service);
if (template) {
setFormData({
...formData(),
@@ -146,7 +156,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
name: formData().name || template.name,
// Clear the payload template when switching services
// Only generic service should have custom payloads
payloadTemplate: service === 'generic' ? formData().payloadTemplate : ''
payloadTemplate: service === 'generic' ? formData().payloadTemplate : '',
});
// Update header inputs when switching services
const headers = template.headers || {};
@@ -154,14 +164,14 @@ export function WebhookConfig(props: WebhookConfigProps) {
Object.entries(headers).map(([key, value], index) => ({
id: `header-${Date.now()}-${index}`,
key,
value
}))
value,
})),
);
}
setShowServiceDropdown(false);
};
const currentTemplate = () => templates().find(t => t.service === formData().service);
const currentTemplate = () => templates().find((t) => t.service === formData().service);
const serviceName = (service: string) => {
const names: Record<string, string> = {
generic: 'Generic',
@@ -173,19 +183,19 @@ export function WebhookConfig(props: WebhookConfigProps) {
pagerduty: 'PagerDuty',
pushover: 'Pushover',
gotify: 'Gotify',
ntfy: 'ntfy'
ntfy: 'ntfy',
};
return names[service] || service;
};
const toggleAllWebhooks = (enabled: boolean) => {
props.webhooks.forEach(webhook => {
props.webhooks.forEach((webhook) => {
props.onUpdate({ ...webhook, enabled });
});
};
const allEnabled = () => props.webhooks.every(w => w.enabled);
const someEnabled = () => props.webhooks.some(w => w.enabled);
const allEnabled = () => props.webhooks.every((w) => w.enabled);
const someEnabled = () => props.webhooks.some((w) => w.enabled);
return (
<div class="space-y-6 min-w-0 w-full">
@@ -195,19 +205,22 @@ export function WebhookConfig(props: WebhookConfigProps) {
{/* Quick Actions Bar */}
<div class="flex flex-col gap-2 rounded border border-gray-200 px-3 py-3 text-xs dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div class="text-gray-600 dark:text-gray-400 sm:text-sm">
{props.webhooks.filter(w => w.enabled).length} of {props.webhooks.length} webhooks enabled
{props.webhooks.filter((w) => w.enabled).length} of {props.webhooks.length} webhooks
enabled
</div>
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<button
onClick={() => toggleAllWebhooks(false)}
disabled={!someEnabled()}
class="w-full rounded border border-gray-300 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 sm:w-auto">
class="w-full rounded border border-gray-300 px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700 sm:w-auto"
>
Disable All
</button>
<button
onClick={() => toggleAllWebhooks(true)}
disabled={allEnabled()}
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/20 sm:w-auto">
class="w-full rounded border border-green-500 px-3 py-1 text-xs text-green-700 transition-colors hover:bg-green-50 dark:border-green-600 dark:text-green-400 dark:hover:bg-green-900/20 sm:w-auto"
>
Enable All
</button>
</div>
@@ -216,9 +229,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
{(webhook) => (
<div class="w-full px-3 py-3 border border-gray-200 text-xs dark:border-gray-700 sm:text-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-medium text-gray-800 dark:text-gray-200">
{webhook.name}
</span>
<span class="font-medium text-gray-800 dark:text-gray-200">{webhook.name}</span>
<button
onClick={() => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
class={`rounded border px-3 py-1 text-xs font-medium transition-colors ${
@@ -270,7 +281,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
</For>
</div>
</Show>
{/* Add/Edit Form */}
<Show when={adding()}>
<div class="space-y-4 text-sm">
@@ -280,19 +291,34 @@ export function WebhookConfig(props: WebhookConfigProps) {
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Service Type
</label>
<button type="button"
<button
type="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 border border-gray-200 dark:border-gray-700 px-3 py-2 mb-3 text-xs">
<For each={['generic', 'discord', 'slack', 'telegram', 'teams', 'teams-adaptive', 'pagerduty', 'pushover', 'gotify', 'ntfy']}>
<For
each={[
'generic',
'discord',
'slack',
'telegram',
'teams',
'teams-adaptive',
'pagerduty',
'pushover',
'gotify',
'ntfy',
]}
>
{(service) => (
<button type="button"
<button
type="button"
onClick={() => selectService(service)}
class={`px-2 py-1.5 text-left border transition-colors text-xs ${
formData().service === service
@@ -304,23 +330,32 @@ export function WebhookConfig(props: WebhookConfigProps) {
{serviceName(service)}
</div>
<div class="text-[11px] 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' :
service === 'pushover' ? 'Mobile push notifications' :
service === 'gotify' ? 'Self-hosted push notifications' :
service === 'ntfy' ? 'Push notifications via ntfy.sh' :
'PagerDuty Events API v2'}
{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'
: service === 'pushover'
? 'Mobile push notifications'
: service === 'gotify'
? 'Self-hosted push notifications'
: service === 'ntfy'
? 'Push notifications via ntfy.sh'
: 'PagerDuty Events API v2'}
</div>
</button>
)}
</For>
</div>
</Show>
<Show when={currentTemplate()?.instructions}>
<div class="mb-3 border-l-2 border-blue-300 pl-3 text-xs leading-relaxed text-blue-800 dark:border-blue-700 dark:text-blue-200">
<h4 class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
@@ -330,13 +365,11 @@ export function WebhookConfig(props: WebhookConfigProps) {
</div>
</Show>
</div>
{/* Basic Configuration */}
<div class="grid w-full grid-cols-1 gap-3 md:grid-cols-2">
<div class={formField}>
<label class={labelClass()}>
Name
</label>
<label class={labelClass()}>Name</label>
<input
type="text"
value={formData().name}
@@ -345,11 +378,9 @@ export function WebhookConfig(props: WebhookConfigProps) {
class={controlClass('px-2 py-1.5')}
/>
</div>
<div class={formField}>
<label class={labelClass()}>
HTTP method
</label>
<label class={labelClass()}>HTTP method</label>
<select
value={formData().method}
onChange={(e) => setFormData({ ...formData(), method: e.currentTarget.value })}
@@ -361,11 +392,9 @@ export function WebhookConfig(props: WebhookConfigProps) {
</select>
</div>
</div>
<div class={formField}>
<label class={labelClass()}>
Webhook URL
</label>
<label class={labelClass()}>Webhook URL</label>
<input
type="url"
value={formData().url}
@@ -374,7 +403,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
class={controlClass('px-2 py-1.5 font-mono')}
/>
</div>
{/* Custom Payload Template - only show for generic service */}
<Show when={formData().service === 'generic'}>
<div class={formField}>
@@ -386,7 +415,9 @@ export function WebhookConfig(props: WebhookConfigProps) {
</label>
<textarea
value={formData().payloadTemplate || ''}
onInput={(e) => setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })}
onInput={(e) =>
setFormData({ ...formData(), payloadTemplate: e.currentTarget.value })
}
placeholder={`{
"text": "Alert: {{.Level}} - {{.Message}}",
"resource": "{{.ResourceName}}",
@@ -397,11 +428,14 @@ export function WebhookConfig(props: WebhookConfigProps) {
class={controlClass('px-2 py-1.5 text-xs font-mono min-h-[160px]')}
/>
<p class={formHelpText + ' mt-1'}>
Available variables: {"{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}"}
Available variables:{' '}
{
'{{.ID}}, {{.Level}}, {{.Type}}, {{.ResourceName}}, {{.Node}}, {{.Message}}, {{.Value}}, {{.Threshold}}, {{.Duration}}, {{.Timestamp}}'
}
</p>
</div>
</Show>
{/* Custom Headers Section */}
<div class={formField}>
<label class={labelClass('flex items-center gap-2')}>
@@ -419,7 +453,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
value={header().key}
onInput={(e) => {
const newKey = e.currentTarget.value;
setHeaderInputs(inputs => {
setHeaderInputs((inputs) => {
const newInputs = [...inputs];
newInputs[index] = { ...newInputs[index], key: newKey };
return newInputs;
@@ -433,7 +467,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
value={header().value}
onInput={(e) => {
const newValue = e.currentTarget.value;
setHeaderInputs(inputs => {
setHeaderInputs((inputs) => {
const newInputs = [...inputs];
newInputs[index] = { ...newInputs[index], value: newValue };
return newInputs;
@@ -445,7 +479,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
<button
type="button"
onClick={() => {
setHeaderInputs(inputs => inputs.filter((_, i) => i !== index));
setHeaderInputs((inputs) => inputs.filter((_, i) => i !== index));
}}
class="px-2 py-1 text-xs text-red-600 hover:underline dark:text-red-400"
>
@@ -458,11 +492,14 @@ export function WebhookConfig(props: WebhookConfigProps) {
type="button"
onClick={() => {
const newId = `header-${Date.now()}-${Math.random()}`;
setHeaderInputs([...headerInputs(), {
id: newId,
key: '',
value: ''
}]);
setHeaderInputs([
...headerInputs(),
{
id: newId,
key: '',
value: '',
},
]);
}}
class="w-full border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
@@ -473,7 +510,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
Common headers: Authorization (Bearer token), X-API-Key, X-Auth-Token
</p>
</div>
<div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
@@ -485,21 +522,21 @@ export function WebhookConfig(props: WebhookConfigProps) {
<span>Enable this webhook</span>
</label>
</div>
<div class="flex justify-end gap-2 text-xs">
<button
<button
onClick={cancelForm}
class="px-3 py-1.5 border border-gray-300 rounded text-xs hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200"
>
Cancel
</button>
<Show when={formData().url && formData().name}>
<button
<button
onClick={() => {
// Test the webhook with current form data
// Build headers from headerInputs
const headers: Record<string, string> = {};
headerInputs().forEach(input => {
headerInputs().forEach((input) => {
if (input.key) {
headers[input.key] = input.value;
}
@@ -514,7 +551,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
{props.testing === (editingId() || 'temp-new-webhook') ? 'Testing...' : 'Test'}
</button>
</Show>
<button
<button
onClick={saveWebhook}
disabled={!formData().name || !formData().url}
class="px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
@@ -524,18 +561,20 @@ export function WebhookConfig(props: WebhookConfigProps) {
</div>
</div>
</Show>
{/* Add Webhook Button */}
<Show when={!adding()}>
<button
<button
onClick={() => {
setAdding(true);
// Initialize with default Content-Type header
setHeaderInputs([{
id: `header-${Date.now()}-0`,
key: 'Content-Type',
value: 'application/json'
}]);
setHeaderInputs([
{
id: `header-${Date.now()}-0`,
key: 'Content-Type',
value: 'application/json',
},
]);
}}
class="w-full border border-dashed border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-800"
>
@@ -13,14 +13,30 @@ const Backups: Component = () => {
<Show when={connected() && !state.pveBackups && !state.pbs}>
<Card padding="lg">
<EmptyState
icon={(
icon={
<div class="inline-flex items-center justify-center w-12 h-12">
<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
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>
)}
}
title="Loading backup information..."
/>
</Card>
@@ -30,11 +46,21 @@ const Backups: Component = () => {
<Show when={!connected()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={(
<svg class="h-12 w-12 text-red-400" 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" />
icon={
<svg
class="h-12 w-12 text-red-400"
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>
)}
}
title="Connection lost"
description="Unable to connect to the backend server. Attempting to reconnect..."
tone="danger"
@@ -32,7 +32,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
{ value: 'storage', label: 'Storage' },
{ value: 'verified', label: 'Verified' },
{ value: 'type', label: 'Guest Type' },
{ value: 'owner', label: 'Owner' }
{ value: 'owner', label: 'Owner' },
];
return (
<Card class="backups-filter mb-3" padding="sm">
@@ -51,10 +51,21 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search backups by name or filter by node"
/>
<svg class="absolute left-3 top-2 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
class="absolute left-3 top-2 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 type="button"
<button
type="button"
class="absolute right-3 top-2 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();
@@ -75,7 +86,12 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
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" />
<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>
@@ -85,41 +101,45 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<div class="flex flex-wrap items-center gap-2">
{/* Source Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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 type="button"
<button
type="button"
onClick={() => props.setViewMode('snapshot')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'snapshot'
? 'bg-white dark:bg-gray-800 text-yellow-600 dark:text-yellow-400 shadow-sm'
? 'bg-white dark:bg-gray-800 text-yellow-600 dark:text-yellow-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Snapshots
</button>
<button type="button"
<button
type="button"
onClick={() => props.setViewMode('pve')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'pve'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PVE
</button>
<button type="button"
<button
type="button"
onClick={() => props.setViewMode('pbs')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'pbs'
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-400 shadow-sm'
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
@@ -130,43 +150,54 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Type Filter - Only show when there are Host backups */}
<Show when={props.hasHostBackups && props.hasHostBackups() && props.typeFilter && props.setTypeFilter}>
<Show
when={
props.hasHostBackups &&
props.hasHostBackups() &&
props.typeFilter &&
props.setTypeFilter
}
>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
<button
type="button"
onClick={() => props.setTypeFilter!('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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 Types
</button>
<button type="button"
<button
type="button"
onClick={() => props.setTypeFilter!('VM')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'VM'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
? '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'
}`}
>
VM
</button>
<button type="button"
<button
type="button"
onClick={() => props.setTypeFilter!('LXC')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'LXC'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
? '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'
}`}
>
LXC
</button>
<button type="button"
<button
type="button"
onClick={() => props.setTypeFilter!('Host')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'Host'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
@@ -178,21 +209,23 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
{/* Group By Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
<button
type="button"
onClick={() => props.setGroupBy('date')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy() === 'date'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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'
}`}
>
By Date
</button>
<button type="button"
<button
type="button"
onClick={() => props.setGroupBy('guest')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy() === 'guest'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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'
}`}
>
@@ -204,20 +237,24 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
{/* Sort controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Sort</span>
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sort
</span>
<select
value={props.sortKey()}
onChange={(e) => props.setSortKey(e.currentTarget.value)}
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400"
>
{sortOptions.map(option => (
{sortOptions.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
<button
type="button"
title={`Sort ${props.sortDirection() === 'asc' ? 'descending' : 'ascending'}`}
onClick={() => props.setSortDirection(props.sortDirection() === 'asc' ? 'desc' : 'asc')}
onClick={() =>
props.setSortDirection(props.sortDirection() === 'asc' ? 'desc' : 'asc')
}
class="inline-flex items-center justify-center h-7 w-7 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg
@@ -227,7 +264,11 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
</button>
</div>
@@ -235,7 +276,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
<button
onClick={() => {
if (props.onReset) {
props.onReset();
@@ -250,17 +291,32 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="14" height="14" 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
width="14"
height="14"
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 hidden sm:inline">Reset</span>
</button>
{/* Active Indicator */}
<Show when={props.search().trim() !== '' || props.viewMode() !== 'all' || props.groupBy() !== 'date' || props.sortKey() !== 'backupTime' || props.sortDirection() !== 'desc'}>
<Show
when={
props.search().trim() !== '' ||
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc'
}
>
<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>
File diff suppressed because it is too large Load Diff
@@ -15,9 +15,9 @@ interface CompactNodeCardProps {
const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
const { activeAlerts } = useWebSocket();
const isOnline = () => props.node.status === 'online' && props.node.uptime > 0;
const cpuPercent = createMemo(() => Math.round(props.node.cpu * 100));
const memPercent = createMemo(() => Math.round(props.node.memory?.usage || 0));
const diskPercent = createMemo(() => {
@@ -26,14 +26,16 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
});
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
const nodeAlerts = createMemo(() => getResourceAlerts(props.node.id || props.node.name, activeAlerts));
const nodeAlerts = createMemo(() =>
getResourceAlerts(props.node.id || props.node.name, activeAlerts),
);
// Get status color
const getMetricColor = (value: number, type: 'cpu' | 'mem' | 'disk') => {
const thresholds = {
cpu: { high: 90, warn: 80 },
mem: { high: 85, warn: 75 },
disk: { high: 90, warn: 80 }
disk: { high: 90, warn: 80 },
};
const t = thresholds[type];
if (value >= t.high) return 'text-red-500';
@@ -44,11 +46,9 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
// Mini progress bar for compact mode
const MiniProgressBar = (props: { value: number; type: 'cpu' | 'mem' | 'disk' }) => (
<div class="w-[80px] h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
<div
class={`h-full transition-all ${
props.value >= 90 ? 'bg-red-500' :
props.value >= 75 ? 'bg-yellow-500' :
'bg-green-500'
props.value >= 90 ? 'bg-red-500' : props.value >= 75 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${props.value}%` }}
/>
@@ -63,24 +63,29 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
border={false}
hoverable
class={`flex items-center gap-2 px-3 py-1.5 ${
props.isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' :
!isOnline() ? 'border-red-500' :
alertStyles.hasAlert ? 'border-orange-500' :
'border-gray-200 dark:border-gray-700'
props.isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: !isOnline()
? 'border-red-500'
: alertStyles.hasAlert
? 'border-orange-500'
: 'border-gray-200 dark:border-gray-700'
} border transition-all cursor-pointer hover:scale-[1.01]`}
onClick={props.onClick}
>
{/* Status dot */}
<span class={`w-2 h-2 rounded-full ${
props.node.connectionHealth === 'degraded'
? 'bg-yellow-500'
: isOnline()
? 'bg-green-500'
: 'bg-red-500'
}`} />
<span
class={`w-2 h-2 rounded-full ${
props.node.connectionHealth === 'degraded'
? 'bg-yellow-500'
: isOnline()
? 'bg-green-500'
: 'bg-red-500'
}`}
/>
{/* Node name */}
<a
<a
href={props.node.host || `https://${props.node.name}:8006`}
target="_blank"
class="font-medium text-sm w-24 truncate hover:text-blue-600 dark:hover:text-blue-400"
@@ -88,14 +93,16 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
>
{props.node.name}
</a>
{/* Cluster/Standalone indicator */}
<Show when={props.node.isClusterMember !== undefined}>
<span class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}>
<span
class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{props.node.isClusterMember ? props.node.clusterName?.slice(0, 3).toUpperCase() : 'SA'}
</span>
</Show>
@@ -133,23 +140,28 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
border={false}
hoverable
class={`border ${
props.isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' :
!isOnline() ? 'border-red-500' :
alertStyles.hasAlert ? 'border-orange-500' :
'border-gray-200 dark:border-gray-700'
props.isSelected
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: !isOnline()
? 'border-red-500'
: alertStyles.hasAlert
? 'border-orange-500'
: 'border-gray-200 dark:border-gray-700'
} cursor-pointer transition-all hover:scale-[1.02]`}
onClick={props.onClick}
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class={`w-2 h-2 rounded-full ${
props.node.connectionHealth === 'degraded'
? 'bg-yellow-500'
: isOnline()
? 'bg-green-500'
: 'bg-red-500'
}`} />
<a
<span
class={`w-2 h-2 rounded-full ${
props.node.connectionHealth === 'degraded'
? 'bg-yellow-500'
: isOnline()
? 'bg-green-500'
: 'bg-red-500'
}`}
/>
<a
href={props.node.host || `https://${props.node.name}:8006`}
target="_blank"
class="font-semibold text-sm hover:text-blue-600 dark:hover:text-blue-400"
@@ -158,11 +170,13 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
</a>
{/* Cluster/Standalone indicator */}
<Show when={props.node.isClusterMember !== undefined}>
<span class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}>
<span
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
</span>
</Show>
@@ -180,23 +194,17 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
<div class="flex items-center gap-2">
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">CPU</span>
<MiniProgressBar value={cpuPercent()} type="cpu" />
<span class={`text-xs ${getMetricColor(cpuPercent(), 'cpu')}`}>
{cpuPercent()}%
</span>
<span class={`text-xs ${getMetricColor(cpuPercent(), 'cpu')}`}>{cpuPercent()}%</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">Mem</span>
<MiniProgressBar value={memPercent()} type="mem" />
<span class={`text-xs ${getMetricColor(memPercent(), 'mem')}`}>
{memPercent()}%
</span>
<span class={`text-xs ${getMetricColor(memPercent(), 'mem')}`}>{memPercent()}%</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs w-8 text-gray-600 dark:text-gray-400">Disk</span>
<MiniProgressBar value={diskPercent()} type="disk" />
<span class={`text-xs ${getMetricColor(diskPercent(), 'disk')}`}>
{diskPercent()}%
</span>
<span class={`text-xs ${getMetricColor(diskPercent(), 'disk')}`}>{diskPercent()}%</span>
</div>
</div>
</Card>
File diff suppressed because it is too large Load Diff
@@ -34,18 +34,34 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
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 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
class="absolute left-3 top-2 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>
<Show when={props.search()}>
<button type="button"
<button
type="button"
class="absolute right-3 top-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
aria-label="Clear search"
title="Clear search"
>
<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="M6 18L18 6M6 6l12 12" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</Show>
@@ -56,31 +72,34 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<div class="flex flex-wrap items-center gap-2">
{/* Type Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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 type="button"
<button
type="button"
onClick={() => props.setViewMode('vm')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
? '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 type="button"
<button
type="button"
onClick={() => props.setViewMode('lxc')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
? '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'
}`}
>
@@ -92,31 +111,34 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
<button
type="button"
onClick={() => props.setStatusMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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 type="button"
<button
type="button"
onClick={() => props.setStatusMode('running')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'running'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
? '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 type="button"
<button
type="button"
onClick={() => props.setStatusMode('stopped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'stopped'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
? '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'
}`}
>
@@ -128,22 +150,24 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
{/* Grouping Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
<button
type="button"
onClick={() => props.setGroupingMode('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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'
}`}
title="Group by node"
>
Grouped
</button>
<button type="button"
<button
type="button"
onClick={() => props.setGroupingMode('flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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'
}`}
title="Flat list view"
@@ -155,7 +179,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
<button
onClick={() => {
props.setSearch('');
props.setSortKey('vmid');
@@ -169,17 +193,31 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="14" height="14" 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
width="14"
height="14"
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 hidden sm:inline">Reset</span>
</button>
{/* Active Indicator */}
<Show when={props.search() || props.viewMode() !== 'all' || props.statusMode() !== 'all' || props.groupingMode() !== 'grouped'}>
<Show
when={
props.search() ||
props.viewMode() !== 'all' ||
props.statusMode() !== 'all' ||
props.groupingMode() !== 'grouped'
}
>
<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>
@@ -188,4 +226,4 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
</div>
</Card>
);
};
};
@@ -11,19 +11,20 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
const diskStats = createMemo(() => {
const disks = props.disks || [];
const total = disks.length;
const healthy = disks.filter(d => d.health === 'PASSED').length;
const failing = disks.filter(d => d.health === 'FAILED').length;
const unknown = disks.filter(d => d.health === 'UNKNOWN' || !d.health).length;
const lowLife = disks.filter(d => d.wearout > 0 && d.wearout < 10).length;
const avgWearout = disks.filter(d => d.wearout > 0).reduce((sum, d) => sum + d.wearout, 0) /
disks.filter(d => d.wearout > 0).length || 0;
const healthy = disks.filter((d) => d.health === 'PASSED').length;
const failing = disks.filter((d) => d.health === 'FAILED').length;
const unknown = disks.filter((d) => d.health === 'UNKNOWN' || !d.health).length;
const lowLife = disks.filter((d) => d.wearout > 0 && d.wearout < 10).length;
const avgWearout =
disks.filter((d) => d.wearout > 0).reduce((sum, d) => sum + d.wearout, 0) /
disks.filter((d) => d.wearout > 0).length || 0;
// Group by node
const byNode: Record<string, number> = {};
disks.forEach(d => {
disks.forEach((d) => {
byNode[d.node] = (byNode[d.node] || 0) + 1;
});
return {
total,
healthy,
@@ -31,10 +32,10 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
unknown,
lowLife,
avgWearout,
byNode
byNode,
};
});
const healthColor = createMemo(() => {
const stats = diskStats();
if (stats.failing > 0) return 'text-red-600 dark:text-red-400';
@@ -42,14 +43,15 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
if (stats.unknown > 0) return 'text-gray-600 dark:text-gray-400';
return 'text-green-600 dark:text-green-400';
});
const healthBg = createMemo(() => {
const stats = diskStats();
if (stats.failing > 0) return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800';
if (stats.lowLife > 0) return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
if (stats.lowLife > 0)
return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700';
});
return (
<Show when={diskStats().total > 0}>
<Card padding="md" border={false} class={`${healthBg()}`}>
@@ -64,7 +66,7 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
{diskStats().healthy}/{diskStats().total}
</span>
</div>
<div class="space-y-2">
{/* Health Status */}
<div class="flex items-center justify-between text-xs">
@@ -92,19 +94,22 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
</Show>
</div>
</div>
{/* Average SSD Life */}
<Show when={diskStats().avgWearout > 0}>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">Avg SSD Life</span>
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
<div
class={`h-1.5 rounded-full transition-all ${
diskStats().avgWearout >= 50 ? 'bg-green-500' :
diskStats().avgWearout >= 20 ? 'bg-yellow-500' :
diskStats().avgWearout >= 10 ? 'bg-orange-500' :
'bg-red-500'
diskStats().avgWearout >= 50
? 'bg-green-500'
: diskStats().avgWearout >= 20
? 'bg-yellow-500'
: diskStats().avgWearout >= 10
? 'bg-orange-500'
: 'bg-red-500'
}`}
style={`width: ${diskStats().avgWearout}%`}
/>
@@ -115,7 +120,7 @@ export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
</div>
</div>
</Show>
{/* Disk Distribution */}
<div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Distribution</div>
@@ -12,7 +12,6 @@ const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
interface GuestRowProps {
guest: Guest;
alertStyles?: {
@@ -30,12 +29,12 @@ interface GuestRowProps {
export function GuestRow(props: GuestRowProps) {
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
// Update custom URL when prop changes
createEffect(() => {
setCustomUrl(props.customUrl);
});
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
const memPercent = createMemo(() => {
if (!props.guest.memory) return 0;
@@ -50,14 +49,14 @@ export function GuestRow(props: GuestRowProps) {
});
const isRunning = createMemo(() => props.guest.status === 'running');
// Get helpful tooltip for disk status
const getDiskStatusTooltip = () => {
if (!isVM(props.guest)) return 'Disk stats unavailable';
const vm = props.guest as VM;
const reason = vm.diskStatusReason;
switch (reason) {
case 'agent-not-running':
return 'Guest agent not running. Install and start qemu-guest-agent in the VM.';
@@ -85,12 +84,14 @@ export function GuestRow(props: GuestRowProps) {
const base = 'transition-all duration-200 relative';
const hover = 'hover:shadow-sm';
// Extract only the background color from alert styles, not the border
const alertBg = props.alertStyles?.hasAlert
? (props.alertStyles.severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20')
const alertBg = props.alertStyles?.hasAlert
? props.alertStyles.severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20'
: '';
const defaultHover = props.alertStyles?.hasAlert ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
const defaultHover = props.alertStyles?.hasAlert
? ''
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming}`;
});
@@ -108,7 +109,7 @@ export function GuestRow(props: GuestRowProps) {
if (!props.alertStyles?.hasAlert) return {};
const color = props.alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
return {
'box-shadow': `inset 4px 0 0 0 ${color}`
'box-shadow': `inset 4px 0 0 0 ${color}`,
};
});
@@ -118,17 +119,26 @@ export function GuestRow(props: GuestRowProps) {
<td class={firstCellClass()}>
<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-red-500'
}`} title={props.guest.status}></span>
<span
class={`h-2 w-2 rounded-full flex-shrink-0 ${
isRunning() ? 'bg-green-500' : 'bg-red-500'
}`}
title={props.guest.status}
></span>
{/* Name - clickable if custom URL is set */}
<Show when={customUrl()} fallback={
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" title={props.guest.name}>
{props.guest.name}
</span>
}>
<a
<Show
when={customUrl()}
fallback={
<span
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"
title={props.guest.name}
>
{props.guest.name}
</span>
}
>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
@@ -138,11 +148,11 @@ export function GuestRow(props: GuestRowProps) {
{props.guest.name}
</a>
</Show>
{/* Tag badges */}
<TagBadges
<TagBadges
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
maxVisible={3}
maxVisible={3}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
/>
@@ -151,11 +161,13 @@ export function GuestRow(props: GuestRowProps) {
{/* Type */}
<td class="py-0.5 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'
}`}>
<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>
@@ -165,11 +177,12 @@ export function GuestRow(props: GuestRowProps) {
{props.guest.vmid}
</td>
{/* Uptime */}
<td class={`py-0.5 px-2 text-sm whitespace-nowrap ${
props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
}`}>
<td
class={`py-0.5 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>
@@ -177,41 +190,50 @@ export function GuestRow(props: GuestRowProps) {
{/* CPU */}
<td class="py-0.5 px-2 w-[140px]">
<MetricBar
value={cpuPercent()}
<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}
sublabel={
props.guest.cpus
? `${((props.guest.cpu || 0) * props.guest.cpus).toFixed(1)}/${props.guest.cpus} cores`
: undefined
}
type="cpu"
/>
</td>
{/* Memory */}
<td class="py-0.5 px-2 w-[140px]">
<MetricBar
value={memPercent()}
<MetricBar
value={memPercent()}
label={`${memPercent().toFixed(0)}%`}
sublabel={props.guest.memory ? `${formatBytes(props.guest.memory.used)}/${formatBytes(props.guest.memory.total)}` : undefined}
sublabel={
props.guest.memory
? `${formatBytes(props.guest.memory.used)}/${formatBytes(props.guest.memory.total)}`
: undefined
}
type="memory"
/>
</td>
{/* Disk */}
<td class="py-0.5 px-2 w-[140px]">
<Show
<Show
when={props.guest.disk && props.guest.disk.total > 0 && diskPercent() !== -1}
fallback={
<span
class="text-gray-400 text-sm cursor-help"
title={getDiskStatusTooltip()}
>
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
-
</span>
}
>
<MetricBar
value={diskPercent()}
<MetricBar
value={diskPercent()}
label={`${diskPercent().toFixed(0)}%`}
sublabel={props.guest.disk ? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}` : undefined}
sublabel={
props.guest.disk
? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}`
: undefined
}
type="disk"
/>
</Show>
@@ -232,7 +254,6 @@ export function GuestRow(props: GuestRowProps) {
<td class="py-0.5 px-2">
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
</td>
</tr>
);
}
@@ -12,10 +12,10 @@ export function IOMetric(props: IOMetricProps) {
const getValue = () => {
return typeof props.value === 'function' ? props.value() : props.value;
};
// Create a local signal that tracks the value
const [currentValue, setCurrentValue] = createSignal(getValue() || 0);
// Update the signal when value changes
createEffect(() => {
const newValue = getValue() || 0;
@@ -28,7 +28,7 @@ export function IOMetric(props: IOMetricProps) {
// 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 = currentValue() / (1024 * 1024);
if (mbps < 1) return 'text-gray-300 dark:text-gray-400';
if (mbps < 10) return 'text-green-600 dark:text-green-400';
@@ -38,12 +38,12 @@ export function IOMetric(props: IOMetricProps) {
return (
<Show when={!props.disabled} fallback={<span class="text-sm text-gray-400">-</span>}>
<div class={`text-sm font-mono ${colorClass()} overflow-visible relative`} style="min-height: 24px;">
<AnimatedMetric
value={currentValue()}
formatter={(v) => formatSpeed(v, 0)}
/>
<div
class={`text-sm font-mono ${colorClass()} overflow-visible relative`}
style="min-height: 24px;"
>
<AnimatedMetric value={currentValue()} formatter={(v) => formatSpeed(v, 0)} />
</div>
</Show>
);
}
}
@@ -9,12 +9,12 @@ interface MetricBarProps {
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';
@@ -37,9 +37,9 @@ export function MetricBar(props: MetricBarProps) {
// 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'
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';
});
@@ -55,7 +55,7 @@ export function MetricBar(props: MetricBarProps) {
return (
<div class="metric-text">
<div class="relative min-w-[120px] w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
<div
class={`absolute top-0 left-0 h-full ${progressColorClass()}`}
style={{ width: `${width()}%` }}
/>
@@ -65,4 +65,4 @@ export function MetricBar(props: MetricBarProps) {
</div>
</div>
);
}
}
@@ -21,31 +21,34 @@ const NodeCard: Component<NodeCardProps> = (props) => {
</Card>
);
}
const isOnline = () => props.node.status === 'online' && props.node.uptime > 0 && props.node.connectionHealth !== 'error';
const isOnline = () =>
props.node.status === 'online' &&
props.node.uptime > 0 &&
props.node.connectionHealth !== 'error';
// Memoize CPU percent to avoid multiple calculations
const cpuPercent = createMemo(() => {
const percent = Math.round(props.node.cpu * 100);
return percent;
});
// Track CPU updates (logging removed for cleaner output)
createEffect(() => {
cpuPercent(); // Just track the value changes
});
const memPercent = createMemo(() => {
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 = createMemo(() => {
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) {
@@ -62,21 +65,26 @@ const NodeCard: Component<NodeCardProps> = (props) => {
// Helper function to create compact progress bar
const createProgressBar = (percentage: number, label: string, colorClass: string) => {
const bgColorClass = 'bg-gray-200 dark:bg-gray-600';
const progressColorClass = {
'red': 'bg-red-500/70 dark:bg-red-500/60',
'yellow': 'bg-yellow-500/70 dark:bg-yellow-500/60',
'green': 'bg-green-500/70 dark:bg-green-500/60'
}[colorClass] || 'bg-gray-500/70 dark:bg-gray-500/60';
const progressColorClass =
{
red: 'bg-red-500/70 dark:bg-red-500/60',
yellow: 'bg-yellow-500/70 dark:bg-yellow-500/60',
green: 'bg-green-500/70 dark:bg-green-500/60',
}[colorClass] || 'bg-gray-500/70 dark:bg-gray-500/60';
return (
<div class="w-[140px]">
<div class="flex justify-between items-center mb-0.5">
<span class="text-[10px] font-medium text-gray-600 dark:text-gray-400">{label}</span>
<span class="text-[10px] font-medium text-gray-700 dark:text-gray-300">{percentage}%</span>
<span class="text-[10px] font-medium text-gray-700 dark:text-gray-300">
{percentage}%
</span>
</div>
<div class={`relative w-full h-2 rounded-full overflow-hidden ${bgColorClass}`}>
<div class={`absolute top-0 left-0 h-full transition-all duration-300 ${progressColorClass}`}
style={{ width: `${percentage}%` }} />
<div
class={`absolute top-0 left-0 h-full transition-all duration-300 ${progressColorClass}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
@@ -100,10 +108,11 @@ const NodeCard: Component<NodeCardProps> = (props) => {
return 'green';
};
const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts);
const nodeAlerts = createMemo(() => getResourceAlerts(props.node.id || props.node.name, activeAlerts));
const nodeAlerts = createMemo(() =>
getResourceAlerts(props.node.id || props.node.name, activeAlerts),
);
// Determine border/ring style based on status and alerts
const getBorderClass = () => {
// Selected nodes get blue ring
@@ -116,21 +125,21 @@ const NodeCard: Component<NodeCardProps> = (props) => {
}
// Alert nodes get colored ring based on severity
if (alertStyles.hasAlert) {
return alertStyles.severity === 'critical'
? 'ring-2 ring-red-500 border-red-200 dark:border-red-600'
return alertStyles.severity === 'critical'
? 'ring-2 ring-red-500 border-red-200 dark:border-red-600'
: 'ring-2 ring-orange-500 border-orange-200 dark:border-orange-500';
}
// Normal nodes get standard border
return '';
};
// Get background class from alert styles but remove the border-l-4 part
const getBackgroundClass = () => {
if (!alertStyles.rowClass) return '';
// Remove border classes from rowClass to avoid conflicts
return alertStyles.rowClass.replace(/border-[^\s]+/g, '').trim();
};
return (
<Card
padding="sm"
@@ -140,10 +149,15 @@ const NodeCard: Component<NodeCardProps> = (props) => {
{/* Header */}
<div class="flex justify-between items-center">
<h3 class="text-xs font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-1">
<a
href={props.node.host || (props.node.name.includes(':') ? `https://${props.node.name}` : `https://${props.node.name}:8006`)}
target="_blank"
rel="noopener noreferrer"
<a
href={
props.node.host ||
(props.node.name.includes(':')
? `https://${props.node.name}`
: `https://${props.node.name}:8006`)
}
target="_blank"
rel="noopener noreferrer"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${props.node.name} web interface`}
>
@@ -151,11 +165,14 @@ const NodeCard: Component<NodeCardProps> = (props) => {
</a>
{/* Cluster/Standalone indicator - more compact */}
<Show when={props.node.isClusterMember !== undefined}>
<span class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`} title={props.node.isClusterMember ? props.node.clusterName : 'Standalone'}>
<span
class={`text-[9px] px-1 py-0.5 rounded-full font-medium ${
props.node.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
title={props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
>
{props.node.isClusterMember ? 'C' : 'S'}
</span>
</Show>
@@ -163,14 +180,19 @@ const NodeCard: Component<NodeCardProps> = (props) => {
<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()} />
<AlertCountBadge
count={alertStyles.alertCount}
severity={alertStyles.severity!}
alerts={nodeAlerts()}
/>
</Show>
</div>
</Show>
</h3>
<span class={`h-2 w-2 rounded-full flex-shrink-0 ${
isOnline() ? 'bg-green-500' : 'bg-red-500'
}`} title={isOnline() ? 'Online' : 'Offline'} />
<span
class={`h-2 w-2 rounded-full flex-shrink-0 ${isOnline() ? 'bg-green-500' : 'bg-red-500'}`}
title={isOnline() ? 'Online' : 'Offline'}
/>
</div>
{/* Metrics - Compact */}
@@ -182,7 +204,9 @@ const NodeCard: Component<NodeCardProps> = (props) => {
{/* Footer Info - More compact */}
<div class="flex justify-between text-[9px] text-gray-500 dark:text-gray-400 mt-1">
<span title={`Uptime: ${formatUptime(props.node.uptime)}`}>{formatUptime(props.node.uptime)}</span>
<span title={`Uptime: ${formatUptime(props.node.uptime)}`}>
{formatUptime(props.node.uptime)}
</span>
<span title={`Load: ${normalizedLoad()}`}>{normalizedLoad()}</span>
</div>
</Card>
@@ -10,7 +10,7 @@ interface PBSCardProps {
const PBSCard: Component<PBSCardProps> = (props) => {
const isOnline = () => props.instance.status === 'online';
const hasSystemStats = () => {
return props.instance.cpu > 0 || props.instance.memory > 0 || props.instance.uptime > 0;
};
@@ -19,28 +19,30 @@ const PBSCard: Component<PBSCardProps> = (props) => {
const isDockerPBS = () => {
// PBS in Docker typically has "docker" in the name
// pbs-docker has datastores but is still Docker, so check the name
return props.instance.status === 'online' &&
props.instance.version &&
!hasSystemStats() &&
props.instance.name.toLowerCase().includes('docker');
return (
props.instance.status === 'online' &&
props.instance.version &&
!hasSystemStats() &&
props.instance.name.toLowerCase().includes('docker')
);
};
// Calculate percentages
const cpuPercent = createMemo(() => Math.round(props.instance.cpu || 0));
const memPercent = createMemo(() => Math.round(props.instance.memory || 0));
const diskPercent = createMemo(() => {
if (!props.instance.datastores || props.instance.datastores.length === 0) return 0;
let totalUsed = 0;
let totalSpace = 0;
props.instance.datastores.forEach(ds => {
props.instance.datastores.forEach((ds) => {
totalUsed += ds.used || 0;
totalSpace += ds.total || 0;
});
return totalSpace > 0 ? Math.round((totalUsed / totalSpace) * 100) : 0;
});
@@ -49,30 +51,34 @@ const PBSCard: Component<PBSCardProps> = (props) => {
if (!props.instance.datastores || props.instance.datastores.length === 0) {
return { used: 0, total: 0 };
}
let totalUsed = 0;
let totalSpace = 0;
props.instance.datastores.forEach(ds => {
props.instance.datastores.forEach((ds) => {
totalUsed += ds.used || 0;
totalSpace += ds.total || 0;
});
return { used: totalUsed, total: totalSpace };
});
// Helper function to create progress bar with text overlay (matching NodeCard)
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';
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-[180px] h-3.5 rounded overflow-hidden ${bgColorClass}`}>
<div class={`absolute top-0 left-0 h-full ${progressColorClass}`} style={{ width: `${percentage}%` }} />
<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>
@@ -100,12 +106,12 @@ const PBSCard: Component<PBSCardProps> = (props) => {
// Format text for progress bars
const cpuText = () => `${cpuPercent()}%`;
const memoryText = () => {
if (!props.instance.memoryTotal || props.instance.memoryTotal === 0) return `${memPercent()}%`;
return `${memPercent()}% (${formatBytes(props.instance.memoryUsed)}/${formatBytes(props.instance.memoryTotal)})`;
};
const diskText = () => {
const usage = diskUsage();
if (usage.total === 0) return '0%';
@@ -134,10 +140,10 @@ const PBSCard: Component<PBSCardProps> = (props) => {
{/* Header */}
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold truncate text-gray-800 dark:text-gray-200 flex items-center gap-2">
<a
href={props.instance.host}
target="_blank"
rel="noopener noreferrer"
<a
href={props.instance.host}
target="_blank"
rel="noopener noreferrer"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${props.instance.name} web interface`}
>
@@ -145,9 +151,11 @@ const PBSCard: Component<PBSCardProps> = (props) => {
</a>
</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={`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.instance.status || 'unknown'}
</span>
@@ -198,15 +206,25 @@ const PBSCard: Component<PBSCardProps> = (props) => {
<span class="font-medium">Datastores: </span>
<span class="text-gray-500">{props.instance.datastores.length}</span>
</span>
<Show when={(() => {
const namespaceCount = props.instance.datastores?.reduce((acc, ds) =>
acc + (ds.namespaces?.filter(ns => ns.path !== '').length || 0), 0) || 0;
return namespaceCount > 0;
})()}>
<Show
when={(() => {
const namespaceCount =
props.instance.datastores?.reduce(
(acc, ds) =>
acc + (ds.namespaces?.filter((ns) => ns.path !== '').length || 0),
0,
) || 0;
return namespaceCount > 0;
})()}
>
<span class="text-gray-500">
{(() => {
const count = props.instance.datastores?.reduce((acc, ds) =>
acc + (ds.namespaces?.filter(ns => ns.path !== '').length || 0), 0) || 0;
const count =
props.instance.datastores?.reduce(
(acc, ds) =>
acc + (ds.namespaces?.filter((ns) => ns.path !== '').length || 0),
0,
) || 0;
return `${count} namespace${count !== 1 ? 's' : ''}`;
})()}
</span>
@@ -229,29 +247,39 @@ const PBSCard: Component<PBSCardProps> = (props) => {
{createProgressBar(diskPercent(), diskText(), getColor(diskPercent(), 'disk'))}
</div>
</Show>
<Show when={props.instance.datastores && props.instance.datastores.length > 0}>
<div class="flex justify-between pt-0.5">
<span>
<span class="font-medium">Datastores: </span>
<span class="text-gray-500">{props.instance.datastores.length}</span>
</span>
<Show when={(() => {
const namespaceCount = props.instance.datastores?.reduce((acc, ds) =>
acc + (ds.namespaces?.filter(ns => ns.path !== '').length || 0), 0) || 0;
return namespaceCount > 0;
})()}>
<Show
when={(() => {
const namespaceCount =
props.instance.datastores?.reduce(
(acc, ds) =>
acc + (ds.namespaces?.filter((ns) => ns.path !== '').length || 0),
0,
) || 0;
return namespaceCount > 0;
})()}
>
<span class="text-gray-500">
{(() => {
const count = props.instance.datastores?.reduce((acc, ds) =>
acc + (ds.namespaces?.filter(ns => ns.path !== '').length || 0), 0) || 0;
const count =
props.instance.datastores?.reduce(
(acc, ds) =>
acc + (ds.namespaces?.filter((ns) => ns.path !== '').length || 0),
0,
) || 0;
return `${count} namespace${count !== 1 ? 's' : ''}`;
})()}
</span>
</Show>
</div>
</Show>
<div class="flex justify-between text-[11px] text-gray-500 dark:text-gray-400 pt-0.5">
<span>PBS v{props.instance.version}</span>
<span class="text-[10px] italic">No Sys.Audit permission</span>
@@ -15,14 +15,14 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
const maxVisible = () => props.maxVisible ?? 3;
const darkModeSignal = useDarkMode();
const isDark = () => props.isDarkMode ?? darkModeSignal();
const visibleTags = () => props.tags?.slice(0, maxVisible()) || [];
const hiddenTags = () => props.tags?.slice(maxVisible()) || [];
const hasHiddenTags = () => hiddenTags().length > 0;
const [hoveredTag, setHoveredTag] = createSignal<string | null>(null);
const [tooltipPos, setTooltipPos] = createSignal<{ x: number; y: number } | null>(null);
return (
<Show when={props.tags && props.tags.length > 0}>
<div class="inline-flex items-center gap-1 ml-2">
@@ -30,7 +30,7 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
{(tag) => {
const colors = () => getTagColorWithSpecial(tag, isDark());
const isActive = () => props.activeSearch?.includes(`tags:${tag}`) || false;
return (
<div
class="relative group"
@@ -49,12 +49,12 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
}}
>
{/* Colored dot indicator */}
<div
<div
class="w-2 h-2 rounded-full hover:scale-150 transition-transform duration-200 ease-out cursor-pointer"
style={{
'background-color': colors().bg,
'box-shadow': isActive()
? isDark()
'box-shadow': isActive()
? isDark()
? `0 0 0 2.5px rgba(255, 255, 255, 0.9)` // White ring in dark mode when active
: `0 0 0 2.5px rgba(0, 0, 0, 0.8)` // Black ring in light mode when active
: 'none', // No box-shadow when not active - just flat circle
@@ -64,10 +64,10 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
);
}}
</For>
{/* Show +X more indicator if there are hidden tags */}
<Show when={hasHiddenTags()}>
<div
<div
class="relative group"
onMouseEnter={(e) => {
setHoveredTag('more');
@@ -85,13 +85,13 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
</div>
</Show>
</div>
{/* Render tooltips in a portal to avoid z-index issues */}
<Portal>
<Show when={hoveredTag() && tooltipPos()}>
{hoveredTag() === 'more' ? (
// Tooltip for hidden tags
<div
<div
class="fixed px-2 py-1 bg-gray-800 dark:bg-gray-700 text-white text-xs rounded shadow-lg pointer-events-none"
style={{
left: `${tooltipPos()!.x}px`,
@@ -101,9 +101,7 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
}}
>
<div class="space-y-0.5">
<For each={hiddenTags()}>
{(tag) => <div>{tag}</div>}
</For>
<For each={hiddenTags()}>{(tag) => <div>{tag}</div>}</For>
</div>
</div>
) : (
@@ -112,15 +110,15 @@ export const TagBadges: Component<TagBadgesProps> = (props) => {
const tag = hoveredTag()!;
const colors = () => getTagColorWithSpecial(tag, isDark());
return (
<div
<div
class="fixed px-2 py-1 text-xs rounded shadow-lg pointer-events-none"
style={{
left: `${tooltipPos()!.x}px`,
top: `${tooltipPos()!.y - 35}px`,
transform: 'translateX(-50%)',
'background-color': colors().bg,
'color': colors().text,
'border': `1px solid ${colors().border}`,
color: colors().text,
border: `1px solid ${colors().border}`,
'z-index': '999999',
}}
>
@@ -18,7 +18,7 @@ export function ThresholdSlider(props: ThresholdSliderProps) {
const colorMap = {
cpu: 'text-blue-500',
memory: 'text-green-500',
disk: 'text-amber-500'
disk: 'text-amber-500',
};
// Calculate visual position - allow full range 0-100%
@@ -45,45 +45,47 @@ export function ThresholdSlider(props: ThresholdSliderProps) {
// 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
<div
class="relative w-full h-3.5 overflow-visible"
onWheel={(e) => isDragging() && e.preventDefault()}
style={{ "touch-action": isDragging() ? "none" : "auto" }}
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
<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'
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}
@@ -95,21 +97,23 @@ export function ThresholdSlider(props: ThresholdSliderProps) {
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" }}
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={{
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
})`
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">
@@ -120,4 +124,4 @@ export function ThresholdSlider(props: ThresholdSliderProps) {
</div>
</div>
);
}
}
@@ -15,9 +15,18 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
<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
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>
<SectionHeader
@@ -31,19 +40,19 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
</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>
<p class="text-sm text-red-800 dark:text-red-200 font-mono">{props.error.message}</p>
</div>
<div class="flex gap-2">
<button type="button"
<button
type="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 type="button"
<button
type="button"
onClick={() => window.location.reload()}
class="flex-1 px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
@@ -51,7 +60,8 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
</button>
</div>
<button type="button"
<button
type="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"
>
@@ -76,7 +86,7 @@ export const ErrorBoundary: Component<ErrorBoundaryProps> = (props) => {
fallback={(error, reset) => {
// Log the error
logError('Error boundary caught error', error);
// Call custom error handler if provided
if (props.onError) {
props.onError(error);
@@ -86,7 +96,7 @@ export const ErrorBoundary: Component<ErrorBoundaryProps> = (props) => {
if (props.fallback) {
return props.fallback(error, reset);
}
return <DefaultErrorFallback error={error} reset={reset} />;
}}
>
@@ -105,9 +115,18 @@ export const ComponentErrorBoundary: Component<{
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
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>
<SectionHeader
title={`Error in ${props.name}`}
@@ -115,10 +134,9 @@ export const ComponentErrorBoundary: Component<{
titleClass="text-red-800 dark:text-red-200"
/>
</div>
<p class="text-xs text-red-700 dark:text-red-300 mb-2">
{error.message}
</p>
<button type="button"
<p class="text-xs text-red-700 dark:text-red-300 mb-2">{error.message}</p>
<button
type="button"
onClick={reset}
class="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
+129 -57
View File
@@ -18,7 +18,7 @@ export const FirstRunSetup: Component = () => {
const [savedToken, setSavedToken] = createSignal('');
const [copied, setCopied] = createSignal<'password' | 'token' | null>(null);
const [themeMode, setThemeMode] = createSignal<'system' | 'light' | 'dark'>('system');
const applyTheme = (mode: 'system' | 'light' | 'dark') => {
if (mode === 'light') {
document.documentElement.classList.remove('dark');
@@ -70,7 +70,7 @@ export const FirstRunSetup: Component = () => {
// Generate 24 bytes (48 hex chars) to avoid hash detection issue
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
};
const handleSetup = async () => {
@@ -91,17 +91,17 @@ export const FirstRunSetup: Component = () => {
}
setIsSettingUp(true);
// Generate password if not custom
const finalPassword = useCustomPassword() ? password() : generatePassword();
if (!useCustomPassword()) {
setGeneratedPassword(finalPassword);
}
// Generate API token
const token = generateToken();
setApiToken(token);
try {
const response = await fetch('/api/security/quick-setup', {
method: 'POST',
@@ -109,33 +109,31 @@ export const FirstRunSetup: Component = () => {
body: JSON.stringify({
username: username(),
password: finalPassword,
apiToken: token
})
apiToken: token,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to setup security');
}
const result = await response.json();
if (result.skipped) {
// Shouldn't happen in first-run, but handle it
window.location.reload();
return;
}
// Save credentials for display
setSavedUsername(username());
setSavedPassword(useCustomPassword() ? password() : generatedPassword());
setSavedToken(token);
// Show credentials
setShowCredentials(true);
showSuccess('Security configured successfully!');
} catch (error) {
showError(`Failed to setup security: ${error}`);
} finally {
@@ -190,23 +188,31 @@ IMPORTANT: Keep these credentials secure!
{/* Logo/Header */}
<div class="text-center mb-8">
<div class="flex items-center justify-center gap-2 mb-4">
<svg
width="48"
height="48"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
<svg
width="48"
height="48"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class="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"/>
<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-4xl font-bold text-gray-800 dark:text-gray-100">Pulse</span>
</div>
<p class="text-gray-600 dark:text-gray-400">
Let's set up your monitoring dashboard
</p>
<p class="text-gray-600 dark:text-gray-400">Let's set up your monitoring dashboard</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden">
@@ -218,7 +224,7 @@ IMPORTANT: Keep these credentials secure!
class="mb-6"
titleClass="text-gray-800 dark:text-gray-100"
/>
<div class="space-y-6">
{/* Username */}
<div>
@@ -239,23 +245,25 @@ IMPORTANT: Keep these credentials secure!
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Admin Password
</label>
<div class="flex gap-2 mb-3">
<button type="button"
<button
type="button"
onClick={() => setUseCustomPassword(false)}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
!useCustomPassword()
? 'bg-blue-600 text-white'
!useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Generate Secure Password
</button>
<button type="button"
<button
type="button"
onClick={() => setUseCustomPassword(true)}
class={`flex-1 py-2 px-4 rounded-lg text-sm font-medium transition-colors ${
useCustomPassword()
? 'bg-blue-600 text-white'
useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
@@ -285,8 +293,8 @@ IMPORTANT: Keep these credentials secure!
<Show when={!useCustomPassword()}>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p class="text-sm text-blue-700 dark:text-blue-300">
A secure 16-character password will be generated for you.
Make sure to save it when shown!
A secure 16-character password will be generated for you. Make sure to save
it when shown!
</p>
</div>
</Show>
@@ -298,7 +306,8 @@ IMPORTANT: Keep these credentials secure!
Theme Preference
</label>
<div class="grid grid-cols-3 gap-2">
<button type="button"
<button
type="button"
onClick={() => {
setThemeMode('system');
applyTheme('system');
@@ -311,7 +320,8 @@ IMPORTANT: Keep these credentials secure!
>
System
</button>
<button type="button"
<button
type="button"
onClick={() => {
setThemeMode('light');
applyTheme('light');
@@ -324,7 +334,8 @@ IMPORTANT: Keep these credentials secure!
>
Light
</button>
<button type="button"
<button
type="button"
onClick={() => {
setThemeMode('dark');
applyTheme('dark');
@@ -339,7 +350,7 @@ IMPORTANT: Keep these credentials secure!
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{themeMode() === 'system'
{themeMode() === 'system'
? 'Using your operating system theme preference'
: `Using ${themeMode()} mode`}
</p>
@@ -373,7 +384,8 @@ IMPORTANT: Keep these credentials secure!
</div>
{/* Setup Button */}
<button type="button"
<button
type="button"
onClick={handleSetup}
disabled={isSettingUp()}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors disabled:cursor-not-allowed"
@@ -388,8 +400,18 @@ IMPORTANT: Keep these credentials secure!
<div class="p-8">
<div class="text-center mb-6">
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/50 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<svg
class="w-8 h-8 text-green-600 dark:text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<SectionHeader
@@ -424,18 +446,39 @@ IMPORTANT: Keep these credentials secure!
<code class="font-mono text-lg text-gray-900 dark:text-gray-100 break-all">
{savedPassword()}
</code>
<button type="button"
<button
type="button"
onClick={() => handleCopy('password')}
class="ml-2 p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Copy password"
>
{copied() === 'password' ? (
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<svg
class="w-5 h-5 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
<svg
class="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
)}
</button>
@@ -451,18 +494,39 @@ IMPORTANT: Keep these credentials secure!
<code class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all">
{savedToken()}
</code>
<button type="button"
<button
type="button"
onClick={() => handleCopy('token')}
class="ml-2 p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
title="Copy token"
>
{copied() === 'token' ? (
<svg class="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<svg
class="w-5 h-5 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
) : (
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
<svg
class="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
)}
</button>
@@ -475,22 +539,30 @@ IMPORTANT: Keep these credentials secure!
Important
</p>
<p class="text-xs text-amber-700 dark:text-amber-300">
These credentials will never be shown again. Save them in a password manager now!
These credentials will never be shown again. Save them in a password manager
now!
</p>
</div>
{/* Action Buttons */}
<div class="flex gap-3">
<button type="button"
<button
type="button"
onClick={downloadCredentials}
class="flex-1 py-3 px-4 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
Download Credentials
</button>
<button type="button"
<button
type="button"
onClick={() => window.location.reload()}
class="flex-1 py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
@@ -504,4 +576,4 @@ IMPORTANT: Keep these credentials secure!
</div>
</div>
);
};
};
+155 -51
View File
@@ -3,7 +3,9 @@ import { setBasicAuth } from '@/utils/apiClient';
import { STORAGE_KEYS } from '@/constants';
// Force include FirstRunSetup with lazy loading
const FirstRunSetup = lazy(() => import('./FirstRunSetup').then(m => ({ default: m.FirstRunSetup })));
const FirstRunSetup = lazy(() =>
import('./FirstRunSetup').then((m) => ({ default: m.FirstRunSetup })),
);
interface LoginProps {
onLogin: () => void;
@@ -49,7 +51,7 @@ export const Login: Component<LoginProps> = (props) => {
return 'Single sign-on failed. Please try again or contact an administrator.';
}
};
onMount(async () => {
// Apply saved theme preference from localStorage
const savedTheme = localStorage.getItem(STORAGE_KEYS.DARK_MODE);
@@ -101,7 +103,7 @@ export const Login: Component<LoginProps> = (props) => {
// On error, assume no auth configured
setAuthStatus({ hasAuthentication: false });
}
} catch (err) {
} catch (_err) {
console.error('[Login] Failed to check auth status:', err);
// On error, assume no auth configured
setAuthStatus({ hasAuthentication: false });
@@ -124,11 +126,11 @@ export const Login: Component<LoginProps> = (props) => {
const response = await fetch('/api/oidc/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
},
body: JSON.stringify({
returnTo: `${window.location.pathname}${window.location.search}`
})
returnTo: `${window.location.pathname}${window.location.search}`,
}),
});
if (!response.ok) {
@@ -144,7 +146,7 @@ export const Login: Component<LoginProps> = (props) => {
}
throw new Error('OIDC response missing authorization URL');
} catch (err) {
} catch (_err) {
console.error('[Login] Failed to start OIDC login:', err);
setOidcError('Failed to start single sign-on. Please try again.');
} finally {
@@ -172,13 +174,13 @@ export const Login: Component<LoginProps> = (props) => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
Accept: 'application/json',
},
body: JSON.stringify({
username: username(),
password: password()
password: password(),
}),
credentials: 'include' // Important for session cookie
credentials: 'include', // Important for session cookie
});
const data = await response.json();
@@ -190,7 +192,9 @@ export const Login: Component<LoginProps> = (props) => {
} else if (response.status === 403) {
// Account is locked
if (data.remainingMinutes) {
setError(`Account locked. Please try again in ${data.remainingMinutes} ${data.remainingMinutes === 1 ? 'minute' : 'minutes'}.`);
setError(
`Account locked. Please try again in ${data.remainingMinutes} ${data.remainingMinutes === 1 ? 'minute' : 'minutes'}.`,
);
} else {
setError(data.message || 'Account temporarily locked due to too many failed attempts.');
}
@@ -203,7 +207,9 @@ export const Login: Component<LoginProps> = (props) => {
} else if (response.status === 401) {
// Invalid credentials with attempt information
if (data.remaining !== undefined && data.remaining > 0) {
setError(`${data.message || 'Invalid username or password.'} (${data.remaining} ${data.remaining === 1 ? 'attempt' : 'attempts'} remaining)`);
setError(
`${data.message || 'Invalid username or password.'} (${data.remaining} ${data.remaining === 1 ? 'attempt' : 'attempts'} remaining)`,
);
} else if (data.locked) {
setError(data.message || 'Invalid username or password. Account is now locked.');
} else {
@@ -215,16 +221,16 @@ export const Login: Component<LoginProps> = (props) => {
} else {
setError(data.message || 'Server error. Please try again.');
}
} catch (err) {
} catch (_err) {
// Try the old method as fallback
try {
const response = await fetch('/api/state', {
headers: {
'Authorization': `Basic ${btoa(`${username()}:${password()}`)}`,
Authorization: `Basic ${btoa(`${username()}:${password()}`)}`,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
Accept: 'application/json',
},
credentials: 'include'
credentials: 'include',
});
if (response.ok) {
@@ -237,7 +243,7 @@ export const Login: Component<LoginProps> = (props) => {
} else {
setError('Server error. Please try again.');
}
} catch (fallbackErr) {
} catch (_fallbackErr) {
setError('Failed to connect to server');
}
} finally {
@@ -247,7 +253,7 @@ export const Login: Component<LoginProps> = (props) => {
// Debug logging
console.log('[Login] Render - loadingAuth:', loadingAuth(), 'authStatus:', authStatus());
return (
<Show
when={!loadingAuth()}
@@ -262,16 +268,35 @@ export const Login: Component<LoginProps> = (props) => {
>
<Show
when={authStatus()?.hasAuthentication === false}
fallback={<LoginForm {...{ username, setUsername, password, setPassword, error, loading, handleSubmit, supportsOIDC, startOidcLogin, oidcLoading, oidcError, oidcMessage }} />}
fallback={
<LoginForm
{...{
username,
setUsername,
password,
setPassword,
error,
loading,
handleSubmit,
supportsOIDC,
startOidcLogin,
oidcLoading,
oidcError,
oidcMessage,
}}
/>
}
>
<Suspense fallback={
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900">
<div class="text-center">
<div class="animate-spin h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading setup...</p>
<Suspense
fallback={
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900">
<div class="text-center">
<div class="animate-spin h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<p class="text-gray-600 dark:text-gray-400">Loading setup...</p>
</div>
</div>
</div>
}>
}
>
<FirstRunSetup />
</Suspense>
</Show>
@@ -294,7 +319,20 @@ const LoginForm: Component<{
oidcError: () => string;
oidcMessage: () => string;
}> = (props) => {
const { username, setUsername, password, setPassword, error, loading, handleSubmit, supportsOIDC, startOidcLogin, oidcLoading, oidcError, oidcMessage } = props;
const {
username,
setUsername,
password,
setPassword,
error,
loading,
handleSubmit,
supportsOIDC,
startOidcLogin,
oidcLoading,
oidcError,
oidcMessage,
} = props;
return (
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900 py-12 px-4 sm:px-6 lg:px-8">
@@ -303,9 +341,9 @@ const LoginForm: Component<{
<div class="flex justify-center mb-4">
<div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-blue-600 to-cyan-600 rounded-full blur opacity-25 group-hover:opacity-75 transition duration-1000 group-hover:duration-200 animate-pulse-slow"></div>
<img
src="/logo.svg"
alt="Pulse Logo"
<img
src="/logo.svg"
alt="Pulse Logo"
class="relative w-24 h-24 transform transition duration-500 group-hover:scale-110"
/>
</div>
@@ -317,7 +355,10 @@ const LoginForm: Component<{
Enter your credentials to continue
</p>
</div>
<form class="mt-8 space-y-6 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg rounded-lg p-8 shadow-xl animate-slide-up" onSubmit={handleSubmit}>
<form
class="mt-8 space-y-6 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg rounded-lg p-8 shadow-xl animate-slide-up"
onSubmit={handleSubmit}
>
<Show when={supportsOIDC()}>
<div class="space-y-3">
<button
@@ -337,7 +378,12 @@ const LoginForm: Component<{
>
<span class="inline-flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.8"
d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9"
/>
</svg>
Continue with Single Sign-On
</span>
@@ -355,10 +401,14 @@ const LoginForm: Component<{
</Show>
<div class="flex items-center gap-3 pt-2">
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
<span class="text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">or</span>
<span class="text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">
or
</span>
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
</div>
<p class="text-xs text-center text-gray-500 dark:text-gray-400">Use your admin credentials to sign in below.</p>
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
Use your admin credentials to sign in below.
</p>
</div>
</Show>
<input type="hidden" name="remember" value="true" />
@@ -368,8 +418,18 @@ const LoginForm: Component<{
Username
</label>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<svg
class="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<input
@@ -389,8 +449,18 @@ const LoginForm: Component<{
Password
</label>
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
<svg
class="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<input
@@ -408,31 +478,50 @@ const LoginForm: Component<{
</div>
<Show when={error()}>
<div class={`rounded-md p-4 ${
error().includes('locked') ? 'bg-orange-50 dark:bg-orange-900/20' : 'bg-red-50 dark:bg-red-900/20'
}`}>
<div
class={`rounded-md p-4 ${
error().includes('locked')
? 'bg-orange-50 dark:bg-orange-900/20'
: 'bg-red-50 dark:bg-red-900/20'
}`}
>
<div class="flex">
<div class="flex-shrink-0">
<Show
<Show
when={error().includes('locked')}
fallback={
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
}
>
<svg class="h-5 w-5 text-orange-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</Show>
</div>
<div class="ml-3">
<p class={`text-sm ${
error().includes('locked') ? 'text-orange-800 dark:text-orange-200' : 'text-red-800 dark:text-red-200'
}`}>{error()}</p>
<p
class={`text-sm ${
error().includes('locked')
? 'text-orange-800 dark:text-orange-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{error()}
</p>
<Show when={error().includes('locked') && error().includes('minute')}>
<p class="text-xs mt-1 text-orange-700 dark:text-orange-300">
Lockouts automatically expire after the specified time. If you need immediate access, contact your administrator.
Lockouts automatically expire after the specified time. If you need immediate
access, contact your administrator.
</p>
</Show>
</div>
@@ -447,9 +536,24 @@ const LoginForm: Component<{
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transform transition hover:scale-105 shadow-lg"
>
<Show when={loading()}>
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" 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
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
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>
<Show when={loading()} fallback="Sign in to Pulse">
@@ -22,4 +22,4 @@ const NotificationContainer: Component = () => {
);
};
export default NotificationContainer;
export default NotificationContainer;
@@ -10,7 +10,7 @@ interface NotificationToastProps {
const NotificationToast: Component<NotificationToastProps> = (props) => {
const [isVisible, setIsVisible] = createSignal(true);
const [isLeaving, setIsLeaving] = createSignal(false);
const handleClose = () => {
setIsLeaving(true);
setTimeout(() => {
@@ -18,33 +18,41 @@ const NotificationToast: Component<NotificationToastProps> = (props) => {
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';
case 'success':
return 'text-green-400';
case 'error':
return 'text-red-400';
default:
return 'text-blue-400';
}
};
const icon = () => {
switch (props.type) {
case 'success':
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" />
<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>
);
@@ -53,25 +61,35 @@ const NotificationToast: Component<NotificationToastProps> = (props) => {
<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" />
<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:
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" />
<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
<div
class={`
fixed top-4 right-4 z-50
backdrop-blur-xl bg-white/10 dark:bg-gray-900/30
@@ -84,22 +102,30 @@ const NotificationToast: Component<NotificationToastProps> = (props) => {
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)"
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={`${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>
<p class="text-gray-800 dark:text-gray-100 font-medium text-sm leading-relaxed">
{props.message}
</p>
</div>
<button type="button"
<button
type="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" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -107,4 +133,4 @@ const NotificationToast: Component<NotificationToastProps> = (props) => {
);
};
export default NotificationToast;
export default NotificationToast;
@@ -37,17 +37,17 @@ export const SecurityWarning: Component = () => {
const response = await fetch('/api/security/status');
if (response.ok) {
const data = await response.json();
// Calculate security score
let score = 0;
const maxScore = 5;
if (data.credentialsEncrypted !== false) score++; // Always true currently
if (data.exportProtected) score++;
if (data.apiTokenConfigured) score++;
if (data.hasHTTPS || window.location.protocol === 'https:') score++;
if (data.hasAuthentication) score++;
setStatus({
hasAuthentication: data.hasAuthentication || false,
hasHTTPS: window.location.protocol === 'https:',
@@ -59,7 +59,7 @@ export const SecurityWarning: Component = () => {
maxScore,
publicAccess: data.publicAccess || false,
isPrivateNetwork: data.isPrivateNetwork,
clientIP: data.clientIP
clientIP: data.clientIP,
});
}
} catch (error) {
@@ -99,27 +99,29 @@ export const SecurityWarning: Component = () => {
const shouldShow = () => {
if (dismissed()) return false;
if (!status()) return false;
// Always show if public access without auth
if (status()!.publicAccess && !status()!.hasAuthentication) {
return true;
}
// Show if score is low
return status()!.score < 4;
};
if (!shouldShow()) {
return null;
}
return (
<Portal>
<div class={`fixed top-0 left-0 right-0 z-50 border-b shadow-sm ${
status()!.publicAccess && !status()!.hasAuthentication
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
}`}>
<div
class={`fixed top-0 left-0 right-0 z-50 border-b shadow-sm ${
status()!.publicAccess && !status()!.hasAuthentication
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
}`}
>
<div class="max-w-7xl mx-auto px-4 py-3">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3">
@@ -127,23 +129,32 @@ export const SecurityWarning: Component = () => {
<div>
<div class="flex items-center gap-3">
<SectionHeader
title={<span>Security score: <span class={getScoreColor(status()!.score, status()!.maxScore)}>{status()!.score}/{status()!.maxScore}</span></span>}
title={
<span>
Security score:{' '}
<span class={getScoreColor(status()!.score, status()!.maxScore)}>
{status()!.score}/{status()!.maxScore}
</span>
</span>
}
size="sm"
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
/>
<button type="button"
<button
type="button"
onClick={() => setShowDetails(!showDetails())}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
{showDetails() ? 'Hide' : 'Show'} Details
</button>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1">
{status()!.publicAccess ? (
<span class="font-semibold text-red-700 dark:text-red-300">
PUBLIC NETWORK ACCESS DETECTED - Your Proxmox credentials are exposed to the internet!
PUBLIC NETWORK ACCESS DETECTED - Your Proxmox credentials are exposed to
the internet!
</span>
) : (
'Your Pulse instance is accessible without authentication. Proxmox credentials could be exposed.'
@@ -154,7 +165,9 @@ export const SecurityWarning: Component = () => {
<div class="mt-3 space-y-1">
<div class="text-xs space-y-1">
<div class="flex items-center gap-2">
<span class={status()!.credentialsEncrypted ? 'text-green-600' : 'text-red-600'}>
<span
class={status()!.credentialsEncrypted ? 'text-green-600' : 'text-red-600'}
>
{status()!.credentialsEncrypted ? '✅' : '❌'}
</span>
<span>Credentials encrypted at rest</span>
@@ -166,7 +179,9 @@ export const SecurityWarning: Component = () => {
<span>Export requires authentication</span>
</div>
<div class="flex items-center gap-2">
<span class={status()!.hasAuthentication ? 'text-green-600' : 'text-red-600'}>
<span
class={status()!.hasAuthentication ? 'text-green-600' : 'text-red-600'}
>
{status()!.hasAuthentication ? '✅' : '❌'}
</span>
<span>Authentication enabled</span>
@@ -202,26 +217,30 @@ export const SecurityWarning: Component = () => {
Learn More
</a>
<div class="relative group">
<button type="button"
<button
type="button"
onClick={() => handleDismiss('day')}
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Dismiss
</button>
<div class="absolute left-0 top-full mt-1 bg-white dark:bg-gray-800 rounded shadow-lg border border-gray-200 dark:border-gray-700 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity">
<button type="button"
<button
type="button"
onClick={() => handleDismiss('day')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
For 1 day
</button>
<button type="button"
<button
type="button"
onClick={() => handleDismiss('week')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
For 1 week
</button>
<button type="button"
<button
type="button"
onClick={() => handleDismiss('forever')}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
>
@@ -11,35 +11,35 @@ export const APIOnlySetup: Component<APIOnlySetupProps> = (props) => {
const [token, setToken] = createSignal<string | null>(null);
const [showToken, setShowToken] = createSignal(false);
const [copied, setCopied] = createSignal(false);
const generateToken = async () => {
setIsGenerating(true);
try {
const response = await fetch('/api/security/regenerate-token', {
method: 'POST',
credentials: 'include'
credentials: 'include',
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to generate token');
}
const data = await response.json();
setToken(data.token);
setShowToken(true);
showSuccess('API token generated! Save it now - it won\'t be shown again.');
showSuccess("API token generated! Save it now - it won't be shown again.");
} catch (error) {
showError(`Failed to generate token: ${error}`);
} finally {
setIsGenerating(false);
}
};
const handleCopy = async () => {
if (!token()) return;
const success = await copyToClipboard(token()!);
if (success) {
setCopied(true);
@@ -48,7 +48,7 @@ export const APIOnlySetup: Component<APIOnlySetupProps> = (props) => {
showError('Failed to copy to clipboard');
}
};
return (
<div class="space-y-4">
<Show when={!showToken()}>
@@ -61,15 +61,16 @@ export const APIOnlySetup: Component<APIOnlySetupProps> = (props) => {
<li>Monitoring integrations</li>
<li>Third-party applications</li>
</ul>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p class="text-xs text-amber-700 dark:text-amber-300">
<strong>Note:</strong> Without password authentication enabled,
the UI will remain publicly accessible.
<strong>Note:</strong> Without password authentication enabled, the UI will remain
publicly accessible.
</p>
</div>
<button type="button"
<button
type="button"
onClick={generateToken}
disabled={isGenerating()}
class="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@@ -78,7 +79,7 @@ export const APIOnlySetup: Component<APIOnlySetupProps> = (props) => {
</button>
</div>
</Show>
<Show when={showToken() && token()}>
<div class="space-y-4">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
@@ -89,14 +90,17 @@ export const APIOnlySetup: Component<APIOnlySetupProps> = (props) => {
Save this token now - it will never be shown again!
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Your API Token</label>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Your API Token
</label>
<div class="flex items-center space-x-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{token()}
</code>
<button type="button"
<button
type="button"
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
@@ -104,9 +108,9 @@ export const APIOnlySetup: Component<APIOnlySetupProps> = (props) => {
</button>
</div>
</div>
<button type="button"
<button
type="button"
onClick={() => {
setShowToken(false);
setToken(null);
@@ -122,4 +126,4 @@ export const APIOnlySetup: Component<APIOnlySetupProps> = (props) => {
</Show>
</div>
);
};
};
@@ -29,7 +29,11 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
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 [progress, setProgress] = createSignal<{
current: number;
total: number;
currentServer?: string;
}>({ current: 0, total: 0 });
const handleBatchAdd = async () => {
// Validate inputs
@@ -63,24 +67,24 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
name: server.hostname || server.ip,
host: `https://${server.ip}:${server.port}`,
verifySSL: false,
...(authType() === 'password'
...(authType() === 'password'
? { user: username(), password: password() }
: { tokenName: tokenName(), tokenValue: tokenValue() }),
// Add default monitoring options based on type
...(server.type === 'pve'
...(server.type === 'pve'
? {
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true
monitorBackups: true,
}
: {
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: false
})
monitorGarbageJobs: false,
}),
} as NodeConfig;
await NodesAPI.addNode(nodeData);
@@ -111,11 +115,11 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
{/* Backdrop */}
<div
<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 */}
@@ -126,29 +130,41 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
class="flex-1"
/>
<Show when={!isAdding()}>
<button type="button"
<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">
<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>
<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>
<span
class={`w-2 h-2 rounded-full ${server.type === 'pve' ? 'bg-orange-500' : 'bg-green-500'}`}
></span>
{server.hostname || server.ip}
</span>
)}
@@ -179,7 +195,9 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
disabled={isAdding()}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">API Token (Recommended)</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
API Token (Recommended)
</span>
</label>
<label class="flex items-center">
<input
@@ -191,7 +209,9 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
disabled={isAdding()}
class="mr-2"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Username & Password</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
Username & Password
</span>
</label>
</div>
</div>
@@ -215,7 +235,7 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
Example: pulse-monitor@pam!pulse-token or pulse-monitor@pbs!pulse-token
</p>
</div>
<div class={formField}>
<label class={labelClass()}>
Token Value <span class="text-red-500">*</span>
@@ -248,7 +268,7 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
class={controlClass()}
/>
</div>
<div class={formField}>
<label class={labelClass()}>
Password <span class="text-red-500">*</span>
@@ -270,9 +290,24 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
<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
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">
@@ -286,7 +321,7 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
</div>
</div>
<div class="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
<div
<div
class="bg-blue-600 dark:bg-blue-400 h-2 rounded-full transition-all"
style={`width: ${(progress().current / progress().total) * 100}%`}
></div>
@@ -294,17 +329,19 @@ export const BatchCredentialModal: Component<BatchCredentialModalProps> = (props
</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"
<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 type="button"
<button
type="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"
@@ -42,16 +42,16 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
// Get CSRF token from cookie
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('pulse_csrf='))
.find((row) => row.startsWith('pulse_csrf='))
?.split('=')[1];
// Get the actual username from sessionStorage or use 'admin' as fallback
const authUser = sessionStorage.getItem('pulse_auth_user') || 'admin';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Basic ${btoa(`${authUser}:${currentPassword()}`)}`,
Authorization: `Basic ${btoa(`${authUser}:${currentPassword()}`)}`,
};
// Add CSRF token if available
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
@@ -76,20 +76,19 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
}
showSuccess('Password changed successfully. Please log in with your new password.');
// Clear form
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
// Close modal and trigger re-authentication
props.onClose();
// Reload page to force re-login with new password
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to change password';
setError(errorMessage);
@@ -112,17 +111,26 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
return (
<Show when={props.isOpen}>
<Portal>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4" style="z-index: 9999">
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
style="z-index: 9999"
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<SectionHeader title="Change password" size="lg" class="flex-1" />
<button type="button"
<button
type="button"
onClick={handleClose}
disabled={loading()}
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
>
<svg class="w-6 h-6" 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" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -157,9 +165,7 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
disabled={loading()}
minLength={8}
/>
<p class={`${formHelpText} mt-1`}>
Minimum 8 characters
</p>
<p class={`${formHelpText} mt-1`}>Minimum 8 characters</p>
</div>
<div class={formField}>
@@ -184,14 +190,16 @@ export const ChangePasswordModal: Component<ChangePasswordModalProps> = (props)
</Show>
<div class="flex justify-end space-x-3 pt-4">
<button type="button"
<button
type="button"
onClick={handleClose}
disabled={loading()}
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
Cancel
</button>
<button type="submit"
<button
type="submit"
disabled={loading()}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
@@ -61,12 +61,12 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
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 || []
errors: data.errors || [],
});
} else {
// No cached results, start a background scan
@@ -96,12 +96,12 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
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 || []
errors: data.errors || [],
});
showSuccess(`Showing ${data.servers.length} cached server(s)`);
return; // Don't start a new scan
@@ -110,7 +110,7 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
} catch (error) {
console.error('Failed to load cached results:', error);
}
// If no cached results or error, start a new scan
handleScan();
};
@@ -144,7 +144,7 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
const result: DiscoveryResult = await response.json();
setDiscoveryResult(result);
if (result.servers.length === 0) {
showError('No Proxmox/PBS servers found on the network');
} else {
@@ -152,7 +152,7 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
showError('Scan timeout - try a smaller subnet range');
} else {
@@ -170,7 +170,14 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
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">
<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>
@@ -185,7 +192,14 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
);
}
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
@@ -198,27 +212,32 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
<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}
/>
<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">
<SectionHeader title="Network discovery" size="md" class="flex-1" />
<button type="button"
<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">
<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 */}
@@ -235,7 +254,7 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
</span>
</Show>
</div>
<div class="flex items-center gap-2">
{/* Subnet selector */}
<select
@@ -254,24 +273,46 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
<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 type="button"
<button
type="button"
onClick={handleRefresh}
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>
}>
<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>
<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>
@@ -309,17 +350,30 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
<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>
}>
<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) => (
@@ -330,10 +384,12 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
<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'}`}>
<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">
@@ -354,10 +410,17 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
</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">
<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>
@@ -372,7 +435,9 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
{/* 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>
<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>}
@@ -383,10 +448,11 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
</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"
<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"
>
@@ -16,28 +16,28 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
const [copied, setCopied] = createSignal(false);
const [currentHint, setCurrentHint] = createSignal(props.currentTokenHint || '');
const [showConfirm, setShowConfirm] = createSignal(false);
// Update hint when props change
createEffect(() => {
if (props.currentTokenHint) {
setCurrentHint(props.currentTokenHint);
}
});
const generateNewToken = async () => {
setIsGenerating(true);
setShowConfirm(false);
try {
const response = await apiFetch('/api/security/regenerate-token', {
method: 'POST'
method: 'POST',
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || 'Failed to generate token');
}
const data = await response.json();
setNewToken(data.token);
// Update the current hint with the new token
@@ -45,17 +45,17 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
setCurrentHint(data.token.slice(0, 8) + '...' + data.token.slice(-4));
}
setShowToken(true);
showSuccess('New API token generated! Save it now - it won\'t be shown again.');
showSuccess("New API token generated! Save it now - it won't be shown again.");
} catch (error) {
showError(`Failed to generate token: ${error}`);
} finally {
setIsGenerating(false);
}
};
const handleCopy = async () => {
if (!newToken()) return;
const success = await copyToClipboard(newToken()!);
if (success) {
setCopied(true);
@@ -64,7 +64,7 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
showError('Failed to copy to clipboard');
}
};
return (
<div class="space-y-4">
<Show when={!showToken()}>
@@ -74,16 +74,16 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
</h4>
<Show when={currentHint() && currentHint().length > 0}>
<div class="mb-3 px-3 py-2 bg-gray-800 dark:bg-gray-950 rounded">
<code class="text-xs text-gray-300 font-mono">
Current token: {currentHint()}
</code>
<code class="text-xs text-gray-300 font-mono">Current token: {currentHint()}</code>
</div>
</Show>
<p class={`${formHelpText} mb-4`}>
An API token is configured for this instance. Use it with the X-API-Token header for automation.
An API token is configured for this instance. Use it with the X-API-Token header for
automation.
</p>
<button type="button"
<button
type="button"
onClick={() => setShowConfirm(true)}
disabled={isGenerating()}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
@@ -92,7 +92,7 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
</button>
</div>
</Show>
<Show when={showToken() && newToken()}>
<div class="space-y-4">
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
@@ -103,17 +103,16 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
Save this token now - it will never be shown again!
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<div class={formField}>
<label class={labelClass('text-xs')}>
Your new API token
</label>
<label class={labelClass('text-xs')}>Your new API token</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{newToken()}
</code>
<button type="button"
<button
type="button"
onClick={handleCopy}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
@@ -122,21 +121,34 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
</div>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div class="flex items-start space-x-2">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
class="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="text-xs text-blue-700 dark:text-blue-300">
<p class="font-semibold">Token Active Immediately!</p>
<p class="mt-1">Your new API token is active and ready to use.</p>
<p class="mt-1 text-blue-600 dark:text-blue-400">The old token (if any) has been invalidated.</p>
<p class="mt-1 text-blue-600 dark:text-blue-400">
The old token (if any) has been invalidated.
</p>
</div>
</div>
</div>
<button type="button"
<button
type="button"
onClick={() => {
setShowToken(false);
setNewToken(null);
@@ -147,27 +159,28 @@ export const GenerateAPIToken: Component<GenerateAPITokenProps> = (props) => {
</button>
</div>
</Show>
<Show when={showConfirm()}>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<SectionHeader
title="Generate new API token?"
size="md"
class="mb-4"
/>
<SectionHeader title="Generate new API token?" size="md" class="mb-4" />
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
This will generate a new API token and <span class="font-semibold text-red-600 dark:text-red-400">immediately invalidate the current token</span>.
Any scripts or integrations using the old token will stop working.
This will generate a new API token and{' '}
<span class="font-semibold text-red-600 dark:text-red-400">
immediately invalidate the current token
</span>
. Any scripts or integrations using the old token will stop working.
</p>
<div class="flex gap-3 justify-end">
<button type="button"
<button
type="button"
onClick={() => setShowConfirm(false)}
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button type="button"
<button
type="button"
onClick={generateNewToken}
class="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
@@ -6,7 +6,6 @@ import { GuestMetadataAPI } from '@/api/guestMetadata';
import type { GuestMetadata } from '@/api/guestMetadata';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface GuestURLsProps {
hasUnsavedChanges: () => boolean;
setHasUnsavedChanges: (value: boolean) => void;
@@ -31,30 +30,31 @@ export function GuestURLs(props: GuestURLsProps) {
const groupedGuests = createMemo(() => {
const search = searchTerm().toLowerCase();
let guests = allGuests();
// Apply search filter
if (search) {
guests = guests.filter(guest =>
guest.name.toLowerCase().includes(search) ||
guest.vmid.toString().includes(search) ||
guest.node.toLowerCase().includes(search)
guests = guests.filter(
(guest) =>
guest.name.toLowerCase().includes(search) ||
guest.vmid.toString().includes(search) ||
guest.node.toLowerCase().includes(search),
);
}
// Group by node
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach(guest => {
guests.forEach((guest) => {
if (!groups[guest.node]) {
groups[guest.node] = [];
}
groups[guest.node].push(guest);
});
// Sort guests within each node by VMID
Object.keys(groups).forEach(node => {
Object.keys(groups).forEach((node) => {
groups[node] = groups[node].sort((a, b) => a.vmid - b.vmid);
});
return groups;
});
@@ -79,7 +79,7 @@ export function GuestURLs(props: GuestURLsProps) {
try {
const metadata = guestMetadata();
const errors: string[] = [];
// Update each guest that has changes
for (const [guestId, meta] of Object.entries(metadata)) {
if (meta.customUrl !== undefined) {
@@ -93,7 +93,7 @@ export function GuestURLs(props: GuestURLsProps) {
}
}
}
if (errors.length > 0) {
// Show specific validation errors
showError(errors.join('\n'));
@@ -112,32 +112,32 @@ export function GuestURLs(props: GuestURLsProps) {
// Validate URL format
const validateURL = (url: string): string | null => {
if (!url) return null; // Empty is valid
// Check for incomplete URLs like "https://emby."
if (url.endsWith('.') && !url.includes('..')) {
return 'URL appears incomplete - please enter a complete domain or IP address';
}
// Check for missing protocol
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
try {
const parsed = new URL(url);
// Check for valid host
if (!parsed.hostname) {
return 'URL must include a valid hostname or IP address';
}
// Check for incomplete hostnames
if (parsed.hostname.endsWith('.') && !parsed.hostname.includes('..')) {
return 'Hostname appears incomplete';
}
return null; // Valid
} catch (e) {
} catch (_err) {
return 'Invalid URL format';
}
};
@@ -152,16 +152,16 @@ export function GuestURLs(props: GuestURLsProps) {
// Update a guest's URL configuration
const updateGuestURL = (guestId: string, url: string) => {
setGuestMetadata(prev => ({
setGuestMetadata((prev) => ({
...prev,
[guestId]: {
...(prev[guestId] || { id: guestId }),
customUrl: url
}
customUrl: url,
},
}));
const error = validateURL(url);
setUrlErrors(prev => {
setUrlErrors((prev) => {
const next = { ...prev };
if (error) {
next[guestId] = error;
@@ -176,15 +176,15 @@ export function GuestURLs(props: GuestURLsProps) {
// Clear a guest's URL configuration
const clearGuestURL = (guestId: string) => {
setGuestMetadata(prev => ({
setGuestMetadata((prev) => ({
...prev,
[guestId]: {
...(prev[guestId] || { id: guestId }),
customUrl: ''
}
customUrl: '',
},
}));
setUrlErrors(prev => {
setUrlErrors((prev) => {
const next = { ...prev };
delete next[guestId];
return next;
@@ -213,15 +213,26 @@ export function GuestURLs(props: GuestURLsProps) {
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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
class="absolute left-3 top-2.5 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
{/* Save Button */}
<Show when={props.hasUnsavedChanges() && Object.keys(urlErrors()).length === 0}>
<div class="flex justify-end">
<button type="button"
<button
type="button"
onClick={saveURLs}
disabled={loading()}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@@ -233,12 +244,18 @@ export function GuestURLs(props: GuestURLsProps) {
{/* Guest URLs Table */}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden w-full">
<Show when={!initialLoad()} fallback={
<div class="flex items-center justify-center py-12">
<div class="text-gray-500 dark:text-gray-400">Loading guest URLs...</div>
</div>
}>
<div class="overflow-x-auto w-full" style="scrollbar-width: none; -ms-overflow-style: none;">
<Show
when={!initialLoad()}
fallback={
<div class="flex items-center justify-center py-12">
<div class="text-gray-500 dark:text-gray-400">Loading guest URLs...</div>
</div>
}
>
<div
class="overflow-x-auto w-full"
style="scrollbar-width: none; -ms-overflow-style: none;"
>
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
`}</style>
@@ -254,7 +271,10 @@ export function GuestURLs(props: GuestURLsProps) {
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-16">
VMID
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" style="min-width: 350px;">
<th
class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
style="min-width: 350px;"
>
Custom URL
</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-20">
@@ -263,121 +283,159 @@ export function GuestURLs(props: GuestURLsProps) {
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<Show when={Object.keys(groupedGuests()).length === 0} fallback={
<For each={Object.entries(groupedGuests()).sort(([a], [b]) => a.localeCompare(b))}>
{([node, guests]) => (
<>
{/* Node header row */}
<tr class="node-header bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td colspan="5" class="px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{node}
</td>
</tr>
{/* Guest rows for this node */}
<For each={guests}>
{(guest) => {
const guestId = guest.id || `${guest.instance}-${guest.node}-${guest.vmid}`;
const fallbackId = `${guest.node}-${guest.vmid}`;
<Show
when={Object.keys(groupedGuests()).length === 0}
fallback={
<For
each={Object.entries(groupedGuests()).sort(([a], [b]) => a.localeCompare(b))}
>
{([node, guests]) => (
<>
{/* Node header row */}
<tr class="node-header bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td
colspan="5"
class="px-3 py-1 text-xs font-medium text-gray-500 dark:text-gray-400"
>
{node}
</td>
</tr>
{/* Guest rows for this node */}
<For each={guests}>
{(guest) => {
const guestId =
guest.id || `${guest.instance}-${guest.node}-${guest.vmid}`;
const fallbackId = `${guest.node}-${guest.vmid}`;
const metadataKey = createMemo(() => resolveMetadataKey(guestId, fallbackId));
const meta = createMemo(() => guestMetadata()[metadataKey()]);
const url = createMemo(() => meta()?.customUrl || '');
const hasUrl = createMemo(() => url().trim().length > 0);
const urlError = createMemo(() => urlErrors()[metadataKey()]);
const metadataKey = createMemo(() =>
resolveMetadataKey(guestId, fallbackId),
);
const meta = createMemo(() => guestMetadata()[metadataKey()]);
const url = createMemo(() => meta()?.customUrl || '');
const hasUrl = createMemo(() => url().trim().length > 0);
const urlError = createMemo(() => urlErrors()[metadataKey()]);
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
<td class="p-1 px-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{guest.name}
</div>
</td>
<td class="p-1 px-2">
<span class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
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'
}`}>
{guest.type === 'qemu' ? 'VM' : 'LXC'}
</span>
</td>
<td class="p-1 px-2 text-sm text-gray-600 dark:text-gray-400">
{guest.vmid}
</td>
<td class="p-1 px-2">
<div>
<input
type="text"
placeholder="https://192.168.1.100:8006"
value={url()}
onInput={(e) => updateGuestURL(metadataKey(), e.currentTarget.value)}
class={`w-full min-w-[300px] px-2 py-1 text-sm border rounded
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
<td class="p-1 px-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{guest.name}
</div>
</td>
<td class="p-1 px-2">
<span
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${
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'
}`}
>
{guest.type === 'qemu' ? 'VM' : 'LXC'}
</span>
</td>
<td class="p-1 px-2 text-sm text-gray-600 dark:text-gray-400">
{guest.vmid}
</td>
<td class="p-1 px-2">
<div>
<input
type="text"
placeholder="https://192.168.1.100:8006"
value={url()}
onInput={(e) =>
updateGuestURL(metadataKey(), e.currentTarget.value)
}
class={`w-full min-w-[300px] px-2 py-1 text-sm border rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:border-transparent ${
urlError()
? 'border-red-500 dark:border-red-400 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-blue-500'
urlError()
? 'border-red-500 dark:border-red-400 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-blue-500'
}`}
style="min-width: 300px;"
/>
<Show when={urlError()}>
<div class="text-xs text-red-600 dark:text-red-400 mt-1">
{urlError()}
style="min-width: 300px;"
/>
<Show when={urlError()}>
<div class="text-xs text-red-600 dark:text-red-400 mt-1">
{urlError()}
</div>
</Show>
</div>
</Show>
</div>
</td>
<td class="p-1 px-2">
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (hasUrl() && url()) {
window.open(url(), '_blank', 'noopener,noreferrer');
}
}}
class={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded border transition-colors ${
hasUrl()
? 'text-blue-600 border-blue-500 hover:bg-blue-50 dark:text-blue-300 dark:border-blue-400 dark:hover:bg-blue-900/30'
: 'text-gray-400 border-gray-300 dark:text-gray-500 dark:border-gray-600 cursor-not-allowed'
}`}
disabled={!hasUrl()}
>
<svg class={`w-3.5 h-3.5 ${hasUrl() ? 'text-current' : 'text-gray-400 dark:text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Test
</button>
<button
type="button"
onClick={() => clearGuestURL(metadataKey())}
class={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded border transition-colors ${
hasUrl()
? 'text-red-600 border-red-500 hover:bg-red-50 dark:text-red-400 dark:border-red-500 dark:hover:bg-red-900/30'
: 'text-gray-400 border-gray-300 dark:text-gray-500 dark:border-gray-600 cursor-not-allowed'
}`}
disabled={!hasUrl()}
>
<svg class={`w-3.5 h-3.5 ${hasUrl() ? 'text-current' : 'text-gray-400 dark:text-gray-500'}`} 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>
Clear
</button>
</div>
</td>
</tr>
);
}}
</For>
</>
)}
</For>
}>
<tr>
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No guests found
</td>
</tr>
</td>
<td class="p-1 px-2">
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (hasUrl() && url()) {
window.open(url(), '_blank', 'noopener,noreferrer');
}
}}
class={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded border transition-colors ${
hasUrl()
? 'text-blue-600 border-blue-500 hover:bg-blue-50 dark:text-blue-300 dark:border-blue-400 dark:hover:bg-blue-900/30'
: 'text-gray-400 border-gray-300 dark:text-gray-500 dark:border-gray-600 cursor-not-allowed'
}`}
disabled={!hasUrl()}
>
<svg
class={`w-3.5 h-3.5 ${hasUrl() ? 'text-current' : 'text-gray-400 dark:text-gray-500'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
Test
</button>
<button
type="button"
onClick={() => clearGuestURL(metadataKey())}
class={`inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded border transition-colors ${
hasUrl()
? 'text-red-600 border-red-500 hover:bg-red-50 dark:text-red-400 dark:border-red-500 dark:hover:bg-red-900/30'
: 'text-gray-400 border-gray-300 dark:text-gray-500 dark:border-gray-600 cursor-not-allowed'
}`}
disabled={!hasUrl()}
>
<svg
class={`w-3.5 h-3.5 ${hasUrl() ? 'text-current' : 'text-gray-400 dark:text-gray-500'}`}
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>
Clear
</button>
</div>
</td>
</tr>
);
}}
</For>
</>
)}
</For>
}
>
<tr>
<td
colspan="5"
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
No guests found
</td>
</tr>
</Show>
</tbody>
</table>
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,11 @@ interface OIDCConfigResponse {
}
const listToString = (values?: string[]) => (values && values.length > 0 ? values.join(', ') : '');
const splitList = (input: string) => input.split(/[,\s]+/).map((v) => v.trim()).filter(Boolean);
const splitList = (input: string) =>
input
.split(/[,\s]+/)
.map((v) => v.trim())
.filter(Boolean);
interface Props {
onConfigUpdated?: (config: OIDCConfigResponse) => void;
@@ -176,12 +180,26 @@ export const OIDCPanel: Component<Props> = (props) => {
};
return (
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9" />
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.8"
d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9"
/>
</svg>
</div>
<SectionHeader
@@ -197,7 +215,11 @@ export const OIDCPanel: Component<Props> = (props) => {
}}
disabled={isEnvLocked() || loading() || saving()}
containerClass="items-center gap-2"
label={<span class="text-xs font-medium text-gray-600 dark:text-gray-300">{form.enabled ? 'Enabled' : 'Disabled'}</span>}
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
{form.enabled ? 'Enabled' : 'Disabled'}
</span>
}
/>
</div>
</div>
@@ -205,9 +227,17 @@ export const OIDCPanel: Component<Props> = (props) => {
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 text-xs text-blue-800 dark:text-blue-200">
<p class="font-semibold mb-2">Getting started</p>
<ol class="space-y-1 list-decimal pl-5">
<li>Register a confidential client with your IdP and set the redirect URL shown below.</li>
<li>
Register a confidential client with your IdP and set the redirect URL shown below.
</li>
<li>Copy the issuer, client ID, and client secret into the fields here.</li>
<li>Grant scopes such as <code class="px-1 py-0.5 bg-blue-100/70 dark:bg-blue-900/40 rounded">openid profile email</code>.</li>
<li>
Grant scopes such as{' '}
<code class="px-1 py-0.5 bg-blue-100/70 dark:bg-blue-900/40 rounded">
openid profile email
</code>
.
</li>
<li>Optionally restrict access by domain, email, or groups.</li>
<li>Save, then sign out to test the new SSO button.</li>
</ol>
@@ -222,7 +252,8 @@ export const OIDCPanel: Component<Props> = (props) => {
<Show when={!loading()}>
<Show when={isEnvLocked()}>
<div class="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded p-3 text-xs text-amber-800 dark:text-amber-200">
<strong>Managed by environment variables:</strong> OIDC settings are currently defined through environment variables. Edit the deployment configuration to make changes.
<strong>Managed by environment variables:</strong> OIDC settings are currently defined
through environment variables. Edit the deployment configuration to make changes.
</div>
</Show>
@@ -281,11 +312,17 @@ export const OIDCPanel: Component<Props> = (props) => {
setForm('clearSecret', false);
}
}}
placeholder={config()?.clientSecretSet ? '•••••••• (leave blank to keep existing)' : 'Enter client secret'}
placeholder={
config()?.clientSecretSet
? '•••••••• (leave blank to keep existing)'
: 'Enter client secret'
}
class={controlClass()}
disabled={isEnvLocked() || saving()}
/>
<p class={formHelpText}>Leave blank to keep the existing secret. Use "Clear" to remove it from storage.</p>
<p class={formHelpText}>
Leave blank to keep the existing secret. Use "Clear" to remove it from storage.
</p>
</div>
<div class={formField}>
<label class={labelClass()}>Redirect URL</label>
@@ -333,7 +370,9 @@ export const OIDCPanel: Component<Props> = (props) => {
class={controlClass()}
disabled={isEnvLocked() || saving()}
/>
<p class={formHelpText}>Claim used to populate the Pulse username (default: preferred_username).</p>
<p class={formHelpText}>
Claim used to populate the Pulse username (default: preferred_username).
</p>
</div>
<div class={formField}>
<label class={labelClass()}>Email claim</label>
@@ -354,7 +393,9 @@ export const OIDCPanel: Component<Props> = (props) => {
class={controlClass()}
disabled={isEnvLocked() || saving()}
/>
<p class={formHelpText}>Optional claim that lists group memberships. Used for group restrictions.</p>
<p class={formHelpText}>
Optional claim that lists group memberships. Used for group restrictions.
</p>
</div>
<div class={formField}>
<label class={labelClass()}>Allowed groups</label>
@@ -366,7 +407,9 @@ export const OIDCPanel: Component<Props> = (props) => {
class={controlClass('min-h-[70px]')}
disabled={isEnvLocked() || saving()}
/>
<p class={formHelpText}>Comma or space separated values. Leave empty to allow any group.</p>
<p class={formHelpText}>
Comma or space separated values. Leave empty to allow any group.
</p>
</div>
<div class={formField}>
<label class={labelClass()}>Allowed domains</label>
@@ -378,7 +421,9 @@ export const OIDCPanel: Component<Props> = (props) => {
class={controlClass('min-h-[70px]')}
disabled={isEnvLocked() || saving()}
/>
<p class={formHelpText}>Restrict access to email domains (without @). Leave empty to allow all.</p>
<p class={formHelpText}>
Restrict access to email domains (without @). Leave empty to allow all.
</p>
</div>
<div class={formField}>
<label class={labelClass()}>Allowed email addresses</label>
@@ -398,7 +443,8 @@ export const OIDCPanel: Component<Props> = (props) => {
<div class="flex flex-wrap items-center justify-between gap-3 pt-4">
<div class="text-xs text-gray-500 dark:text-gray-400">
Redirect URL registered with your IdP must match Pulse: {config()?.defaultRedirect || ''}
Redirect URL registered with your IdP must match Pulse:{' '}
{config()?.defaultRedirect || ''}
</div>
<div class="flex gap-3">
<button
@@ -44,7 +44,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
// Generate 24 bytes (48 hex chars) to avoid hash detection issue with 64-char tokens
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
};
const handleCopy = async (text: string, type: 'username' | 'password' | 'token') => {
@@ -57,7 +57,6 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
}
};
const setupSecurity = async () => {
// Validate custom password if using
if (useCustomPassword()) {
@@ -70,15 +69,15 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
return;
}
}
setIsSettingUp(true);
try {
// Generate or use custom credentials
const newCredentials: SecurityCredentials = {
username: customUsername(),
password: useCustomPassword() ? customPassword() : generatePassword(),
apiToken: generateToken()
apiToken: generateToken(),
};
// Call API to enable security
@@ -91,7 +90,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
...newCredentials,
force: isRotation,
}),
credentials: 'include' // Include cookies for CSRF
credentials: 'include', // Include cookies for CSRF
});
if (!response.ok) {
@@ -101,10 +100,13 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
// Parse response to check if setup was skipped
const result = await response.json();
if (result.skipped) {
// Security was already configured, don't show credentials
showError(result.message || 'Security is already configured. Please remove existing security first if you want to reconfigure.');
showError(
result.message ||
'Security is already configured. Please remove existing security first if you want to reconfigure.',
);
if (props.onConfigured) {
props.onConfigured();
}
@@ -114,10 +116,14 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
// Response is successful and security was newly configured
setCredentials(newCredentials);
setShowCredentials(true);
// Show success message
showSuccess(isRotation ? 'Admin credentials generated. Save them before continuing.' : 'Security configured. Save your credentials before continuing.');
showSuccess(
isRotation
? 'Admin credentials generated. Save them before continuing.'
: 'Security configured. Save your credentials before continuing.',
);
// DON'T notify parent yet - wait until user dismisses credentials
// if (props.onConfigured) {
// props.onConfigured();
@@ -131,7 +137,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
const downloadCredentials = () => {
if (!credentials()) return;
const content = `Pulse Admin Credentials ${isRotation ? '(Rotated)' : ''}
Generated: ${new Date().toISOString()}
@@ -163,14 +169,28 @@ Important:
<div class="space-y-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
<svg
class="h-6 w-6 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div class="flex-1">
<SectionHeader
title={isRotation ? 'Generate new admin credentials' : 'Quick security setup'}
description={isRotation ? 'Create a fresh password and API token. This will:' : 'Enable authentication with one click. This will:'}
description={
isRotation
? 'Create a fresh password and API token. This will:'
: 'Enable authentication with one click. This will:'
}
size="sm"
titleClass="text-gray-900 dark:text-gray-100"
descriptionClass="!text-xs text-gray-600 dark:text-gray-400"
@@ -178,7 +198,9 @@ Important:
<ul class="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-400">
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
{isRotation ? 'Generate a new secure password' : 'Generate secure random password'}
{isRotation
? 'Generate a new secure password'
: 'Generate secure random password'}
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
@@ -186,7 +208,9 @@ Important:
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
{isRotation ? 'Create a new API token for automation' : 'Create API token for automation'}
{isRotation
? 'Create a new API token for automation'
: 'Create API token for automation'}
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
@@ -198,25 +222,25 @@ Important:
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 space-y-3">
<div class="flex items-center justify-between">
<label class={labelClass()}>
Password Setup
</label>
<label class={labelClass()}>Password Setup</label>
<div class="flex items-center space-x-2">
<button type="button"
<button
type="button"
onClick={() => setUseCustomPassword(false)}
class={`px-3 py-1 text-xs rounded-lg transition-colors ${
!useCustomPassword()
? 'bg-blue-600 text-white'
!useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
Auto-Generate
</button>
<button type="button"
<button
type="button"
onClick={() => setUseCustomPassword(true)}
class={`px-3 py-1 text-xs rounded-lg transition-colors ${
useCustomPassword()
? 'bg-blue-600 text-white'
useCustomPassword()
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
@@ -228,9 +252,7 @@ Important:
<Show when={useCustomPassword()}>
<div class="space-y-2">
<div class={formField}>
<label class={labelClass()}>
Username
</label>
<label class={labelClass()}>Username</label>
<input
type="text"
value={customUsername()}
@@ -240,9 +262,7 @@ Important:
/>
</div>
<div class={formField}>
<label class={labelClass()}>
Password (min 8 characters)
</label>
<label class={labelClass()}>Password (min 8 characters)</label>
<input
type="password"
value={customPassword()}
@@ -252,9 +272,7 @@ Important:
/>
</div>
<div class={formField}>
<label class={labelClass()}>
Confirm password
</label>
<label class={labelClass()}>Confirm password</label>
<input
type="password"
value={confirmPassword()}
@@ -267,46 +285,75 @@ Important:
</Show>
<Show when={!useCustomPassword()}>
<p class={formHelpText}>
A secure 16-character password will be generated for you
</p>
<p class={formHelpText}>A secure 16-character password will be generated for you</p>
</Show>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<div class="flex">
<svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mr-2 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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="text-xs text-yellow-700 dark:text-yellow-300">
<p class="font-semibold">Important:</p>
<p>{useCustomPassword()
? 'Your password will be hashed before storage'
: 'Credentials will be shown only once. Save them immediately!'}</p>
<p>
{useCustomPassword()
? 'Your password will be hashed before storage'
: 'Credentials will be shown only once. Save them immediately!'}
</p>
<Show when={isRotation}>
<p class="mt-1">
Existing sessions will be logged out once Pulse restarts with the new credentials.
Existing sessions will be logged out once Pulse restarts with the new
credentials.
</p>
</Show>
</div>
</div>
</div>
<button type="button"
<button
type="button"
onClick={setupSecurity}
disabled={isSettingUp()}
class="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSettingUp() ? (
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
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>
{isRotation ? 'Rotating credentials...' : 'Setting up security...'}
</span>
) : isRotation ? (
'Rotate credentials'
) : (
(isRotation ? 'Rotate credentials' : 'Enable Security Now')
'Enable Security Now'
)}
</button>
</div>
@@ -321,7 +368,8 @@ Important:
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
/>
<button type="button"
<button
type="button"
onClick={downloadCredentials}
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
@@ -337,14 +385,13 @@ Important:
<div class="space-y-3">
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class={labelClass('text-xs')}>
Username
</label>
<label class={labelClass('text-xs')}>Username</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700">
{credentials()!.username}
</code>
<button type="button"
<button
type="button"
onClick={() => handleCopy(credentials()!.username, 'username')}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
@@ -354,14 +401,13 @@ Important:
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class={labelClass('text-xs')}>
Password
</label>
<label class={labelClass('text-xs')}>Password</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{credentials()!.password}
</code>
<button type="button"
<button
type="button"
onClick={() => handleCopy(credentials()!.password, 'password')}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
@@ -371,14 +417,13 @@ Important:
</div>
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
<label class={labelClass('text-xs')}>
API token
</label>
<label class={labelClass('text-xs')}>API token</label>
<div class="mt-1 flex items-center gap-2">
<code class="flex-1 font-mono text-sm bg-white dark:bg-gray-800 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 break-all">
{credentials()!.apiToken}
</code>
<button type="button"
<button
type="button"
onClick={() => handleCopy(credentials()!.apiToken!, 'token')}
class="px-3 py-2 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
>
@@ -405,9 +450,10 @@ Important:
Save your credentials above - they won't be shown again.
</p>
</div>
<div class="flex justify-end">
<button type="button"
<button
type="button"
onClick={() => {
setShowCredentials(false);
// Now notify parent that configuration is complete
@@ -65,9 +65,7 @@ export const SecurityPostureSummary: Component<SecurityPostureSummaryProps> = (p
key: 'https',
label: 'HTTPS',
enabled: Boolean(props.status.hasHTTPS),
description: props.status.hasHTTPS
? 'Connection is encrypted.'
: 'Serving over HTTP.',
description: props.status.hasHTTPS ? 'Connection is encrypted.' : 'Serving over HTTP.',
},
{
key: 'audit',
@@ -102,7 +100,9 @@ export const SecurityPostureSummary: Component<SecurityPostureSummaryProps> = (p
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200'
} px-3 py-1 text-xs font-semibold rounded-full`}
>
{props.status.publicAccess && !props.status.isPrivateNetwork ? 'Public network access' : 'Private network access'}
{props.status.publicAccess && !props.status.isPrivateNetwork
? 'Public network access'
: 'Private network access'}
</span>
<Show when={props.status.clientIP}>
<span class="hidden md:inline-flex items-center px-3 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
@@ -114,11 +114,22 @@ export const SecurityPostureSummary: Component<SecurityPostureSummaryProps> = (p
<Show when={props.status.requiresAuth}>
<div class="flex items-start gap-2 p-3 rounded-lg bg-green-50 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300 border border-green-200 dark:border-green-800">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<svg
class="w-4 h-4 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>
Authentication is required for this instance. Keep at least one trusted login path enabled before disabling password auth.
Authentication is required for this instance. Keep at least one trusted login path
enabled before disabling password auth.
</span>
</div>
</Show>
@@ -128,14 +139,11 @@ export const SecurityPostureSummary: Component<SecurityPostureSummaryProps> = (p
{(item) => (
<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-800">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">{item.label}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{item.label}
</span>
<span class={badgeClasses(item.enabled)}>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -146,7 +154,9 @@ export const SecurityPostureSummary: Component<SecurityPostureSummaryProps> = (p
{item.enabled ? 'On' : 'Off'}
</span>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">{item.description}</p>
<p class="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
{item.description}
</p>
</div>
)}
</For>
File diff suppressed because it is too large Load Diff
@@ -13,60 +13,61 @@ export const DiskList: Component<DiskListProps> = (props) => {
// Filter disks based on selected node and search term
const filteredDisks = createMemo(() => {
let disks = props.disks || [];
// Filter by node if selected
if (props.selectedNode) {
disks = disks.filter(d => d.node === props.selectedNode);
disks = disks.filter((d) => d.node === props.selectedNode);
}
// Filter by search term
if (props.searchTerm) {
const term = props.searchTerm.toLowerCase();
disks = disks.filter(d =>
d.model.toLowerCase().includes(term) ||
d.devPath.toLowerCase().includes(term) ||
d.serial.toLowerCase().includes(term) ||
d.node.toLowerCase().includes(term)
disks = disks.filter(
(d) =>
d.model.toLowerCase().includes(term) ||
d.devPath.toLowerCase().includes(term) ||
d.serial.toLowerCase().includes(term) ||
d.node.toLowerCase().includes(term),
);
}
// Sort by node and devPath - create a copy to avoid mutating store
return [...disks].sort((a, b) => {
if (a.node !== b.node) return a.node.localeCompare(b.node);
return a.devPath.localeCompare(b.devPath);
});
});
// Get health status color and badge
const getHealthStatus = (disk: PhysicalDisk) => {
if (disk.health === 'PASSED') {
// Check wearout for SSDs
if (disk.wearout > 0 && disk.wearout < 10) {
return {
color: 'text-yellow-700 dark:text-yellow-400',
return {
color: 'text-yellow-700 dark:text-yellow-400',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
text: 'LOW LIFE'
text: 'LOW LIFE',
};
}
return {
color: 'text-green-700 dark:text-green-400',
return {
color: 'text-green-700 dark:text-green-400',
bgColor: 'bg-green-100 dark:bg-green-900/30',
text: 'HEALTHY'
text: 'HEALTHY',
};
} else if (disk.health === 'FAILED') {
return {
color: 'text-red-700 dark:text-red-400',
return {
color: 'text-red-700 dark:text-red-400',
bgColor: 'bg-red-100 dark:bg-red-900/30',
text: 'FAILED'
text: 'FAILED',
};
}
return {
color: 'text-gray-700 dark:text-gray-400',
return {
color: 'text-gray-700 dark:text-gray-400',
bgColor: 'bg-gray-100 dark:bg-gray-700',
text: 'UNKNOWN'
text: 'UNKNOWN',
};
};
// Get disk type badge color
const getDiskTypeBadge = (type: string) => {
switch (type.toLowerCase()) {
@@ -80,7 +81,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
};
return (
<div>
<Show when={filteredDisks().length === 0}>
@@ -90,22 +91,40 @@ export const DiskList: Component<DiskListProps> = (props) => {
{props.searchTerm && ` matching "${props.searchTerm}"`}
</Card>
</Show>
<Show when={filteredDisks().length > 0}>
<Card padding="none" class="overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">Node</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">Device</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">Model</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">Type</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">FS</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">Health</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">SSD Life</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">Temp</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">Size</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Node
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Device
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Model
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Type
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
FS
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Health
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
SSD Life
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden sm:table-cell">
Temp
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider">
Size
</th>
<th class="px-2 py-1.5 w-8"></th>
</tr>
</thead>
@@ -113,74 +132,98 @@ export const DiskList: Component<DiskListProps> = (props) => {
<For each={filteredDisks()}>
{(disk) => {
const health = getHealthStatus(disk);
return (
<>
<tr
class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
>
<td class="px-2 py-1.5 text-xs">
<span class="font-medium text-gray-900 dark:text-gray-100">{disk.node}</span>
</td>
<td class="px-2 py-1.5 text-xs">
<span class="font-mono text-gray-600 dark:text-gray-400">{disk.devPath}</span>
</td>
<td class="px-2 py-1.5 text-xs">
<span class="text-gray-700 dark:text-gray-300">{disk.model || 'Unknown'}</span>
</td>
<td class="px-2 py-1.5 text-xs">
<span class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${getDiskTypeBadge(disk.type)}`}>
{disk.type.toUpperCase()}
</span>
</td>
<td class="px-2 py-1.5 text-xs">
<Show when={disk.used && disk.used !== 'unknown'} fallback={<span class="text-gray-400">-</span>}>
<span class="text-[10px] font-mono text-gray-600 dark:text-gray-400">
{disk.used}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-2 py-1.5 text-xs">
<span class="font-medium text-gray-900 dark:text-gray-100">
{disk.node}
</span>
</Show>
</td>
<td class="px-2 py-1.5 text-xs">
<span class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${health.bgColor} ${health.color}`}>
{health.text}
</span>
</td>
<td class="px-2 py-1.5 text-xs">
<Show when={disk.wearout > 0} fallback={<span class="text-gray-400">-</span>}>
<div class="relative w-24 h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${
disk.wearout >= 50 ? 'bg-green-500/60 dark:bg-green-500/50' :
disk.wearout >= 20 ? 'bg-yellow-500/60 dark:bg-yellow-500/50' :
disk.wearout >= 10 ? 'bg-orange-500/60 dark:bg-orange-500/50' :
'bg-red-500/60 dark:bg-red-500/50'
}`}
style={{ width: `${disk.wearout}%` }}
/>
<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="whitespace-nowrap px-0.5">
{disk.wearout}%
</span>
</td>
<td class="px-2 py-1.5 text-xs">
<span class="font-mono text-gray-600 dark:text-gray-400">
{disk.devPath}
</span>
</td>
<td class="px-2 py-1.5 text-xs">
<span class="text-gray-700 dark:text-gray-300">
{disk.model || 'Unknown'}
</span>
</td>
<td class="px-2 py-1.5 text-xs">
<span
class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${getDiskTypeBadge(disk.type)}`}
>
{disk.type.toUpperCase()}
</span>
</td>
<td class="px-2 py-1.5 text-xs">
<Show
when={disk.used && disk.used !== 'unknown'}
fallback={<span class="text-gray-400">-</span>}
>
<span class="text-[10px] font-mono text-gray-600 dark:text-gray-400">
{disk.used}
</span>
</div>
</Show>
</td>
<td class="px-2 py-1.5 text-xs hidden sm:table-cell">
<Show when={disk.temperature > 0} fallback={<span class="text-gray-400">-</span>}>
<span class={`font-medium ${
disk.temperature > 70 ? 'text-red-600 dark:text-red-400' :
disk.temperature > 60 ? 'text-yellow-600 dark:text-yellow-400' :
'text-gray-600 dark:text-gray-400'
}`}>
{disk.temperature}°C
</Show>
</td>
<td class="px-2 py-1.5 text-xs">
<span
class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${health.bgColor} ${health.color}`}
>
{health.text}
</span>
</Show>
</td>
<td class="px-2 py-1.5 text-xs">
<span class="text-gray-700 dark:text-gray-300">{formatBytes(disk.size)}</span>
</td>
<td class="px-2 py-1.5"></td>
</tr>
</td>
<td class="px-2 py-1.5 text-xs">
<Show
when={disk.wearout > 0}
fallback={<span class="text-gray-400">-</span>}
>
<div class="relative w-24 h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${
disk.wearout >= 50
? 'bg-green-500/60 dark:bg-green-500/50'
: disk.wearout >= 20
? 'bg-yellow-500/60 dark:bg-yellow-500/50'
: disk.wearout >= 10
? 'bg-orange-500/60 dark:bg-orange-500/50'
: 'bg-red-500/60 dark:bg-red-500/50'
}`}
style={{ width: `${disk.wearout}%` }}
/>
<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="whitespace-nowrap px-0.5">{disk.wearout}%</span>
</span>
</div>
</Show>
</td>
<td class="px-2 py-1.5 text-xs hidden sm:table-cell">
<Show
when={disk.temperature > 0}
fallback={<span class="text-gray-400">-</span>}
>
<span
class={`font-medium ${
disk.temperature > 70
? 'text-red-600 dark:text-red-400'
: disk.temperature > 60
? 'text-yellow-600 dark:text-yellow-400'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{disk.temperature}°C
</span>
</Show>
</td>
<td class="px-2 py-1.5 text-xs">
<span class="text-gray-700 dark:text-gray-300">
{formatBytes(disk.size)}
</span>
</td>
<td class="px-2 py-1.5"></td>
</tr>
</>
);
}}
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,7 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
{ value: 'status', label: 'Status' },
{ value: 'usage', label: 'Usage %' },
{ value: 'free', label: 'Free Capacity' },
{ value: 'total', label: 'Total Capacity' }
{ value: 'total', label: 'Total Capacity' },
];
return (
@@ -43,10 +43,21 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
title="Search storage by name or filter by node"
/>
<svg class="absolute left-3 top-2 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
class="absolute left-3 top-2 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 type="button"
<button
type="button"
class="absolute right-3 top-2 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();
@@ -67,7 +78,12 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
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" />
<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>
@@ -78,21 +94,23 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
{/* Group By Filter */}
<Show when={props.groupBy && props.setGroupBy}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
<button
type="button"
onClick={() => props.setGroupBy!('node')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy!() === 'node'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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'
}`}
>
By Node
</button>
<button type="button"
<button
type="button"
onClick={() => props.setGroupBy!('storage')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy!() === 'storage'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
? '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'
}`}
>
@@ -104,20 +122,24 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
{/* Sort controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Sort</span>
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sort
</span>
<select
value={props.sortKey()}
onChange={(e) => props.setSortKey(e.currentTarget.value)}
class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400"
>
{sortOptions.map(option => (
{sortOptions.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
<button
type="button"
title={`Sort ${props.sortDirection() === 'asc' ? 'descending' : 'ascending'}`}
onClick={() => props.setSortDirection(props.sortDirection() === 'asc' ? 'desc' : 'asc')}
onClick={() =>
props.setSortDirection(props.sortDirection() === 'asc' ? 'desc' : 'asc')
}
class="inline-flex items-center justify-center h-7 w-7 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<svg
@@ -127,14 +149,18 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 9l4-4 4 4m0 6l-4 4-4-4"
/>
</svg>
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
<button
onClick={() => {
props.setSearch('');
props.setSortKey('name');
@@ -146,17 +172,31 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="14" height="14" 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
width="14"
height="14"
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 hidden sm:inline">Reset</span>
</button>
{/* Active Indicator */}
<Show when={props.search().trim() !== '' || props.sortKey() !== 'name' || props.sortDirection() !== 'asc' || (props.groupBy && props.groupBy!() !== 'node')}>
<Show
when={
props.search().trim() !== '' ||
props.sortKey() !== 'name' ||
props.sortDirection() !== 'asc' ||
(props.groupBy && props.groupBy!() !== '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>
+46 -20
View File
@@ -25,7 +25,12 @@ const Toast: Component<ToastProps> = (props) => {
<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" />
<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>
),
@@ -33,7 +38,12 @@ const Toast: Component<ToastProps> = (props) => {
<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" />
<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>
),
@@ -41,7 +51,12 @@ const Toast: Component<ToastProps> = (props) => {
<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" />
<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>
),
@@ -49,17 +64,22 @@ const Toast: Component<ToastProps> = (props) => {
<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" />
<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'
info: 'text-blue-400',
};
const handleClose = () => {
@@ -78,7 +98,7 @@ const Toast: Component<ToastProps> = (props) => {
show() ? 'translate-x-0 opacity-100 scale-100' : 'translate-x-full opacity-0 scale-95'
} animate-slide-in-glass`}
>
<div
<div
class={`
backdrop-blur-xl bg-white/10 dark:bg-gray-900/30
border border-white/20 dark:border-gray-700/30
@@ -87,25 +107,33 @@ const Toast: Component<ToastProps> = (props) => {
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)"
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-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>
<p class="mt-1 text-xs text-gray-700 dark:text-gray-300 opacity-90">
{props.toast.message}
</p>
</Show>
</div>
<button type="button"
<button
type="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" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -125,7 +153,7 @@ export const ToastContainer: Component = () => {
const [toasts, setToasts] = createSignal<ToastMessage[]>([]);
const removeToast = (id: string) => {
setToasts(toasts().filter(t => t.id !== id));
setToasts(toasts().filter((t) => t.id !== id));
};
// Expose global toast function
@@ -137,10 +165,8 @@ export const ToastContainer: Component = () => {
return (
<Portal>
<div class="fixed top-4 right-4 z-[9999] space-y-2 max-w-sm">
<For each={toasts()}>
{(toast) => <Toast toast={toast} onRemove={removeToast} />}
</For>
<For each={toasts()}>{(toast) => <Toast toast={toast} onRemove={removeToast} />}</For>
</div>
</Portal>
);
};
};
+51 -25
View File
@@ -3,12 +3,12 @@ import { updateStore } from '@/stores/updates';
export function UpdateBanner() {
const [isExpanded, setIsExpanded] = createSignal(false);
// Get deployment type message
const getUpdateInstructions = () => {
const versionInfo = updateStore.versionInfo();
const deploymentType = versionInfo?.deploymentType || 'systemd';
switch (deploymentType) {
case 'proxmoxve':
return "ProxmoxVE users: type 'update' in console";
@@ -17,16 +17,16 @@ export function UpdateBanner() {
case 'source':
return 'Source: pull and rebuild';
default:
return ''; // No message, just the version info
return ''; // No message, just the version info
}
};
const getShortMessage = () => {
const info = updateStore.updateInfo();
if (!info) return '';
return `New version available: ${info.latestVersion}`;
};
return (
<Show when={updateStore.isUpdateVisible()}>
<div class="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200 relative animate-slideDown">
@@ -34,21 +34,37 @@ export function UpdateBanner() {
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
{/* Update icon */}
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v6m0 0l3-3m-3 3l-3-3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17l.621 2.485A2 2 0 0 0 4.561 21h14.878a2 2 0 0 0 1.94-1.515L22 17" stroke-linecap="round" stroke-linejoin="round"/>
<svg
class="w-4 h-4 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M12 2v6m0 0l3-3m-3 3l-3-3"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M2 17l.621 2.485A2 2 0 0 0 4.561 21h14.878a2 2 0 0 0 1.94-1.515L22 17"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{getShortMessage()}</span>
{!isExpanded() && getUpdateInstructions() && (
<>
<span class="text-blue-600 dark:text-blue-400 text-sm hidden sm:inline"></span>
<span class="text-blue-600 dark:text-blue-400 text-sm hidden sm:inline">{getUpdateInstructions()}</span>
<span class="text-blue-600 dark:text-blue-400 text-sm hidden sm:inline">
{getUpdateInstructions()}
</span>
</>
)}
{!isExpanded() && (
<a
<a
href={`https://github.com/rcourtman/Pulse/releases/tag/${updateStore.updateInfo()?.latestVersion}`}
target="_blank"
rel="noopener noreferrer"
@@ -59,7 +75,7 @@ export function UpdateBanner() {
)}
</div>
</div>
<div class="flex items-center gap-2">
{/* Expand/Collapse button */}
<button
@@ -67,38 +83,46 @@ export function UpdateBanner() {
class="p-1 hover:bg-blue-100 dark:hover:bg-blue-800/30 rounded transition-colors"
title={isExpanded() ? 'Show less' : 'Show more'}
>
<svg
class={`w-4 h-4 transform transition-transform ${isExpanded() ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
<svg
class={`w-4 h-4 transform transition-transform ${isExpanded() ? 'rotate-180' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
{/* Dismiss button */}
<button
onClick={() => updateStore.dismissUpdate()}
class="p-1 hover:bg-blue-100 dark:hover:bg-blue-800/30 rounded transition-colors"
title="Dismiss this update"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
class="w-4 h-4"
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>
</div>
{/* Expanded content */}
<Show when={isExpanded()}>
<div class="mt-2 pb-1">
<div class="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<p>
<span class="font-medium">Current:</span> {updateStore.versionInfo()?.version || 'Unknown'}
<span class="font-medium ml-1">Latest:</span> {updateStore.updateInfo()?.latestVersion}
<span class="font-medium">Current:</span>{' '}
{updateStore.versionInfo()?.version || 'Unknown'}
<span class="font-medium ml-1">Latest:</span>{' '}
{updateStore.updateInfo()?.latestVersion}
</p>
{getUpdateInstructions() && (
<p>
@@ -106,10 +130,12 @@ export function UpdateBanner() {
</p>
)}
<Show when={updateStore.updateInfo()?.isPrerelease}>
<p class="text-orange-600 dark:text-orange-400 text-xs">This is a pre-release version</p>
<p class="text-orange-600 dark:text-orange-400 text-xs">
This is a pre-release version
</p>
</Show>
<div class="flex gap-3 mt-2">
<a
<a
href={`https://github.com/rcourtman/Pulse/releases/tag/${updateStore.updateInfo()?.latestVersion}`}
target="_blank"
rel="noopener noreferrer"
@@ -131,4 +157,4 @@ export function UpdateBanner() {
</div>
</Show>
);
}
}
@@ -9,35 +9,33 @@ interface AlertIndicatorProps {
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 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
<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
<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`,
@@ -66,25 +64,24 @@ interface AlertCountBadgeProps {
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 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
<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}
@@ -93,7 +90,7 @@ export const AlertCountBadge: Component<AlertCountBadgeProps> = (props) => {
</span>
<Show when={showTooltip() && props.alerts && props.alerts.length > 0}>
<Portal>
<div
<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`,
@@ -112,4 +109,4 @@ export const AlertCountBadge: Component<AlertCountBadgeProps> = (props) => {
</Show>
</>
);
};
};
@@ -18,7 +18,7 @@ export const AnimatedMetric: Component<AnimatedMetricProps> = (props) => {
createEffect(() => {
const newVal = props.value;
const prevVal = displayValue();
// Skip first render
if (!hasInitialized) {
hasInitialized = true;
@@ -26,15 +26,15 @@ export const AnimatedMetric: Component<AnimatedMetricProps> = (props) => {
setOldValue(newVal);
return;
}
// Only animate if value changed
if (newVal !== prevVal) {
clearTimeout(timeoutId);
// Store old value for ghost
setOldValue(prevVal);
setShowGhost(true);
// Set animation direction
if (newVal > prevVal) {
setAnimClass('up');
@@ -43,10 +43,10 @@ export const AnimatedMetric: Component<AnimatedMetricProps> = (props) => {
setAnimClass('down');
console.log('[AnimatedMetric] Going DOWN:', prevVal, '->', newVal);
}
// Update to new value
setDisplayValue(newVal);
// Remove ghost after animation
timeoutId = window.setTimeout(() => {
setShowGhost(false);
@@ -60,16 +60,19 @@ export const AnimatedMetric: Component<AnimatedMetricProps> = (props) => {
const format = props.formatter || ((v: number) => formatBytes(v) + '/s');
return (
<div class="metric-container" style="position: relative; display: inline-block; overflow: visible;">
<div
class="metric-container"
style="position: relative; display: inline-block; overflow: visible;"
>
{showGhost() && (
<span
<span
class={`metric-ghost metric-ghost-${animClass()}`}
style="position: absolute; top: 0; left: 0; z-index: 1;"
>
{format(oldValue())}
</span>
)}
<span
<span
class={`metric-value ${showGhost() ? `metric-entering-${animClass()}` : ''}`}
style="position: relative; z-index: 2; display: inline-block;"
>
@@ -77,4 +80,4 @@ export const AnimatedMetric: Component<AnimatedMetricProps> = (props) => {
</span>
</div>
);
};
};
@@ -16,18 +16,21 @@ const toneClassMap: Record<Tone, string> = {
info: 'bg-blue-50/70 dark:bg-blue-900/20',
success: 'bg-green-50/70 dark:bg-green-900/20',
warning: 'bg-amber-50/80 dark:bg-amber-900/20',
danger: 'bg-red-50/80 dark:bg-red-900/20'
danger: 'bg-red-50/80 dark:bg-red-900/20',
};
const paddingClassMap: Record<Padding, string> = {
none: 'p-0',
sm: 'p-3',
md: 'p-4',
lg: 'p-6'
lg: 'p-6',
};
export function Card(props: CardProps) {
const merged = mergeProps({ tone: 'default' as Tone, padding: 'md' as Padding, hoverable: false, border: true }, props);
const merged = mergeProps(
{ tone: 'default' as Tone, padding: 'md' as Padding, hoverable: false, border: true },
props,
);
const [local, rest] = splitProps(merged, ['tone', 'padding', 'hoverable', 'border', 'class']);
const baseClass = 'rounded-lg shadow-sm transition-shadow duration-200';
@@ -17,7 +17,7 @@ const titleToneClass: Record<EmptyStateTone, string> = {
info: 'text-blue-700 dark:text-blue-300',
success: 'text-green-700 dark:text-green-300',
warning: 'text-amber-700 dark:text-amber-300',
danger: 'text-red-700 dark:text-red-300'
danger: 'text-red-700 dark:text-red-300',
};
const descriptionToneClass: Record<EmptyStateTone, string> = {
@@ -25,27 +25,35 @@ const descriptionToneClass: Record<EmptyStateTone, string> = {
info: 'text-blue-600 dark:text-blue-300',
success: 'text-green-600 dark:text-green-300',
warning: 'text-amber-600 dark:text-amber-300',
danger: 'text-red-600 dark:text-red-300'
danger: 'text-red-600 dark:text-red-300',
};
export function EmptyState(props: EmptyStateProps) {
const merged = mergeProps({ tone: 'default' as EmptyStateTone, align: 'center' as const }, props);
const [local, others] = splitProps(merged, ['icon', 'title', 'description', 'actions', 'tone', 'align', 'class']);
const [local, others] = splitProps(merged, [
'icon',
'title',
'description',
'actions',
'tone',
'align',
'class',
]);
const alignment = local.align;
const tone = local.tone;
const containerClass = [
'flex flex-col gap-3',
alignment === 'center' ? 'items-center text-center' : 'items-start text-left',
local.class ?? ''
].join(' ').trim();
local.class ?? '',
]
.join(' ')
.trim();
return (
<div class={containerClass} {...others}>
<Show when={local.icon}>
<div class={alignment === 'center' ? 'flex justify-center' : ''}>
{local.icon}
</div>
<div class={alignment === 'center' ? 'flex justify-center' : ''}>{local.icon}</div>
</Show>
<SectionHeader
align={alignment}
@@ -57,7 +65,13 @@ export function EmptyState(props: EmptyStateProps) {
descriptionClass={`text-xs ${descriptionToneClass[tone]}`.trim()}
/>
<Show when={local.actions}>
<div class={alignment === 'center' ? 'mt-2 flex flex-col items-center gap-2' : 'mt-2 flex flex-col gap-2'}>
<div
class={
alignment === 'center'
? 'mt-2 flex flex-col items-center gap-2'
: 'mt-2 flex flex-col gap-2'
}
>
{local.actions}
</div>
</Show>
@@ -4,9 +4,10 @@ const baseHelp = 'text-xs text-gray-500 dark:text-gray-400';
const baseControl = [
'w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
].join(' ');
const baseCheckbox = 'rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:focus:ring-blue-400';
const baseCheckbox =
'rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:focus:ring-blue-400';
const join = (base: string, extra?: string) => (extra ? `${base} ${extra}`.trim() : base);
@@ -53,5 +54,5 @@ export default {
formCheckbox,
labelClass,
controlClass,
helpTextClass
helpTextClass,
};
@@ -23,46 +23,54 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
// Combine and sort nodes based on tab
const sortedItems = createMemo(() => {
const items: Array<{ type: 'pve' | 'pbs'; data: Node | PBSInstance }> = [];
// Add PVE nodes (shown on all tabs)
if (props.nodes) {
props.nodes.forEach(node => items.push({ type: 'pve', data: node }));
props.nodes.forEach((node) => items.push({ type: 'pve', data: node }));
}
// Add PBS instances (shown on all tabs)
if (props.pbsInstances) {
props.pbsInstances.forEach(pbs => items.push({ type: 'pbs', data: pbs }));
props.pbsInstances.forEach((pbs) => items.push({ type: 'pbs', data: pbs }));
}
// Sort by type (PVE first) then by status then by name
return items.sort((a, b) => {
// PVE nodes come before PBS
if (a.type !== b.type) return a.type === 'pve' ? -1 : 1;
// Then by online status
const aOnline = a.type === 'pve'
? (a.data as Node).status === 'online'
: ((a.data as PBSInstance).status === 'healthy' || (a.data as PBSInstance).status === 'online');
const bOnline = b.type === 'pve'
? (b.data as Node).status === 'online'
: ((b.data as PBSInstance).status === 'healthy' || (b.data as PBSInstance).status === 'online');
const aOnline =
a.type === 'pve'
? (a.data as Node).status === 'online'
: (a.data as PBSInstance).status === 'healthy' ||
(a.data as PBSInstance).status === 'online';
const bOnline =
b.type === 'pve'
? (b.data as Node).status === 'online'
: (b.data as PBSInstance).status === 'healthy' ||
(b.data as PBSInstance).status === 'online';
if (aOnline !== bOnline) return aOnline ? -1 : 1;
// Then by name
return a.data.name.localeCompare(b.data.name);
});
});
// Get column header based on tab
const getCountHeader = () => {
switch (props.currentTab) {
case 'dashboard': return ['VMs', 'Containers'];
case 'storage': return ['Storage', 'Disks'];
case 'backups': return ['Backups'];
default: return [];
case 'dashboard':
return ['VMs', 'Containers'];
case 'storage':
return ['Storage', 'Disks'];
case 'backups':
return ['Backups'];
default:
return [];
}
};
// Get count values for a node
const getNodeCounts = (item: { type: 'pve' | 'pbs'; data: Node | PBSInstance }) => {
if (item.type === 'pbs') {
@@ -81,16 +89,16 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return [];
}
}
const node = item.data as Node;
switch (props.currentTab) {
case 'dashboard':
const vmCount = props.vms?.filter(vm => vm.node === node.name).length || 0;
const containerCount = props.containers?.filter(ct => ct.node === node.name).length || 0;
const vmCount = props.vms?.filter((vm) => vm.node === node.name).length || 0;
const containerCount = props.containers?.filter((ct) => ct.node === node.name).length || 0;
return [vmCount, containerCount];
case 'storage':
const storageCount = props.storage?.filter(s => s.node === node.name).length || 0;
const diskCount = state.physicalDisks?.filter(d => d.node === node.name).length || 0;
const storageCount = props.storage?.filter((s) => s.node === node.name).length || 0;
const diskCount = state.physicalDisks?.filter((d) => d.node === node.name).length || 0;
return [storageCount, diskCount];
case 'backups':
return [props.backupCounts?.[node.name] || 0];
@@ -111,16 +119,26 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<th class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-1/4">
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-20">Status</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24">Uptime</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32">CPU</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32">Memory</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-20">
Status
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24">
Uptime
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32">
CPU
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32">
Memory
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32">
{props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}
</th>
<For each={getCountHeader()}>
{(header) => (
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-16">{header}</th>
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-16">
{header}
</th>
)}
</For>
</tr>
@@ -129,89 +147,106 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<For each={sortedItems()}>
{(item) => {
const isPVE = item.type === 'pve';
const node = isPVE ? item.data as Node : null;
const pbs = !isPVE ? item.data as PBSInstance : null;
const isOnline = () => isPVE
? node!.status === 'online' && node!.uptime > 0
: (pbs!.status === 'healthy' || pbs!.status === 'online');
const cpuPercent = () => isPVE
? Math.round((node!.cpu || 0) * 100)
: Math.round(pbs!.cpu || 0);
const memPercent = () => isPVE
? Math.round(node!.memory?.usage || 0)
: (pbs!.memoryTotal ? Math.round((pbs!.memoryUsed / pbs!.memoryTotal) * 100) : 0);
const node = isPVE ? (item.data as Node) : null;
const pbs = !isPVE ? (item.data as PBSInstance) : null;
const isOnline = () =>
isPVE
? node!.status === 'online' && node!.uptime > 0
: pbs!.status === 'healthy' || pbs!.status === 'online';
const cpuPercent = () =>
isPVE ? Math.round((node!.cpu || 0) * 100) : Math.round(pbs!.cpu || 0);
const memPercent = () =>
isPVE
? Math.round(node!.memory?.usage || 0)
: pbs!.memoryTotal
? Math.round((pbs!.memoryUsed / pbs!.memoryTotal) * 100)
: 0;
const diskPercent = () => {
if (isPVE) {
return node!.disk ? Math.round((node!.disk.used / node!.disk.total) * 100) : 0;
} else {
// Calculate total storage for PBS
if (!pbs!.datastores) return 0;
const totals = pbs!.datastores.reduce((acc, ds) => {
acc.used += ds.used || 0;
acc.total += ds.total || 0;
return acc;
}, { used: 0, total: 0 });
const totals = pbs!.datastores.reduce(
(acc, ds) => {
acc.used += ds.used || 0;
acc.total += ds.total || 0;
return acc;
},
{ used: 0, total: 0 },
);
return totals.total > 0 ? Math.round((totals.used / totals.total) * 100) : 0;
}
};
const getDiskSublabel = () => {
if (isPVE && node!.disk) {
return `${formatBytes(node!.disk.used)}/${formatBytes(node!.disk.total)}`;
} else if (!isPVE && pbs!.datastores) {
const totals = pbs!.datastores.reduce((acc, ds) => {
acc.used += ds.used || 0;
acc.total += ds.total || 0;
return acc;
}, { used: 0, total: 0 });
const totals = pbs!.datastores.reduce(
(acc, ds) => {
acc.used += ds.used || 0;
acc.total += ds.total || 0;
return acc;
},
{ used: 0, total: 0 },
);
return `${formatBytes(totals.used)}/${formatBytes(totals.total)}`;
}
return undefined;
};
const nodeId = isPVE ? node!.name : pbs!.name;
const isSelected = () => props.selectedNode === nodeId;
// Use the full resource ID for alert matching
const resourceId = isPVE ? (node!.id || node!.name) : (pbs!.id || pbs!.name);
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
const alertStyles = getAlertStyles(resourceId, activeAlerts);
// Get row styles including box-shadow for alert border
const rowStyle = createMemo(() => {
const styles: Record<string, string> = {};
if (isSelected()) {
styles['box-shadow'] = '0 0 0 1px rgba(59, 130, 246, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.1)';
styles['box-shadow'] =
'0 0 0 1px rgba(59, 130, 246, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.1)';
}
if (alertStyles.hasAlert) {
const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
styles['box-shadow'] = `inset 4px 0 0 0 ${color}${isSelected() ? ', 0 0 0 1px rgba(59, 130, 246, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.1)' : ''}`;
styles['box-shadow'] =
`inset 4px 0 0 0 ${color}${isSelected() ? ', 0 0 0 1px rgba(59, 130, 246, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.1)' : ''}`;
}
return styles;
});
return (
<tr
<tr
class={`cursor-pointer transition-all duration-200 relative ${
isSelected()
? 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 z-10'
isSelected()
? 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 z-10'
: alertStyles.hasAlert
? (alertStyles.severity === 'critical'
? alertStyles.severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40'
: 'bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30')
: props.selectedNode
? 'opacity-50 hover:opacity-80 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'
: 'bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30'
: props.selectedNode
? 'opacity-50 hover:opacity-80 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'
}`}
style={rowStyle()}
onClick={() => props.onNodeClick(nodeId, item.type)}
>
<td class={`pr-2 py-0.5 whitespace-nowrap ${alertStyles.hasAlert ? 'pl-4' : 'pl-3'}`}>
<td
class={`pr-2 py-0.5 whitespace-nowrap ${alertStyles.hasAlert ? 'pl-4' : 'pl-3'}`}
>
<div class="flex items-center gap-1">
<a
href={isPVE ? (node!.host || `https://${node!.name}:8006`) : (pbs!.host || `https://${pbs!.name}:8007`)}
<a
href={
isPVE
? node!.host || `https://${node!.name}:8006`
: pbs!.host || `https://${pbs!.name}:8007`
}
target="_blank"
onClick={(e) => e.stopPropagation()}
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400"
@@ -229,11 +264,13 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</span>
</Show>
<Show when={isPVE && node!.isClusterMember !== undefined}>
<span class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${
node!.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}>
<span
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${
node!.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
</span>
</Show>
@@ -251,46 +288,59 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
<td class="px-2 py-0.5 whitespace-nowrap">
<div class="flex items-center gap-1">
<span class={`h-2 w-2 flex-shrink-0 rounded-full ${
isOnline() ? 'bg-green-500' : 'bg-red-500'
}`} />
<span
class={`h-2 w-2 flex-shrink-0 rounded-full ${
isOnline() ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span class="text-xs text-gray-600 dark:text-gray-400">
{isOnline() ? 'Online' : 'Offline'}
</span>
</div>
</td>
<td class="px-2 py-0.5 whitespace-nowrap">
<span class={`text-xs ${
isPVE && node!.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
}`}>
<Show when={isOnline() && (isPVE ? node!.uptime : pbs!.uptime)} fallback="-">
<span
class={`text-xs ${
isPVE && node!.uptime < 3600
? 'text-orange-500'
: 'text-gray-600 dark:text-gray-400'
}`}
>
<Show
when={isOnline() && (isPVE ? node!.uptime : pbs!.uptime)}
fallback="-"
>
{formatUptime(isPVE ? node!.uptime : pbs!.uptime)}
</Show>
</span>
</td>
<td class="px-2 py-0.5">
<MetricBar
value={cpuPercent()}
<MetricBar
value={cpuPercent()}
label={`${cpuPercent()}%`}
sublabel={isPVE && node!.cpuInfo?.cores ? `${node!.cpuInfo.cores} cores` : undefined}
sublabel={
isPVE && node!.cpuInfo?.cores ? `${node!.cpuInfo.cores} cores` : undefined
}
type="cpu"
/>
</td>
<td class="px-2 py-0.5">
<MetricBar
value={memPercent()}
<MetricBar
value={memPercent()}
label={`${memPercent()}%`}
sublabel={isPVE && node!.memory
? `${formatBytes(node!.memory.used)}/${formatBytes(node!.memory.total)}`
: (!isPVE && pbs!.memoryTotal
? `${formatBytes(pbs!.memoryUsed)}/${formatBytes(pbs!.memoryTotal)}`
: undefined)}
sublabel={
isPVE && node!.memory
? `${formatBytes(node!.memory.used)}/${formatBytes(node!.memory.total)}`
: !isPVE && pbs!.memoryTotal
? `${formatBytes(pbs!.memoryUsed)}/${formatBytes(pbs!.memoryTotal)}`
: undefined
}
type="memory"
/>
</td>
<td class="px-2 py-0.5">
<MetricBar
value={diskPercent()}
<MetricBar
value={diskPercent()}
label={`${diskPercent()}%`}
sublabel={getDiskSublabel()}
type="disk"
@@ -14,7 +14,7 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
const checkScroll = () => {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
setShowLeftFade(scrollLeft > 0);
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
@@ -38,9 +38,9 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
<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
<div
ref={scrollContainer}
class="overflow-x-auto"
style="scrollbar-width: none; -ms-overflow-style: none;"
@@ -48,15 +48,13 @@ export const ScrollableTable: Component<ScrollableTableProps> = (props) => {
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
`}</style>
<div style={{ "min-width": props.minWidth || 'auto' }}>
{props.children}
</div>
<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>
);
};
};
@@ -11,10 +11,23 @@ type SectionHeaderProps = {
} & Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title'>;
export function SectionHeader(props: SectionHeaderProps) {
const merged = mergeProps({ align: 'left' as const, size: 'md' as const, titleClass: '', descriptionClass: '' }, props);
const [local, rest] = splitProps(merged, ['label', 'title', 'description', 'align', 'size', 'titleClass', 'descriptionClass', 'class']);
const merged = mergeProps(
{ align: 'left' as const, size: 'md' as const, titleClass: '', descriptionClass: '' },
props,
);
const [local, rest] = splitProps(merged, [
'label',
'title',
'description',
'align',
'size',
'titleClass',
'descriptionClass',
'class',
]);
const alignmentClass = local.align === 'center' ? 'text-center items-center' : 'text-left items-start';
const alignmentClass =
local.align === 'center' ? 'text-center items-center' : 'text-left items-start';
const sizeClass = () => {
switch (local.size) {
case 'sm':
@@ -33,11 +46,15 @@ export function SectionHeader(props: SectionHeaderProps) {
{local.label}
</span>
</Show>
<h2 class={`${sizeClass()} font-semibold text-gray-900 dark:text-gray-100 ${local.titleClass ?? ''}`.trim()}>
<h2
class={`${sizeClass()} font-semibold text-gray-900 dark:text-gray-100 ${local.titleClass ?? ''}`.trim()}
>
{local.title}
</h2>
<Show when={local.description}>
<p class={`text-sm text-gray-600 dark:text-gray-400 ${local.descriptionClass ?? ''}`.trim()}>
<p
class={`text-sm text-gray-600 dark:text-gray-400 ${local.descriptionClass ?? ''}`.trim()}
>
{local.description}
</p>
</Show>
@@ -12,7 +12,16 @@ type SettingsPanelProps = {
} & JSX.HTMLAttributes<HTMLDivElement>;
export function SettingsPanel(props: SettingsPanelProps) {
const [local, rest] = splitProps(props, ['title', 'description', 'action', 'bodyClass', 'children', 'class', 'tone', 'padding']);
const [local, rest] = splitProps(props, [
'title',
'description',
'action',
'bodyClass',
'children',
'class',
'tone',
'padding',
]);
return (
<Card
@@ -32,9 +41,7 @@ export function SettingsPanel(props: SettingsPanelProps) {
<div class="md:ml-6">{local.action}</div>
</Show>
</div>
<div class={local.bodyClass ?? 'space-y-4'}>
{local.children}
</div>
<div class={local.bodyClass ?? 'space-y-4'}>{local.children}</div>
</Card>
);
}
@@ -8,7 +8,13 @@ export type ToggleProps = {
export function Toggle(props: ToggleProps) {
const merged = mergeProps({ containerClass: '' }, props);
const [local, rest] = splitProps(merged, ['label', 'description', 'containerClass', 'class', 'disabled']);
const [local, rest] = splitProps(merged, [
'label',
'description',
'containerClass',
'class',
'disabled',
]);
const isDisabled = () => Boolean(local.disabled);
const isChecked = () => {
@@ -24,8 +30,12 @@ export function Toggle(props: ToggleProps) {
};
return (
<label class={`flex items-center gap-3 ${local.containerClass ?? ''} ${local.class ?? ''}`.trim()}>
<span class={`relative inline-flex h-6 w-11 flex-shrink-0 items-center ${isDisabled() ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}>
<label
class={`flex items-center gap-3 ${local.containerClass ?? ''} ${local.class ?? ''}`.trim()}
>
<span
class={`relative inline-flex h-6 w-11 flex-shrink-0 items-center ${isDisabled() ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
<input type="checkbox" class="sr-only" disabled={local.disabled} {...rest} />
<span
class={`absolute inset-0 rounded-full transition ${
@@ -44,9 +54,7 @@ export function Toggle(props: ToggleProps) {
{(local.label || local.description) && (
<span class="flex flex-col text-sm text-gray-700 dark:text-gray-300">
{local.label}
<span class="text-xs text-gray-500 dark:text-gray-400">
{local.description}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{local.description}</span>
</span>
)}
</label>
@@ -13,9 +13,9 @@ function sanitizeContent(content: string): string {
// Remove any HTML tags and encode special characters
return content
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/&/g, '&amp;') // Encode ampersands
.replace(/</g, '&lt;') // Encode less than
.replace(/>/g, '&gt;') // Encode greater than
.replace(/&/g, '&amp;') // Encode ampersands
.replace(/</g, '&lt;') // Encode less than
.replace(/>/g, '&gt;') // Encode greater than
.replace(/"/g, '&quot;') // Encode quotes
.replace(/'/g, '&#x27;'); // Encode apostrophes
}
@@ -23,41 +23,41 @@ function sanitizeContent(content: string): string {
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 = 20; // Increased padding for better separation
let x = props.x + padding;
let y = props.y - rect.height - padding - 10; // Extra 10px vertical separation
// 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}>
@@ -69,7 +69,7 @@ const Tooltip: Component<TooltipProps> = (props) => {
top: '0',
transform: `translate(${position().x}px, ${position().y}px)`,
opacity: props.visible ? '1' : '0',
transition: 'opacity 200ms ease-out'
transition: 'opacity 200ms ease-out',
}}
textContent={sanitizeContent(props.content)}
/>
@@ -88,7 +88,7 @@ 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);
@@ -97,16 +97,11 @@ export function createTooltipSystem() {
},
hide: () => {
setVisible(false);
}
},
};
return () => (
<Tooltip
content={content()}
x={position().x}
y={position().y}
visible={visible()}
/>
<Tooltip content={content()} x={position().x} y={position().y} visible={visible()} />
);
}
@@ -118,4 +113,4 @@ export function hideTooltip() {
tooltipInstance?.hide();
}
export default Tooltip;
export default Tooltip;
@@ -17,7 +17,7 @@ interface UnifiedNodeSelectorProps {
export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props) => {
const { state } = useWebSocket();
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
// Handle ESC key to deselect node
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedNode()) {
@@ -25,60 +25,60 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
props.onNodeSelect?.(null, null);
}
};
onMount(() => {
document.addEventListener('keydown', handleKeyDown);
});
onCleanup(() => {
document.removeEventListener('keydown', handleKeyDown);
});
// Reset selection when tab changes
createEffect(() => {
props.currentTab;
setSelectedNode(null);
});
// No longer syncing with search term - selection is independent
// This allows users to select a node AND search within it
// Calculate backup counts for nodes and PBS instances
const backupCounts = createMemo(() => {
const counts: Record<string, number> = {};
// Count PVE backups and snapshots by node
const nodes = props.nodes || state.nodes;
if (nodes) {
nodes.forEach((node) => {
let count = 0;
// Count storage backups (excluding PBS backups which are counted separately)
if (state.pveBackups?.storageBackups) {
count += state.pveBackups.storageBackups.filter(b =>
b.node === node.name && !b.isPBS
count += state.pveBackups.storageBackups.filter(
(b) => b.node === node.name && !b.isPBS,
).length;
}
// Count snapshots
if (state.pveBackups?.guestSnapshots) {
count += state.pveBackups.guestSnapshots.filter(s => s.node === node.name).length;
count += state.pveBackups.guestSnapshots.filter((s) => s.node === node.name).length;
}
counts[node.name] = count;
});
}
// Count PBS backups by instance
if (state.pbs && state.pbsBackups) {
state.pbs.forEach(pbs => {
counts[pbs.name] = state.pbsBackups?.filter(b => b.instance === pbs.name).length || 0;
state.pbs.forEach((pbs) => {
counts[pbs.name] = state.pbsBackups?.filter((b) => b.instance === pbs.name).length || 0;
});
}
return counts;
});
const handleNodeClick = (nodeId: string, nodeType: 'pve' | 'pbs') => {
// Toggle selection
if (selectedNode() === nodeId) {
@@ -89,18 +89,18 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
props.onNodeSelect?.(nodeId, nodeType);
}
};
// Parent components now handle conditional rendering, so we can render directly
const nodes = createMemo(() => props.nodes || state.nodes || []);
return (
<div class="space-y-2 mb-4">
<NodeSummaryTable
nodes={nodes()}
pbsInstances={props.currentTab === 'backups' ? state.pbs : undefined}
vms={state.vms} // Always use unfiltered data for counts
containers={state.containers} // Always use unfiltered data for counts
storage={state.storage} // Always use unfiltered data for counts
vms={state.vms} // Always use unfiltered data for counts
containers={state.containers} // Always use unfiltered data for counts
storage={state.storage} // Always use unfiltered data for counts
backupCounts={backupCounts()}
currentTab={props.currentTab}
selectedNode={selectedNode()}
+11 -12
View File
@@ -2,18 +2,18 @@
// 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
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
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)
@@ -26,17 +26,17 @@ export const IO_THRESHOLDS = {
// Animation durations (in milliseconds)
export const ANIMATIONS = {
TOAST_SLIDE: 300, // Toast slide in/out animation
TOAST_SLIDE: 300, // Toast slide in/out animation
} as const;
// UI configuration
export const UI = {
DEBOUNCE_DELAY: 300, // 300ms - input debounce delay
DEBOUNCE_DELAY: 300, // 300ms - input debounce delay
} as const;
// WebSocket configuration
export const WEBSOCKET = {
PING_INTERVAL: 25000, // 25 seconds - WebSocket ping interval
PING_INTERVAL: 25000, // 25 seconds - WebSocket ping interval
MESSAGE_TYPES: {
INITIAL_STATE: 'initialState',
RAW_DATA: 'rawData',
@@ -67,4 +67,3 @@ export const LOG_LEVELS = {
} as const;
export type LogLevel = keyof typeof LOG_LEVELS;
@@ -8,7 +8,7 @@ interface UseIntersectionObserverOptions {
export function useIntersectionObserver(
ref: () => HTMLElement | undefined,
options: UseIntersectionObserverOptions = {}
options: UseIntersectionObserverOptions = {},
) {
const [isIntersecting, setIsIntersecting] = createSignal(false);
let observer: IntersectionObserver | undefined;
@@ -28,8 +28,8 @@ export function useIntersectionObserver(
{
threshold: options.threshold || 0,
rootMargin: options.rootMargin || '50px',
root: options.root || null
}
root: options.root || null,
},
);
observer.observe(element);
@@ -42,4 +42,4 @@ export function useIntersectionObserver(
});
return isIntersecting;
}
}
+29 -21
View File
@@ -52,60 +52,64 @@
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);
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);
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;
@@ -150,19 +154,19 @@
@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;
@@ -173,7 +177,7 @@
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');
}
@@ -272,7 +276,10 @@ body,
.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;
transition:
background-color 150ms ease-in-out,
color 150ms ease-in-out,
border-color 150ms ease-in-out;
}
/* Pulse logo animation - subtle ripple effect */
@@ -289,7 +296,8 @@ body,
}
@keyframes pulse-ring {
0%, 100% {
0%,
100% {
opacity: 0.92;
transform: scale(1);
}
@@ -363,7 +371,8 @@ body,
}
@keyframes pulse-slow {
0%, 100% {
0%,
100% {
opacity: 0.25;
}
50% {
@@ -382,4 +391,3 @@ body,
.animate-pulse-slow {
animation: pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
+1 -2
View File
@@ -19,7 +19,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
console.log('[Index] Starting Pulse app...');
logger.info('Pulse monitoring dashboard starting');
if (root) {
console.log('[Index] Root element found, rendering App...');
try {
@@ -35,4 +34,4 @@ if (root) {
}
} else {
console.error('[Index] Root element not found!');
}
}
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -1,7 +1,12 @@
// Event bus for cross-component communication
// Event types
export type EventType = 'node_auto_registered' | 'refresh_nodes' | 'discovery_updated' | 'discovery_status' | 'theme_changed';
export type EventType =
| 'node_auto_registered'
| 'refresh_nodes'
| 'discovery_updated'
| 'discovery_status'
| 'theme_changed';
// Event data types
export interface NodeAutoRegisteredData {
@@ -41,12 +46,12 @@ export interface DiscoveryStatusData {
// Map event types to their data types
export type EventDataMap = {
'node_auto_registered': NodeAutoRegisteredData;
'refresh_nodes': void;
'discovery_updated': DiscoveryUpdatedData;
'discovery_status': DiscoveryStatusData;
'theme_changed': string; // 'light' or 'dark'
}
node_auto_registered: NodeAutoRegisteredData;
refresh_nodes: void;
discovery_updated: DiscoveryUpdatedData;
discovery_status: DiscoveryStatusData;
theme_changed: string; // 'light' or 'dark'
};
// Generic event handler
type EventHandler<T = unknown> = (data?: T) => void;
@@ -59,7 +64,7 @@ class EventBus {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as EventHandler<unknown>);
// Return unsubscribe function
return () => {
this.handlers.get(event)?.delete(handler as EventHandler<unknown>);
@@ -73,7 +78,7 @@ class EventBus {
emit<T extends EventType>(event: T, data?: EventDataMap[T]) {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach(handler => handler(data));
handlers.forEach((handler) => handler(data));
}
}
}
+11 -11
View File
@@ -11,34 +11,34 @@ const [notifications, setNotifications] = createStore<Notification[]>([]);
export const notificationStore = {
notifications,
add: (notification: Omit<Notification, 'id'>) => {
const id = Date.now().toString();
setNotifications(prev => [...prev, { ...notification, id }]);
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));
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 });
}
};
},
};
+19 -15
View File
@@ -50,7 +50,7 @@ const checkForUpdates = async (force = false): Promise<void> => {
const now = Date.now();
// Skip if checked recently (unless forced)
if (!force && state.lastCheck && (now - state.lastCheck) < CHECK_INTERVAL) {
if (!force && state.lastCheck && now - state.lastCheck < CHECK_INTERVAL) {
// Use cached data if available
if (state.updateInfo) {
// First check if version matches (in case user updated)
@@ -67,7 +67,7 @@ const checkForUpdates = async (force = false): Promise<void> => {
// Version matches, use cached data
setUpdateInfo(state.updateInfo);
setUpdateAvailable(state.updateInfo.available);
// Check if this version was dismissed
if (state.dismissedVersion === state.updateInfo.latestVersion) {
setIsDismissed(true);
@@ -90,7 +90,7 @@ const checkForUpdates = async (force = false): Promise<void> => {
// First get version info to check deployment type
const version = await UpdatesAPI.getVersion();
setVersionInfo(version);
// Clear cache if version has changed (user updated)
if (state.updateInfo && state.updateInfo.currentVersion !== version.version) {
// Version changed, clear the cache
@@ -104,10 +104,10 @@ const checkForUpdates = async (force = false): Promise<void> => {
setUpdateAvailable(false);
return;
}
// Skip dev builds unless forcing (contains -dirty or commit hash after version)
const isDirtyBuild = version.version.includes('-dirty') ||
/v\d+\.\d+\.\d+.*-g[0-9a-f]+/.test(version.version);
const isDirtyBuild =
version.version.includes('-dirty') || /v\d+\.\d+\.\d+.*-g[0-9a-f]+/.test(version.version);
if (isDirtyBuild && !force) {
setUpdateAvailable(false);
return;
@@ -115,7 +115,7 @@ const checkForUpdates = async (force = false): Promise<void> => {
// Get the saved update channel from system settings
const info = await UpdatesAPI.checkForUpdates();
setUpdateInfo(info);
setUpdateAvailable(info.available);
@@ -130,7 +130,7 @@ const checkForUpdates = async (force = false): Promise<void> => {
saveState({
...state,
lastCheck: now,
updateInfo: info
updateInfo: info,
});
} catch (error) {
console.error('Failed to check for updates:', error);
@@ -149,7 +149,7 @@ const dismissUpdate = () => {
const state = loadState();
saveState({
...state,
dismissedVersion: info.latestVersion
dismissedVersion: info.latestVersion,
});
setIsDismissed(true);
@@ -176,12 +176,12 @@ export const updateStore = {
isDismissed,
lastError,
isUpdateVisible,
// Actions
checkForUpdates,
dismissUpdate,
clearDismissed,
// Manual testing helpers
simulateUpdate: (version: string = 'v5.0.0') => {
setUpdateInfo({
@@ -191,11 +191,11 @@ export const updateStore = {
releaseNotes: 'Test update notification',
releaseDate: new Date().toISOString(),
downloadUrl: '#',
isPrerelease: false
isPrerelease: false,
});
setUpdateAvailable(true);
setIsDismissed(false);
}
},
};
// Expose for testing in development
@@ -205,6 +205,10 @@ declare global {
}
}
if (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168')) {
if (
import.meta.env.DEV ||
window.location.hostname === 'localhost' ||
window.location.hostname.startsWith('192.168')
) {
window.updateStore = updateStore;
}
}
@@ -12,9 +12,9 @@ export function getGlobalWebSocketStore() {
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;
}
}
+273 -247
View File
@@ -1,6 +1,14 @@
import { createSignal, onCleanup } from 'solid-js';
import { createStore } from 'solid-js/store';
import type { State, WSMessage, Alert, ResolvedAlert, PVEBackups, VM, Container } from '@/types/api';
import type {
State,
WSMessage,
Alert,
ResolvedAlert,
PVEBackups,
VM,
Container,
} from '@/types/api';
import { logger } from '@/utils/logger';
import { POLLING_INTERVALS, WEBSOCKET } from '@/constants';
import { notificationStore } from './notifications';
@@ -22,7 +30,7 @@ export function createWebSocketStore(url: string) {
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: []
guestSnapshots: [],
} as PVEBackups,
pbsBackups: [],
performance: {
@@ -32,7 +40,7 @@ export function createWebSocketStore(url: string) {
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0
cacheMisses: 0,
},
connectionHealth: {},
stats: {
@@ -40,16 +48,16 @@ export function createWebSocketStore(url: string) {
uptime: 0,
pollingCycles: 0,
webSocketClients: 0,
version: '2.0.0'
version: '2.0.0',
},
activeAlerts: [],
recentlyResolved: [],
lastUpdate: ''
lastUpdate: '',
});
const [activeAlerts, setActiveAlerts] = createStore<Record<string, Alert>>({});
const [recentlyResolved, setRecentlyResolved] = createStore<Record<string, ResolvedAlert>>({});
const [updateProgress, setUpdateProgress] = createSignal<unknown>(null);
// Track alerts with pending acknowledgment changes to prevent race conditions
const pendingAckChanges = new Set<string>();
@@ -71,7 +79,7 @@ export function createWebSocketStore(url: string) {
}
ws = null;
}
// Add a small delay before reconnecting to avoid rapid reconnect loops
if (reconnectAttempt > 0) {
const delay = Math.min(100 * reconnectAttempt, 1000);
@@ -81,7 +89,7 @@ export function createWebSocketStore(url: string) {
}, delay);
return;
}
ws = new WebSocket(url);
setupWebSocket();
} catch (err) {
@@ -92,25 +100,25 @@ export function createWebSocketStore(url: string) {
const handleReconnect = () => {
if (isReconnecting) return;
isReconnecting = true;
setReconnecting(true);
// Clear any existing timeout
if (reconnectTimeout) {
window.clearTimeout(reconnectTimeout);
reconnectTimeout = 0;
}
// Calculate exponential backoff delay
const delay = Math.min(
initialReconnectDelay * Math.pow(2, reconnectAttempt),
maxReconnectDelay
maxReconnectDelay,
);
logger.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`);
reconnectAttempt++;
reconnectTimeout = window.setTimeout(() => {
isReconnecting = false;
connect();
@@ -126,7 +134,7 @@ export function createWebSocketStore(url: string) {
setReconnecting(false); // Clear reconnecting state
reconnectAttempt = 0; // Reset reconnect attempts on successful connection
isReconnecting = false;
// Start heartbeat to keep connection alive
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval);
@@ -136,262 +144,280 @@ export function createWebSocketStore(url: string) {
ws.send(JSON.stringify({ type: 'ping', data: { timestamp: Date.now() } }));
}
}, heartbeatIntervalMs);
// 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) {
console.log('[WebSocket] Updating nodes:', message.data.nodes?.length || 0);
setState('nodes', message.data.nodes);
}
if (message.data.vms !== undefined) {
// Transform tags from comma-separated strings to arrays
const transformedVMs = message.data.vms.map((vm: VM) => {
const originalTags = vm.tags;
let transformedTags: string[];
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
// String with content - split into array
transformedTags = originalTags.split(',').map((t: string) => t.trim()).filter((t: string) => t.length > 0);
} else if (Array.isArray(originalTags)) {
// Already an array - filter out empty/whitespace-only tags
transformedTags = originalTags.filter((tag: string) =>
typeof tag === 'string' && tag.trim().length > 0
);
} else {
// null, undefined, empty string, or other - convert to empty array
transformedTags = [];
}
return {
...vm,
tags: transformedTags
};
});
setState('vms', transformedVMs);
}
if (message.data.containers !== undefined) {
// Transform tags from comma-separated strings to arrays
const transformedContainers = message.data.containers.map((container: Container) => {
const originalTags = container.tags;
let transformedTags: string[];
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
// String with content - split into array
transformedTags = originalTags.split(',').map((t: string) => t.trim()).filter((t: string) => t.length > 0);
} else if (Array.isArray(originalTags)) {
// Already an array - filter out empty/whitespace-only tags
transformedTags = originalTags.filter((tag: string) =>
typeof tag === 'string' && tag.trim().length > 0
);
} else {
// null, undefined, empty string, or other - convert to empty array
transformedTags = [];
}
return {
...container,
tags: transformedTags
};
});
setState('containers', transformedContainers);
}
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);
if (message.data.physicalDisks !== undefined) setState('physicalDisks', message.data.physicalDisks);
// 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> = {};
if (message.data.activeAlerts && Array.isArray(message.data.activeAlerts)) {
message.data.activeAlerts.forEach((alert: Alert) => {
newAlerts[alert.id] = alert;
});
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) {
console.log('[WebSocket] Updating nodes:', message.data.nodes?.length || 0);
setState('nodes', message.data.nodes);
}
if (message.data.vms !== undefined) {
// Transform tags from comma-separated strings to arrays
const transformedVMs = message.data.vms.map((vm: VM) => {
const originalTags = vm.tags;
let transformedTags: string[];
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
// String with content - split into array
transformedTags = originalTags
.split(',')
.map((t: string) => t.trim())
.filter((t: string) => t.length > 0);
} else if (Array.isArray(originalTags)) {
// Already an array - filter out empty/whitespace-only tags
transformedTags = originalTags.filter(
(tag: string) => typeof tag === 'string' && tag.trim().length > 0,
);
} else {
// null, undefined, empty string, or other - convert to empty array
transformedTags = [];
}
// Clear existing alerts and set new ones
const currentAlertIds = Object.keys(activeAlerts);
currentAlertIds.forEach(id => {
if (!newAlerts[id]) {
setActiveAlerts(id, undefined as unknown as Alert);
}
});
// Add new alerts (skip those with pending ack changes)
Object.entries(newAlerts).forEach(([id, alert]) => {
// Skip updating if this alert has a pending acknowledgment change
if (pendingAckChanges.has(id)) {
// Check if the acknowledgment state from server matches our pending change
const currentAlert = activeAlerts[id];
if (currentAlert && currentAlert.acknowledged !== alert.acknowledged) {
logger.debug(`Skipping update for alert ${id} - has pending ack change (local: ${currentAlert.acknowledged}, server: ${alert.acknowledged})`);
return;
}
// If the server has caught up with our change, we can clear the pending flag
pendingAckChanges.delete(id);
}
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> = {};
if (message.data.recentlyResolved && Array.isArray(message.data.recentlyResolved)) {
message.data.recentlyResolved.forEach((alert: ResolvedAlert) => {
newResolvedAlerts[alert.id] = alert;
});
return {
...vm,
tags: transformedTags,
};
});
setState('vms', transformedVMs);
}
if (message.data.containers !== undefined) {
// Transform tags from comma-separated strings to arrays
const transformedContainers = message.data.containers.map((container: Container) => {
const originalTags = container.tags;
let transformedTags: string[];
if (originalTags && typeof originalTags === 'string' && originalTags.trim()) {
// String with content - split into array
transformedTags = originalTags
.split(',')
.map((t: string) => t.trim())
.filter((t: string) => t.length > 0);
} else if (Array.isArray(originalTags)) {
// Already an array - filter out empty/whitespace-only tags
transformedTags = originalTags.filter(
(tag: string) => typeof tag === 'string' && tag.trim().length > 0,
);
} else {
// null, undefined, empty string, or other - convert to empty array
transformedTags = [];
}
// Clear existing resolved alerts and set new ones
const currentResolvedIds = Object.keys(recentlyResolved);
currentResolvedIds.forEach(id => {
if (!newResolvedAlerts[id]) {
setRecentlyResolved(id, undefined as unknown as ResolvedAlert);
}
return {
...container,
tags: transformedTags,
};
});
setState('containers', transformedContainers);
}
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);
if (message.data.physicalDisks !== undefined)
setState('physicalDisks', message.data.physicalDisks);
// 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> = {};
if (message.data.activeAlerts && Array.isArray(message.data.activeAlerts)) {
message.data.activeAlerts.forEach((alert: Alert) => {
newAlerts[alert.id] = alert;
});
// Add new resolved alerts
Object.entries(newResolvedAlerts).forEach(([id, alert]) => {
setRecentlyResolved(id, alert);
});
// Updated recentlyResolved
}
setState('lastUpdate', message.data.lastUpdate || new Date().toISOString());
// Clear existing alerts and set new ones
const currentAlertIds = Object.keys(activeAlerts);
currentAlertIds.forEach((id) => {
if (!newAlerts[id]) {
setActiveAlerts(id, undefined as unknown as Alert);
}
});
// Add new alerts (skip those with pending ack changes)
Object.entries(newAlerts).forEach(([id, alert]) => {
// Skip updating if this alert has a pending acknowledgment change
if (pendingAckChanges.has(id)) {
// Check if the acknowledgment state from server matches our pending change
const currentAlert = activeAlerts[id];
if (currentAlert && currentAlert.acknowledged !== alert.acknowledged) {
logger.debug(
`Skipping update for alert ${id} - has pending ack change (local: ${currentAlert.acknowledged}, server: ${alert.acknowledged})`,
);
return;
}
// If the server has caught up with our change, we can clear the pending flag
pendingAckChanges.delete(id);
}
setActiveAlerts(id, alert);
});
// Updated activeAlerts
}
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';
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
eventBus.emit('refresh_nodes');
} else if (message.type === 'discovery_update') {
// Discovery scan completed with new results
eventBus.emit('discovery_updated', message.data);
} else if (message.type === 'discovery_started') {
eventBus.emit('discovery_status', {
scanning: true,
subnet: message.data?.subnet,
timestamp: message.data?.timestamp
});
} else if (message.type === 'discovery_complete') {
eventBus.emit('discovery_status', {
scanning: false,
timestamp: message.data?.timestamp
});
} else if (message.type === 'settingsUpdate') {
// Settings have been updated (e.g., theme change)
if (message.data?.theme) {
// Emit event for theme change
eventBus.emit('theme_changed', message.data.theme);
logger.info('Theme update received via WebSocket:', message.data.theme);
}
} else {
// Log any unhandled message types in dev mode only
if (import.meta.env.DEV) {
// Silently ignore unhandled message types
// 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> = {};
if (message.data.recentlyResolved && Array.isArray(message.data.recentlyResolved)) {
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 as unknown as ResolvedAlert);
}
});
// 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';
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
eventBus.emit('refresh_nodes');
} else if (message.type === 'discovery_update') {
// Discovery scan completed with new results
eventBus.emit('discovery_updated', message.data);
} else if (message.type === 'discovery_started') {
eventBus.emit('discovery_status', {
scanning: true,
subnet: message.data?.subnet,
timestamp: message.data?.timestamp,
});
} else if (message.type === 'discovery_complete') {
eventBus.emit('discovery_status', {
scanning: false,
timestamp: message.data?.timestamp,
});
} else if (message.type === 'settingsUpdate') {
// Settings have been updated (e.g., theme change)
if (message.data?.theme) {
// Emit event for theme change
eventBus.emit('theme_changed', message.data.theme);
logger.info('Theme update received via WebSocket:', message.data.theme);
}
} else {
// Log any unhandled message types in dev mode only
if (import.meta.env.DEV) {
// Silently ignore unhandled message types
}
} catch (err) {
logger.error('Failed to process WebSocket message', err);
}
};
} 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);
// Clear heartbeat interval
if (heartbeatInterval) {
window.clearInterval(heartbeatInterval);
heartbeatInterval = 0;
}
// Don't try to reconnect if the close was intentional (code 1000)
if (event.code === 1000 && event.reason === 'Reconnecting') {
return;
}
handleReconnect();
};
@@ -452,6 +478,6 @@ export function createWebSocketStore(url: string) {
}
setActiveAlerts(alertId, { ...existingAlert, ...updates });
}
}
},
};
}
+3 -3
View File
@@ -193,14 +193,14 @@
}
/* Node row click animation */
@keyframes nodeClick {
0% {
0% {
transform: scale(1);
}
50% {
50% {
transform: scale(0.98);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
100% {
100% {
transform: scale(1);
}
}
+60 -46
View File
@@ -35,8 +35,8 @@ export interface Node {
cpuInfo: CPUInfo;
lastSeen: string;
connectionHealth: string;
isClusterMember?: boolean; // True if part of a cluster
clusterName?: string; // Name of cluster (empty if standalone)
isClusterMember?: boolean; // True if part of a cluster
clusterName?: string; // Name of cluster (empty if standalone)
}
export interface VM {
@@ -113,9 +113,9 @@ export interface Storage {
export interface ZFSPool {
name: string;
state: string; // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL
status: string; // Healthy, Degraded, Faulted, etc.
scan: string; // Current scan status (scrub, resilver, none)
state: string; // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL
status: string; // Healthy, Degraded, Faulted, etc.
scan: string; // Current scan status (scrub, resilver, none)
readErrors: number;
writeErrors: number;
checksumErrors: number;
@@ -124,8 +124,8 @@ export interface ZFSPool {
export interface ZFSDevice {
name: string;
type: string; // disk, mirror, raidz, raidz2, raidz3, spare, log, cache
state: string; // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL
type: string; // disk, mirror, raidz, raidz2, raidz3, spare, log, cache
state: string; // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL
readErrors: number;
writeErrors: number;
checksumErrors: number;
@@ -374,7 +374,7 @@ export interface ResolvedAlert extends Alert {
}
// WebSocket message types
export type WSMessage =
export type WSMessage =
| { type: 'initialState'; data: State }
| { type: 'rawData'; data: State }
| { type: 'error'; error: string }
@@ -384,47 +384,61 @@ export type WSMessage =
| { type: 'alert'; data: Alert }
| { type: 'alertResolved'; data: { alertId: string } }
| { type: 'settingsUpdate'; data: { theme?: string } }
| { type: 'update:progress'; data: {
phase: string;
progress: number;
message: string;
| {
type: 'update:progress';
data: {
phase: string;
progress: number;
message: string;
};
}
| {
type: 'node_auto_registered';
data: {
type: string;
host: string;
name: string;
tokenId: string;
hasToken: boolean;
verifySSL?: boolean;
status?: string;
};
}
}
| { 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;
immediate?: boolean;
scanning?: boolean;
cached?: boolean;
}}
| { type: 'discovery_started'; data?: {
subnet?: string;
timestamp?: number;
scanning?: boolean;
}}
| { type: 'discovery_complete'; data?: {
timestamp?: number;
scanning?: boolean;
}};
| {
type: 'discovery_update';
data: {
servers: Array<{
ip: string;
port: number;
type: string;
version: string;
hostname?: string;
release?: string;
}>;
errors?: string[];
timestamp?: number;
immediate?: boolean;
scanning?: boolean;
cached?: boolean;
};
}
| {
type: 'discovery_started';
data?: {
subnet?: string;
timestamp?: number;
scanning?: boolean;
};
}
| {
type: 'discovery_complete';
data?: {
timestamp?: number;
scanning?: boolean;
};
};
// Utility types
export type Status = 'running' | 'stopped' | 'paused' | 'unknown';
+29 -29
View File
@@ -1,9 +1,9 @@
/**
* Configuration Type Definitions
*
*
* This file defines the types for Pulse's configuration structure.
* Configuration is split into three files:
*
*
* 1. .env - Authentication credentials (AuthConfig)
* 2. system.json - Application settings (SystemConfig)
* 3. nodes.enc - Encrypted node credentials (NodesConfig)
@@ -14,11 +14,11 @@
* These are environment variables for authentication ONLY
*/
export interface AuthConfig {
PULSE_AUTH_USER: string; // Admin username
PULSE_AUTH_PASS: string; // Bcrypt hashed password
API_TOKEN: string; // API authentication token
ENABLE_AUDIT_LOG?: boolean; // @deprecated - use PULSE_AUDIT_LOG
PULSE_AUDIT_LOG?: boolean; // Enable audit logging
PULSE_AUTH_USER: string; // Admin username
PULSE_AUTH_PASS: string; // Bcrypt hashed password
API_TOKEN: string; // API authentication token
ENABLE_AUDIT_LOG?: boolean; // @deprecated - use PULSE_AUDIT_LOG
PULSE_AUDIT_LOG?: boolean; // Enable audit logging
}
/**
@@ -27,20 +27,20 @@ export interface AuthConfig {
*/
export interface SystemConfig {
// Note: PVE polling is hardcoded to 10s (Proxmox cluster/resources updates every 10s)
pbsPollingInterval?: number; // PBS polling interval in seconds
connectionTimeout?: number; // Seconds before timeout (default: 10)
autoUpdateEnabled: boolean; // Enable auto-updates
updateChannel?: string; // Update channel: 'stable' | 'rc' | 'beta'
autoUpdateCheckInterval?: number; // Hours between update checks
autoUpdateTime?: string; // Time for updates (HH:MM format)
allowedOrigins?: string; // CORS allowed origins
backendPort?: number; // Backend API port (default: 7655)
frontendPort?: number; // Frontend UI port (default: 7655)
theme?: string; // Theme preference: 'light' | 'dark' | undefined (system default)
discoveryEnabled?: boolean; // Enable/disable network discovery
discoverySubnet?: string; // Subnet to scan for discovery (default: 'auto')
allowEmbedding?: boolean; // Allow iframe embedding
allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding
pbsPollingInterval?: number; // PBS polling interval in seconds
connectionTimeout?: number; // Seconds before timeout (default: 10)
autoUpdateEnabled: boolean; // Enable auto-updates
updateChannel?: string; // Update channel: 'stable' | 'rc' | 'beta'
autoUpdateCheckInterval?: number; // Hours between update checks
autoUpdateTime?: string; // Time for updates (HH:MM format)
allowedOrigins?: string; // CORS allowed origins
backendPort?: number; // Backend API port (default: 7655)
frontendPort?: number; // Frontend UI port (default: 7655)
theme?: string; // Theme preference: 'light' | 'dark' | undefined (system default)
discoveryEnabled?: boolean; // Enable/disable network discovery
discoverySubnet?: string; // Subnet to scan for discovery (default: 'auto')
allowEmbedding?: boolean; // Allow iframe embedding
allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding
}
/**
@@ -50,8 +50,8 @@ export interface NodeInstance {
name: string;
url: string;
username: string;
password?: string; // Encrypted at rest
token?: string; // Optional API token
password?: string; // Encrypted at rest
token?: string; // Optional API token
fingerprint?: string; // TLS certificate fingerprint
}
@@ -59,14 +59,14 @@ export interface NodeInstance {
* PVE-specific node configuration
*/
export interface PVENodeConfig extends NodeInstance {
realm?: string; // Authentication realm (pam, pve, etc.)
realm?: string; // Authentication realm (pam, pve, etc.)
}
/**
* PBS-specific node configuration
*/
export interface PBSNodeConfig extends NodeInstance {
datastore?: string; // Default datastore
datastore?: string; // Default datastore
}
/**
@@ -81,9 +81,9 @@ export interface NodesConfig {
* Complete configuration structure
*/
export interface PulseConfig {
auth: Partial<AuthConfig>; // From .env
system: SystemConfig; // From system.json
nodes: NodesConfig; // From nodes.enc
auth: Partial<AuthConfig>; // From .env
system: SystemConfig; // From system.json
nodes: NodesConfig; // From nodes.enc
}
/**
@@ -149,5 +149,5 @@ export const DEFAULT_CONFIG: {
allowedOrigins: '',
backendPort: 7655,
frontendPort: 7655,
}
},
};
+1 -1
View File
@@ -138,4 +138,4 @@ export interface Alert {
acknowledged: boolean;
acknowledgedBy?: string;
acknowledgedAt?: string;
}
}
+1 -1
View File
@@ -64,4 +64,4 @@ export interface SettingsUpdateResponse {
success: boolean;
requiresRestart: boolean;
message?: string;
}
}
+18 -17
View File
@@ -1,23 +1,23 @@
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>
) => {
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 => {
Object.values(activeAlerts).forEach((alert) => {
if (alert.resourceId === resourceId) {
alertCount++;
if (alert.level === 'critical' || (alert.level === 'warning' && highestSeverity !== 'critical')) {
if (
alert.level === 'critical' ||
(alert.level === 'warning' && highestSeverity !== 'critical')
) {
highestSeverity = alert.level;
}
}
});
// Return appropriate styling based on alert severity
if (highestSeverity === 'critical') {
return {
@@ -26,35 +26,36 @@ export const getAlertStyles = (
badgeClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
hasAlert: true,
alertCount,
severity: 'critical' as const
severity: 'critical' as const,
};
}
if (highestSeverity === 'warning') {
return {
rowClass: 'bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 dark:border-yellow-400',
rowClass:
'bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 dark:border-yellow-400',
indicatorClass: 'bg-yellow-500',
badgeClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
hasAlert: true,
alertCount,
severity: 'warning' as const
severity: 'warning' as const,
};
}
return {
rowClass: '',
indicatorClass: '',
badgeClass: '',
hasAlert: false,
alertCount: 0,
severity: null
severity: null,
};
};
// Get alert messages for a specific resource
export const getResourceAlerts = (
resourceId: string,
activeAlerts: Record<string, Alert>
activeAlerts: Record<string, Alert>,
): Alert[] => {
return Object.values(activeAlerts).filter(alert => alert.resourceId === resourceId);
};
return Object.values(activeAlerts).filter((alert) => alert.resourceId === resourceId);
};
+44 -36
View File
@@ -42,7 +42,7 @@ class ApiClient {
this.apiToken = value;
}
}
} catch (e) {
} catch (_err) {
// Invalid stored auth, ignore
}
}
@@ -51,13 +51,16 @@ class ApiClient {
setBasicAuth(username: string, password: string) {
const encoded = btoa(`${username}:${password}`);
this.authHeader = `Basic ${encoded}`;
// Store in session storage
sessionStorage.setItem('pulse_auth', JSON.stringify({
type: 'basic',
value: this.authHeader
}));
sessionStorage.setItem(
'pulse_auth',
JSON.stringify({
type: 'basic',
value: this.authHeader,
}),
);
// Also store username for password change functionality
sessionStorage.setItem('pulse_auth_user', username);
}
@@ -65,12 +68,15 @@ class ApiClient {
// Set API token
setApiToken(token: string) {
this.apiToken = token;
// Store in session storage
sessionStorage.setItem('pulse_auth', JSON.stringify({
type: 'token',
value: token
}));
sessionStorage.setItem(
'pulse_auth',
JSON.stringify({
type: 'token',
value: token,
}),
);
}
// Clear all authentication
@@ -121,7 +127,7 @@ class ApiClient {
const finalOptions: RequestInit = {
...fetchOptions,
headers: finalHeaders,
credentials: 'include' // Important for session cookies
credentials: 'include', // Important for session cookies
};
const response = await fetch(url, finalOptions);
@@ -144,7 +150,7 @@ class ApiClient {
const retryResponse = await fetch(url, {
...fetchOptions,
headers: finalHeaders,
credentials: 'include'
credentials: 'include',
});
return retryResponse;
}
@@ -155,18 +161,18 @@ class ApiClient {
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 2000; // Default 2 seconds
console.warn(`Rate limit hit, retrying after ${waitTime}ms`);
// Wait and retry once
await new Promise(resolve => setTimeout(resolve, waitTime));
await new Promise((resolve) => setTimeout(resolve, waitTime));
const retryResponse = await fetch(url, {
...fetchOptions,
headers: finalHeaders,
credentials: 'include'
credentials: 'include',
});
return retryResponse;
}
@@ -179,21 +185,21 @@ class ApiClient {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
...options.headers,
},
});
if (!response.ok) {
const text = await response.text();
// Try to extract just the error message without HTTP status codes
let errorMessage = text;
// If it looks like an HTML error page, try to extract the message
if (text.includes('<pre>') && text.includes('</pre>')) {
const match = text.match(/<pre>(.*?)<\/pre>/s);
if (match) errorMessage = match[1];
}
// If the backend sent a plain text error, use it directly
if (!text.includes('<') && text.length < 200) {
errorMessage = text;
@@ -201,16 +207,16 @@ class ApiClient {
// For long responses, just use a generic message
errorMessage = `Request failed with status ${response.status}`;
}
throw new Error(errorMessage || `Request failed with status ${response.status}`);
}
const text = await response.text();
if (!text) return null as T;
try {
return JSON.parse(text) as T;
} catch (e) {
} catch (_err) {
console.error('Failed to parse JSON response:', text);
throw new Error('Invalid JSON response from server');
}
@@ -220,25 +226,25 @@ class ApiClient {
async checkAuthRequired(): Promise<boolean> {
try {
// Try to access a protected endpoint without auth
const response = await fetch('/api/state', {
const response = await fetch('/api/state', {
method: 'GET',
credentials: 'omit' // Don't send cookies or auth
credentials: 'omit', // Don't send cookies or auth
});
// If we get 401, auth is required
if (response.status === 401) {
return true;
}
// If we get 200, no auth required
return false;
} catch (e) {
} catch (_err) {
// Network error - try the security status endpoint
try {
const response = await fetch('/api/security/status');
const data = await response.json();
return data.hasAuthentication || data.requiresAuth || false;
} catch (err) {
} catch (_fallbackErr) {
// Can't determine, assume no auth
return false;
}
@@ -251,9 +257,11 @@ export const apiClient = new ApiClient();
// Export convenience functions
export const apiFetch = (url: string, options?: FetchOptions) => apiClient.fetch(url, options);
export const apiFetchJSON = <T = unknown>(url: string, options?: FetchOptions) => apiClient.fetchJSON<T>(url, options);
export const setBasicAuth = (username: string, password: string) => apiClient.setBasicAuth(username, password);
export const apiFetchJSON = <T = unknown>(url: string, options?: FetchOptions) =>
apiClient.fetchJSON<T>(url, options);
export const setBasicAuth = (username: string, password: string) =>
apiClient.setBasicAuth(username, password);
export const setApiToken = (token: string) => apiClient.setApiToken(token);
export const clearAuth = () => apiClient.clearAuth();
export const hasAuth = () => apiClient.hasAuth();
export const checkAuthRequired = () => apiClient.checkAuthRequired();
export const checkAuthRequired = () => apiClient.checkAuthRequired();
+1 -1
View File
@@ -21,4 +21,4 @@ export async function copyToClipboard(text: string): Promise<boolean> {
console.error('Failed to copy to clipboard:', err);
return false;
}
}
}
+4 -7
View File
@@ -23,25 +23,22 @@ export function handleError(error: unknown, context: ErrorContext = {}): void {
logger.error(`[${context.component || 'Unknown'}] ${error.message}`, {
...error.context,
...context,
error: error.stack
error: error.stack,
});
} else if (error instanceof Error) {
logger.error(`[${context.component || 'Unknown'}] ${error.message}`, {
...context,
error: error.stack
error: error.stack,
});
} else {
logger.error(`[${context.component || 'Unknown'}] Unknown error`, {
...context,
error: String(error)
error: String(error),
});
}
}
export function handleAsyncError<T>(
promise: Promise<T>,
context: ErrorContext = {}
): Promise<T> {
export function handleAsyncError<T>(promise: Promise<T>, context: ErrorContext = {}): Promise<T> {
return promise.catch((error) => {
handleError(error, context);
throw error;
+24 -13
View File
@@ -2,11 +2,11 @@
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]}`;
}
@@ -17,11 +17,11 @@ export function formatSpeed(bytesPerSecond: number, decimals = 0): string {
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) {
@@ -31,25 +31,36 @@ export function formatUptime(seconds: number): string {
}
}
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 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);
@@ -58,7 +69,7 @@ export function formatRelativeTime(timestamp: number): string {
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) {
@@ -72,4 +83,4 @@ export function formatRelativeTime(timestamp: number): string {
} else {
return diffYears === 1 ? '1 year ago' : `${diffYears} years ago`;
}
}
}
+15 -16
View File
@@ -11,13 +11,12 @@ export function createLocalStorageSignal<T>(
key: string,
defaultValue: T,
parse?: (value: string) => T,
stringify?: (value: T) => string
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 initialValue =
stored !== null ? (parse ? parse(stored) : (stored as unknown as T)) : defaultValue;
const [value, setValue] = createSignal<T>(initialValue);
@@ -41,13 +40,13 @@ export function createLocalStorageSignal<T>(
*/
export function createLocalStorageBooleanSignal(
key: string,
defaultValue: boolean = false
defaultValue: boolean = false,
): Signal<boolean> {
return createLocalStorageSignal(
key,
key,
defaultValue,
(val) => val === 'true',
(val) => String(val)
(val) => String(val),
);
}
@@ -58,13 +57,13 @@ export function createLocalStorageBooleanSignal(
*/
export function createLocalStorageNumberSignal(
key: string,
defaultValue: number = 0
defaultValue: number = 0,
): Signal<number> {
return createLocalStorageSignal(
key,
defaultValue,
(val) => Number(val),
(val) => String(val)
(val) => String(val),
);
}
@@ -75,24 +74,24 @@ 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 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;
} as const;
+11 -6
View File
@@ -5,21 +5,26 @@ export const logger = {
debug: (message: string, data?: unknown) => {
if (isDev) console.log(`[DEBUG] ${message}`, data || '');
},
info: (message: string, data?: unknown) => {
// Only show critical info messages in production
if (isDev || message.includes('established') || message.includes('error') || message.includes('failed')) {
if (
isDev ||
message.includes('established') ||
message.includes('error') ||
message.includes('failed')
) {
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;
export const logError = logger.error;
+86 -51
View File
@@ -5,7 +5,15 @@ export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
export type LogicalOperator = 'AND' | 'OR';
export interface MetricCondition {
field: 'cpu' | 'memory' | 'disk' | 'diskRead' | 'diskWrite' | 'networkIn' | 'networkOut' | 'uptime';
field:
| 'cpu'
| 'memory'
| 'disk'
| 'diskRead'
| 'diskWrite'
| 'networkIn'
| 'networkOut'
| 'uptime';
operator: ComparisonOperator;
value: number;
}
@@ -38,11 +46,10 @@ export interface FilterStack {
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", "size>1000000000")
const metricMatch = term.match(/^(\w+)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i);
if (metricMatch) {
@@ -51,15 +58,15 @@ export function parseFilter(term: string): ParsedFilter {
if (isNaN(parsedValue)) {
return {
type: 'raw',
rawText: term
rawText: term,
};
}
return {
type: 'metric',
field: field.toLowerCase(),
operator: operator as ComparisonOperator,
value: parsedValue
value: parsedValue,
};
}
@@ -70,14 +77,14 @@ export function parseFilter(term: string): ParsedFilter {
return {
type: 'text',
field: field.toLowerCase(),
value: value.trim()
value: value.trim(),
};
}
// Otherwise treat as raw text search
return {
type: 'raw',
rawText: term
rawText: term,
};
}
@@ -93,12 +100,12 @@ export function parseFilterStack(searchString: string): FilterStack {
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);
@@ -108,16 +115,18 @@ export function parseFilterStack(searchString: string): FilterStack {
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);
const metricMatch = conditionStr.match(
/^(cpu|memory|disk|diskRead|diskWrite|networkIn|networkOut)\s*(>|<|>=|<=|=|==)\s*(\d+(?:\.\d+)?)$/i,
);
if (metricMatch) {
const [, field, operator, value] = metricMatch;
return {
@@ -126,7 +135,7 @@ function parseCondition(conditionStr: string): Condition | null {
value: (() => {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
})()
})(),
} as MetricCondition;
}
@@ -136,7 +145,7 @@ function parseCondition(conditionStr: string): Condition | null {
const [, field, value] = textMatch;
return {
field: field.toLowerCase() as 'name' | 'node' | 'vmid' | 'tags',
value: value.trim()
value: value.trim(),
} as TextCondition;
}
@@ -145,26 +154,26 @@ function parseCondition(conditionStr: string): Condition | 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
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) {
@@ -177,13 +186,13 @@ export function parseSearchQuery(query: string): ParsedQuery {
return {
conditions: [],
logicalOperator: 'AND',
rawText: query
rawText: query,
};
}
return {
conditions,
logicalOperator
logicalOperator,
};
}
@@ -191,11 +200,11 @@ type FilterableItem = VM | Container | PBSBackup | StorageBackup | BackupTask |
function evaluateMetricCondition(guest: FilterableItem, condition: MetricCondition): boolean {
let value: number;
switch (condition.field) {
case 'cpu':
// CPU is stored as decimal (0-1), convert to percentage
value = ('cpu' in guest ? (guest.cpu || 0) : 0) * 100;
value = ('cpu' in guest ? guest.cpu || 0 : 0) * 100;
break;
case 'memory':
value = 'memory' in guest && guest.memory ? guest.memory.usage : 0;
@@ -205,7 +214,10 @@ function evaluateMetricCondition(guest: FilterableItem, condition: MetricConditi
break;
case 'uptime':
// Uptime in seconds (only for running VMs/containers)
value = 'status' in guest && guest.status === 'running' && 'uptime' in guest ? (guest.uptime || 0) : 0;
value =
'status' in guest && guest.status === 'running' && 'uptime' in guest
? guest.uptime || 0
: 0;
break;
default:
// For backup-specific numeric fields like 'size'
@@ -240,7 +252,7 @@ function evaluateMetricCondition(guest: FilterableItem, condition: MetricConditi
function evaluateTextCondition(guest: FilterableItem, condition: TextCondition): boolean {
const searchValue = condition.value.toLowerCase();
switch (condition.field) {
case 'name':
return 'name' in guest && guest.name ? guest.name.toLowerCase().includes(searchValue) : false;
@@ -254,13 +266,19 @@ function evaluateTextCondition(guest: FilterableItem, condition: TextCondition):
const tagsArray = Array.isArray(guest.tags)
? guest.tags.filter((tag): tag is string => typeof tag === 'string')
: typeof guest.tags === 'string'
? guest.tags.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0)
? guest.tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0)
: [];
if (tagsArray.length === 0) return false;
// Support comma-separated tag searches (OR logic)
const searchTags = searchValue.split(',').map(t => t.trim()).filter(t => t.length > 0);
return searchTags.some(searchTag =>
tagsArray.some(tag => tag.toLowerCase().includes(searchTag.toLowerCase()))
const searchTags = searchValue
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
return searchTags.some((searchTag) =>
tagsArray.some((tag) => tag.toLowerCase().includes(searchTag.toLowerCase())),
);
default:
// For backup-specific fields
@@ -283,12 +301,17 @@ function evaluateTextCondition(guest: FilterableItem, condition: TextCondition):
export function evaluateSearchQuery(guest: FilterableItem, 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 =>
('name' in guest && guest.name && guest.name.toLowerCase().includes(term)) ||
('vmid' in guest && guest.vmid && guest.vmid.toString().includes(term)) ||
('node' in guest && guest.node && guest.node.toLowerCase().includes(term)) ||
('status' in guest && guest.status && guest.status.toLowerCase().includes(term))
const searchTerms = query.rawText
.toLowerCase()
.split(',')
.map((term) => term.trim())
.filter((term) => term.length > 0);
return searchTerms.some(
(term) =>
('name' in guest && guest.name && guest.name.toLowerCase().includes(term)) ||
('vmid' in guest && guest.vmid && guest.vmid.toString().includes(term)) ||
('node' in guest && guest.node && guest.node.toLowerCase().includes(term)) ||
('status' in guest && guest.status && guest.status.toLowerCase().includes(term)),
);
}
@@ -298,7 +321,7 @@ export function evaluateSearchQuery(guest: FilterableItem, query: ParsedQuery):
}
// Evaluate conditions
const results = query.conditions.map(condition => {
const results = query.conditions.map((condition) => {
if ('operator' in condition) {
return evaluateMetricCondition(guest, condition);
} else {
@@ -308,9 +331,9 @@ export function evaluateSearchQuery(guest: FilterableItem, query: ParsedQuery):
// Apply logical operator
if (query.logicalOperator === 'AND') {
return results.every(result => result);
return results.every((result) => result);
} else {
return results.some(result => result);
return results.some((result) => result);
}
}
@@ -320,12 +343,12 @@ export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack):
return true;
}
const results = stack.filters.map(filter => {
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
value: filter.value as number,
};
return evaluateMetricCondition(guest, condition);
} else if (filter.type === 'text' && filter.field && filter.value) {
@@ -333,27 +356,39 @@ export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack):
if (filter.field === 'tags') {
const condition: TextCondition = {
field: 'tags',
value: filter.value as string
value: filter.value as string,
};
return evaluateTextCondition(guest, condition);
}
const condition: TextCondition = {
field: filter.field as TextCondition['field'],
value: filter.value as string
value: filter.value as string,
};
return evaluateTextCondition(guest, condition);
} else if (filter.type === 'raw' && filter.rawText) {
const term = filter.rawText.toLowerCase();
// Check name, vmid, node, status, and tags for raw text matches
const nameMatch = 'name' in guest && typeof guest.name === 'string' && guest.name.toLowerCase().includes(term);
const nameMatch =
'name' in guest &&
typeof guest.name === 'string' &&
guest.name.toLowerCase().includes(term);
const vmidMatch = 'vmid' in guest && !!guest.vmid && guest.vmid.toString().includes(term);
const nodeMatch = 'node' in guest && typeof guest.node === 'string' && guest.node.toLowerCase().includes(term);
const statusMatch = 'status' in guest && typeof guest.status === 'string' && guest.status.toLowerCase().includes(term);
const nodeMatch =
'node' in guest &&
typeof guest.node === 'string' &&
guest.node.toLowerCase().includes(term);
const statusMatch =
'status' in guest &&
typeof guest.status === 'string' &&
guest.status.toLowerCase().includes(term);
// Also check if any tags contain the search term
const tagMatch = 'tags' in guest && Array.isArray(guest.tags)
? guest.tags.filter((tag): tag is string => typeof tag === 'string').some(tag => tag.toLowerCase().includes(term))
: false;
const tagMatch =
'tags' in guest && Array.isArray(guest.tags)
? guest.tags
.filter((tag): tag is string => typeof tag === 'string')
.some((tag) => tag.toLowerCase().includes(term))
: false;
return nameMatch || vmidMatch || nodeMatch || statusMatch || tagMatch;
}
@@ -370,13 +405,13 @@ export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack):
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;
}
+23 -20
View File
@@ -9,7 +9,7 @@ function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
@@ -22,22 +22,22 @@ function hashString(str: string): number {
export function getTagColor(tag: string): { bg: string; text: string; border: string } {
// Get a hash of the tag
const hash = hashString(tag.toLowerCase());
// Generate hue from hash (0-360 degrees)
const hue = hash % 360;
// Use moderate saturation for subtle but visible colors
// These values are tuned to be noticeable without being distracting
const saturation = 65; // Moderate saturation
const lightnessBg = 60; // Slightly muted background
const lightnessText = 25; // Dark text for contrast
const lightnessBorder = 50; // Medium border
// For dark mode, we'll adjust these in the component
return {
bg: `hsl(${hue}, ${saturation}%, ${lightnessBg}%)`,
text: `hsl(${hue}, ${saturation}%, ${lightnessText}%)`,
border: `hsl(${hue}, ${saturation}%, ${lightnessBorder}%)`
border: `hsl(${hue}, ${saturation}%, ${lightnessBorder}%)`,
};
}
@@ -48,11 +48,11 @@ export function getTagColorDark(tag: string): { bg: string; text: string; border
const hash = hashString(tag.toLowerCase());
const hue = hash % 360;
const saturation = 55; // Moderate saturation in dark mode
return {
bg: `hsl(${hue}, ${saturation}%, 35%)`, // Subtler background
text: `hsl(${hue}, ${saturation}%, 85%)`, // Light text
border: `hsl(${hue}, ${saturation}%, 45%)` // Subtle border
border: `hsl(${hue}, ${saturation}%, 45%)`, // Subtle border
};
}
@@ -72,35 +72,38 @@ interface TagColorTheme {
* These override the hash-based colors for specific tags
*/
const specialTagColors: Record<string, TagColorTheme> = {
'production': {
production: {
light: { bg: 'rgb(254, 226, 226)', text: 'rgb(153, 27, 27)', border: 'rgb(239, 68, 68)' },
dark: { bg: 'rgb(127, 29, 29)', text: 'rgb(254, 202, 202)', border: 'rgb(185, 28, 28)' }
dark: { bg: 'rgb(127, 29, 29)', text: 'rgb(254, 202, 202)', border: 'rgb(185, 28, 28)' },
},
'staging': {
staging: {
light: { bg: 'rgb(254, 243, 199)', text: 'rgb(146, 64, 14)', border: 'rgb(245, 158, 11)' },
dark: { bg: 'rgb(120, 53, 15)', text: 'rgb(253, 230, 138)', border: 'rgb(217, 119, 6)' }
dark: { bg: 'rgb(120, 53, 15)', text: 'rgb(253, 230, 138)', border: 'rgb(217, 119, 6)' },
},
'development': {
development: {
light: { bg: 'rgb(220, 252, 231)', text: 'rgb(22, 101, 52)', border: 'rgb(34, 197, 94)' },
dark: { bg: 'rgb(20, 83, 45)', text: 'rgb(187, 247, 208)', border: 'rgb(34, 197, 94)' }
dark: { bg: 'rgb(20, 83, 45)', text: 'rgb(187, 247, 208)', border: 'rgb(34, 197, 94)' },
},
'backup': {
backup: {
light: { bg: 'rgb(219, 234, 254)', text: 'rgb(30, 58, 138)', border: 'rgb(59, 130, 246)' },
dark: { bg: 'rgb(30, 58, 138)', text: 'rgb(191, 219, 254)', border: 'rgb(59, 130, 246)' }
}
dark: { bg: 'rgb(30, 58, 138)', text: 'rgb(191, 219, 254)', border: 'rgb(59, 130, 246)' },
},
};
/**
* Get color for a tag, checking special colors first
*/
export function getTagColorWithSpecial(tag: string, isDarkMode: boolean): { bg: string; text: string; border: string } {
export function getTagColorWithSpecial(
tag: string,
isDarkMode: boolean,
): { bg: string; text: string; border: string } {
const lowerTag = tag.toLowerCase();
// Check if it's a special tag
if (specialTagColors[lowerTag]) {
return isDarkMode ? specialTagColors[lowerTag].dark : specialTagColors[lowerTag].light;
}
// Otherwise use hash-based color
return isDarkMode ? getTagColorDark(tag) : getTagColor(tag);
}
}

Some files were not shown because too many files have changed in this diff Show More