refactor v2

This commit is contained in:
Mateusz Gruszczyński
2026-03-02 09:41:50 +01:00
parent ef6f81fe9e
commit 016b2f5321
20 changed files with 1210 additions and 397 deletions

94
static/js/apiHelper.js Normal file
View File

@@ -0,0 +1,94 @@
(function () {
const modal = () => document.getElementById('apiHelperModal');
function open() {
const m = modal();
if (m) m.style.display = 'flex';
generate();
}
function close() {
const m = modal();
if (m) m.style.display = 'none';
}
function isoFromLocal(value) {
if (!value) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d.toISOString();
}
function generate() {
const endpoint = document.getElementById('apiEndpoint')?.value || '/api/events';
const range = document.getElementById('apiRange')?.value?.trim();
const start = isoFromLocal(document.getElementById('apiStart')?.value);
const end = isoFromLocal(document.getElementById('apiEnd')?.value);
const params = new URLSearchParams();
if (range) params.set('range', range);
if (start) params.set('start', start);
if (end) params.set('end', end);
const url = `${window.location.origin}${endpoint}${params.toString() ? '?' + params.toString() : ''}`;
const outUrl = document.getElementById('apiUrlOutput');
const outCurl = document.getElementById('apiCurlOutput');
if (outUrl) outUrl.value = url;
if (outCurl) outCurl.value = `curl "${url}"`;
}
async function callApi() {
generate();
const url = document.getElementById('apiUrlOutput')?.value;
const pre = document.getElementById('apiResponse');
if (!url || !pre) return;
pre.textContent = 'Loading...';
try {
const res = await fetch(url);
const data = await res.json();
pre.textContent = JSON.stringify(data, null, 2);
} catch (e) {
pre.textContent = 'Error: ' + e;
}
}
async function copy(id) {
const el = document.getElementById(id);
if (!el || !el.value) return;
try { await navigator.clipboard.writeText(el.value); } catch {}
}
function bind() {
document.getElementById('openApiHelperBtn')?.addEventListener('click', open);
document.getElementById('closeApiHelperBtn')?.addEventListener('click', close);
document.getElementById('genApiBtn')?.addEventListener('click', generate);
document.getElementById('callApiBtn')?.addEventListener('click', callApi);
document.getElementById('copyApiUrlBtn')?.addEventListener('click', () => copy('apiUrlOutput'));
document.getElementById('copyCurlBtn')?.addEventListener('click', () => copy('apiCurlOutput'));
document.addEventListener('click', (e) => {
const m = modal();
if (m && e.target === m) close();
});
}
document.addEventListener('DOMContentLoaded', bind);
window.apiHelper = { open, close, generate, callApi };
})();
document.querySelectorAll('.vm-range-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.vm-range-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const input = document.getElementById('apiRange');
if (input) input.value = btn.dataset.range;
});
});

134
static/js/chart.js Normal file
View File

