refactor: Phase 1 code cleanup - constants, logging, and repository organization
This commit is contained in:
7
dashcaddy-api/.gitignore
vendored
Normal file
7
dashcaddy-api/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
# Backups
|
||||||
|
.backup/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
__tests__/jest.setup.js
|
||||||
|
audit-routes.js
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -277,8 +277,10 @@ module.exports = function configureMiddleware(app, {
|
|||||||
{ path: '/api/health', exact: true },
|
{ path: '/api/health', exact: true },
|
||||||
{ path: '/probe/', prefix: true },
|
{ path: '/probe/', prefix: true },
|
||||||
{ 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 },
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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).');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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
5
dashcaddy-installer/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
123
dashcaddy-installer/LOGO_INTEGRATION.md
Normal file
123
dashcaddy-installer/LOGO_INTEGRATION.md
Normal 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 ✅
|
||||||
149
dashcaddy-installer/README-TESTER.txt
Normal file
149
dashcaddy-installer/README-TESTER.txt
Normal 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
|
||||||
18
dashcaddy-installer/jest.config.js
Normal file
18
dashcaddy-installer/jest.config.js
Normal 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']
|
||||||
|
};
|
||||||
2
dashcaddy-installer/jest.setup.js
Normal file
2
dashcaddy-installer/jest.setup.js
Normal 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
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
2
status/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
402
status/DEPLOYMENT_GUIDE.md
Normal file
402
status/DEPLOYMENT_GUIDE.md
Normal 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
229
status/EMBY_DEPLOYMENT.md
Normal 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
183
status/QUICK_DEPLOY_EMBY.md
Normal 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
214
status/README.md
Normal 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
|
||||||
8125
status/index.html.backup-before-file-logs
Normal file
8125
status/index.html.backup-before-file-logs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,15 +364,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 !== '••••••••') {
|
||||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
if (isArr) {
|
||||||
method: 'POST',
|
// Use arr credentials endpoint — validates key, tests connection, stores in arr.* namespace
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const svcUrl = getServiceUrl(currentService);
|
||||||
body: JSON.stringify({ apiKey })
|
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`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
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
|
||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user