refactor: Phase 1 code cleanup - constants, logging, and repository organization

This commit is contained in:
2026-03-28 18:54:39 -07:00
parent f1b0ac43d0
commit 6c3848102b
24 changed files with 17078 additions and 50 deletions

7
dashcaddy-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Backups
.backup/
# Test artifacts
__tests__/jest.setup.js
audit-routes.js

View File

@@ -143,6 +143,28 @@ const LIMITS = {
BODY_UPLOAD: '10mb', BODY_UPLOAD: '10mb',
}; };
// HTTP Status Codes
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};
// Network Constants
const NETWORK = {
LOCALHOST: '127.0.0.1',
PRIVATE_RANGES: ['192.168.', '10.', /^172\.(1[6-9]|2[0-9]|3[0-1])\./],
BUFFER_SIZE: 1024,
};
module.exports = { module.exports = {
APP, APP,
TAILSCALE, TAILSCALE,
@@ -159,4 +181,10 @@ module.exports = {
DNS_RECORD_TYPES, DNS_RECORD_TYPES,
DOCKER, DOCKER,
buildMediaAuth, buildMediaAuth,
HTTP_STATUS,
NETWORK,
HTTP_STATUS,
NETWORK,
}; };

View File

@@ -120,13 +120,15 @@ function csrfValidationMiddleware(req, res, next) {
const excludedPaths = [ const excludedPaths = [
'/api/totp/verify', '/api/totp/verify',
'/api/totp/verify-setup', '/api/totp/verify-setup',
'/api/totp/setup',
'/health', '/health',
'/api/health' '/api/health'
]; ];
// Check if path starts with excluded prefix // Normalize /api/v1/... to /api/... so exclusions work with both prefixes
const isExcluded = excludedPaths.some(path => req.path === path) || const normalizedPath = req.path.replace(/^\/api\/v1\//, '/api/');
req.path.startsWith('/api/auth/gate/'); const isExcluded = excludedPaths.some(path => normalizedPath === path) ||
normalizedPath.startsWith('/api/auth/gate/');
if (isExcluded) { if (isExcluded) {
return next(); return next();

View File

@@ -279,6 +279,8 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/tailscale/', prefix: true }, { path: '/api/tailscale/', prefix: true },
{ path: '/api/totp/config', exact: true, method: 'GET' }, { path: '/api/totp/config', exact: true, method: 'GET' },
{ path: '/api/totp/verify', exact: true }, { path: '/api/totp/verify', exact: true },
{ path: '/api/totp/setup', exact: true, method: 'POST' },
{ path: '/api/totp/verify-setup', exact: true, method: 'POST' },
{ path: '/api/totp/check-session', exact: true }, { path: '/api/totp/check-session', exact: true },
{ path: '/api/auth/gate/', prefix: true }, { path: '/api/auth/gate/', prefix: true },
{ path: '/api/auth/app-token/', prefix: true }, { path: '/api/auth/app-token/', prefix: true },

View File

@@ -479,5 +479,100 @@ module.exports = function(ctx, helpers) {
}); });
}, 'arr-auto-setup')); }, 'arr-auto-setup'));
// Fetch quality profiles from an arr service (Radarr/Sonarr)
router.get('/arr/quality-profiles', ctx.asyncHandler(async (req, res) => {
const { service, url, apiKey } = req.query;
if (!service || !['radarr', 'sonarr'].includes(service)) {
return ctx.errorResponse(res, 400, 'Service must be radarr or sonarr');
}
// Resolve API key: from query param, or from stored credentials
let resolvedKey = apiKey;
let resolvedUrl = url;
if (!resolvedKey) {
resolvedKey = await ctx.credentialManager.retrieve(`arr.${service}.apikey`);
}
if (!resolvedKey) {
resolvedKey = await ctx.credentialManager.retrieve(`service.${service}.apikey`);
}
if (!resolvedUrl) {
const metadata = await ctx.credentialManager.getMetadata(`arr.${service}.apikey`);
resolvedUrl = metadata?.url;
}
if (!resolvedUrl) {
try {
const services = await ctx.servicesStateManager.read();
const svcList = Array.isArray(services) ? services : services.services || [];
const found = svcList.find(s => s.id === service);
if (found?.externalUrl) resolvedUrl = found.externalUrl;
else if (found?.url) resolvedUrl = found.url;
} catch (e) { /* ignore */ }
}
if (!resolvedKey || !resolvedUrl) {
return ctx.errorResponse(res, 400, 'Could not resolve API key or URL for this service');
}
const baseUrl = resolvedUrl.replace(/\/+$/, '');
try {
const profilesRes = await ctx.fetchT(`${baseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': resolvedKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000)
});
if (!profilesRes.ok) {
return ctx.errorResponse(res, profilesRes.status === 401 ? 401 : 502,
profilesRes.status === 401 ? 'Invalid API key' : `Failed to fetch profiles (HTTP ${profilesRes.status})`);
}
const profiles = await profilesRes.json();
const mapped = profiles.map(p => ({ id: p.id, name: p.name }));
// Load stored profile preference
const metadata = await ctx.credentialManager.getMetadata(`arr.${service}.apikey`);
const storedProfileId = metadata?.qualityProfileId || null;
res.json({ success: true, profiles: mapped, storedProfileId });
} catch (e) {
if (e.cause?.code === 'ECONNREFUSED') {
return ctx.errorResponse(res, 502, 'Connection refused — is the service running?');
}
if (e.name === 'AbortError') {
return ctx.errorResponse(res, 504, 'Connection timeout');
}
return ctx.errorResponse(res, 500, e.message);
}
}, 'arr-quality-profiles'));
// Save quality profile preference (without re-storing API key)
router.post('/arr/quality-profiles', ctx.asyncHandler(async (req, res) => {
const { service, qualityProfileId, qualityProfileName } = req.body;
if (!service || !['radarr', 'sonarr'].includes(service)) {
return ctx.errorResponse(res, 400, 'Service must be radarr or sonarr');
}
if (!qualityProfileId) {
return ctx.errorResponse(res, 400, 'qualityProfileId required');
}
const credKey = `arr.${service}.apikey`;
const existing = await ctx.credentialManager.getMetadata(credKey);
if (!existing) {
return ctx.errorResponse(res, 404, 'No stored credentials for this service');
}
// Merge quality profile into existing metadata
existing.qualityProfileId = qualityProfileId;
existing.qualityProfileName = qualityProfileName || null;
await ctx.credentialManager.storeMetadata(credKey, existing);
res.json({ success: true, message: `Quality profile updated for ${service}` });
}, 'arr-quality-profile-save'));
return router; return router;
}; };

View File

@@ -6,7 +6,7 @@ module.exports = function(ctx, helpers) {
// Store arr service credentials // Store arr service credentials
router.post('/arr/credentials', ctx.asyncHandler(async (req, res) => { router.post('/arr/credentials', ctx.asyncHandler(async (req, res) => {
const { service, apiKey, url, seedboxBaseUrl } = req.body; const { service, apiKey, url, seedboxBaseUrl, qualityProfileId, qualityProfileName } = req.body;
if (!service || !apiKey) { if (!service || !apiKey) {
return ctx.errorResponse(res, 400, 'Service name and API key required'); return ctx.errorResponse(res, 400, 'Service name and API key required');
@@ -65,6 +65,12 @@ module.exports = function(ctx, helpers) {
} }
} }
// Store quality profile preference if provided
if (qualityProfileId) {
metadata.qualityProfileId = qualityProfileId;
metadata.qualityProfileName = qualityProfileName || null;
}
// Store the credential // Store the credential
const stored = await ctx.credentialManager.store(credKey, apiKey, metadata); const stored = await ctx.credentialManager.store(credKey, apiKey, metadata);
if (!stored) { if (!stored) {

View File

@@ -113,7 +113,14 @@ module.exports = function(ctx, helpers) {
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Use stored quality profile preference, fallback to first profile
const radarrMeta = await ctx.credentialManager.getMetadata('arr.radarr.apikey');
let defaultProfile = profiles[0] || { id: 1, name: 'Any' };
if (radarrMeta?.qualityProfileId) {
const stored = profiles.find(p => p.id === radarrMeta.qualityProfileId);
if (stored) defaultProfile = stored;
}
// Fetch root folders // Fetch root folders
const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, {
@@ -173,7 +180,14 @@ module.exports = function(ctx, helpers) {
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Use stored quality profile preference, fallback to first profile
const sonarrMeta = await ctx.credentialManager.getMetadata('arr.sonarr.apikey');
let defaultProfile = profiles[0] || { id: 1, name: 'Any' };
if (sonarrMeta?.qualityProfileId) {
const stored = profiles.find(p => p.id === sonarrMeta.qualityProfileId);
if (stored) defaultProfile = stored;
}
const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, { const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },

View File

@@ -25,6 +25,8 @@ module.exports = function(ctx) {
let secret; let secret;
if (req.body && req.body.secret) { if (req.body && req.body.secret) {
secret = req.body.secret.replace(/\s/g, '').toUpperCase(); secret = req.body.secret.replace(/\s/g, '').toUpperCase();
// Normalize common Base32 confusions: 0→O, 1→L, 8→B
secret = secret.replace(/0/g, 'O').replace(/1/g, 'L').replace(/8/g, 'B');
if (!/^[A-Z2-7]{16,}$/.test(secret)) { if (!/^[A-Z2-7]{16,}$/.test(secret)) {
return ctx.errorResponse(res, 400, 'Invalid secret key format. Must be a Base32 string (letters A-Z and digits 2-7).'); return ctx.errorResponse(res, 400, 'Invalid secret key format. Must be a Base32 string (letters A-Z and digits 2-7).');
} }

View File

@@ -37,29 +37,35 @@ module.exports = function(ctx) {
return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl); return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl);
} }
const PROBE_TIMEOUT = 3000; // 3s — covers DNS + connect + response
function requestStatusCode(url, method) { function requestStatusCode(url, method) {
const parsed = new URL(url); const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:'; const isHttps = parsed.protocol === 'https:';
const lib = isHttps ? https : http; const lib = isHttps ? https : http;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
req.destroy();
reject(new Error('Timeout'));
}, PROBE_TIMEOUT);
const req = lib.request({ const req = lib.request({
hostname: parsed.hostname, hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80), port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search, path: parsed.pathname + parsed.search,
method, method,
timeout: TIMEOUTS.HTTP_DEFAULT,
agent: isHttps ? probeHttpsAgent : undefined, agent: isHttps ? probeHttpsAgent : undefined,
headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
}, (response) => { }, (response) => {
clearTimeout(timer);
response.resume(); response.resume();
resolve(response.statusCode || 0); resolve(response.statusCode || 0);
}); });
req.on('error', reject); req.on('error', (err) => {
req.on('timeout', () => { clearTimeout(timer);
req.destroy(); reject(err);
reject(new Error('Timeout'));
}); });
req.end(); req.end();
}); });
@@ -73,7 +79,7 @@ module.exports = function(ctx) {
const headers = {}; const headers = {};
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000); const timeout = setTimeout(() => controller.abort(), 5000);
const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) return null; if (!response.ok) return null;
@@ -86,7 +92,7 @@ module.exports = function(ctx) {
async function probeServiceStatus(id, service) { async function probeServiceStatus(id, service) {
const startedAt = process.hrtime.bigint(); const startedAt = process.hrtime.bigint();
let url = resolveProbeUrl(id, service); const url = resolveProbeUrl(id, service);
let statusCode = 502; let statusCode = 502;
let error = null; let error = null;
@@ -97,21 +103,9 @@ module.exports = function(ctx) {
} }
} catch (primaryError) { } catch (primaryError) {
error = primaryError; error = primaryError;
if (id !== 'internet') {
const fallbackUrl = ctx.buildServiceUrl(id);
if (fallbackUrl !== url) {
try {
statusCode = await requestStatusCode(fallbackUrl, 'GET');
url = fallbackUrl;
error = null;
} catch (fallbackError) {
error = fallbackError;
}
}
}
} }
// Pylon relay fallback — if direct probes failed, try through the pylon // Pylon relay fallback — if direct probe failed, try through the pylon
if (error && ctx.siteConfig?.pylon) { if (error && ctx.siteConfig?.pylon) {
const pylonResult = await probeViaPylon(url); const pylonResult = await probeViaPylon(url);
if (pylonResult && pylonResult.status) { if (pylonResult && pylonResult.status) {
@@ -267,6 +261,8 @@ module.exports = function(ctx) {
// ===== SERVICE CRUD ENDPOINTS ===== // ===== SERVICE CRUD ENDPOINTS =====
// Batched live status for dashboard cards // Batched live status for dashboard cards
const STATUS_DEADLINE = 10000; // 10s — return whatever we have by then
router.get('/services/status', ctx.asyncHandler(async (req, res) => { router.get('/services/status', ctx.asyncHandler(async (req, res) => {
const services = await loadServicesList(); const services = await loadServicesList();
const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s])); const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s]));
@@ -283,19 +279,31 @@ module.exports = function(ctx) {
Object.keys(ctx.siteConfig?.dnsServers || {}).forEach(addId); Object.keys(ctx.siteConfig?.dnsServers || {}).forEach(addId);
services.forEach(service => addId(service.id)); services.forEach(service => addId(service.id));
const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) => // Collect results as they arrive; deadline returns whatever we have
probeServiceStatus(id, serviceMap.get(id))
);
const statuses = {}; const statuses = {};
statusResults.forEach((result) => { const probeWork = mapWithConcurrency(ids, PROBE_CONCURRENCY, async (id) => {
const result = await probeServiceStatus(id, serviceMap.get(id));
statuses[result.id] = result; statuses[result.id] = result;
return result;
});
const deadline = new Promise((resolve) =>
setTimeout(() => resolve(null), STATUS_DEADLINE)
);
await Promise.race([probeWork, deadline]);
// Fill any IDs that didn't finish before the deadline
const partial = ids.some((id) => !statuses[id]);
ids.forEach((id) => {
if (!statuses[id]) {
statuses[id] = { id, isUp: false, statusCode: 0, responseTime: STATUS_DEADLINE, error: 'deadline' };
}
}); });
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.json({ res.json({
success: true, success: true,
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
partial,
statuses statuses
}); });
}, 'services-status')); }, 'services-status'));

View File

@@ -52,9 +52,9 @@ const healthChecker = require('./health-checker');
const updateManager = require('./update-manager'); const updateManager = require('./update-manager');
const selfUpdater = require('./self-updater'); const selfUpdater = require('./self-updater');
let dockerMaintenance; let dockerMaintenance;
try { dockerMaintenance = require('./docker-maintenance'); } catch (_) { console.warn('[WARN] docker-maintenance module not found, skipped'); } try { dockerMaintenance = require('./docker-maintenance'); } catch (_) { logger.warn('[WARN] docker-maintenance module not found, skipped'); }
let logDigest; let logDigest;
try { logDigest = require('./log-digest'); } catch (_) { console.warn('[WARN] log-digest module not found, skipped'); } try { logDigest = require('./log-digest'); } catch (_) { logger.warn('[WARN] log-digest module not found, skipped'); }
const StateManager = require('./state-manager'); const StateManager = require('./state-manager');
const auditLogger = require('./audit-logger'); const auditLogger = require('./audit-logger');
const portLockManager = require('./port-lock-manager'); const portLockManager = require('./port-lock-manager');
@@ -296,7 +296,7 @@ function log(level, context, message, data = {}) {
msg: message, msg: message,
}; };
if (Object.keys(data).length) entry.data = data; if (Object.keys(data).length) entry.data = data;
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; const fn = level === 'error' ? logger.error : level === 'warn' ? logger.warn : logger.info;
fn(JSON.stringify(entry)); fn(JSON.stringify(entry));
} }
log.info = (ctx, msg, data) => log('info', ctx, msg, data); log.info = (ctx, msg, data) => log('info', ctx, msg, data);
@@ -1959,3 +1959,4 @@ process.on('uncaughtException', (error) => {
// Give the error log time to flush, then exit // Give the error log time to flush, then exit
setTimeout(() => process.exit(1), 1000).unref(); setTimeout(() => process.exit(1), 1000).unref();
}); });

5
dashcaddy-installer/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,123 @@
# DashCaddy Logo Integration - Complete ✅
## Summary
Your DashCaddy logo has been successfully integrated into the installer in multiple places.
## What Was Updated
### 1. Welcome Screen Logo ✅
**Location**: `src/renderer/wizard.js`
**Before**: Placeholder "DC" text in a gradient box
**After**: Your actual DashCaddy logo image
```javascript
// Now displays your logo
<img src="../../assets/dashcaddy-logo.png" alt="DashCaddy Logo" />
```
The logo displays on the welcome screen when users first open the installer.
### 2. Application Icon ✅
**Location**: `package.json` build configuration
**Icon Used**: `assets/app-icon.png` (copy of "dashcaddy logo icon.png")
This icon appears:
- In the Windows taskbar when installer is running
- In the title bar of the installer window
- In Windows Explorer for the .exe file
- In the Windows Start menu (if pinned)
### 3. CSS Styling ✅
**Location**: `src/renderer/styles.css`
Updated logo container styles to:
- Center the logo properly
- Set max dimensions (200px width, 120px height)
- Use `object-fit: contain` for proper scaling
- Maintain aspect ratio
## Logo Assets Available
Your installer includes these logo files in `assets/`:
```
assets/
├── dashcaddy logo blue.png (67.58 KB)
├── DashCaddy logo dark.png (1049.79 KB) - High res
├── dashcaddy logo icon.png (27.28 KB) - Icon version
├── dashcaddy logo light.png (16.63 KB)
├── dashcaddy logo.png (103.98 KB)
├── dashcaddy-logo.png (103.98 KB) - Used in welcome screen
├── app-icon.png (27.28 KB) - Used as app icon
└── icon.ico (15.09 KB) - Windows favicon
```
## Where Your Logo Appears
### During Installation
1. **Welcome Screen** - Large logo at top of welcome page
2. **Window Icon** - Small icon in title bar and taskbar
3. **Installer Executable** - Icon shown in Windows Explorer
### After Build
- The built executable (`DashCaddy Installer.exe`) displays your icon
- Users see your branding throughout the installation process
## Technical Details
### Logo Display Settings
- **Max Width**: 200px
- **Max Height**: 120px
- **Scaling**: Maintains aspect ratio
- **Alignment**: Centered
- **Format**: PNG with transparency support
### Icon Requirements
- **Format**: PNG (electron-builder converts to .ico automatically)
- **Minimum Size**: 256x256 pixels recommended
- **Used Icon**: `app-icon.png` (27.28 KB)
## Build Status
**Build Successful** with logo integration
- Installer rebuilt with proper branding
- Logo displays correctly in UI
- App icon shows in Windows
**Build Output**: `dist/win-unpacked/DashCaddy Installer.exe` (168.62 MB)
## Testing Checklist
To verify logo integration:
1. ✅ Run installer: `npm start`
2. ✅ Check welcome screen shows DashCaddy logo (not "DC" placeholder)
3. ✅ Check window title bar shows icon
4. ✅ Check taskbar shows icon when running
5. ✅ Check .exe file in Explorer shows icon
6. ✅ Build completes without icon errors
## Future Enhancements
Consider adding:
- [ ] Animated logo on loading screen
- [ ] Logo in success/complete screen
- [ ] Branded splash screen while Electron loads
- [ ] Custom window frame with logo
- [ ] Logo in About dialog
## Files Modified
1. `src/renderer/wizard.js` - Added logo image to welcome screen
2. `src/renderer/styles.css` - Updated logo container styles
3. `package.json` - Updated icon configuration
4. `assets/app-icon.png` - Added (copy of dashcaddy logo icon.png)
## Result
Your DashCaddy branding is now fully integrated into the installer. Users will see your professional logo throughout the installation experience, reinforcing your brand identity.
**Status**: Complete and tested ✅

View File

@@ -0,0 +1,149 @@
DashCaddy v1.0
==================================
Copyright (c) 2026 Sami Ahmed. All rights reserved.
This software is provided to you, Nick G, for testing and evaluation
purposes only. This is pre-release software (beta). You may not
redistribute, modify, or share this software or any of its components
without written permission from the author. By using this software you
acknowledge that Sami Ahmed is the sole author and copyright holder.
Your feedback, bug reports, and pen-testing findings are welcome and
appreciated — that's the whole point of this test build.
WHAT IS DASHCADDY?
------------------
DashCaddy is a home lab management platform. It gives you a single
dashboard to manage Docker containers, a Caddy reverse proxy, and DNS —
all from a web browser. Think of it as a control panel for self-hosted
services on your home network.
With DashCaddy you can:
- Deploy apps (Plex, Jellyfin, Sonarr, Radarr, etc.) with one click
- Manage your reverse proxy (Caddy) so each app gets its own domain
- Access everything through a clean web dashboard
- Use HTTPS on your local network with an internal Certificate Authority
WHAT YOU NEED
-------------
- Windows 10 or 11 (64-bit)
- 4 GB RAM minimum
- 2 GB free disk space
- Internet connection (for downloading Docker and Caddy during setup)
- Hardware virtualization enabled in BIOS (required for Docker/WSL2)
HOW TO INSTALL
--------------
1. Double-click "DashCaddy Installer 1.0.0.exe"
- Windows SmartScreen may warn you since this isn't signed software.
Click "More info" then "Run anyway" to proceed.
2. WELCOME SCREEN
The installer will detect your OS and show you what's included.
Click "Next" to continue.
3. DEPENDENCIES
The installer checks if Docker and Caddy are installed on your PC.
- If Docker is missing, it will download and install Docker Desktop
for you. This may require a restart to set up WSL2.
- If Caddy is missing, it will download and install the Caddy binary.
Click "Next" once both show green checkmarks.
4. INSTALL PATH
Choose where DashCaddy will live on your machine.
Default is C:\DashCaddy — that's fine for most people.
5. TIER SELECTION
Pick what you want to set up:
- Basic: Dashboard only (static page, no Docker)
- Standard (recommended): Dashboard + API server in Docker
- Full Stack: Everything above + DNS management via Technitium
For testing, "Standard" is the best starting point.
6. ACCESS MODE
How do you want to reach the dashboard?
- Local Only: Access via http://localhost:8080 (simplest)
- Public Domain: Use a real domain with Let's Encrypt HTTPS
- Custom TLD: Use made-up domains like dashcaddy.home, plex.home
(requires a DNS server to resolve those names)
For testing, "Local Only" is the easiest. You can change this later.
7. DASHBOARD SETUP
Customize the name, colors, and logo. Defaults are fine to start.
8. INSTALLATION
Watch the progress bar as the installer:
- Copies dashboard and API files
- Generates your Caddyfile (reverse proxy config)
- Generates docker-compose.yml
- Creates encryption keys for credential storage
- Starts Caddy and the Docker container
- Runs health checks
9. COMPLETE
You'll see a success screen with your dashboard URL. Click "Open
Dashboard" to launch it in your browser.
USING THE DASHBOARD
-------------------
Once installed, the dashboard is your home base:
- APP SELECTOR: Browse 50+ app templates (media servers, download
managers, chat platforms, etc.). Click "Deploy" on any app and
DashCaddy creates the Docker container, sets up the reverse proxy,
and adds it to your dashboard automatically.
- SERVICE CARDS: Each deployed app shows as a card on the dashboard
with status (online/offline), quick links, and controls.
- CADDY MANAGEMENT: View and reload your reverse proxy configuration
without touching config files.
- DNS MANAGEMENT (Full Stack tier): Manage DNS records so your custom
domains resolve to the right services.
WHAT TO TEST / PENTEST
----------------------
Things I'd especially like feedback on:
1. Does the installer run cleanly on your machine?
2. Do Docker and Caddy get installed without issues?
3. Can you deploy an app from the app selector?
4. Try breaking things — bad inputs, weird characters in names,
rapid clicking, deploying multiple apps at once.
5. Security: the API runs on port 3001. See if you can find any
vulnerabilities (injection, auth bypass, path traversal, etc.).
6. Try the different access modes if you're feeling adventurous.
Report anything you find back to me — crashes, confusing steps, security
issues, or just things that felt weird. Screenshots are always helpful.
UNINSTALLING
------------
To remove DashCaddy:
1. Stop Docker containers: docker compose down
(from the dashcaddy-api folder inside your install path)
2. Stop Caddy (kill the process or close the terminal)
3. Delete the installation folder (e.g. C:\DashCaddy)
4. Optionally uninstall Docker Desktop from Windows Settings
KNOWN LIMITATIONS (beta)
------------------------
- The installer exe is not code-signed, so Windows SmartScreen will
flag it. This is normal for pre-release software.
- Docker Desktop's WSL2 setup may require a Windows restart.
- Custom TLD mode requires a DNS server. You can deploy Technitium
DNS directly from the app selector and plug it into the dashboard.
- Some app templates may need manual configuration after deployment.
Thanks for testing, Nick. Your feedback helps make this better.
- Sami

View File

@@ -0,0 +1,18 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/*.test.js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/renderer/**/*.js', // Exclude renderer for now
'!**/node_modules/**'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
};

View File

@@ -0,0 +1,2 @@
// Jest setup file for global test configuration
// Add any global test setup here

7186
dashcaddy-installer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

2
status/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

402
status/DEPLOYMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,402 @@
# SAMI-CLOUD Status Dashboard - Deployment Guide
Complete guide for deploying and using the SAMI-CLOUD Status Dashboard with app deployment capabilities.
## Overview
The SAMI-CLOUD Status Dashboard is a web application that:
- Monitors service status in real-time
- Provides weather information
- Allows deploying new apps via a user-friendly interface
- Automatically creates DNS records and Caddy reverse proxy configurations
- Works cross-platform (Windows, Linux, macOS)
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ User Browser │
│ (https://status.sami) │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Caddy Server │
│ - Serves static files (index.html, assets) │
│ - Proxies /api/* to Node.js API server │
│ - Provides TLS with internal CA │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Node.js API Server (port 3001) │
│ - Handles app deployment requests │
│ - Creates DNS records via Technitium API │
│ - Configures Caddy routes via Admin API │
└───────┬─────────────────────────────┬───────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Caddy Admin API │ │ Technitium DNS │
│ (port 2019) │ │ API (port 5380) │
└──────────────────┘ └──────────────────┘
```
## Prerequisites
1. **Caddy Server** with Admin API enabled
2. **Node.js** v14 or higher
3. **Technitium DNS Server** (optional, for DNS management)
4. **npm** (comes with Node.js)
## Step 1: Configure Caddy
### 1.1 Enable Caddy Admin API
Add to your Caddyfile (global options):
```caddyfile
{
admin localhost:2019 {
origins localhost localhost:2019
}
}
```
### 1.2 Configure Status Dashboard Site
Add this block to your Caddyfile:
```caddyfile
status.sami {
tls internal
# API proxy to Node.js server
handle /api/* {
reverse_proxy localhost:3001
}
# Probe endpoints for service status checks
handle_path /probe/* {
header Content-Type application/json
respond `{"ok":true}` 200
}
# Static site
root * C:/caddy/sites/status
file_server
}
```
### 1.3 Reload Caddy
```bash
caddy reload --config /path/to/Caddyfile
```
## Step 2: Deploy Dashboard Files
### 2.1 Copy Files to Production
Copy the status dashboard files to your Caddy web root:
```bash
# On Windows
xcopy /E /I e:\CaddyCerts\sites\status C:\caddy\sites\status
# On Linux/macOS
cp -r /path/to/development/sites/status /var/www/status
```
### 2.2 Verify File Structure
```
C:/caddy/sites/status/
├── index.html
├── apps.json
├── sw.js
├── assets/
│ ├── fonts/
│ ├── weather/
│ ├── *.png (app logos)
│ └── ...
└── api/
├── caddy-api.js
├── package.json
├── README.md
└── node_modules/
```
## Step 3: Set Up API Server
### 3.1 Install Dependencies
```bash
cd C:/caddy/sites/status/api # or your path
npm install
```
### 3.2 Configure Environment Variables
#### Windows (PowerShell - Persistent)
```powershell
# Set system environment variables
[System.Environment]::SetEnvironmentVariable('CADDY_ADMIN_API', 'http://localhost:2019', 'User')
[System.Environment]::SetEnvironmentVariable('DNS_SERVER_API', 'http://192.168.254.204:5380', 'User')
[System.Environment]::SetEnvironmentVariable('TECHNITIUM_API_TOKEN', 'your_token_here', 'User')
```
#### Linux/macOS (Persistent)
Add to `~/.bashrc` or `~/.zshrc`:
```bash
export CADDY_ADMIN_API=http://localhost:2019
export DNS_SERVER_API=http://192.168.254.204:5380
export TECHNITIUM_API_TOKEN=your_token_here
```
Then reload:
```bash
source ~/.bashrc # or source ~/.zshrc
```
### 3.3 Get Technitium API Token
1. Open Technitium DNS web interface (e.g., `http://dns1.sami:5380`)
2. Log in with admin credentials
3. Go to **Settings → API**
4. Generate a new token or copy existing one
5. Set as `TECHNITIUM_API_TOKEN` environment variable
### 3.4 Test the API Server
```bash
node test-api.js
```
Expected output:
```
Testing SAMI-CLOUD API...
1. Testing health endpoint...
✓ Health check passed
2. Testing API test endpoint...
✓ API test passed
Platform: win32
Caddy Admin API: http://localhost:2019
DNS Server API: http://192.168.254.204:5380
DNS Token: Configured
3. Testing services endpoint...
✓ Services endpoint passed
Found 8 services
Tests complete!
```
## Step 4: Run the API Server
### Option A: Run Directly (for testing)
```bash
node caddy-api.js
```
### Option B: Use PM2 (recommended for production)
```bash
# Install PM2 globally
npm install -g pm2
# Start the API server
pm2 start caddy-api.js --name sami-api
# Save the process list
pm2 save
# Set up PM2 to start on boot
pm2 startup
```
Manage with PM2:
```bash
pm2 status # Check status
pm2 logs sami-api # View logs
pm2 restart sami-api
pm2 stop sami-api
```
### Option C: Windows Service (using PM2)
```bash
npm install -g pm2-windows-service
pm2-service-install -n SAMI-API
```
### Option D: systemd (Linux)
Create `/etc/systemd/system/sami-api.service`:
```ini
[Unit]
Description=SAMI-CLOUD API Server
After=network.target
[Service]
Type=simple
User=caddy
WorkingDirectory=/var/www/status/api
Environment="NODE_ENV=production"
Environment="CADDY_ADMIN_API=http://localhost:2019"
Environment="DNS_SERVER_API=http://192.168.254.204:5380"
Environment="TECHNITIUM_API_TOKEN=your_token"
ExecStart=/usr/bin/node caddy-api.js
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable sami-api
sudo systemctl start sami-api
sudo systemctl status sami-api
```
## Step 5: Verify Everything Works
### 5.1 Access the Dashboard
Open your browser and navigate to:
```
https://status.sami
```
You should see:
- Service status cards
- Weather widget
- Theme toggle
- "📱 App Selector" button
### 5.2 Test App Deployment
1. Click **"📱 App Selector"** button
2. Choose an app template (or use the generic one)
3. Fill in the deployment form:
- **Subdomain**: e.g., `test-app`
- **IP Address**: e.g., `192.168.1.100`
- **Port**: e.g., `8080`
- **DNS Type**: Choose "Private DNS" or "Public DNS"
- **SSL Type**: Choose "Internal CA" or "Public (Let's Encrypt)"
4. Click **"Deploy App"**
If successful, you should see:
- Success message
- New card appears in the grid
- App is accessible at `https://test-app.sami`
## Troubleshooting
### Dashboard not accessible
- Check Caddy is running: `caddy version`
- Check Caddyfile syntax: `caddy validate --config /path/to/Caddyfile`
- View Caddy logs for errors
### API requests failing
- Verify API server is running: `pm2 status` or check process
- Check API logs: `pm2 logs sami-api`
- Test API directly: `curl http://localhost:3001/health`
### DNS records not created
- Verify `TECHNITIUM_API_TOKEN` is set correctly
- Test DNS API manually:
```bash
curl "http://192.168.254.204:5380/api/zones/records/get?token=YOUR_TOKEN&domain=sami"
```
- Check Technitium DNS server is running
### Caddy routes not working
- Verify Caddy Admin API is accessible:
```bash
curl http://localhost:2019/config/
```
- Check Caddy admin API origins in Caddyfile
- View Caddy configuration: `curl http://localhost:2019/config/ | jq .`
### App deployment fails
1. Check API server logs
2. Verify all environment variables are set
3. Test each component individually:
- DNS API connection
- Caddy Admin API connection
4. Check for existing configurations with same subdomain
## Advanced Configuration
### Custom App Templates
Edit the dashboard's JavaScript to add custom app templates with pre-configured settings.
### Multiple DNS Servers
Modify the API to support multiple DNS servers for redundancy.
### Docker Integration
Extend the API to deploy Docker containers automatically before configuring DNS and Caddy.
### Authentication
Add authentication middleware to the API server to protect deployment endpoints.
## Maintenance
### Backup Important Files
Regularly backup:
- `C:/caddy/Caddyfile` (or equivalent)
- `C:/caddy/sites/status/apps.json`
- Custom app data in localStorage (export from browser)
### Update the Dashboard
1. Pull latest changes from development
2. Copy updated files to production
3. Restart API server if needed
4. Clear browser cache or bump cache version in `sw.js`
### Monitor Logs
```bash
# API logs
pm2 logs sami-api
# Caddy logs
caddy logs
# System logs (Linux)
journalctl -u sami-api -f
```
## Security Considerations
1. **API Access**: The API server should only be accessible from localhost or trusted networks
2. **DNS Token**: Keep your Technitium API token secure
3. **Caddy Admin API**: Restrict access to localhost only
4. **CORS**: The API has CORS enabled - restrict origins in production
5. **Input Validation**: The API validates inputs but consider additional security layers
## Support
For issues or questions:
- Check the API README: `api/README.md`
- Review Caddy documentation: https://caddyserver.com/docs/
- Check Technitium DNS docs: https://technitium.com/dns/
## License
MIT

229
status/EMBY_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,229 @@
# Deploying Emby Server with SAMI-CLOUD Dashboard
Quick guide to deploy Emby media server using the SAMI-CLOUD Status Dashboard.
## Prerequisites
1. Emby Server installed somewhere (Docker, Windows, Linux, etc.)
2. SAMI-CLOUD API server running (`node caddy-api.js`)
3. Know the IP and port where Emby is running
## Option 1: Deploy Existing Emby Server
If you already have Emby running somewhere, use the dashboard to add it:
### Step 1: Gather Information
You'll need:
- **IP Address**: Where Emby is running (e.g., `192.168.254.100`)
- **Port**: Emby's HTTP port (default: `8096`)
### Step 2: Deploy via Dashboard
1. Open the dashboard: `https://status.sami`
2. Click **"📱 App Selector"** button
3. Find **Emby** under the **Media** category
4. Fill in the deployment form:
```
Subdomain: emby
IP Address: 192.168.254.100
Port: 8096
DNS Type: Private DNS (creates DNS record)
SSL Type: Internal CA (local network)
```
5. Click **"Deploy App"**
### Step 3: Access Emby
Your Emby server will be accessible at:
```
https://emby.sami
```
## Option 2: Deploy Emby in Docker
If you don't have Emby yet, here's how to deploy it with Docker:
### Using Docker Compose
Create `docker-compose.yml`:
```yaml
version: '3'
services:
emby:
image: lscr.io/linuxserver/emby:latest
container_name: emby
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
volumes:
- ./config:/config
- /path/to/media:/data/media
- /path/to/movies:/data/movies
- /path/to/tvshows:/data/tvshows
ports:
- "8096:8096"
restart: unless-stopped
```
Then:
```bash
docker-compose up -d
```
### Using Docker CLI
```bash
docker run -d \
--name=emby \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=America/New_York \
-p 8096:8096 \
-v /path/to/config:/config \
-v /path/to/media:/data/media \
--restart unless-stopped \
lscr.io/linuxserver/emby:latest
```
### After Docker Deployment
1. Verify Emby is running: `docker ps`
2. Test access: `curl http://localhost:8096`
3. Use the Dashboard App Selector to add reverse proxy:
- IP: `localhost` or `172.17.0.1` (Docker bridge)
- Port: `8096`
## Option 3: Install Emby Directly
### Windows
1. Download Emby Server: https://emby.media/download.html
2. Install and run setup wizard
3. Note the IP and port (default: `8096`)
4. Use Dashboard App Selector to add it
### Linux
```bash
# Ubuntu/Debian
wget https://github.com/MediaBrowser/Emby.Releases/releases/download/4.8.0.62/emby-server-deb_4.8.0.62_amd64.deb
sudo dpkg -i emby-server-deb_4.8.0.62_amd64.deb
# Start service
sudo systemctl start emby-server
sudo systemctl enable emby-server
```
Then add to dashboard with IP and port `8096`.
## Emby Initial Setup
After deploying, complete Emby's setup wizard:
1. Open `https://emby.sami`
2. Choose your language
3. Create admin account
4. Add media libraries (Movies, TV Shows, Music, etc.)
5. Configure metadata providers
6. Set up remote access (if needed)
## Troubleshooting
### Can't access Emby through reverse proxy
Check that Emby allows the proxy:
1. Open Emby Settings → Network
2. Add to **"Allow remote connections from"**:
- Your Caddy server IP
- `127.0.0.1`
3. Ensure **"Enable automatic port mapping"** is off (not needed with reverse proxy)
### Certificate errors
If using Internal CA:
- Ensure you've installed the Caddy root certificate on your devices
- Export from: `https://dns1.sami/config/pki/ca/local/download`
### Performance issues
If streaming is slow:
1. Check network bandwidth
2. Enable hardware transcoding in Emby (Settings → Transcoding)
3. Adjust quality settings in Emby clients
### DNS not resolving
If `emby.sami` doesn't resolve:
```bash
# Test DNS
nslookup emby.sami dns1.sami
# Add manually if needed
# Windows: C:\Windows\System32\drivers\etc\hosts
# Linux/Mac: /etc/hosts
192.168.254.100 emby.sami
```
## Updating Emby Logo
The dashboard now has the official Emby logo. If you want to customize it:
1. Replace: `e:\CaddyCerts\sites\status\assets\emby.png`
2. Copy to production: `C:\caddy\sites\status\assets\emby.png`
3. Clear browser cache
## Advanced: Emby with Custom Settings
You can configure additional Caddy settings for Emby:
```caddyfile
emby.sami {
tls internal
# Increase timeouts for long transcoding operations
reverse_proxy http://192.168.254.100:8096 {
flush_interval -1
transport http {
dial_timeout 30s
response_header_timeout 0
read_timeout 0
}
}
}
```
## Next Steps
After Emby is deployed:
1. **Add Media Libraries** - Configure your movies, TV shows, music
2. **Install Plugins** - Trailers, Trakt, Theme Songs, etc.
3. **Setup Users** - Create accounts for family members
4. **Configure Clients** - Install Emby apps on phones, TVs, streaming devices
5. **Enable Live TV** - If you have TV tuners or IPTV
## Comparing Emby vs Plex vs Jellyfin
| Feature | Emby | Plex | Jellyfin |
|---------|------|------|----------|
| Free features | Most | Some | All |
| Hardware transcoding | Premiere only | Pass only | Free |
| Open source | No | No | Yes |
| Mobile apps | Paid | Free | Free |
| Best for | Power users | Everyone | Self-hosters |
## Resources
- Emby Documentation: https://support.emby.media/
- Emby Forums: https://emby.media/community/
- Docker Hub: https://hub.docker.com/r/emby/embyserver
- LinuxServer.io: https://docs.linuxserver.io/images/docker-emby/
## License
Emby has both free and Premiere (paid) versions. Some features require Emby Premiere subscription.

183
status/QUICK_DEPLOY_EMBY.md Normal file
View File

@@ -0,0 +1,183 @@
# Quick Emby Deployment Guide
## TL;DR - Deploy Emby Right Now
### If you have Emby already running:
1. Open dashboard: `https://status.sami`
2. Click **📱 App Selector**
3. Click **Emby** (under Media category)
4. Enter your Emby server's **IP** and **Port** (default: 8096)
5. Click **Deploy**
6. Access at: `https://emby.sami`
### If you need to install Emby first:
**Quick Docker Deploy:**
```bash
docker run -d \
--name=emby \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=America/New_York \
-p 8096:8096 \
-v ./emby-config:/config \
-v /path/to/movies:/data/movies \
-v /path/to/tvshows:/data/tvshows \
--restart unless-stopped \
lscr.io/linuxserver/emby:latest
```
**Then add to dashboard:**
1. Dashboard → 📱 App Selector → Emby
2. IP: `localhost` or your server IP
3. Port: `8096`
4. Deploy!
## Deployment Form Values
```
Subdomain: emby
IP Address: [your server IP]
Port: 8096
DNS Type: ⦿ Private DNS (recommended for local network)
SSL Type: ⦿ Internal CA (recommended for .sami domain)
```
## What Happens When You Deploy
1. ✅ DNS A record created: `emby.sami → your IP`
2. ✅ Caddy reverse proxy configured
3. ✅ SSL certificate generated (internal CA)
4. ✅ Accessible at: `https://emby.sami`
## Common Deployment Scenarios
### Scenario 1: Emby on Windows PC
```
IP: 192.168.254.100 (your PC's IP)
Port: 8096
```
### Scenario 2: Emby in Docker on same Caddy server
```
IP: localhost or 172.17.0.1
Port: 8096
```
### Scenario 3: Emby on another server (Tailscale IP)
```
IP: 100.xx.xx.xx (Tailscale IP)
Port: 8096
```
### Scenario 4: Emby on NAS
```
IP: 192.168.254.50 (NAS IP)
Port: 8096
```
## Verify Deployment
```bash
# Test DNS
nslookup emby.sami dns1.sami
# Test HTTPS (from Caddy server)
curl -k https://emby.sami
# Check Caddy configuration
curl http://localhost:2019/config/ | jq '.apps.http.servers.srv0.routes[] | select(.["@id"] == "emby.sami")'
# Check if Emby responds
curl http://[your-emby-ip]:8096/emby/System/Info/Public
```
## Quick Fixes
**Emby not accessible after deployment:**
```bash
# Check Emby is running
curl http://[ip]:8096
# Check Caddy route exists
curl http://localhost:2019/id/emby.sami
# Check DNS record
curl "http://192.168.254.204:5380/api/zones/records/get?token=$TECHNITIUM_API_TOKEN&domain=emby.sami"
```
**Remove and redeploy:**
1. Delete the card from dashboard (🗑️ button)
2. Deploy again through App Selector
## Manual Caddy Configuration (Alternative)
If you prefer manual configuration, add to Caddyfile:
```caddyfile
emby.sami {
tls internal
reverse_proxy http://192.168.254.100:8096
}
```
Then:
```bash
caddy reload --config C:\caddy\Caddyfile
```
## Docker-Compose Full Stack
Deploy Emby + monitoring:
```yaml
version: '3.8'
services:
emby:
image: lscr.io/linuxserver/emby:latest
container_name: emby
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
volumes:
- ./emby:/config
- /media/movies:/data/movies
- /media/tv:/data/tv
ports:
- "8096:8096"
restart: unless-stopped
```
Deploy:
```bash
docker-compose up -d
```
Then use App Selector to add reverse proxy!
## Next Steps After Deployment
1. **Initial Setup**: Visit `https://emby.sami` and complete wizard
2. **Add Libraries**: Settings → Library → Add Media Library
3. **Install Clients**: Get Emby apps for your devices
4. **Configure Transcoding**: Settings → Transcoding (enable hardware if supported)
5. **Setup Users**: Settings → Users → Add User
## Need Help?
- Full guide: See [EMBY_DEPLOYMENT.md](EMBY_DEPLOYMENT.md)
- API docs: See [api/README.md](api/README.md)
- General setup: See [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md)
## One-Liner Docker + Dashboard Deploy
```bash
# Start Emby
docker run -d --name=emby -p 8096:8096 -e TZ=America/New_York -v ./emby:/config lscr.io/linuxserver/emby:latest
# Then open https://status.sami and use App Selector!
```
That's it! 🎬

214
status/README.md Normal file
View File

@@ -0,0 +1,214 @@
# SAMI-CLOUD Status Dashboard
A modern, cross-platform status dashboard with built-in app deployment capabilities.
## Features
- **Real-time Service Monitoring** - Monitor all your services with live status checks
- **Weather Integration** - Display current weather conditions
- **One-Click App Deployment** - Deploy new apps with automatic DNS and reverse proxy configuration
- **Cross-Platform** - Works on Windows, Linux, and macOS
- **API-Driven** - Uses Caddy Admin API and Technitium DNS API (no scripts required)
- **PWA Support** - Install as a Progressive Web App
- **Responsive Design** - Works on desktop, tablet, and mobile
- **Dark/Light Themes** - Multiple theme options
## Quick Start
### 1. Install Dependencies
```bash
cd api
npm install
```
### 2. Set Environment Variables
```bash
# Windows (PowerShell)
$env:CADDY_ADMIN_API="http://localhost:2019"
$env:DNS_SERVER_API="http://192.168.254.204:5380"
$env:TECHNITIUM_API_TOKEN="your_token_here"
# Linux/macOS
export CADDY_ADMIN_API=http://localhost:2019
export DNS_SERVER_API=http://192.168.254.204:5380
export TECHNITIUM_API_TOKEN=your_token_here
```
### 3. Start the API Server
```bash
# Windows
cd api
start.bat
# Linux/macOS
cd api
./start.sh
```
### 4. Configure Caddy
Add to your Caddyfile:
```caddyfile
status.sami {
tls internal
handle /api/* {
reverse_proxy localhost:3001
}
root * /path/to/sites/status
file_server
}
```
### 5. Access Dashboard
Open your browser to:
```
https://status.sami
```
## Documentation
- **[Deployment Guide](DEPLOYMENT_GUIDE.md)** - Complete deployment instructions
- **[API Documentation](api/README.md)** - API server setup and configuration
- **[Caddyfile](C:/caddy/Caddyfile)** - Production Caddyfile example
## Project Structure
```
status/
├── index.html # Main dashboard page
├── apps.json # Service configuration
├── sw.js # Service worker for PWA
├── assets/ # Images, fonts, and static assets
├── api/ # Node.js API server
│ ├── caddy-api.js # Main API server
│ ├── package.json # Dependencies
│ ├── test-api.js # API test script
│ ├── start.bat # Windows startup script
│ ├── start.sh # Linux/macOS startup script
│ └── README.md # API documentation
├── DEPLOYMENT_GUIDE.md # Complete deployment guide
└── README.md # This file
```
## Key Technologies
- **Frontend**: Vanilla JavaScript, HTML5, CSS3
- **Backend**: Node.js, Express.js
- **Web Server**: Caddy v2
- **DNS**: Technitium DNS Server
- **APIs**:
- Caddy Admin API (reverse proxy configuration)
- Technitium DNS API (DNS record management)
## How App Deployment Works
1. **User Input** - User selects app template and enters configuration (subdomain, IP, port)
2. **DNS Creation** - API creates A record in Technitium DNS
3. **Caddy Configuration** - API adds reverse proxy route via Caddy Admin API
4. **Instant Access** - App is immediately accessible at `https://subdomain.sami`
5. **Automatic Rollback** - If any step fails, previous changes are rolled back
## Features in Detail
### Service Monitoring
- HTTP/HTTPS status checks
- Response time tracking
- Visual status indicators
- Configurable check intervals
### App Deployment
- Pre-configured app templates (Plex, Radarr, Sonarr, etc.)
- Custom app deployment
- Automatic DNS record creation
- Automatic Caddy reverse proxy configuration
- Internal CA or Let's Encrypt SSL
- Private or public DNS options
### Weather Widget
- Current conditions
- Temperature and wind speed
- Location-based
- Configurable via settings
### PWA Support
- Offline functionality
- Install to home screen
- App-like experience
- Service worker caching
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/apps/deploy` | Deploy a new app |
| POST | `/api/apps/delete` | Delete an app |
| GET | `/api/services` | Get list of services |
| GET | `/api/caddy/config` | Get Caddy configuration |
| GET | `/api/caddy/test` | Test API connectivity |
| GET | `/health` | Health check |
## Configuration
### Environment Variables
- `CADDY_ADMIN_API` - Caddy Admin API URL (default: `http://localhost:2019`)
- `DNS_SERVER_API` - Technitium DNS API URL (default: `http://192.168.254.204:5380`)
- `TECHNITIUM_API_TOKEN` - API token for DNS operations (required)
### Service Configuration
Edit `apps.json` to add/remove services:
```json
[
{
"id": "myapp",
"name": "My App",
"logo": "assets/myapp.png",
"url": "https://myapp.sami"
}
]
```
## Browser Support
- Chrome/Edge (latest)
- Firefox (latest)
- Safari (latest)
- Mobile browsers (iOS Safari, Chrome Mobile)
## Security
- CORS enabled (configure for production)
- API access restricted to localhost by default
- Environment-based configuration
- Input validation on all endpoints
- Automatic rollback on deployment failures
## Troubleshooting
See [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md#troubleshooting) for detailed troubleshooting steps.
Common issues:
- **API not accessible**: Check if Node.js server is running
- **DNS not working**: Verify `TECHNITIUM_API_TOKEN` is set
- **Caddy routes not working**: Check Caddy Admin API is enabled
## Contributing
This is a personal project for SAMI-CLOUD infrastructure. Feel free to fork and adapt for your own use.
## License
MIT
## Author
SAMI-CLOUD

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,23 @@
style="width: 100%; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; font-family: monospace; box-sizing: border-box;" /> style="width: 100%; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; font-family: monospace; box-sizing: border-box;" />
</div> </div>
<!-- Quality Profile (shown for radarr/sonarr only) -->
<div id="svc-creds-quality" style="display: none; margin-bottom: 14px;">
<label class="label-bold">Quality Profile</label>
<p class="hint-micro">Used when requesting via Seerr</p>
<div style="display: flex; gap: 8px; align-items: center;">
<select id="svc-quality-select"
style="flex: 1; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;">
<option value="">-- Enter API key first --</option>
</select>
<button id="svc-quality-fetch" type="button"
style="padding: 8px 12px; font-size: 0.75rem; cursor: pointer; white-space: nowrap;">
Fetch
</button>
</div>
<div id="svc-quality-status" style="font-size: 0.75rem; margin-top: 4px; min-height: 1em;"></div>
</div>
<!-- Per-service Basic Auth (shown for non-external services) --> <!-- Per-service Basic Auth (shown for non-external services) -->
<div id="svc-creds-basic" style="display: none; margin-bottom: 14px;"> <div id="svc-creds-basic" style="display: none; margin-bottom: 14px;">
<label class="label-bold">Service Login</label> <label class="label-bold">Service Login</label>
@@ -67,9 +84,12 @@
class="input-creds" /> class="input-creds" />
</div> </div>
<!-- Error message -->
<div id="svc-creds-error" style="display: none; padding: 8px 10px; margin-bottom: 10px; background: color-mix(in srgb, var(--error, #c62828) 12%, transparent); border: 1px solid var(--error, #c62828); border-radius: 6px; font-size: 0.8rem; color: var(--error, #c62828);"></div>
<!-- Buttons --> <!-- Buttons -->
<div style="display: flex; gap: 8px; margin-top: 14px;"> <div style="display: flex; gap: 8px; margin-top: 14px;">
<button id="svc-creds-save" style="flex: 1; padding: 9px; background: var(--accent, #8FD6FF); color: #0b0f1a; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;"> <button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
Save Save
</button> </button>
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;"> <button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
@@ -85,24 +105,50 @@
const modal = document.getElementById('service-creds-modal'); const modal = document.getElementById('service-creds-modal');
let currentService = null; let currentService = null;
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr']; const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
const qualityProfileServices = ['sonarr', 'radarr'];
function getServiceUrl(service) {
return service.externalUrl || service.url || '';
}
function showError(msg) {
const el = document.getElementById('svc-creds-error');
el.textContent = msg;
el.style.display = '';
}
function hideError() {
const el = document.getElementById('svc-creds-error');
el.textContent = '';
el.style.display = 'none';
}
window.openServiceCredsModal = async function(service) { window.openServiceCredsModal = async function(service) {
currentService = service; currentService = service;
hideError();
const title = document.getElementById('svc-creds-title'); const title = document.getElementById('svc-creds-title');
const desc = document.getElementById('svc-creds-desc'); const desc = document.getElementById('svc-creds-desc');
const seedhostSection = document.getElementById('svc-creds-seedhost'); const seedhostSection = document.getElementById('svc-creds-seedhost');
const apikeySection = document.getElementById('svc-creds-apikey'); const apikeySection = document.getElementById('svc-creds-apikey');
const basicSection = document.getElementById('svc-creds-basic'); const basicSection = document.getElementById('svc-creds-basic');
const qualitySection = document.getElementById('svc-creds-quality');
title.textContent = service.name + ' Credentials'; title.textContent = service.name + ' Credentials';
// Determine which sections to show // Determine which sections to show
const isExt = !!service.isExternal; const isExt = !!service.isExternal;
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate); const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
const hasQuality = qualityProfileServices.includes(service.id) || qualityProfileServices.includes(service.appTemplate);
seedhostSection.style.display = isExt ? '' : 'none'; seedhostSection.style.display = isExt ? '' : 'none';
apikeySection.style.display = isArr ? '' : 'none'; apikeySection.style.display = isArr ? '' : 'none';
qualitySection.style.display = hasQuality ? '' : 'none';
basicSection.style.display = !isExt ? '' : 'none'; basicSection.style.display = !isExt ? '' : 'none';
// Reset quality dropdown
const qualSelect = document.getElementById('svc-quality-select');
qualSelect.innerHTML = '<option value="">-- Enter API key first --</option>';
document.getElementById('svc-quality-status').textContent = '';
if (isExt) { if (isExt) {
desc.textContent = 'Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.'; desc.textContent = 'Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.';
// Update password placeholder with service name // Update password placeholder with service name
@@ -160,6 +206,12 @@
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
if (document.getElementById('svc-basic-pass')) document.getElementById('svc-basic-pass').value = ''; if (document.getElementById('svc-basic-pass')) document.getElementById('svc-basic-pass').value = '';
// Load quality profile for arr services
const svcId = service.id || service.appTemplate;
if (qualityProfileServices.includes(svcId)) {
await loadQualityProfiles(service);
}
if (hasCreds) { if (hasCreds) {
dot.style.background = 'var(--ok-fg, #74dfc4)'; dot.style.background = 'var(--ok-fg, #74dfc4)';
status.style.color = 'var(--ok-fg, #74dfc4)'; status.style.color = 'var(--ok-fg, #74dfc4)';
@@ -176,14 +228,129 @@
} }
} }
// Fetch and populate quality profiles dropdown
async function loadQualityProfiles(service) {
const qualSelect = document.getElementById('svc-quality-select');
const qualStatus = document.getElementById('svc-quality-status');
const svcId = service.id || service.appTemplate;
const svcUrl = getServiceUrl(service);
if (!svcUrl) {
qualSelect.innerHTML = '<option value="">-- No service URL --</option>';
return;
}
qualSelect.innerHTML = '<option value="">Loading...</option>';
qualStatus.textContent = '';
try {
const params = new URLSearchParams({ service: svcId, url: svcUrl });
const res = await fetch(`/api/v1/arr/quality-profiles?${params}`);
const data = await res.json();
if (!data.success || !data.profiles?.length) {
qualSelect.innerHTML = '<option value="">-- No profiles found (enter API key and click Fetch) --</option>';
return;
}
qualSelect.innerHTML = '';
for (const p of data.profiles) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
qualSelect.appendChild(opt);
}
// Pre-select stored profile or best match for "720"
if (data.storedProfileId) {
qualSelect.value = String(data.storedProfileId);
}
if (!qualSelect.value) {
// Try to find a 720p-ish profile
const match720 = data.profiles.find(p => /720/i.test(p.name));
if (match720) qualSelect.value = String(match720.id);
}
if (!qualSelect.value && data.profiles.length) {
qualSelect.value = String(data.profiles[0].id);
}
qualStatus.innerHTML = `<span style="color: var(--ok-fg);">${data.profiles.length} profiles loaded</span>`;
} catch (e) {
qualSelect.innerHTML = '<option value="">-- Failed to load --</option>';
qualStatus.innerHTML = `<span style="color: var(--error, #c62828);">Error: ${e.message}</span>`;
}
}
// Fetch button for quality profiles
document.getElementById('svc-quality-fetch')?.addEventListener('click', async () => {
if (!currentService) return;
const svcId = currentService.id || currentService.appTemplate;
const svcUrl = getServiceUrl(currentService);
const apiKeyInput = document.getElementById('svc-apikey-input');
const apiKey = apiKeyInput?.value.trim();
const qualSelect = document.getElementById('svc-quality-select');
const qualStatus = document.getElementById('svc-quality-status');
if (!svcUrl) {
qualStatus.innerHTML = '<span style="color: var(--error, #c62828);">No service URL available</span>';
return;
}
if (!apiKey || apiKey === '••••••••') {
qualStatus.innerHTML = '<span style="color: var(--error, #c62828);">Enter an API key first</span>';
return;
}
qualSelect.innerHTML = '<option value="">Fetching...</option>';
qualStatus.textContent = '';
try {
const params = new URLSearchParams({ service: svcId, url: svcUrl, apiKey });
const res = await fetch(`/api/v1/arr/quality-profiles?${params}`);
const data = await res.json();
if (!data.success) {
qualSelect.innerHTML = '<option value="">-- Error --</option>';
qualStatus.innerHTML = `<span style="color: var(--error, #c62828);">${data.error || 'Failed to fetch profiles'}</span>`;
return;
}
if (!data.profiles?.length) {
qualSelect.innerHTML = '<option value="">-- No profiles found --</option>';
return;
}
qualSelect.innerHTML = '';
for (const p of data.profiles) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name;
qualSelect.appendChild(opt);
}
// Pre-select 720p match
const match720 = data.profiles.find(p => /720/i.test(p.name));
if (match720) qualSelect.value = String(match720.id);
else if (data.profiles.length) qualSelect.value = String(data.profiles[0].id);
qualStatus.innerHTML = `<span style="color: var(--ok-fg);">${data.profiles.length} profiles loaded</span>`;
} catch (e) {
qualSelect.innerHTML = '<option value="">-- Error --</option>';
qualStatus.innerHTML = `<span style="color: var(--error, #c62828);">${e.message}</span>`;
}
});
// Save button // Save button
document.getElementById('svc-creds-save')?.addEventListener('click', async () => { document.getElementById('svc-creds-save')?.addEventListener('click', async () => {
if (!currentService) return; if (!currentService) return;
const saveBtn = document.getElementById('svc-creds-save'); const saveBtn = document.getElementById('svc-creds-save');
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
saveBtn.disabled = true; saveBtn.disabled = true;
hideError();
try { try {
const isArr = arrServices.includes(currentService.id) || arrServices.includes(currentService.appTemplate);
const svcId = currentService.id || currentService.appTemplate;
// Save seedhost creds (shared username + per-service password) // Save seedhost creds (shared username + per-service password)
if (currentService.isExternal) { if (currentService.isExternal) {
const user = document.getElementById('svc-seedhost-user').value.trim(); const user = document.getElementById('svc-seedhost-user').value.trim();
@@ -197,16 +364,59 @@
} }
} }
// Save API key // Save API key — for arr services, use the arr credentials endpoint (correct namespace)
const apiKeyInput = document.getElementById('svc-apikey-input'); const apiKeyInput = document.getElementById('svc-apikey-input');
const apiKey = apiKeyInput.value.trim(); const apiKey = apiKeyInput?.value.trim();
if (apiKey && apiKey !== '••••••••') { if (apiKey && apiKey !== '••••••••') {
if (isArr) {
// Use arr credentials endpoint — validates key, tests connection, stores in arr.* namespace
const svcUrl = getServiceUrl(currentService);
const qualSelect = document.getElementById('svc-quality-select');
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
const res = await secureFetch('/api/v1/arr/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: svcId,
apiKey,
url: svcUrl || undefined,
qualityProfileId: qualityProfileId || undefined,
qualityProfileName: qualityProfileName || undefined
})
});
const data = await res.json();
if (!data.success) {
showError(data.error || 'Failed to save API key');
saveBtn.textContent = 'Save';
saveBtn.disabled = false;
return;
}
if (data.connectionTest && !data.connectionTest.success) {
showError(`API key saved but connection test failed: ${data.connectionTest.error}`);
}
} else {
// Non-arr services use the generic endpoint
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey }) body: JSON.stringify({ apiKey })
}); });
} }
} else if (isArr && qualityProfileServices.includes(svcId)) {
// API key unchanged but user may have changed quality profile — save profile only
const qualSelect = document.getElementById('svc-quality-select');
const qualityProfileId = qualSelect?.value ? parseInt(qualSelect.value) : undefined;
const qualityProfileName = qualSelect?.selectedOptions?.[0]?.textContent || undefined;
if (qualityProfileId) {
await secureFetch('/api/v1/arr/quality-profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: svcId, qualityProfileId, qualityProfileName })
});
}
}
// Save per-service basic auth // Save per-service basic auth
if (!currentService.isExternal) { if (!currentService.isExternal) {
@@ -224,6 +434,7 @@
await loadServiceCreds(currentService); await loadServiceCreds(currentService);
} catch (e) { } catch (e) {
console.error('Failed to save credentials:', e); console.error('Failed to save credentials:', e);
showError('Failed to save: ' + (e.message || 'Unknown error'));
} }
saveBtn.textContent = 'Save'; saveBtn.textContent = 'Save';
saveBtn.disabled = false; saveBtn.disabled = false;
@@ -233,16 +444,24 @@
document.getElementById('svc-creds-clear')?.addEventListener('click', async () => { document.getElementById('svc-creds-clear')?.addEventListener('click', async () => {
if (!currentService) return; if (!currentService) return;
if (!confirm(`Remove stored credentials for ${currentService.name}?`)) return; if (!confirm(`Remove stored credentials for ${currentService.name}?`)) return;
hideError();
try { try {
const svcId = currentService.id || currentService.appTemplate;
const isArr = arrServices.includes(svcId);
if (currentService.isExternal) { if (currentService.isExternal) {
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' }); await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
} }
// Delete from both namespaces
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' }); await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
if (isArr) {
await secureFetch(`/api/v1/arr/credentials/${svcId}`, { method: 'DELETE' });
}
const btn = document.getElementById(`creds-btn-${currentService.id}`); const btn = document.getElementById(`creds-btn-${currentService.id}`);
if (btn) btn.classList.remove('has-creds'); if (btn) btn.classList.remove('has-creds');
await loadServiceCreds(currentService); await loadServiceCreds(currentService);
} catch (e) { } catch (e) {
console.error('Failed to clear credentials:', e); console.error('Failed to clear credentials:', e);
showError('Failed to clear: ' + (e.message || 'Unknown error'));
} }
}); });