@@ -0,0 +1,134 @@
window.setupMainChart = function setupMainChart() {
const ctx = document.getElementById('voltageChart');
if (!ctx) return;
window.voltageChart = new Chart(ctx, {
type: 'line',
data: { datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', axis: 'x', intersect: false },
scales: {
x: {
type: 'time',
time: {
displayFormats: {
millisecond: 'HH:mm:ss.SSS',
second: 'HH:mm:ss',
minute: 'HH:mm',
hour: 'HH:mm',
day: 'dd LLL',
week: 'dd LLL',
month: 'LLL yyyy',
quarter: 'LLL yyyy',
year: 'yyyy'
},
tooltipFormat: 'dd.MM.yyyy HH:mm:ss'
},
grid: { color: '#2d3139' },
ticks: { color: '#8b949e' }
},
y: {
beginAtZero: false,
min: 190,
max: 255,
grid: { color: '#2d3139' },
ticks: { stepSize: 5, color: '#c9d1d9' }
}
},
plugins: {
zoom: {
limits: {
x: { min: 'original', max: () => Date.now(), minRange: 60 * 1000 },
y: { min: 190, max: 255 }
},
zoom: {
wheel: { enabled: true },
pinch: { enabled: true },
drag: { enabled: true, backgroundColor: 'rgba(54, 162, 235, 0.3)' },
mode: 'x',
onZoomComplete: async ({ chart }) => {
const now = Date.now();
if (chart.scales.x.max > now) {
chart.scales.x.max = now;
chart.update('none');
}
document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active'));
window.currentTimeRange = 'precise';
await window.reloadDataForRange(chart.scales.x.min, chart.scales.x.max);
}
},
pan: {
enabled: true,
mode: 'x',
onPanComplete: async ({ chart }) => {
const now = Date.now();
if (chart.scales.x.max > now) {
const rangeWidth = chart.scales.x.max - chart.scales.x.min;
chart.scales.x.max = now;
chart.scales.x.min = now - rangeWidth;
chart.update('none');
}
document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active'));
window.currentTimeRange = 'precise';
await window.reloadDataForRange(chart.scales.x.min, chart.scales.x.max);
}
}
},
legend: {
labels: {
color: '#c9d1d9',
filter: (item) => !['Zanik', 'Powrot', 'Awaria'].some(word => item.text.includes(word))
}
},
tooltip: {
enabled: true,
position: 'nearest',
callbacks: {
label: (ctx) => {
const datasetLabel = ctx.dataset.label || '';
const raw = ctx.raw;
const val = (raw && raw.realV !== undefined) ? raw.realV : ctx.parsed.y;
if (datasetLabel.includes('Zanik')) return 'ZANIK FAZY';
if (datasetLabel.includes('Powrot')) return `POWROT: ${val.toFixed(1)}V`;
if (datasetLabel.includes('Awaria')) return null;
return `${datasetLabel}: ${val.toFixed(1)}V`;
}
}
}
}
}
});
};
window.updateRangeLabel = function updateRangeLabel(min, max) {
const start = new Date(min);
const end = new Date(max);
const optTime = { hour: '2-digit', minute: '2-digit', hour12: false };
const optDate = { day: '2-digit', month: '2-digit' };
let rangeText = '';
if (start.toDateString() === end.toDateString()) {
rangeText = `Zakres: ${start.toLocaleDateString('pl-PL', optDate)}, ${start.toLocaleTimeString('pl-PL', optTime)} - ${end.toLocaleTimeString('pl-PL', optTime)}`;
} else {
rangeText = `Zakres: ${start.toLocaleDateString('pl-PL', optDate)} ${start.toLocaleTimeString('pl-PL', optTime)} - ${end.toLocaleDateString('pl-PL', optDate)} ${end.toLocaleTimeString('pl-PL', optTime)}`;
}
const label = document.getElementById('eventRangeLabel');
if (label) label.textContent = rangeText;
const chartDisplay = document.getElementById('chartRangeDisplay');
if (chartDisplay) {
const optDateShort = { day: '2-digit', month: '2-digit' };
if (start.toDateString() === end.toDateString()) {
chartDisplay.textContent = `${start.toLocaleDateString('pl-PL', optDateShort)} ${start.toLocaleTimeString('pl-PL', optTime)} - ${end.toLocaleTimeString('pl-PL', optTime)}`;
} else {
chartDisplay.textContent = `${start.toLocaleDateString('pl-PL', optDateShort)} ${start.toLocaleTimeString('pl-PL', optTime)} - ${end.toLocaleDateString('pl-PL', optDateShort)} ${end.toLocaleTimeString('pl-PL', optTime)}`;
}
}
};

121
static/js/data.js Normal file
View File

