Skip to content

Commit 1e5451e

Browse files
rcourtmanclaude
andcommitted
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>
1 parent 569d34b commit 1e5451e

File tree

8 files changed

+885
-0
lines changed

8 files changed

+885
-0
lines changed

server/configApi.js

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
const fs = require('fs').promises;
2+
const path = require('path');
3+
const { loadConfiguration } = require('./configLoader');
4+
const { initializeApiClients } = require('./apiClients');
5+
6+
class ConfigApi {
7+
constructor() {
8+
this.envPath = path.join(__dirname, '../.env');
9+
}
10+
11+
/**
12+
* Get current configuration (without secrets)
13+
*/
14+
async getConfig() {
15+
try {
16+
const config = await this.readEnvFile();
17+
18+
return {
19+
proxmox: config.PROXMOX_HOST ? {
20+
host: config.PROXMOX_HOST,
21+
port: config.PROXMOX_PORT || '8006',
22+
tokenId: config.PROXMOX_TOKEN_ID,
23+
// Don't send the secret
24+
} : null,
25+
pbs: config.PBS_HOST ? {
26+
host: config.PBS_HOST,
27+
port: config.PBS_PORT || '8007',
28+
tokenId: config.PBS_TOKEN_ID,
29+
// Don't send the secret
30+
} : null
31+
};
32+
} catch (error) {
33+
console.error('Error reading configuration:', error);
34+
return { proxmox: null, pbs: null };
35+
}
36+
}
37+
38+
/**
39+
* Save configuration to .env file
40+
*/
41+
async saveConfig(config) {
42+
try {
43+
// Read existing .env file to preserve other settings
44+
const existingConfig = await this.readEnvFile();
45+
46+
// Update with new values
47+
if (config.proxmox) {
48+
existingConfig.PROXMOX_HOST = config.proxmox.host;
49+
existingConfig.PROXMOX_PORT = config.proxmox.port || '8006';
50+
existingConfig.PROXMOX_TOKEN_ID = config.proxmox.tokenId;
51+
existingConfig.PROXMOX_TOKEN_SECRET = config.proxmox.tokenSecret;
52+
}
53+
54+
if (config.pbs) {
55+
existingConfig.PBS_HOST = config.pbs.host;
56+
existingConfig.PBS_PORT = config.pbs.port || '8007';
57+
existingConfig.PBS_TOKEN_ID = config.pbs.tokenId;
58+
existingConfig.PBS_TOKEN_SECRET = config.pbs.tokenSecret;
59+
}
60+
61+
// Write back to .env file
62+
await this.writeEnvFile(existingConfig);
63+
64+
// Reload configuration in the application
65+
await this.reloadConfiguration();
66+
67+
return { success: true };
68+
} catch (error) {
69+
console.error('Error saving configuration:', error);
70+
throw error;
71+
}
72+
}
73+
74+
/**
75+
* Test configuration by attempting to connect
76+
*/
77+
async testConfig(config) {
78+
try {
79+
// Create temporary endpoint configuration
80+
const testEndpoints = [{
81+
id: 'test-primary',
82+
name: 'Test Primary',
83+
host: config.proxmox.host,
84+
port: parseInt(config.proxmox.port) || 8006,
85+
tokenId: config.proxmox.tokenId,
86+
tokenSecret: config.proxmox.tokenSecret,
87+
enabled: true
88+
}];
89+
90+
const testPbsConfigs = config.pbs ? [{
91+
id: 'test-pbs',
92+
name: 'Test PBS',
93+
host: config.pbs.host,
94+
port: parseInt(config.pbs.port) || 8007,
95+
tokenId: config.pbs.tokenId,
96+
tokenSecret: config.pbs.tokenSecret
97+
}] : [];
98+
99+
// Try to initialize API clients with test config
100+
const { apiClients, pbsApiClients } = await initializeApiClients(testEndpoints, testPbsConfigs);
101+
102+
// Try a simple API call to verify connection
103+
const testClient = apiClients.get('test-primary');
104+
if (testClient) {
105+
await testClient.client.get('/nodes');
106+
}
107+
108+
return { success: true };
109+
} catch (error) {
110+
console.error('Configuration test failed:', error);
111+
return {
112+
success: false,
113+
error: error.message || 'Failed to connect to Proxmox server'
114+
};
115+
}
116+
}
117+
118+
/**
119+
* Read .env file and parse it
120+
*/
121+
async readEnvFile() {
122+
try {
123+
const content = await fs.readFile(this.envPath, 'utf8');
124+
const config = {};
125+
126+
content.split('\n').forEach(line => {
127+
const trimmedLine = line.trim();
128+
if (trimmedLine && !trimmedLine.startsWith('#')) {
129+
const [key, ...valueParts] = trimmedLine.split('=');
130+
if (key) {
131+
// Handle values that might contain = signs
132+
let value = valueParts.join('=').trim();
133+
// Remove quotes if present
134+
if ((value.startsWith('"') && value.endsWith('"')) ||
135+
(value.startsWith("'") && value.endsWith("'"))) {
136+
value = value.slice(1, -1);
137+
}
138+
config[key.trim()] = value;
139+
}
140+
}
141+
});
142+
143+
return config;
144+
} catch (error) {
145+
if (error.code === 'ENOENT') {
146+
// .env file doesn't exist yet
147+
return {};
148+
}
149+
throw error;
150+
}
151+
}
152+
153+
/**
154+
* Write configuration back to .env file
155+
*/
156+
async writeEnvFile(config) {
157+
const lines = [];
158+
159+
// Add header
160+
lines.push('# Pulse Configuration');
161+
lines.push('# Generated by Pulse Web Configuration');
162+
lines.push('');
163+
164+
// Group related settings
165+
const groups = {
166+
'Proxmox VE Settings': ['PROXMOX_HOST', 'PROXMOX_PORT', 'PROXMOX_TOKEN_ID', 'PROXMOX_TOKEN_SECRET'],
167+
'Proxmox Backup Server Settings': ['PBS_HOST', 'PBS_PORT', 'PBS_TOKEN_ID', 'PBS_TOKEN_SECRET'],
168+
'Other Settings': [] // Will contain all other keys
169+
};
170+
171+
// Find other keys not in predefined groups
172+
Object.keys(config).forEach(key => {
173+
let found = false;
174+
Object.values(groups).forEach(groupKeys => {
175+
if (groupKeys.includes(key)) found = true;
176+
});
177+
if (!found && key !== '') {
178+
groups['Other Settings'].push(key);
179+
}
180+
});
181+
182+
// Write each group
183+
Object.entries(groups).forEach(([groupName, keys]) => {
184+
if (keys.length > 0 && keys.some(key => config[key])) {
185+
lines.push(`# ${groupName}`);
186+
keys.forEach(key => {
187+
if (config[key] !== undefined && config[key] !== '') {
188+
const value = config[key];
189+
// Quote values that contain spaces or special characters
190+
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
191+
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
192+
}
193+
});
194+
lines.push('');
195+
}
196+
});
197+
198+
await fs.writeFile(this.envPath, lines.join('\n'), 'utf8');
199+
}
200+
201+
/**
202+
* Reload configuration without restarting the server
203+
*/
204+
async reloadConfiguration() {
205+
try {
206+
// Clear the require cache for dotenv
207+
delete require.cache[require.resolve('dotenv')];
208+
209+
// Reload environment variables
210+
require('dotenv').config();
211+
212+
// Reload configuration
213+
const { endpoints, pbsConfigs, isConfigPlaceholder } = loadConfiguration();
214+
215+
// Get state manager instance
216+
const stateManager = require('./state');
217+
218+
// Update configuration status
219+
stateManager.setConfigPlaceholderStatus(isConfigPlaceholder);
220+
stateManager.setEndpointConfigurations(endpoints, pbsConfigs);
221+
222+
// Reinitialize API clients
223+
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
224+
225+
// Update global references
226+
if (global.pulseApiClients) {
227+
global.pulseApiClients.apiClients = apiClients;
228+
global.pulseApiClients.pbsApiClients = pbsApiClients;
229+
}
230+
231+
// Update global config placeholder status
232+
if (global.pulseConfigStatus) {
233+
global.pulseConfigStatus.isPlaceholder = isConfigPlaceholder;
234+
}
235+
236+
console.log('Configuration reloaded successfully');
237+
return true;
238+
} catch (error) {
239+
console.error('Error reloading configuration:', error);
240+
throw error;
241+
}
242+
}
243+
244+
/**
245+
* Set up API routes
246+
*/
247+
setupRoutes(app) {
248+
// Get current configuration
249+
app.get('/api/config', async (req, res) => {
250+
try {
251+
const config = await this.getConfig();
252+
res.json(config);
253+
} catch (error) {
254+
res.status(500).json({ error: 'Failed to read configuration' });
255+
}
256+
});
257+
258+
// Save configuration
259+
app.post('/api/config', async (req, res) => {
260+
try {
261+
await this.saveConfig(req.body);
262+
res.json({ success: true });
263+
} catch (error) {
264+
res.status(500).json({
265+
success: false,
266+
error: error.message || 'Failed to save configuration'
267+
});
268+
}
269+
});
270+
271+
// Test configuration
272+
app.post('/api/config/test', async (req, res) => {
273+
try {
274+
const result = await this.testConfig(req.body);
275+
res.json(result);
276+
} catch (error) {
277+
res.status(500).json({
278+
success: false,
279+
error: error.message || 'Failed to test configuration'
280+
});
281+
}
282+
});
283+
284+
// Reload configuration
285+
app.post('/api/config/reload', async (req, res) => {
286+
try {
287+
await this.reloadConfiguration();
288+
res.json({ success: true });
289+
} catch (error) {
290+
res.status(500).json({
291+
success: false,
292+
error: error.message || 'Failed to reload configuration'
293+
});
294+
}
295+
});
296+
}
297+
}
298+
299+
module.exports = ConfigApi;

0 commit comments

Comments
 (0)