Split monolithic app.js into native ES modules
Replace the single 7034-line app.js with 17 ES module files organized into core/ (state, api, i18n, ui) and features/ (calibration, dashboard, device-discovery, devices, displays, kc-targets, pattern-templates, profiles, streams, tabs, targets, tutorials) with an app.js entry point that registers ~90 onclick globals on window. No bundler needed — FastAPI serves modules directly via <script type="module">. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
322
server/src/wled_controller/static/js/features/dashboard.js
Normal file
322
server/src/wled_controller/static/js/features/dashboard.js
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Dashboard — real-time target status overview.
|
||||
*/
|
||||
|
||||
import { apiKey, _dashboardWS, set_dashboardWS, _dashboardLoading, set_dashboardLoading } from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { escapeHtml, handle401Error } from '../core/api.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds || seconds <= 0) return '-';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export async function loadDashboard() {
|
||||
if (_dashboardLoading) return;
|
||||
set_dashboardLoading(true);
|
||||
const container = document.getElementById('dashboard-content');
|
||||
if (!container) { set_dashboardLoading(false); return; }
|
||||
|
||||
try {
|
||||
const [targetsResp, profilesResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
|
||||
fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null),
|
||||
]);
|
||||
if (targetsResp.status === 401) { handle401Error(); return; }
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] };
|
||||
const profiles = profilesData.profiles || [];
|
||||
|
||||
if (targets.length === 0 && profiles.length === 0) {
|
||||
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const enriched = await Promise.all(targets.map(async (target) => {
|
||||
try {
|
||||
const [stateResp, metricsResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }),
|
||||
fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }),
|
||||
]);
|
||||
const state = stateResp.ok ? await stateResp.json() : {};
|
||||
const metrics = metricsResp.ok ? await metricsResp.json() : {};
|
||||
return { ...target, state, metrics };
|
||||
} catch {
|
||||
return target;
|
||||
}
|
||||
}));
|
||||
|
||||
const running = enriched.filter(t => t.state && t.state.processing);
|
||||
const stopped = enriched.filter(t => !t.state || !t.state.processing);
|
||||
|
||||
let html = '';
|
||||
|
||||
if (profiles.length > 0) {
|
||||
const activeProfiles = profiles.filter(p => p.is_active);
|
||||
const inactiveProfiles = profiles.filter(p => !p.is_active);
|
||||
|
||||
html += `<div class="dashboard-section">
|
||||
<div class="dashboard-section-header">
|
||||
${t('dashboard.section.profiles')}
|
||||
<span class="dashboard-section-count">${profiles.length}</span>
|
||||
</div>
|
||||
${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
|
||||
${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (running.length > 0) {
|
||||
html += `<div class="dashboard-section">
|
||||
<div class="dashboard-section-header">
|
||||
${t('dashboard.section.running')}
|
||||
<span class="dashboard-section-count">${running.length}</span>
|
||||
<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>
|
||||
</div>
|
||||
${running.map(target => renderDashboardTarget(target, true)).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (stopped.length > 0) {
|
||||
html += `<div class="dashboard-section">
|
||||
<div class="dashboard-section-header">
|
||||
${t('dashboard.section.stopped')}
|
||||
<span class="dashboard-section-count">${stopped.length}</span>
|
||||
</div>
|
||||
${stopped.map(target => renderDashboardTarget(target, false)).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
|
||||
} finally {
|
||||
set_dashboardLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardTarget(target, isRunning) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
||||
const icon = '⚡';
|
||||
const typeLabel = isLed ? 'LED' : 'Key Colors';
|
||||
|
||||
let subtitleParts = [typeLabel];
|
||||
if (isLed && state.device_name) {
|
||||
subtitleParts.push(state.device_name);
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-';
|
||||
const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-';
|
||||
const uptime = formatUptime(metrics.uptime_seconds);
|
||||
const errors = metrics.errors_count || 0;
|
||||
|
||||
let healthDot = '';
|
||||
if (isLed && state.device_last_checked != null) {
|
||||
const cls = state.device_online ? 'health-online' : 'health-offline';
|
||||
healthDot = `<span class="health-dot ${cls}"></span>`;
|
||||
}
|
||||
|
||||
return `<div class="dashboard-target">
|
||||
<div class="dashboard-target-info">
|
||||
<span class="dashboard-target-icon">${icon}</span>
|
||||
<div>
|
||||
<div class="dashboard-target-name">${escapeHtml(target.name)}${healthDot}</div>
|
||||
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-target-metrics">
|
||||
<div class="dashboard-metric">
|
||||
<div class="dashboard-metric-value">${fpsActual}/${fpsTarget}</div>
|
||||
<div class="dashboard-metric-label">${t('dashboard.fps')}</div>
|
||||
</div>
|
||||
<div class="dashboard-metric">
|
||||
<div class="dashboard-metric-value">${uptime}</div>
|
||||
<div class="dashboard-metric-label">${t('dashboard.uptime')}</div>
|
||||
</div>
|
||||
<div class="dashboard-metric">
|
||||
<div class="dashboard-metric-value">${errors}</div>
|
||||
<div class="dashboard-metric-label">${t('dashboard.errors')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-target-actions">
|
||||
<button class="btn btn-icon btn-warning" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏸</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
return `<div class="dashboard-target">
|
||||
<div class="dashboard-target-info">
|
||||
<span class="dashboard-target-icon">${icon}</span>
|
||||
<div>
|
||||
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
|
||||
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-target-metrics"></div>
|
||||
<div class="dashboard-target-actions">
|
||||
<button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDashboardProfile(profile) {
|
||||
const isActive = profile.is_active;
|
||||
const isDisabled = !profile.enabled;
|
||||
|
||||
let condSummary = '';
|
||||
if (profile.conditions.length > 0) {
|
||||
const parts = profile.conditions.map(c => {
|
||||
if (c.condition_type === 'application') {
|
||||
const apps = (c.apps || []).join(', ');
|
||||
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
|
||||
return `${apps} (${matchLabel})`;
|
||||
}
|
||||
return c.condition_type;
|
||||
});
|
||||
const logic = profile.condition_logic === 'and' ? ' & ' : ' | ';
|
||||
condSummary = parts.join(logic);
|
||||
}
|
||||
|
||||
const statusBadge = isDisabled
|
||||
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>`
|
||||
: isActive
|
||||
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
|
||||
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
|
||||
|
||||
const targetCount = profile.target_ids.length;
|
||||
const activeCount = (profile.active_target_ids || []).length;
|
||||
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
|
||||
|
||||
return `<div class="dashboard-target dashboard-profile">
|
||||
<div class="dashboard-target-info">
|
||||
<span class="dashboard-target-icon">📋</span>
|
||||
<div>
|
||||
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
|
||||
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
|
||||
</div>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<div class="dashboard-target-metrics">
|
||||
<div class="dashboard-metric">
|
||||
<div class="dashboard-metric-value">${targetsInfo}</div>
|
||||
<div class="dashboard-metric-label">${t('dashboard.targets')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-target-actions">
|
||||
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
|
||||
${profile.enabled ? '⏸' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function dashboardToggleProfile(profileId, enable) {
|
||||
try {
|
||||
const endpoint = enable ? 'enable' : 'disable';
|
||||
const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
loadDashboard();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to toggle profile', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardStartTarget(targetId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('device.started'), 'success');
|
||||
loadDashboard();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to start: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to start processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardStopTarget(targetId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('device.stopped'), 'success');
|
||||
loadDashboard();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to stop: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to stop processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function dashboardStopAll() {
|
||||
try {
|
||||
const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
|
||||
if (targetsResp.status === 401) { handle401Error(); return; }
|
||||
const data = await targetsResp.json();
|
||||
const running = (data.targets || []).filter(t => t.id);
|
||||
await Promise.all(running.map(t =>
|
||||
fetch(`${API_BASE}/picture-targets/${t.id}/stop`, { method: 'POST', headers: getHeaders() }).catch(() => {})
|
||||
));
|
||||
loadDashboard();
|
||||
} catch (error) {
|
||||
showToast('Failed to stop all targets', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function startDashboardWS() {
|
||||
stopDashboardWS();
|
||||
if (!apiKey) return;
|
||||
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`;
|
||||
try {
|
||||
set_dashboardWS(new WebSocket(url));
|
||||
_dashboardWS.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'state_change' || data.type === 'profile_state_changed') {
|
||||
loadDashboard();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
_dashboardWS.onclose = () => { set_dashboardWS(null); };
|
||||
_dashboardWS.onerror = () => { set_dashboardWS(null); };
|
||||
} catch {
|
||||
set_dashboardWS(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopDashboardWS() {
|
||||
if (_dashboardWS) {
|
||||
_dashboardWS.close();
|
||||
set_dashboardWS(null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user