@@ -0,0 +1,121 @@
window.processPhaseData = function processPhaseData(id, data) {
const lineData = [];
const outagePoints = [];
const recoveryPoints = [];
const outageLineData = [];
let wasOutage = false;
data.forEach(p => {
const v = p.voltage;
const t = new Date(p.time);
if (v < 180 && v !== null) {
const pOut = { x: t, y: 195, realV: v };
outagePoints.push(pOut);
outageLineData.push(pOut);
lineData.push({ x: t, y: null });
wasOutage = true;
} else {
if (wasOutage) {
const pRec = { x: t, y: v, realV: v };
recoveryPoints.push(pRec);
outageLineData.push(pRec);
wasOutage = false;
} else {
outageLineData.push({ x: t, y: null });
}
lineData.push({ x: t, y: v });
}
});
return {
lineDataset: {
label: window.phasesConfig[id].label,
data: lineData,
borderColor: window.phasesConfig[id].color,
backgroundColor: window.phasesConfig[id].color + '15',
tension: 0.1,
borderWidth: 2,
spanGaps: false,
pointRadius: 0
},
outageLine: {
label: 'Awaria ' + window.phasesConfig[id].label,
data: outageLineData,
borderColor: '#ff0000',
borderDash: [5, 5],
borderWidth: 1,
pointRadius: 0,
fill: false,
spanGaps: false,
showLine: true
},
outageDataset: outagePoints.length ? {
label: 'Zanik ' + window.phasesConfig[id].label,
data: outagePoints,
type: 'scatter',
pointRadius: 4,
pointBackgroundColor: '#ff0000',
pointBorderColor: '#fff',
pointBorderWidth: 1,
z: 99
} : null,
recoveryDataset: recoveryPoints.length ? {
label: 'Powrot ' + window.phasesConfig[id].label,
data: recoveryPoints,
type: 'scatter',
pointRadius: 4,
pointBackgroundColor: '#3fb950',
pointBorderColor: '#fff',
pointBorderWidth: 1,
z: 99
} : null
};
};
window.reloadDataForRange = async function reloadDataForRange(min, max, rangeName = null) {
const now = Date.now();
if (max && max > now) max = now;
if (min && min > now) min = now - 3600000;
const urlParams = rangeName
? `range=${rangeName}`
: `start=${new Date(min).toISOString()}&end=${new Date(max).toISOString()}`;
const newDatasets = [];
for (const id of Object.keys(window.phasesConfig)) {
try {
const raw = await fetch(`/api/timeseries/${id}?${urlParams}`).then(r => r.json());
const proc = window.processPhaseData(id, raw);
newDatasets.push(proc.lineDataset);
if (proc.outageLine) newDatasets.push(proc.outageLine);
if (proc.outageDataset) newDatasets.push(proc.outageDataset);
if (proc.recoveryDataset) newDatasets.push(proc.recoveryDataset);
} catch (e) {
console.error("Błąd pobierania fazy " + id, e);
}
}
try {
const events = await fetch(`/api/events?${urlParams}`).then(r => r.json());
window.renderEventLog(events, rangeName || 'precise');
} catch (e) {
console.error("Błąd pobierania zdarzeń", e);
}
window.voltageChart.data.datasets = newDatasets;
if (window.disableChartAnimationOnce) {
window.voltageChart.update('none');
window.disableChartAnimationOnce = false;
} else {
if (rangeName) window.voltageChart.update();
else window.voltageChart.update('none');
}
const finalMin = rangeName ? window.voltageChart.scales.x.min : (min || window.voltageChart.scales.x.min);
const finalMax = rangeName ? window.voltageChart.scales.x.max : (max || window.voltageChart.scales.x.max);
window.updateRangeLabel(finalMin, finalMax);
};

74
static/js/events.js Normal file
View File

