feat: add web-based configuration interface

- Create configuration setup page at /setup.html for entering Proxmox credentials
- Add configuration API endpoints for save, test, and reload operations
- Implement automatic .env file watching with hot reload on changes
- Add configuration warning banner when placeholder config detected
- Auto-redirect to setup page when configuration is missing
- Add WebSocket notifications for configuration reload events
- Update empty states with configuration required message
- No SSH or manual restarts needed - everything through web UI

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com
2025-05-30 10:35:25 +01:00
parent e49da4612c
commit d3a82d14df
8 changed files with 885 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
const fs = require('fs').promises;
const path = require('path');
const { loadConfiguration } = require('./configLoader');
const { initializeApiClients } = require('./apiClients');
class ConfigApi {
constructor() {
this.envPath = path.join(__dirname, '../.env');
}
/**
* Get current configuration (without secrets)
*/
async getConfig() {
try {
const config = await this.readEnvFile();
return {
proxmox: config.PROXMOX_HOST ? {
host: config.PROXMOX_HOST,
port: config.PROXMOX_PORT || '8006',
tokenId: config.PROXMOX_TOKEN_ID,
// Don't send the secret
} : null,
pbs: config.PBS_HOST ? {
host: config.PBS_HOST,
port: config.PBS_PORT || '8007',
tokenId: config.PBS_TOKEN_ID,
// Don't send the secret
} : null
};
} catch (error) {
console.error('Error reading configuration:', error);
return { proxmox: null, pbs: null };
}
}
/**
* Save configuration to .env file
*/
async saveConfig(config) {
try {
// Read existing .env file to preserve other settings
const existingConfig = await this.readEnvFile();
// Update with new values
if (config.proxmox) {
existingConfig.PROXMOX_HOST = config.proxmox.host;
existingConfig.PROXMOX_PORT = config.proxmox.port || '8006';
existingConfig.PROXMOX_TOKEN_ID = config.proxmox.tokenId;
existingConfig.PROXMOX_TOKEN_SECRET = config.proxmox.tokenSecret;
}
if (config.pbs) {
existingConfig.PBS_HOST = config.pbs.host;
existingConfig.PBS_PORT = config.pbs.port || '8007';
existingConfig.PBS_TOKEN_ID = config.pbs.tokenId;
existingConfig.PBS_TOKEN_SECRET = config.pbs.tokenSecret;
}
// Write back to .env file
await this.writeEnvFile(existingConfig);
// Reload configuration in the application
await this.reloadConfiguration();
return { success: true };
} catch (error) {
console.error('Error saving configuration:', error);
throw error;
}
}
/**
* Test configuration by attempting to connect
*/
async testConfig(config) {
try {
// Create temporary endpoint configuration
const testEndpoints = [{
id: 'test-primary',
name: 'Test Primary',
host: config.proxmox.host,
port: parseInt(config.proxmox.port) || 8006,
tokenId: config.proxmox.tokenId,
tokenSecret: config.proxmox.tokenSecret,
enabled: true
}];
const testPbsConfigs = config.pbs ? [{
id: 'test-pbs',
name: 'Test PBS',
host: config.pbs.host,
port: parseInt(config.pbs.port) || 8007,
tokenId: config.pbs.tokenId,
tokenSecret: config.pbs.tokenSecret
}] : [];
// Try to initialize API clients with test config
const { apiClients, pbsApiClients } = await initializeApiClients(testEndpoints, testPbsConfigs);
// Try a simple API call to verify connection
const testClient = apiClients.get('test-primary');
if (testClient) {
await testClient.client.get('/nodes');
}
return { success: true };
} catch (error) {
console.error('Configuration test failed:', error);
return {
success: false,
error: error.message || 'Failed to connect to Proxmox server'
};
}
}
/**
* Read .env file and parse it
*/
async readEnvFile() {
try {
const content = await fs.readFile(this.envPath, 'utf8');
const config = {};
content.split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#')) {
const [key, ...valueParts] = trimmedLine.split('=');
if (key) {
// Handle values that might contain = signs
let value = valueParts.join('=').trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
config[key.trim()] = value;
}
}
});
return config;
} catch (error) {
if (error.code === 'ENOENT') {
// .env file doesn't exist yet
return {};
}
throw error;
}
}
/**
* Write configuration back to .env file
*/
async writeEnvFile(config) {
const lines = [];
// Add header
lines.push('# Pulse Configuration');
lines.push('# Generated by Pulse Web Configuration');
lines.push('');
// Group related settings
const groups = {
'Proxmox VE Settings': ['PROXMOX_HOST', 'PROXMOX_PORT', 'PROXMOX_TOKEN_ID', 'PROXMOX_TOKEN_SECRET'],
'Proxmox Backup Server Settings': ['PBS_HOST', 'PBS_PORT', 'PBS_TOKEN_ID', 'PBS_TOKEN_SECRET'],
'Other Settings': [] // Will contain all other keys
};
// Find other keys not in predefined groups
Object.keys(config).forEach(key => {
let found = false;
Object.values(groups).forEach(groupKeys => {
if (groupKeys.includes(key)) found = true;
});
if (!found && key !== '') {
groups['Other Settings'].push(key);
}
});
// Write each group
Object.entries(groups).forEach(([groupName, keys]) => {
if (keys.length > 0 && keys.some(key => config[key])) {
lines.push(`# ${groupName}`);
keys.forEach(key => {
if (config[key] !== undefined && config[key] !== '') {
const value = config[key];
// Quote values that contain spaces or special characters
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
}
});
lines.push('');
}
});
await fs.writeFile(this.envPath, lines.join('\n'), 'utf8');
}
/**
* Reload configuration without restarting the server
*/
async reloadConfiguration() {
try {
// Clear the require cache for dotenv
delete require.cache[require.resolve('dotenv')];
// Reload environment variables
require('dotenv').config();
// Reload configuration
const { endpoints, pbsConfigs, isConfigPlaceholder } = loadConfiguration();
// Get state manager instance
const stateManager = require('./state');
// Update configuration status
stateManager.setConfigPlaceholderStatus(isConfigPlaceholder);
stateManager.setEndpointConfigurations(endpoints, pbsConfigs);
// Reinitialize API clients
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Update global references
if (global.pulseApiClients) {
global.pulseApiClients.apiClients = apiClients;
global.pulseApiClients.pbsApiClients = pbsApiClients;
}
// Update global config placeholder status
if (global.pulseConfigStatus) {
global.pulseConfigStatus.isPlaceholder = isConfigPlaceholder;
}
console.log('Configuration reloaded successfully');
return true;
} catch (error) {
console.error('Error reloading configuration:', error);
throw error;
}
}
/**
* Set up API routes
*/
setupRoutes(app) {
// Get current configuration
app.get('/api/config', async (req, res) => {
try {
const config = await this.getConfig();
res.json(config);
} catch (error) {
res.status(500).json({ error: 'Failed to read configuration' });
}
});
// Save configuration
app.post('/api/config', async (req, res) => {
try {
await this.saveConfig(req.body);
res.json({ success: true });
} catch (error) {
res.status(500).json({
success: false,
error: error.message || 'Failed to save configuration'
});
}
});
// Test configuration
app.post('/api/config/test', async (req, res) => {
try {
const result = await this.testConfig(req.body);
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
error: error.message || 'Failed to test configuration'
});
}
});
// Reload configuration
app.post('/api/config/reload', async (req, res) => {
try {
await this.reloadConfiguration();
res.json({ success: true });
} catch (error) {
res.status(500).json({
success: false,
error: error.message || 'Failed to reload configuration'
});
}
});
}
}
module.exports = ConfigApi;
+82
View File
@@ -35,6 +35,9 @@ try {
// Set the placeholder status in stateManager *after* config loading is complete
stateManager.setConfigPlaceholderStatus(configIsPlaceholder);
// Store globally for config reload
global.pulseConfigStatus = { isPlaceholder: configIsPlaceholder };
// Set endpoint configurations for client use
stateManager.setEndpointConfigurations(endpoints, pbsConfigs);
@@ -66,6 +69,10 @@ let pbsApiClients = {};
// Note: Client initialization is now async and happens in startServer()
// --- END API Client Initialization ---
// Configuration API
const ConfigApi = require('./configApi');
const configApi = new ConfigApi();
// --- REMOVED OLD CLIENT INIT LOGIC ---
// The following blocks were moved to apiClients.js
// endpoints.forEach(endpoint => { ... });
@@ -118,6 +125,12 @@ app.use(express.static(publicDir, { index: false }));
// Route to serve the main HTML file for the root path
app.get('/', (req, res) => {
// Check if configuration is placeholder or missing
if (configIsPlaceholder) {
// Redirect to setup page
return res.redirect('/setup.html');
}
const indexPath = path.join(publicDir, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
@@ -129,6 +142,9 @@ app.get('/', (req, res) => {
});
// --- API Routes ---
// Set up configuration API routes
configApi.setupRoutes(app);
// Health check endpoint
app.get('/healthz', (req, res) => {
res.status(200).send('OK');
@@ -837,6 +853,13 @@ function gracefulShutdown(signal) {
if (discoveryTimeoutId) clearTimeout(discoveryTimeoutId);
if (metricTimeoutId) clearTimeout(metricTimeoutId);
// Clean up file watchers
if (envWatcher) {
envWatcher.close();
envWatcher = null;
}
clearTimeout(reloadDebounceTimer);
// Close WebSocket connections
if (io) {
io.close();
@@ -881,6 +904,57 @@ function gracefulShutdown(signal) {
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// --- Environment File Watcher ---
let envWatcher = null;
let reloadDebounceTimer = null;
function setupEnvFileWatcher() {
const envPath = path.join(__dirname, '../.env');
// Check if the file exists
if (!fs.existsSync(envPath)) {
console.log('No .env file found, skipping file watcher setup');
return;
}
console.log('Setting up .env file watcher for automatic configuration reload');
envWatcher = fs.watch(envPath, (eventType, filename) => {
if (eventType === 'change') {
// Debounce the reload to avoid multiple reloads for rapid changes
clearTimeout(reloadDebounceTimer);
reloadDebounceTimer = setTimeout(async () => {
console.log('.env file changed, reloading configuration...');
try {
await configApi.reloadConfiguration();
// Notify connected clients about configuration change
io.emit('configurationReloaded', {
message: 'Configuration has been updated',
timestamp: Date.now()
});
console.log('Configuration reloaded successfully');
} catch (error) {
console.error('Failed to reload configuration:', error);
// Notify clients about the error
io.emit('configurationError', {
message: 'Failed to reload configuration',
error: error.message,
timestamp: Date.now()
});
}
}, 1000); // Wait 1 second after last change before reloading
}
});
envWatcher.on('error', (error) => {
console.error('Error watching .env file:', error);
});
}
// --- Start the server ---
async function startServer() {
try {
@@ -888,6 +962,10 @@ async function startServer() {
const initializedClients = await initializeApiClients(endpoints, pbsConfigs);
apiClients = initializedClients.apiClients;
pbsApiClients = initializedClients.pbsApiClients;
// Store globally for config reload
global.pulseApiClients = { apiClients, pbsApiClients };
console.log("INFO: All API clients initialized.");
} catch (initError) {
console.error("FATAL: Failed to initialize API clients:", initError);
@@ -905,6 +983,10 @@ async function startServer() {
// Schedule the first metric run *after* the initial discovery completes and server is listening
scheduleNextMetric();
// Watch .env file for changes
setupEnvFileWatcher();
// Setup hot reload in development mode
if (process.env.NODE_ENV === 'development' && chokidar) {
const publicPath = path.join(__dirname, '../src/public');
+1
View File
@@ -944,6 +944,7 @@
<script src="/js/charts.js" defer></script>
<script src="/js/ui/thresholds.js" defer></script>
<script src="/js/ui/empty-states.js" defer></script>
<script src="/js/ui/config-banner.js" defer></script>
<script src="/js/ui/status-icons.js" defer></script>
<script src="/js/ui/loading-skeletons.js" defer></script>
<script src="/js/ui/common.js" defer></script>
+3
View File
@@ -65,6 +65,9 @@ document.addEventListener('DOMContentLoaded', function() {
const state = PulseApp.state.getFullState();
PulseApp.alerts?.updateAlertsFromState?.(state);
// Check and show configuration banner if needed
PulseApp.ui.configBanner?.checkAndShowBanner();
// Simple and direct scroll preservation - just focus on main table
const mainTableContainer = document.querySelector('.table-container');
if (mainTableContainer) {
+31
View File
@@ -34,6 +34,10 @@ PulseApp.socketHandler = (() => {
// Development features
socket.on('hotReload', handleHotReload);
// Configuration reload events
socket.on('configurationReloaded', handleConfigurationReloaded);
socket.on('configurationError', handleConfigurationError);
// Handle connection errors
socket.on('connect_error', handleConnectError);
@@ -123,6 +127,33 @@ PulseApp.socketHandler = (() => {
window.location.reload();
}
}
function handleConfigurationReloaded(data) {
console.log('[Socket] Configuration reloaded:', data.message);
// Show notification to user
if (PulseApp.alerts && PulseApp.alerts.showNotification) {
PulseApp.alerts.showNotification({
message: 'Configuration has been updated and reloaded',
severity: 'info'
});
}
// Request fresh data with new configuration
socket.emit('requestData');
}
function handleConfigurationError(data) {
console.error('[Socket] Configuration reload error:', data.error);
// Show error notification to user
if (PulseApp.alerts && PulseApp.alerts.showNotification) {
PulseApp.alerts.showNotification({
message: 'Failed to reload configuration: ' + data.error,
severity: 'critical'
});
}
}
function handleConnectError(error) {
console.error('[Socket] Connection error:', error);
+89
View File
@@ -0,0 +1,89 @@
// Configuration banner component
PulseApp.ui = PulseApp.ui || {};
PulseApp.ui.configBanner = (() => {
let bannerElement = null;
let isShowing = false;
function createBanner() {
const banner = document.createElement('div');
banner.id = 'config-banner';
banner.className = 'fixed top-0 left-0 right-0 bg-yellow-500 dark:bg-yellow-600 text-white px-4 py-3 z-50 shadow-lg transform -translate-y-full transition-transform duration-300 ease-in-out';
banner.innerHTML = `
<div class="container w-[95%] max-w-screen-xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<div>
<p class="font-medium">Configuration Required</p>
<p class="text-sm opacity-90">Pulse needs to be configured with your Proxmox credentials to start monitoring.</p>
</div>
</div>
<div class="flex items-center gap-2">
<a href="/setup.html"
class="bg-white dark:bg-gray-800 text-yellow-600 dark:text-yellow-500 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
Configure Now
</a>
<button onclick="PulseApp.ui.configBanner.hide()"
class="text-white hover:text-gray-200 p-1 rounded-md hover:bg-yellow-600 dark:hover:bg-yellow-700 transition-colors">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L13.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 13.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
`;
document.body.appendChild(banner);
return banner;
}
function show() {
if (isShowing) return;
if (!bannerElement) {
bannerElement = createBanner();
}
// Add space to the body to prevent content overlap
document.body.style.paddingTop = '80px';
// Trigger the slide-down animation
setTimeout(() => {
bannerElement.classList.remove('-translate-y-full');
}, 100);
isShowing = true;
}
function hide() {
if (!isShowing || !bannerElement) return;
// Slide up animation
bannerElement.classList.add('-translate-y-full');
// Remove padding after animation
setTimeout(() => {
document.body.style.paddingTop = '';
isShowing = false;
}, 300);
}
function checkAndShowBanner() {
const isConfigPlaceholder = PulseApp.state?.get('isConfigPlaceholder');
if (isConfigPlaceholder) {
show();
} else {
hide();
}
}
return {
show,
hide,
checkAndShowBanner
};
})();
+13
View File
@@ -80,6 +80,19 @@ PulseApp.ui.emptyStates = (() => {
text: 'Retry',
onclick: 'location.reload()'
}]
},
'config-required': {
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-4 text-yellow-500 dark:text-yellow-400">
<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"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>`,
title: 'Configuration Required',
message: 'Pulse needs to be configured with your Proxmox credentials before it can start monitoring.',
actions: [{
text: 'Configure Now',
onclick: 'window.location.href="/setup.html"'
}]
}
};
+367
View File
@@ -0,0 +1,367 @@
<!DOCTYPE html>
<html lang="en" class="scrollbar">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pulse - Configuration Setup</title>
<link rel="icon" href="/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/output.css">
<style>
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
.pulse-logo-circle {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
transform-origin: center;
}
.pulse-logo-outer { fill: #2563eb; }
.pulse-logo-inner { fill: #ffffff; }
.dark .pulse-logo-outer { fill: #3b82f6; }
.dark .pulse-logo-inner { fill: #dbeafe; }
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8">
<!-- Header -->
<div class="text-center mb-8">
<div class="flex items-center justify-center gap-2 mb-4">
<svg width="40" height="40" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" class="pulse-logo">
<title>Pulse Logo</title>
<circle class="pulse-logo-outer" cx="50" cy="50" r="45"/>
<circle class="pulse-logo-inner pulse-logo-circle" cx="50" cy="50" r="25"/>
</svg>
<h1 class="text-3xl font-bold text-gray-800 dark:text-gray-200">Pulse Setup</h1>
</div>
<p class="text-gray-600 dark:text-gray-400">Configure your Proxmox connection to get started</p>
</div>
<!-- Configuration Form -->
<form id="config-form" class="space-y-6">
<!-- Primary Proxmox VE Configuration -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-200">Primary Proxmox VE Server</h2>
<div class="space-y-4">
<div>
<label for="proxmox-host" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Host Address <span class="text-red-500">*</span>
</label>
<input type="text" id="proxmox-host" name="proxmox-host" required
placeholder="192.168.1.100 or proxmox.example.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">IP address or hostname of your Proxmox VE server</p>
</div>
<div>
<label for="proxmox-port" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Port
</label>
<input type="number" id="proxmox-port" name="proxmox-port"
placeholder="8006 (default)"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="proxmox-token-id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Token ID <span class="text-red-500">*</span>
</label>
<input type="text" id="proxmox-token-id" name="proxmox-token-id" required
placeholder="user@pam!token-name"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Format: username@realm!token-name</p>
</div>
<div>
<label for="proxmox-token-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Token Secret <span class="text-red-500">*</span>
</label>
<div class="relative">
<input type="password" id="proxmox-token-secret" name="proxmox-token-secret" required
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
class="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="button" onclick="togglePasswordVisibility('proxmox-token-secret')"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- PBS Configuration (Optional) -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-200">Proxmox Backup Server (Optional)</h2>
<div class="space-y-4">
<div>
<label for="pbs-host" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Host Address
</label>
<input type="text" id="pbs-host" name="pbs-host"
placeholder="192.168.1.101 or pbs.example.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="pbs-port" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Port
</label>
<input type="number" id="pbs-port" name="pbs-port"
placeholder="8007 (default)"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="pbs-token-id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Token ID
</label>
<input type="text" id="pbs-token-id" name="pbs-token-id"
placeholder="user@pbs!token-name"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="pbs-token-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
API Token Secret
</label>
<div class="relative">
<input type="password" id="pbs-token-secret" name="pbs-token-secret"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
class="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<button type="button" onclick="togglePasswordVisibility('pbs-token-secret')"
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Error Display -->
<div id="error-message" class="hidden bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<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" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200">Configuration Error</h3>
<div class="mt-2 text-sm text-red-700 dark:text-red-300" id="error-text"></div>
</div>
</div>
</div>
<!-- Success Display -->
<div id="success-message" class="hidden bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-green-800 dark:text-green-200">Success!</h3>
<div class="mt-2 text-sm text-green-700 dark:text-green-300">Configuration saved. Redirecting to dashboard...</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-4">
<button type="submit" id="save-button"
class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Save Configuration
</button>
<button type="button" onclick="testConnection()"
class="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
Test Connection
</button>
</div>
</form>
<!-- Help Text -->
<div class="mt-8 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
Need help? Check out the
<a href="https://github.com/Daemonslayer2048/pulse#configuration" target="_blank"
class="text-blue-600 dark:text-blue-400 hover:underline">configuration guide</a>
</p>
</div>
</div>
</div>
<script src="/js/theme.js"></script>
<script>
// Initialize theme
PulseApp.theme.init();
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
input.type = input.type === 'password' ? 'text' : 'password';
}
function showError(message) {
const errorDiv = document.getElementById('error-message');
const errorText = document.getElementById('error-text');
const successDiv = document.getElementById('success-message');
errorText.textContent = message;
errorDiv.classList.remove('hidden');
successDiv.classList.add('hidden');
}
function showSuccess() {
const errorDiv = document.getElementById('error-message');
const successDiv = document.getElementById('success-message');
errorDiv.classList.add('hidden');
successDiv.classList.remove('hidden');
}
function hideMessages() {
document.getElementById('error-message').classList.add('hidden');
document.getElementById('success-message').classList.add('hidden');
}
async function testConnection() {
const formData = new FormData(document.getElementById('config-form'));
const config = {
proxmox: {
host: formData.get('proxmox-host'),
port: formData.get('proxmox-port') || '8006',
tokenId: formData.get('proxmox-token-id'),
tokenSecret: formData.get('proxmox-token-secret')
}
};
if (!config.proxmox.host || !config.proxmox.tokenId || !config.proxmox.tokenSecret) {
showError('Please fill in all required Proxmox fields');
return;
}
hideMessages();
const button = event.target;
button.disabled = true;
button.textContent = 'Testing...';
try {
const response = await fetch('/api/config/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const result = await response.json();
if (response.ok && result.success) {
showSuccess();
setTimeout(() => hideMessages(), 3000);
} else {
showError(result.error || 'Connection test failed');
}
} catch (error) {
showError('Failed to test connection: ' + error.message);
} finally {
button.disabled = false;
button.textContent = 'Test Connection';
}
}
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const config = {
proxmox: {
host: formData.get('proxmox-host'),
port: formData.get('proxmox-port') || '8006',
tokenId: formData.get('proxmox-token-id'),
tokenSecret: formData.get('proxmox-token-secret')
}
};
// Add PBS config if provided
if (formData.get('pbs-host')) {
config.pbs = {
host: formData.get('pbs-host'),
port: formData.get('pbs-port') || '8007',
tokenId: formData.get('pbs-token-id'),
tokenSecret: formData.get('pbs-token-secret')
};
}
hideMessages();
const button = document.getElementById('save-button');
button.disabled = true;
button.textContent = 'Saving...';
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const result = await response.json();
if (response.ok && result.success) {
showSuccess();
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
showError(result.error || 'Failed to save configuration');
button.disabled = false;
button.textContent = 'Save Configuration';
}
} catch (error) {
showError('Failed to save configuration: ' + error.message);
button.disabled = false;
button.textContent = 'Save Configuration';
}
});
// Load existing configuration if available
async function loadExistingConfig() {
try {
const response = await fetch('/api/config');
if (response.ok) {
const config = await response.json();
if (config.proxmox) {
document.getElementById('proxmox-host').value = config.proxmox.host || '';
document.getElementById('proxmox-port').value = config.proxmox.port || '';
document.getElementById('proxmox-token-id').value = config.proxmox.tokenId || '';
}
if (config.pbs) {
document.getElementById('pbs-host').value = config.pbs.host || '';
document.getElementById('pbs-port').value = config.pbs.port || '';
document.getElementById('pbs-token-id').value = config.pbs.tokenId || '';
}
}
} catch (error) {
console.error('Failed to load existing configuration:', error);
}
}
// Load config on page load
loadExistingConfig();
</script>
</body>
</html>