Add dashboard tab with real-time target status overview
Dashboard is the new default tab showing running/stopped targets with FPS, uptime, errors metrics. Updates live via events WebSocket. Includes Stop All button and Start/Stop per target. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ let refreshInterval = null;
|
|||||||
let apiKey = null;
|
let apiKey = null;
|
||||||
let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh
|
let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh
|
||||||
let kcTestTargetId = null; // currently testing KC target
|
let kcTestTargetId = null; // currently testing KC target
|
||||||
|
let _dashboardWS = null; // WebSocket for dashboard live updates
|
||||||
|
|
||||||
// Toggle hint description visibility next to a label
|
// Toggle hint description visibility next to a label
|
||||||
function toggleHint(btn) {
|
function toggleHint(btn) {
|
||||||
@@ -588,10 +589,16 @@ function switchTab(name) {
|
|||||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
|
||||||
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
|
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
|
||||||
localStorage.setItem('activeTab', name);
|
localStorage.setItem('activeTab', name);
|
||||||
if (name === 'streams') {
|
if (name === 'dashboard') {
|
||||||
loadPictureSources();
|
loadDashboard();
|
||||||
} else if (name === 'targets') {
|
startDashboardWS();
|
||||||
loadTargetsTab();
|
} else {
|
||||||
|
stopDashboardWS();
|
||||||
|
if (name === 'streams') {
|
||||||
|
loadPictureSources();
|
||||||
|
} else if (name === 'targets') {
|
||||||
|
loadTargetsTab();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,9 +606,8 @@ function initTabs() {
|
|||||||
let saved = localStorage.getItem('activeTab');
|
let saved = localStorage.getItem('activeTab');
|
||||||
// Migrate legacy 'devices' tab to 'targets' (devices now live inside targets)
|
// Migrate legacy 'devices' tab to 'targets' (devices now live inside targets)
|
||||||
if (saved === 'devices') saved = 'targets';
|
if (saved === 'devices') saved = 'targets';
|
||||||
if (saved && document.getElementById(`tab-${saved}`)) {
|
if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
|
||||||
switchTab(saved);
|
switchTab(saved);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1333,14 +1339,251 @@ function startAutoRefresh() {
|
|||||||
refreshInterval = setInterval(() => {
|
refreshInterval = setInterval(() => {
|
||||||
// Only refresh if user is authenticated
|
// Only refresh if user is authenticated
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
const activeTab = localStorage.getItem('activeTab') || 'targets';
|
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
||||||
if (activeTab === 'targets') {
|
if (activeTab === 'targets') {
|
||||||
loadTargetsTab();
|
loadTargetsTab();
|
||||||
|
} else if (activeTab === 'dashboard') {
|
||||||
|
loadDashboard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 2000); // Refresh every 2 seconds
|
}, 2000); // Refresh every 2 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ──
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _dashboardLoading = false;
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
if (_dashboardLoading) return;
|
||||||
|
_dashboardLoading = true;
|
||||||
|
const container = document.getElementById('dashboard-content');
|
||||||
|
if (!container) { _dashboardLoading = false; return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
|
||||||
|
if (targetsResp.status === 401) { handle401Error(); return; }
|
||||||
|
|
||||||
|
const targetsData = await targetsResp.json();
|
||||||
|
const targets = targetsData.targets || [];
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch state + metrics for each target in parallel
|
||||||
|
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 = '';
|
||||||
|
|
||||||
|
// Running section
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopped section
|
||||||
|
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 {
|
||||||
|
_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 = isLed ? '💡' : '🎨';
|
||||||
|
|
||||||
|
let subtitleParts = [];
|
||||||
|
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">
|
||||||
|
<span class="dashboard-status-dot active" title="${t('device.status.processing')}">●</span>
|
||||||
|
<button class="btn btn-icon btn-danger" 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>
|
||||||
|
<span class="dashboard-badge-stopped">${t('dashboard.section.stopped')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-target-metrics"></div>
|
||||||
|
<div class="dashboard-target-actions">
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
_dashboardWS = new WebSocket(url);
|
||||||
|
_dashboardWS.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'state_change') {
|
||||||
|
loadDashboard();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
_dashboardWS.onclose = () => { _dashboardWS = null; };
|
||||||
|
_dashboardWS.onerror = () => { _dashboardWS = null; };
|
||||||
|
} catch {
|
||||||
|
_dashboardWS = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDashboardWS() {
|
||||||
|
if (_dashboardWS) {
|
||||||
|
_dashboardWS.close();
|
||||||
|
_dashboardWS = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toast notifications
|
// Toast notifications
|
||||||
function showToast(message, type = 'info') {
|
function showToast(message, type = 'info') {
|
||||||
const toast = document.getElementById('toast');
|
const toast = document.getElementById('toast');
|
||||||
|
|||||||
@@ -35,10 +35,17 @@
|
|||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
|
||||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
|
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
|
||||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
|
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-dashboard">
|
||||||
|
<div id="dashboard-content">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-targets">
|
<div class="tab-panel" id="tab-targets">
|
||||||
<div id="targets-panel-content">
|
<div id="targets-panel-content">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
@@ -56,7 +63,7 @@
|
|||||||
(function() {
|
(function() {
|
||||||
var saved = localStorage.getItem('activeTab');
|
var saved = localStorage.getItem('activeTab');
|
||||||
if (saved === 'devices') saved = 'targets';
|
if (saved === 'devices') saved = 'targets';
|
||||||
if (!saved || !document.getElementById('tab-' + saved)) saved = 'targets';
|
if (!saved || !document.getElementById('tab-' + saved)) saved = 'dashboard';
|
||||||
document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); });
|
document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); });
|
||||||
document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.toggle('active', panel.id === 'tab-' + saved); });
|
document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.toggle('active', panel.id === 'tab-' + saved); });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -445,5 +445,15 @@
|
|||||||
"overlay.started": "Overlay visualization started",
|
"overlay.started": "Overlay visualization started",
|
||||||
"overlay.stopped": "Overlay visualization stopped",
|
"overlay.stopped": "Overlay visualization stopped",
|
||||||
"overlay.error.start": "Failed to start overlay",
|
"overlay.error.start": "Failed to start overlay",
|
||||||
"overlay.error.stop": "Failed to stop overlay"
|
"overlay.error.stop": "Failed to stop overlay",
|
||||||
|
"dashboard.title": "📊 Dashboard",
|
||||||
|
"dashboard.section.running": "Running",
|
||||||
|
"dashboard.section.stopped": "Stopped",
|
||||||
|
"dashboard.no_targets": "No targets configured",
|
||||||
|
"dashboard.uptime": "Uptime",
|
||||||
|
"dashboard.fps": "FPS",
|
||||||
|
"dashboard.errors": "Errors",
|
||||||
|
"dashboard.device": "Device",
|
||||||
|
"dashboard.stop_all": "Stop All",
|
||||||
|
"dashboard.failed": "Failed to load dashboard"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,5 +445,15 @@
|
|||||||
"overlay.started": "Визуализация наложения запущена",
|
"overlay.started": "Визуализация наложения запущена",
|
||||||
"overlay.stopped": "Визуализация наложения остановлена",
|
"overlay.stopped": "Визуализация наложения остановлена",
|
||||||
"overlay.error.start": "Не удалось запустить наложение",
|
"overlay.error.start": "Не удалось запустить наложение",
|
||||||
"overlay.error.stop": "Не удалось остановить наложение"
|
"overlay.error.stop": "Не удалось остановить наложение",
|
||||||
|
"dashboard.title": "📊 Обзор",
|
||||||
|
"dashboard.section.running": "Запущенные",
|
||||||
|
"dashboard.section.stopped": "Остановленные",
|
||||||
|
"dashboard.no_targets": "Нет настроенных целей",
|
||||||
|
"dashboard.uptime": "Время работы",
|
||||||
|
"dashboard.fps": "FPS",
|
||||||
|
"dashboard.errors": "Ошибки",
|
||||||
|
"dashboard.device": "Устройство",
|
||||||
|
"dashboard.stop_all": "Остановить все",
|
||||||
|
"dashboard.failed": "Не удалось загрузить обзор"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3105,3 +3105,151 @@ input:-webkit-autofill:focus {
|
|||||||
flex: 0 0 24px;
|
flex: 0 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard ── */
|
||||||
|
|
||||||
|
.dashboard-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section-header {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section-count {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stop-all {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-name {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-name .health-dot {
|
||||||
|
margin-right: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-subtitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 80px 60px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-value {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-metric-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-status-dot {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-status-dot.active {
|
||||||
|
color: #4CAF50;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-no-targets {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-badge-stopped {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-target {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-target-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user