mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
chore: tidy repo formatting and linting
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
Generated
+1431
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,4 @@ export class MonitoringAPI {
|
||||
const response = await apiFetch(`${this.baseUrl}/diagnostics/export`);
|
||||
return response.blob();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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, '&') // Encode ampersands
|
||||
.replace(/</g, '<') // Encode less than
|
||||
.replace(/>/g, '>') // Encode greater than
|
||||
.replace(/&/g, '&') // Encode ampersands
|
||||
.replace(/</g, '<') // Encode less than
|
||||
.replace(/>/g, '>') // Encode greater than
|
||||
.replace(/"/g, '"') // Encode quotes
|
||||
.replace(/'/g, '''); // 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()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,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 });
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,4 +138,4 @@ export interface Alert {
|
||||
acknowledged: boolean;
|
||||
acknowledgedBy?: string;
|
||||
acknowledgedAt?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,4 +64,4 @@ export interface SettingsUpdateResponse {
|
||||
success: boolean;
|
||||
requiresRestart: boolean;
|
||||
message?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -21,4 +21,4 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user