diff --git a/app.py b/app.py index df717c2..30e5f74 100644 --- a/app.py +++ b/app.py @@ -1,21 +1,35 @@ -import os -import warnings - -os.environ['EVENTLET_NO_GREENDNS'] = 'yes' import eventlet eventlet.monkey_patch(all=True) +import os +import warnings +import logging from flask import Flask, render_template, jsonify, request from flask_socketio import SocketIO from influxdb import InfluxDBClient +from datetime import datetime, timedelta import config +import hashlib warnings.filterwarnings("ignore", category=DeprecationWarning, module="eventlet") +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger('werkzeug') +logger.setLevel(logging.INFO) app = Flask(__name__) app.config['SECRET_KEY'] = config.FLASK_CONFIG['secret_key'] -socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet') +app.config['SEND_FILE_MAX_AGE_DEFAULT'] = config.FLASK_CONFIG.get('static_cache_timeout', 60) +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet', logger=False) +def get_file_hash(filename): + full_path = os.path.join(app.static_folder, filename) + try: + with open(full_path, "rb") as f: + return hashlib.md5(f.read()).hexdigest()[:8] + except FileNotFoundError: + return "1" + +# Klient InfluxDB v1 def get_influx_client(): client = InfluxDBClient( host=config.INFLUXDB_CONFIG['host'], @@ -26,6 +40,8 @@ def get_influx_client(): client.switch_user(config.INFLUXDB_CONFIG['username'], config.INFLUXDB_CONFIG['password']) return client +# --- LOGIKA --- + def get_current_voltage(phase_id): client = get_influx_client() entity_id = config.PHASES[phase_id]['entity_id'] @@ -41,34 +57,182 @@ def get_current_voltage(phase_id): client.close() return {'voltage': 0, 'timestamp': None} +# --- ENDPOINTY --- + +@app.context_processor +def inject_static_version(): + def static_v(filename): + full_path = os.path.join(app.static_folder, filename) + try: + with open(full_path, "rb") as f: + v = hashlib.md5(f.read()).hexdigest()[:8] + except Exception: + v = "1" + + return f"{request.script_root}/static/{filename}?v={v}" + return dict(static_v=static_v) + +@app.after_request +def add_header(response): + if request.path.startswith('/static/'): + response.cache_control.max_age = 31536000 + response.cache_control.public = True + else: + response.cache_control.no_cache = True + response.cache_control.no_store = True + response.cache_control.must_revalidate = True + return response + +@app.route('/favicon.ico') +def favicon(): + return '', 204 + @app.route('/') def index(): return render_template('index.html', phases=config.PHASES, time_ranges=config.TIME_RANGES, default_range=config.DEFAULT_TIME_RANGE, footer=config.FOOTER) @app.route('/api/timeseries/') -def api_timeseries(phase_id): - if phase_id not in config.PHASES: return jsonify({'error': 'Invalid phase'}), 400 +def get_timeseries(phase_id): + if phase_id not in config.PHASES: + return jsonify({'error': 'Invalid phase'}), 400 + client = get_influx_client() - t_range = request.args.get('range', config.DEFAULT_TIME_RANGE) - cfg = config.TIME_RANGES.get(t_range, config.TIME_RANGES['24h']) - query = config.PHASES[phase_id]['query'].replace('$timeFilter', cfg['filter']).replace('$__interval', cfg['interval']) + range_param = request.args.get('range', config.DEFAULT_TIME_RANGE) + start_param = request.args.get('start') + end_param = request.args.get('end') + entity_id = config.PHASES[phase_id]['entity_id'] + + if start_param and end_param: + time_filter = f"time >= '{start_param}' AND time <= '{end_param}'" + interval = "1m" + else: + clean_range = range_param.replace(" ", "") + time_filter = f"time > now() - {clean_range}" + + mapping = { + "1h": "10s", + "6h": "30s", + "24h": "2m", + "7d": "10m", + "30d": "30m", + "60d": "1h", + "120d": "2h", + "180d": "4h", + "365d": "6h" + } + + interval = mapping.get(range_param, "1m") + + query = f''' + SELECT mean("value") AS voltage + FROM "{config.MEASUREMENT}" + WHERE "entity_id" = '{entity_id}' + AND {time_filter} + GROUP BY time({interval}) fill(none) + ''' + try: result = client.query(query) - data = [] - for p in result.get_points(): - val = p.get('voltage') or p.get('min') or p.get('mean') or p.get('value') - if val is not None: - data.append({'time': p['time'], 'voltage': round(float(val), 2)}) - else: - data.append({'time': p['time'], 'voltage': 0}) + data = [{"time": p['time'], "voltage": round(p['voltage'], 2)} + for p in result.get_points() + if p.get('voltage') is not None] return jsonify(data) except Exception as e: - print(f"History Error: {e}") + app.logger.error(f"Timeseries Error: {e} | Query: {query}") return jsonify([]) finally: client.close() +from datetime import datetime, timedelta, timezone + +@app.route('/api/events') +def get_events(): + client = get_influx_client() + range_p = request.args.get('range', '24h') + start_p = request.args.get('start') + end_p = request.args.get('end') + + now_utc = datetime.now(timezone.utc) + + if start_p and end_p: + dt_view_start = datetime.fromisoformat(start_p.replace('Z', '+00:00')) + dt_view_end = datetime.fromisoformat(end_p.replace('Z', '+00:00')) + time_filter = f"time >= '{start_p}' - 24h AND time <= '{end_p}'" + else: + clean_range = range_p.replace(" ", "") + num = int(''.join(filter(str.isdigit, clean_range))) + unit = clean_range[-1] + delta = timedelta(hours=num) if unit == 'h' else timedelta(days=num) + + dt_view_start = now_utc - delta + dt_view_end = now_utc + time_filter = f"time > now() - {clean_range} - 24h" + + all_events = [] + try: + for p_id, p_cfg in config.PHASES.items(): + query = f''' + SELECT mean("value") AS volts + FROM "{config.MEASUREMENT}" + WHERE "entity_id" = '{p_cfg["entity_id"]}' + AND {time_filter} + GROUP BY time(1m) fill(none) + ''' + result = client.query(query) + points = list(result.get_points()) + + i = 0 + while i < len(points): + val = points[i].get('volts') + if val is not None and float(val) < 207: + start_str = points[i]['time'] + dt_s = datetime.fromisoformat(start_str.replace('Z', '+00:00')) + + j = i + while j + 1 < len(points): + v_next = points[j+1].get('volts') + if v_next is not None and float(v_next) < 207: + j += 1 + else: + break + + end_str = points[j]['time'] + dt_e = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + + duration = (dt_e - dt_s).total_seconds() / 120 + + if duration >= 0.5: + if dt_e >= dt_view_start and dt_s <= dt_view_end: + all_events.append({ + "start": start_str, + "end": end_str, + "phase": p_id, + "duration": round(duration, 1) + }) + i = j + i += 1 + + return jsonify(sorted(all_events, key=lambda x: x['start'], reverse=True)) + except Exception as e: + app.logger.error(f"Event Logic Error: {e}") + return jsonify([]) + finally: + client.close() + + +@app.route('/api/outages/') +def api_outages(phase_id): + client = get_influx_client() + t_range = request.args.get('range', '24h') + entity_id = config.PHASES[phase_id]['entity_id'] + query = f'SELECT "value" FROM "{config.MEASUREMENT}" WHERE "entity_id" = \'{entity_id}\' AND "value" < 10 AND time > now() - {t_range}' + try: + result = client.query(query) + return jsonify(list(result.get_points())) + finally: + client.close() + def background_voltage_update(): while True: try: @@ -83,5 +247,9 @@ def background_voltage_update(): eventlet.sleep(config.CHART_CONFIG['update_interval'] / 1000) if __name__ == '__main__': + print("\n" + "="*50) + print(f"Voltage Monitor API / Port: {config.FLASK_CONFIG['port']}") + print("="*50 + "\n") + eventlet.spawn(background_voltage_update) socketio.run(app, host='0.0.0.0', port=config.FLASK_CONFIG['port']) diff --git a/config.py b/config.py index 64a76ae..f2f279c 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,7 @@ import os # Konfiguracja InfluxDB - Twoje specyficzne ustawienia INFLUXDB_CONFIG = { 'host': os.getenv('INFLUXDB_HOST', 'stats.mngmnt.r.local'), - 'port': int(os.getenv('INFLUXDB_PORT', 8086)), + 'port': int(os.getenv('INFLUXDB_PORT', 8087)), 'database': os.getenv('INFLUXDB_DATABASE', 'ha'), 'username': os.getenv('INFLUXDB_USER', ''), 'password': os.getenv('INFLUXDB_PASSWORD', ''), @@ -34,12 +34,15 @@ PHASES = { # Zakresy czasu TIME_RANGES = { - '1h': {'filter': '1h', 'interval': '1m', 'label': '1h'}, - '6h': {'filter': '6h', 'interval': '5m', 'label': '6h'}, - '12h': {'filter': '12h', 'interval': '10m', 'label': '12h'}, - '24h': {'filter': '24h', 'interval': '1h', 'label': '24h'}, - '7d': {'filter': '7d', 'interval': '6h', 'label': '7d'}, - '30d': {'filter': '30d', 'interval': '1d', 'label': '30d'} + '1h': {'filter': '1h', 'interval': '10s', 'label': '1h'}, + '6h': {'filter': '6h', 'interval': '1m', 'label': '6h'}, + '24h': {'filter': '24h', 'interval': '5m', 'label': '24h'}, + '7d': {'filter': '7d', 'interval': '30m', 'label': '7d'}, + '30d': {'filter': '30d', 'interval': '2h', 'label': '30d'}, + '60d': {'filter': '60d', 'interval': '4h', 'label': '60d'}, + '120d': {'filter': '120d', 'interval': '8h', 'label': '120d'}, + '180d': {'filter': '180d', 'interval': '12h', 'label': '6m'}, + '365d': {'filter': '365d', 'interval': '1d', 'label': '1r'} } DEFAULT_TIME_RANGE = '6h' @@ -60,7 +63,7 @@ FLASK_CONFIG = { 'host': '0.0.0.0', 'port': 8798, 'debug': False, - 'secret_key': os.getenv('SECRET_KEY', 'voltage-monitor-secret-key') + 'secret_key': os.getenv('SECRET_KEY', 'voltage-monitor-secret-key'), } FOOTER = {'author': 'www.linuxiarz.pl', 'year': '2026'} diff --git a/docker-compose.yml b/docker-compose.yml index 8356b62..5f8fc10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,10 +9,11 @@ services: - ./static:/app/static:ro environment: - INFLUXDB_HOST=stats.mngmnt.r.local - - INFLUXDB_PORT=8086 + - INFLUXDB_PORT=8087 - INFLUXDB_DATABASE=ha - PYTHONUNBUFFERED=1 - PYTHONDONTWRITEBYTECODE=1 + - SECRET_KEY=alamakota restart: unless-stopped networks: - monitoring diff --git a/requirements.txt b/requirements.txt index 0d14321..794dabf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Flask influxdb +influxdb-client python-socketio flask-socketio gunicorn diff --git a/static/css/style.css b/static/css/style.css index 6473aa9..5c20d6f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -73,4 +73,11 @@ footer { padding: 20px 0; opacity: 0.7; } @media (max-width: 576px) { .voltage-value { font-size: 0.95rem; } .main-chart-card { height: 50vh; padding: 10px; } -} \ No newline at end of file +} +.events-card { background-color: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; padding: 15px; } +.event-item { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid #30363d; } +.event-item:last-child { border-bottom: none; } +.event-badge { width: 12px; height: 12px; border-radius: 50%; margin-right: 12px; flex-shrink: 0; } +.event-time { font-family: monospace; font-size: 0.85rem; color: #8b949e; margin-right: 15px; } +.event-desc { font-size: 0.9rem; color: #c9d1d9; } +.no-events { color: #8b949e; font-style: italic; text-align: center; padding: 10px; } diff --git a/static/js/monitor.js b/static/js/monitor.js index 693d4be..993b3e4 100644 --- a/static/js/monitor.js +++ b/static/js/monitor.js @@ -3,200 +3,332 @@ let currentTimeRange = '6h'; let phasesConfig = {}; const gauges = {}; let voltageChart = null; - const THRESHOLDS = { min: 207, max: 253 }; +/** + * Inicjalizacja monitora + */ function initMonitor(phases, defaultRange) { phasesConfig = phases; currentTimeRange = defaultRange; + setupGauges(); + setupMainChart(); - const gaugeConfig = { - type: 'doughnut', - data: { - datasets: [{ - data: [0, 100], - backgroundColor: ['#198754', '#1a1d20'], - borderWidth: 0, - circumference: 180, - rotation: 270 - }] - }, - options: { - responsive: true, - maintainAspectRatio: true, - cutout: '75%', - plugins: { legend: { display: false }, tooltip: { enabled: false } } - } - }; - - Object.keys(phasesConfig).forEach(id => { - const canvas = document.getElementById('gauge' + id); - if (canvas) { - gauges[id] = new Chart(canvas, JSON.parse(JSON.stringify(gaugeConfig))); - updateGaugeUI(id, 230); - } - }); - - const ctxChart = document.getElementById('voltageChart'); - if (ctxChart) { - voltageChart = new Chart(ctxChart, { - type: 'line', - data: { datasets: [] }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - x: { - type: 'time', - time: { displayFormats: { hour: 'HH:mm', minute: 'HH:mm' }, tooltipFormat: 'HH:mm' }, - grid: { color: '#2d3139' } - }, - y: { - beginAtZero: false, - suggestedMin: 210, - suggestedMax: 255, - grid: { color: '#2d3139' }, - ticks: { stepSize: 5, color: '#c9d1d9' } - } - }, - plugins: { - legend: { - labels: { - color: '#c9d1d9', - filter: function(item) { - return !item.text.includes('Zanik') && !item.text.includes('Powrot'); - } - } - }, - tooltip: { - callbacks: { - label: function(context) { - if (context.dataset.label.includes('Zanik')) { - return 'ZANIK: ' + context.raw.realV.toFixed(1) + 'V'; - } - if (context.dataset.label.includes('Powrot')) { - return 'POWROT: ' + context.raw.realV.toFixed(1) + 'V'; - } - let label = context.dataset.label || ''; - if (context.parsed.y !== null) { - label += ': ' + context.parsed.y.toFixed(1) + 'V'; - } - return label; - } - } - } - } - } - }); - } - - socket.on('voltage_update', function(data) { + socket.on('voltage_update', (data) => { Object.keys(phasesConfig).forEach(id => { const val = data['phase' + id]; - const textElement = document.getElementById('value' + id); - if (val !== undefined && val !== null) { - const numVal = parseFloat(val); - if (textElement) { - textElement.textContent = numVal.toFixed(1) + 'V'; - textElement.style.color = numVal < 200 ? '#dc3545' : '#fff'; - } - updateGaugeUI(id, numVal); + 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' : '#fff'; + updateGaugeUI(id, num); } }); if (data.timestamp) { - const date = new Date(data.timestamp); - document.getElementById('lastUpdate').textContent = 'Odczyt: ' + date.toLocaleTimeString('pl-PL', {hour12: false}); + document.getElementById('lastUpdate').textContent = 'Odczyt: ' + + new Date(data.timestamp).toLocaleTimeString('pl-PL', {hour12: false}); } }); window.changeTimeRange(currentTimeRange); } -function updateGaugeUI(id, val) { - if (!gauges[id]) return; - const percentage = Math.max(0, Math.min(100, ((val - 190) / 80) * 100)); - let color = '#198754'; - if (val < THRESHOLDS.min || val > THRESHOLDS.max) color = '#dc3545'; - else if (val < 212 || val > 248) color = '#ffc107'; - gauges[id].data.datasets[0].data = [percentage, 100 - percentage]; - gauges[id].data.datasets[0].backgroundColor = [color, '#1a1d20']; - gauges[id].update('none'); +/** + * Konfiguracja wykresu głównego (Zoom + Pan) + */ +function setupMainChart() { + const ctx = document.getElementById('voltageChart'); + if (!ctx) return; + + 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: { hour: 'HH:mm', minute: 'HH:mm' }, + tooltipFormat: 'yyyy-MM-dd HH:mm:ss' + }, + grid: { color: '#2d3139' }, + ticks: { color: '#8b949e' } + }, + y: { + beginAtZero: false, + suggestedMin: 200, + suggestedMax: 250, + grid: { color: '#2d3139' }, + ticks: { stepSize: 5, color: '#c9d1d9' } + } + }, + plugins: { + zoom: { + limits: { + x: { + rangeMax: 30 * 24 * 60 * 60 * 1000 + } + }, + zoom: { + wheel: { enabled: true }, + pinch: { enabled: true }, + drag: { + enabled: true, + backgroundColor: 'rgba(54, 162, 235, 0.3)' + }, + mode: 'x', + onZoomComplete: async ({chart}) => { + document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active')); + currentTimeRange = 'precise'; + await reloadDataForRange(chart.scales.x.min, chart.scales.x.max); + } + }, + pan: { + enabled: true, + mode: 'x', + onPanComplete: async ({chart}) => { + document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active')); + currentTimeRange = 'precise'; + await 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.changeTimeRange = async function(range) { +/** + * Pobieranie danych i gotowych eventów z backendu + */ +async function reloadDataForRange(min, max, rangeName = null) { + let urlParams = rangeName + ? `range=${rangeName}` + : `start=${new Date(min).toISOString()}&end=${new Date(max).toISOString()}`; + + const newDatasets = []; + + for (let id of Object.keys(phasesConfig)) { + try { + const raw = await fetch(`/api/timeseries/${id}?${urlParams}`).then(r => r.json()); + const proc = 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()); + renderEventLog(events, rangeName || 'precise'); + } catch (e) { + console.error("Błąd pobierania zdarzeń", e); + } + + voltageChart.data.datasets = newDatasets; + if (rangeName) { + voltageChart.update(); + } else { + voltageChart.update('none'); + } + + const finalMin = rangeName ? voltageChart.scales.x.min : (min || voltageChart.scales.x.min); + const finalMax = rangeName ? voltageChart.scales.x.max : (max || voltageChart.scales.x.max); + updateRangeLabel(finalMin, finalMax); +} + +/** + * Dynamiczna aktualizacja napisu zakresu czasu nad logami + */ +function updateRangeLabel(min, max) { + const label = document.getElementById('eventRangeLabel'); + if (!label) return; + 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' }; + + if (start.toDateString() === end.toDateString()) { + label.textContent = `Zakres: ${start.toLocaleDateString('pl-PL', optDate)}, ${start.toLocaleTimeString('pl-PL', optTime)} - ${end.toLocaleTimeString('pl-PL', optTime)}`; + } else { + label.textContent = `Zakres: ${start.toLocaleDateString('pl-PL', optDate)} ${start.toLocaleTimeString('pl-PL', optTime)} - ${end.toLocaleDateString('pl-PL', optDate)} ${end.toLocaleTimeString('pl-PL', optTime)}`; + } +} + +window.changeTimeRange = function(range) { currentTimeRange = range; + + if (voltageChart) { + voltageChart.options.scales.x.min = undefined; + 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'); - if (!voltageChart) return; - const phaseIds = Object.keys(phasesConfig); - voltageChart.data.datasets = []; - - for (let id of phaseIds) { - try { - const response = await fetch(`/api/timeseries/${id}?range=${range}`); - const data = await response.json(); - const lineData = []; - const outagePoints = []; - const recoveryPoints = []; - let wasOutage = false; - - data.forEach(p => { - const v = p.voltage; - const t = new Date(p.time); - const minY = voltageChart.scales.y.min || 190; - - if (v < 200 && v !== null) { - outagePoints.push({ x: t, y: minY + 0.5, realV: v }); - lineData.push({ x: t, y: null }); - wasOutage = true; - } else { - if (wasOutage) { - recoveryPoints.push({ x: t, y: v, realV: v }); - wasOutage = false; - } - lineData.push({ x: t, y: v }); - } - }); - - voltageChart.data.datasets.push({ - label: phasesConfig[id].label, - data: lineData, - borderColor: phasesConfig[id].color, - backgroundColor: phasesConfig[id].color + '15', - tension: 0, - borderWidth: 2, - spanGaps: false, - pointRadius: 0 - }); - - if (outagePoints.length > 0) { - voltageChart.data.datasets.push({ - label: 'Zanik ' + phasesConfig[id].label, - data: outagePoints, - type: 'scatter', - pointRadius: 5, - pointBackgroundColor: '#ff0000ff', - pointBorderColor: phasesConfig[id].color, - pointBorderWidth: 2, - z: 999 - }); - } - - if (recoveryPoints.length > 0) { - voltageChart.data.datasets.push({ - label: 'Powrot ' + phasesConfig[id].label, - data: recoveryPoints, - type: 'scatter', - pointRadius: 5, - pointBackgroundColor: '#3fb99aff', - pointBorderColor: '#ffffff', - pointBorderWidth: 2, - z: 999 - }); - } - } catch (e) { console.error(e); } - } - voltageChart.update(); + reloadDataForRange(null, null, range); }; + +/** + * Przetwarzanie danych z obsługą przerywanej linii awarii + */ +function processPhaseData(id, data) { + const lineData = [], outagePoints = [], recoveryPoints = [], outageLineData = []; + let wasOutage = false; + + data.forEach(p => { + const v = p.voltage, 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: phasesConfig[id].label, data: lineData, borderColor: phasesConfig[id].color, + backgroundColor: phasesConfig[id].color + '15', tension: 0.1, borderWidth: 2, spanGaps: false, pointRadius: 0 + }, + outageLine: { + label: 'Awaria ' + 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 ' + phasesConfig[id].label, data: outagePoints, type: 'scatter', + pointRadius: 4, pointBackgroundColor: '#ff0000', pointBorderColor: '#fff', pointBorderWidth: 1, z: 99 + } : null, + recoveryDataset: recoveryPoints.length ? { + label: 'Powrot ' + phasesConfig[id].label, data: recoveryPoints, type: 'scatter', + pointRadius: 4, pointBackgroundColor: '#3fb950', pointBorderColor: '#fff', pointBorderWidth: 1, z: 99 + } : null + }; +} + +function renderEventLog(events, range) { + const container = document.getElementById('eventLogContainer'); + if (!container) return; + container.innerHTML = ''; + + 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 = phasesConfig[ev.phase]; + const item = document.createElement('div'); + item.className = 'event-item'; + item.style.display = 'flex'; + item.style.alignItems = 'center'; + item.style.gap = '10px'; + + const dateOpt = { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }; + + item.innerHTML = ` +
+
${start.toLocaleString('pl-PL', dateOpt)} - ${end.toLocaleTimeString('pl-PL', {hour:'2-digit', minute:'2-digit'})}
+
Faza ${phase.label}: brak przez ${dur} min.
+ + `; + + const btn = item.querySelector('.show-event-btn'); + btn.addEventListener('click', async () => { + const eventCenter = start.getTime() + (end.getTime() - start.getTime()) / 2; + const windowMs = 3 * 60 * 60 * 1000; + const viewStart = eventCenter - (windowMs / 2); + const viewEnd = eventCenter + (windowMs / 2); + + document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active')); + currentTimeRange = 'precise'; + + if (voltageChart) { + voltageChart.options.scales.x.min = undefined; + voltageChart.options.scales.x.max = undefined; + } + await reloadDataForRange(viewStart, viewEnd, null); + + document.getElementById('voltageChart').scrollIntoView({ behavior: 'smooth' }); + }); + + container.appendChild(item); + }); + } else { + container.innerHTML = '
Brak zarejestrowanych zaników.
'; + } +} + + +function setupGauges() { + const config = { + type: 'doughnut', + data: { datasets: [{ data: [0, 100], backgroundColor: ['#198754', '#1a1d20'], borderWidth: 0, circumference: 180, rotation: 270 }] }, + options: { responsive: true, maintainAspectRatio: true, cutout: '75%', plugins: { legend: { display: false }, tooltip: { enabled: false } } } + }; + Object.keys(phasesConfig).forEach(id => { + const canvas = document.getElementById('gauge' + id); + if (canvas) gauges[id] = new Chart(canvas, JSON.parse(JSON.stringify(config))); + }); +} + +function updateGaugeUI(id, val) { + if (!gauges[id]) return; + const pct = Math.max(0, Math.min(100, ((val - 190) / 80) * 100)); + let color = (val < THRESHOLDS.min || val > THRESHOLDS.max) ? '#dc3545' : ((val < 212 || val > 248) ? '#ffc107' : '#198754'); + gauges[id].data.datasets[0].data = [pct, 100 - pct]; + gauges[id].data.datasets[0].backgroundColor = [color, '#1a1d20']; + gauges[id].update('none'); +} diff --git a/templates/base.html b/templates/base.html index 69a6a7a..47a1287 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,7 +5,7 @@ VoltMonitor - +
@@ -19,6 +19,7 @@ + {% block scripts %}{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index dc43f64..d1e0f60 100644 --- a/templates/index.html +++ b/templates/index.html @@ -26,24 +26,39 @@
- -
- {% for key, r in time_ranges.items() %} - - {% endfor %} + +
+
+ {% for key, r in time_ranges.items() %} + + {% endfor %} +
+ +
+
+
Dziennik zdarzeń (zaniki faz)
+ Ładowanie... +
+
+
Ładowanie zdarzeń...
+
+
{% endblock %} {% block scripts %} - + + +