@@ -0,0 +1,74 @@
window.renderEventLog = function renderEventLog(events, range) {
const container = document.getElementById('eventLogContainer');
if (!container) return;
container.innerHTML = '';
if (events && events.error === "range_too_large") {
container.innerHTML = `
<div style="text-align: center; padding: 30px; color: #ffbb33; border: 1px dashed #ffbb33; border-radius: 12px; margin: 10px;">
<div style="font-size: 2rem; margin-bottom: 10px;">⚠️</div>
<p style="margin: 0;">${events.message}</p>
</div>`;
return;
}
if (events && events.length > 0) {
events.forEach(ev => {
const start = new Date(ev.start);
const end = new Date(ev.end);
const dur = ev.duration;
const phase = window.phasesConfig[ev.phase];
const typeConfig = {
'zanik': { label: 'Brak zasilania', color: '#ff4444' },
'niskie': { label: 'Zbyt niskie', color: '#ffbb33' },
'wysokie': { label: 'Zbyt wysokie', color: '#aa66cc' }
};
const cfg = typeConfig[ev.type] || { label: 'Problem', color: '#888' };
const item = document.createElement('div');
item.className = 'event-item';
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.justifyContent = 'space-between';
const timeRangeStr = `${start.toLocaleTimeString('pl-PL', {hour:'2-digit', minute:'2-digit'})} - ${end.toLocaleTimeString('pl-PL', {hour:'2-digit', minute:'2-digit'})}`;
item.innerHTML = `
<div class="event-content">
<div class="event-badge" style="background-color: ${cfg.color}; box-shadow: 0 0 0 2px ${phase.color};"></div>
<div class="event-time">${start.toLocaleDateString('pl-PL', {day:'2-digit', month:'2-digit'})}, ${timeRangeStr}</div>
<div class="event-desc">
<span style="color: ${phase.color}; font-weight: bold;">${phase.label}</span>:
<strong>${cfg.label}</strong> przez ${dur} min.
</div>
</div>
<button onclick="showEventOnChart('${ev.start}')" class="zoom-btn-mobile">
Pokaż na wykresie 🔍
</button>
`;
container.appendChild(item);
});
} else {
container.innerHTML = '<div class="no-events">Brak zarejestrowanych zdarzeń w tym zakresie.</div>';
}
};
window.showEventOnChart = function showEventOnChart(startTimeStr) {
const eventTime = new Date(startTimeStr).getTime();
const padding = 3 * 60 * 60 * 1000;
const min = eventTime - padding;
const max = eventTime + padding;
if (window.voltageChart) {
document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active'));
window.voltageChart.options.scales.x.min = min;
window.voltageChart.options.scales.x.max = max;
window.currentTimeRange = 'precise';
window.reloadDataForRange(min, max);
}
};

25
static/js/index.js Normal file
View File

@@ -0,0 +1,25 @@
window.initMonitor = function initMonitor(phases, defaultRange) {
window.phasesConfig = phases;
window.currentTimeRange = defaultRange;
setupGauges();
window.setupMainChart();
window.bindMonitorSocketHandlers();
window.changeTimeRange(window.currentTimeRange);
};
window.changeTimeRange = function changeTimeRange(range) {
window.currentTimeRange = range;
if (window.voltageChart) {
window.voltageChart.options.scales.x.min = undefined;
window.voltageChart.options.scales.x.max = undefined;
}
document.querySelectorAll('.time-btn').forEach(btn => btn.classList.remove('active'));
const activeBtn = document.querySelector(`[data-range="${range}"]`);
if (activeBtn) activeBtn.classList.add('active');
window.reloadDataForRange(null, null, range);
};

View File