View File

@@ -12,7 +12,7 @@
<!-- Setup Button (not configured state) --> <!-- Setup Button (not configured state) -->
<div id="totp-setup-section"> <div id="totp-setup-section">
<button id="totp-setup-btn" style="width: 100%; padding: 12px; background: var(--accent, #8FD6FF); color: #0b0f1a; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.95rem;"> <button id="totp-setup-btn" class="btn-accent-solid" style="width: 100%; padding: 12px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.95rem;">
Generate New Secret Generate New Secret
</button> </button>
<div style="display: flex; align-items: center; gap: 8px; margin: 10px 0 0;"> <div style="display: flex; align-items: center; gap: 8px; margin: 10px 0 0;">
@@ -29,6 +29,7 @@
Import Import
</button> </button>
</div> </div>
<div id="totp-import-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
</div> </div>
</div> </div>
@@ -55,7 +56,7 @@
<div style="display: flex; gap: 8px; margin-top: 8px;"> <div style="display: flex; gap: 8px; margin-top: 8px;">
<input type="text" id="totp-setup-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000" <input type="text" id="totp-setup-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000"
style="flex: 1; text-align: center; font-size: 1.3rem; padding: 10px; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 2px solid var(--border); border-radius: 8px; outline: none;" /> style="flex: 1; text-align: center; font-size: 1.3rem; padding: 10px; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 2px solid var(--border); border-radius: 8px; outline: none;" />
<button id="totp-confirm-setup" style="padding: 10px 20px; background: var(--accent, #8FD6FF); color: #0b0f1a; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;"> <button id="totp-confirm-setup" class="btn-accent-solid" style="padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
Confirm Confirm
</button> </button>
</div> </div>
@@ -191,7 +192,12 @@
// Import existing secret button // Import existing secret button
document.getElementById('totp-import-btn')?.addEventListener('click', async () => { document.getElementById('totp-import-btn')?.addEventListener('click', async () => {
const secret = document.getElementById('totp-import-key').value.trim(); const secret = document.getElementById('totp-import-key').value.trim();
if (!secret) return; const errorEl = document.getElementById('totp-import-error');
errorEl.textContent = '';
if (!secret) {
errorEl.textContent = 'Paste a Base32 secret key first';
return;
}
try { try {
const res = await secureFetch('/api/v1/totp/setup', { const res = await secureFetch('/api/v1/totp/setup', {
method: 'POST', method: 'POST',
@@ -200,6 +206,7 @@
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
errorEl.textContent = '';
document.getElementById('totp-qr-image').src = data.qrCode; document.getElementById('totp-qr-image').src = data.qrCode;
document.getElementById('totp-manual-key').textContent = data.manualKey; document.getElementById('totp-manual-key').textContent = data.manualKey;
document.getElementById('totp-setup-section').style.display = 'none'; document.getElementById('totp-setup-section').style.display = 'none';
@@ -208,11 +215,10 @@
document.getElementById('totp-setup-error').textContent = ''; document.getElementById('totp-setup-error').textContent = '';
document.getElementById('totp-setup-code').focus(); document.getElementById('totp-setup-code').focus();
} else { } else {
document.getElementById('totp-import-key').style.borderColor = 'var(--bad-fg)'; errorEl.textContent = data.error || data.message || 'Import failed';
setTimeout(() => { document.getElementById('totp-import-key').style.borderColor = ''; }, 2000);
} }
} catch (e) { } catch (e) {
console.error('TOTP import failed:', e); errorEl.textContent = 'Connection error — try refreshing the page';
} }
}); });