mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
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:
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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"'
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user