@@ -1,11 +1,7 @@
/**
* Otwiera modal z wyborem własnego zakresu dat
*/
function openCustomRangePicker() {
const modal = document.getElementById('customRangeModal');
if (!modal) return;
// Ustaw domyślne wartości - od 24h temu do teraz
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
@@ -15,17 +11,11 @@ function openCustomRangePicker() {
modal.style.display = 'flex';
}
/**
* Zamyka modal z wyborem zakresu
*/
function closeCustomRangePicker() {
const modal = document.getElementById('customRangeModal');
if (modal) modal.style.display = 'none';
}
/**
* Stosuje wybrany własny zakres
*/
async function applyCustomRange() {
const startInput = document.getElementById('customStartDate');
const endInput = document.getElementById('customEndDate');
@@ -74,9 +64,6 @@ async function applyCustomRange() {
}
}
/**
* Formatuje datę do formatu datetime-local input
*/
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -87,7 +74,6 @@ function formatDateTimeLocal(date) {
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
// Zamknij modal po kliknięciu poza nim
document.addEventListener('click', function(event) {
const modal = document.getElementById('customRangeModal');
if (modal && event.target === modal) {

View File

@@ -1,4 +1,6 @@
const socket = io();
window.socket = io();
const socket = window.socket;
let currentTimeRange = '6h';
let phasesConfig = {};
const gauges = {};

5
static/js/pageInit.js Normal file
View File

@@ -0,0 +1,5 @@
function initPage(phases, defaultRange) {
if (typeof initMonitor === 'function') {
initMonitor(phases, defaultRange);
}
}

39
static/js/socket.js Normal file
View File

@@ -0,0 +1,39 @@
window.bindMonitorSocketHandlers = function bindMonitorSocketHandlers() {
const socket = window.socket;
if (!socket) return;
socket.on('voltage_update', (data) => {
Object.keys(window.phasesConfig).forEach(id => {
const val = data['phase' + id];
const textEl = document.getElementById('value' + id);
if (val !== undefined && val !== null && textEl) {
const num = parseFloat(val);
textEl.textContent = num.toFixed(1) + 'V';
textEl.style.color = num < 200 ? '#dc3545' : (num > 260 ? '#ffc107' : '#fff');
updateGaugeUI(id, num);
}
});
if (data.timestamp) {
const el = document.getElementById('lastUpdate');
if (el) {
el.textContent = 'Ostatni odczyt: ' + new Date(data.timestamp).toLocaleTimeString('pl-PL', { hour12: false });
}
const ts = new Date(data.timestamp).getTime();
if (!isNaN(ts)) window.lastLiveTs = ts;
const ms = window.rangeToMs(window.currentTimeRange);
if (ms && window.lastLiveTs) {
window.updateRangeLabel(window.lastLiveTs - ms, window.lastLiveTs);
}
}
});
socket.on('refresh_timeseries', async () => {
if (window.currentTimeRange === 'precise') return;
window.disableChartAnimationOnce = true;
await window.reloadDataForRange(null, null, window.currentTimeRange);
});
};

View File

@@ -0,0 +1 @@
window.socket = window.socket || io();

16
static/js/state.js Normal file
View File

@@ -0,0 +1,16 @@
window.currentTimeRange = window.currentTimeRange || '6h';
window.phasesConfig = window.phasesConfig || {};
window.gauges = window.gauges || {};
window.voltageChart = window.voltageChart || null;
window.THRESHOLDS = window.THRESHOLDS || { min: 207, max: 253 };
window.disableChartAnimationOnce = window.disableChartAnimationOnce || false;
window.lastLiveTs = null;
window.rangeToMs = function(r){
const m = String(r||'').trim().match(/^(\d+)\s*([mhdw])$/i);
if(!m) return null;
const n = parseInt(m[1],10);
const u = m[2].toLowerCase();
const mult = { m:60e3, h:3600e3, d:86400e3, w:7*86400e3 }[u];
return n * mult;
};

35
static/js/topbarStatus.js Normal file
View File

@@ -0,0 +1,35 @@
(function () {
const dot = () => document.getElementById('connDot');
const txt = () => document.getElementById('connText');
const lastTop = () => document.getElementById('lastUpdateTop');
function setStatus(state) {
const d = dot(), t = txt();
if (!d || !t) return;
d.classList.remove('online', 'offline', 'connecting');
if (state === 'online') { d.classList.add('online'); t.textContent = 'Online'; }
else if (state === 'offline') { d.classList.add('offline'); t.textContent = 'Offline'; }
else { d.classList.add('connecting'); t.textContent = 'Łączenie…'; }
}
function bind() {
if (!window.socket) { setStatus('offline'); return; }
setStatus('connecting');
window.socket.on('connect', () => setStatus('online'));
window.socket.on('disconnect', () => setStatus('offline'));
if (window.socket.io) window.socket.io.on('reconnect_attempt', () => setStatus('connecting'));
window.socket.on('voltage_update', (data) => {
if (!data || !data.timestamp) return;
const el = lastTop();
if (!el) return;
const t = new Date(data.timestamp);
const s = t.toLocaleTimeString('pl-PL', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
el.textContent = `Aktualizacja: ${s}`;
});
}
document.addEventListener('DOMContentLoaded', bind);
})();