diff --git a/Dockerfile b/Dockerfile index d1907f1..943ff46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,5 +5,11 @@ ENV PYTHONUNBUFFERED=1 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . +# Expose dynamic port EXPOSE 8798 -CMD ["python", "app.py"] + +# Healthcheck +HEALTHCHECK CMD python -c "import requests; r=requests.get('http://localhost:${FLASK_PORT:-8798}',timeout=5);exit(0 if r.ok else 1)" || exit 1 + +ENV FLASK_PORT=${FLASK_PORT:-8798} +CMD ["sh", "-c", "gunicorn -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 --threads 16 --bind 0.0.0.0:$FLASK_PORT --timeout 60 app:app"] \ No newline at end of file diff --git a/app.py b/app.py index ea48535..5007831 100644 --- a/app.py +++ b/app.py @@ -17,7 +17,7 @@ app = Flask(__name__) app.config['SECRET_KEY'] = config.FLASK_CONFIG['secret_key'] app.config['SEND_FILE_MAX_AGE_DEFAULT'] = config.FLASK_CONFIG.get('static_cache_timeout', 60) # Bez Eventlet (deprecated) – prosty tryb wątkowy. -socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading", logger=False) +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading', logger=False) def get_file_hash(filename): full_path = os.path.join(app.static_folder, filename) @@ -291,11 +291,32 @@ def api_outages(phase_id): finally: client.close() +clients = 0 +task = None + +@socketio.on('connect') +def handle_connect(): + global clients, task + clients += 1 + if task is None: + task = socketio.start_background_task(background_voltage_update) + +@socketio.on('disconnect') +def handle_disconnect(): + global clients + clients = max(0, clients - 1) + def background_voltage_update(): + global clients last_refresh = 0 refresh_every_s = config.CHART_CONFIG.get("refresh_interval_seconds", 15) + interval_s = config.CHART_CONFIG['update_interval'] / 1000.0 while True: + if clients == 0: + socketio.sleep(1) + continue + try: voltages = {'timestamp': None} @@ -305,29 +326,21 @@ def background_voltage_update(): if res['timestamp']: voltages['timestamp'] = res['timestamp'] - # aktualizacja gauge socketio.emit('voltage_update', voltages) - # cykliczny refresh wykresu + eventów now = time.time() if now - last_refresh >= refresh_every_s: socketio.emit('refresh_timeseries', {'ts': int(now)}) last_refresh = now except Exception as e: - print(f"Worker Error: {e}") + app.logger.error(f"Worker Error: {e}") - #time.sleep(config.CHART_CONFIG['update_interval'] / 1000.0) + socketio.sleep(interval_s) if __name__ == '__main__': print("\n" + "="*50) print(f"Voltage Monitor API / Port: {config.FLASK_CONFIG['port']}") print("="*50 + "\n") - socketio.start_background_task(background_voltage_update) - socketio.run( - app, - host="0.0.0.0", - port=config.FLASK_CONFIG["port"], - allow_unsafe_werkzeug=True - ) \ No newline at end of file + socketio.run(app, host='0.0.0.0', port=config.FLASK_CONFIG['port'], allow_unsafe_werkzeug=True) \ No newline at end of file diff --git a/config.py b/config.py index 7934ac9..c60e4af 100644 --- a/config.py +++ b/config.py @@ -82,5 +82,5 @@ FOOTER = { "url": "https://www.linuxiarz.pl" }, "project": "Voltage Monitor", - "version": "1..0" + "version": "2.0" } diff --git a/docker-compose.yml b/docker-compose.yml index fc22802..d9f507e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: - PYTHONUNBUFFERED=1 - PYTHONDONTWRITEBYTECODE=1 - SECRET_KEY=alamakota + - FLASK_PORT=${APP_PORT:-8798} + command: gunicorn -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker -w 1 --bind 0.0.0.0:${FLASK_PORT:-8798} --timeout 60 app:app restart: unless-stopped networks: - monitoring diff --git a/requirements.txt b/requirements.txt index 6fee4fe..1eb5555 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,7 @@ influxdb #influxdb-client simple-websocket python-socketio -flask-socketio \ No newline at end of file +flask-socketio +gunicorn +gevent +gevent-websocket \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index d6059d9..745a59d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,345 +1,461 @@ -:root { - --bg-dark: #0d1117; - --card-bg: #161b22; - --border-color: #30363d; - --text-main: #c9d1d9; - --blue-accent: #58a6ff; +:root{ + --bg-dark:#0d1117; + --card-bg:#161b22; + --border-color:#30363d; + --text-main:#c9d1d9; + --blue-accent:#58a6ff; + --muted:#8b949e; + --shadow:0 8px 28px rgba(0,0,0,.35); } -body { - background-color: var(--bg-dark); - color: var(--text-main); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - overflow-x: hidden; - padding: 10px; +body{ + background-color:var(--bg-dark); + color:var(--text-main); + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; + font-size:14px; + margin:0; + overflow-x:hidden; + padding:10px; } -/* Wskaźniki napięcia */ -.gauge-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; - margin-bottom: 15px; +.border-dotted{border-style:dotted!important} + +.vm-shell{max-width:1120px;margin:0 auto} + +.vm-card{ + background-color:var(--card-bg); + border:1px solid rgba(255,255,255,.06); + border-radius:14px; } -.gauge-card { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 12px 5px; - text-align: center; +.vm-topbar{ + position:sticky; + top:0; + z-index:50; + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:10px 12px; + margin-bottom:12px; + background:rgba(13,17,23,.82); + backdrop-filter:blur(10px); + border:1px solid rgba(255,255,255,.06); + border-radius:14px; } -.gauge-canvas-container { - max-width: 80px; - margin: 0 auto; +.vm-topbar-left, +.vm-topbar-right{ + display:flex; + align-items:center; + gap:10px; } -.gauge-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--blue-accent); - margin-top: 2px; +.vm-topbar-text{ + font-size:.82rem; + color:var(--muted); } -.voltage-value { - font-size: 1.1rem; - font-weight: 800; - color: #ffffff; +.vm-dot{ + width:10px; + height:10px; + border-radius:999px; + background:#6c757d; } -/* Selektor czasu - Desktop (jedna linia) */ -.time-selector-wrapper { - display: flex; - flex-wrap: nowrap; - justify-content: center; - gap: 6px; - margin-bottom: 20px; +.vm-dot.online{background:#198754} +.vm-dot.offline{background:#dc3545} +.vm-dot.connecting{background:#ffc107} + +.gauge-grid{ + display:grid; + grid-template-columns:repeat(3,1fr); + gap:10px; + margin-bottom:15px; } -.time-btn { - flex: 0 1 auto; - font-size: 0.75rem !important; - height: 32px; - min-width: 45px; - padding: 2px 10px !important; - border: 1px solid var(--border-color) !important; - color: var(--blue-accent) !important; - background: transparent; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; +.gauge-card{ + background-color:var(--card-bg); + border:1px solid var(--border-color); + border-radius:12px; + padding:12px 5px; + text-align:center; + box-shadow:var(--shadow); } -.time-btn.active { - background-color: #1f6feb !important; - color: white !important; - border-color: #1f6feb !important; +.gauge-card:hover{ + transform:translateY(-2px); + transition:transform .15s ease; } -/* Wykres główny */ -.main-chart-card { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 15px; - height: 50vh; - min-height: 320px; - margin-bottom: 15px; +.gauge-canvas-container{ + max-width:80px; + margin:0 auto; } -/* Karta logów i nagłówek */ -.events-card { - background-color: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 15px; +.gauge-label{ + font-size:.75rem; + font-weight:600; + color:var(--blue-accent); + margin-top:2px; } -.events-header { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 5px; - margin-bottom: 12px; +.voltage-value{ + font-size:1.1rem; + font-weight:800; + color:#fff; } -#eventRangeLabel { - font-size: 0.75rem; - color: #8b949e; -} -/* Kontener zdarzenia */ -.event-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 0; - border-bottom: 1px solid var(--border-color); +.time-selector-wrapper{ + display:flex; + flex-wrap:nowrap; + justify-content:center; + gap:6px; + margin-bottom:20px; } -.event-content { - display: flex; - align-items: center; - gap: 12px; - flex: 1; +.time-btn{ + flex:0 1 auto; + font-size:.75rem!important; + height:32px; + min-width:45px; + padding:2px 10px!important; + border:1px solid var(--border-color)!important; + color:var(--blue-accent)!important; + background:transparent; + border-radius:6px; + cursor:pointer; + transition:all .2s; + display:flex; + align-items:center; + justify-content:center; } -.event-badge { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; +.time-btn.active{ + background-color:#1f6feb!important; + color:#fff!important; + border-color:#1f6feb!important; } -.event-time { - font-family: monospace; - font-size: 0.85rem; - color: #8b949e; - white-space: nowrap; +.main-chart-card{ + background-color:var(--card-bg); + border:1px solid var(--border-color); + border-radius:12px; + padding:15px; + height:50vh; + min-height:320px; + margin-bottom:15px; + box-shadow:var(--shadow); } -.event-desc { - font-size: 0.85rem; - color: var(--text-main); +.chart-range-badge{ + position:absolute; + top:10px; + right:10px; + background:rgba(22,27,34,.9); + border:1px solid var(--border-color); + border-radius:6px; + padding:4px 10px; + font-size:.7rem; + color:var(--muted); + z-index:10; + pointer-events:none; + backdrop-filter:blur(4px); + font-family:monospace; } -/* Przycisk lupy - Desktop */ -.zoom-btn-mobile { - padding: 5px 10px; - background: rgba(88, 166, 255, 0.1); - border: 1px solid var(--blue-accent); - color: var(--blue-accent); - border-radius: 6px; - font-size: 0.75rem; - cursor: pointer; - white-space: nowrap; - margin-left: 15px; +.events-card{ + background-color:var(--card-bg); + border:1px solid var(--border-color); + border-radius:12px; + padding:15px; + box-shadow:var(--shadow); } -@media (max-width: 576px) { - .event-item { - flex-direction: column; - align-items: flex-start; - } - - .event-content { - flex-wrap: wrap; - gap: 6px; - width: 100%; - } - - .event-desc { - width: 100%; - margin-bottom: 6px; - } - - .time-selector-wrapper { - display: grid; - grid-template-columns: repeat(5, 1fr) !important; - gap: 10px !important; - padding: 0 10px; - } - - .time-btn { - width: 80% !important; - height: 30px !important; - font-size: 0.75rem !important; - } - .zoom-btn-mobile { - width: 100%; - margin-left: 0; - margin-top: 8px; - text-align: center; - padding: 8px 10px; - font-size: 0.8rem; - } - - .zoom-btn-mobile:active { - background: rgba(88, 166, 255, 0.2); - transform: scale(0.98); - } +.events-header{ + display:flex; + justify-content:space-between; + align-items:center; + flex-wrap:wrap; + gap:5px; + margin-bottom:12px; } -.border-dotted { - border-style: dotted !important; +#eventRangeLabel{font-size:.75rem;color:var(--muted)} + +.event-item{ + display:flex; + align-items:center; + justify-content:space-between; + padding:10px 0; + border-bottom:1px solid var(--border-color); } -/* Modal własnego zakresu */ -#customRangeModal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); - z-index: 1000; - justify-content: center; - align-items: center; +.event-content{ + display:flex; + align-items:center; + gap:12px; + flex:1; } -.modal-content { - background: var(--card-bg); - padding: 25px; - border-radius: 12px; - max-width: 420px; - width: 90%; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); - border: 1px solid var(--border-color); +.event-badge{ + width:10px; + height:10px; + border-radius:50%; + flex-shrink:0; } -.modal-content h3 { - margin-top: 0; - color: var(--text-main); - margin-bottom: 20px; - font-size: 1.25rem; - font-weight: 600; +.event-time{ + font-family:monospace; + font-size:.85rem; + color:var(--muted); + white-space:nowrap; } -.modal-form-group { - margin-bottom: 15px; +.event-desc{ + font-size:.85rem; + color:var(--text-main); } -.modal-form-group:last-of-type { - margin-bottom: 25px; +.event-type-icon{ + width:22px; + text-align:center; + opacity:.95; } -.modal-label { - display: block; - color: #8b949e; - margin-bottom: 8px; - font-size: 0.875rem; - font-weight: 500; +.zoom-btn-mobile{ + padding:5px 10px; + background:rgba(88,166,255,.1); + border:1px solid var(--blue-accent); + color:var(--blue-accent); + border-radius:6px; + font-size:.75rem; + cursor:pointer; + white-space:nowrap; + margin-left:15px; } -.modal-input { - width: 100%; - padding: 10px; - background: var(--bg-dark); - border: 1px solid var(--border-color); - border-radius: 6px; - color: var(--text-main); - font-size: 14px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; - box-sizing: border-box; +#customRangeModal{ + display:none; + position:fixed; + inset:0; + background:rgba(0,0,0,.8); + z-index:1000; + justify-content:center; + align-items:center; } -.modal-input:focus { - outline: none; - border-color: var(--blue-accent); +.modal-content{ + background:var(--card-bg); + padding:25px; + border-radius:12px; + max-width:420px; + width:90%; + box-shadow:0 8px 32px rgba(0,0,0,.6); + border:1px solid var(--border-color); } -.modal-buttons { - display: flex; - gap: 10px; - justify-content: flex-end; +.modal-content h3{ + margin:0 0 20px; + color:var(--text-main); + font-size:1.25rem; + font-weight:600; } -.modal-btn { - padding: 8px 20px; - border-radius: 6px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.2s; - border: none; +.modal-form-group{margin-bottom:15px} +.modal-form-group:last-of-type{margin-bottom:25px} + +.modal-label{ + display:block; + color:var(--muted); + margin-bottom:8px; + font-size:.875rem; + font-weight:500; } -.modal-btn-cancel { - background: transparent; - border: 1px solid var(--border-color); - color: var(--text-main); +.modal-input{ + width:100%; + padding:10px; + background:var(--bg-dark); + border:1px solid var(--border-color); + border-radius:6px; + color:var(--text-main); + font-size:14px; + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; + box-sizing:border-box; } -.modal-btn-cancel:hover { - background: rgba(48, 54, 61, 0.5); +.modal-input:focus{outline:none;border-color:var(--blue-accent)} +.modal-input::-webkit-calendar-picker-indicator{filter:invert(1);cursor:pointer} + +.modal-buttons{ + display:flex; + gap:10px; + justify-content:flex-end; } -.modal-btn-apply { - background: #1f6feb; - border: 1px solid #1f6feb; - color: #ffffff; - font-weight: 600; +.modal-btn{ + padding:8px 20px; + border-radius:6px; + cursor:pointer; + font-size:14px; + font-weight:500; + transition:all .2s; + border:none; } -.modal-btn-apply:hover { - background: #1a5acc; +.modal-btn-cancel{ + background:transparent; + border:1px solid var(--border-color); + color:var(--text-main); } -.modal-input::-webkit-calendar-picker-indicator { - filter: invert(1); - cursor: pointer; +.modal-btn-cancel:hover{background:rgba(48,54,61,.5)} + +.modal-btn-apply{ + background:#1f6feb; + border:1px solid #1f6feb; + color:#fff; + font-weight:600; } -/* Badge zakresu wykresu */ -.chart-range-badge { - position: absolute; - top: 10px; - right: 10px; - background: rgba(22, 27, 34, 0.9); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 4px 10px; - font-size: 0.7rem; - color: #8b949e; - z-index: 10; - pointer-events: none; - backdrop-filter: blur(4px); - font-family: monospace; +.modal-btn-apply:hover{background:#1a5acc} + +#apiHelperModal{ + display:none; + position:fixed; + inset:0; + background:rgba(0,0,0,.82); + z-index:1100; + justify-content:center; + align-items:center; } -@media (max-width: 576px) { - .chart-range-badge { - font-size: 0.65rem; - padding: 3px 8px; - top: 8px; - right: 8px; - } +.vm-modal-wide{max-width:860px} + +.vm-modal-head{ + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + margin-bottom:12px; } +.vm-icon-btn{ + background:transparent; + border:1px solid rgba(255,255,255,.12); + color:var(--text-main); + border-radius:8px; + padding:6px 10px; + cursor:pointer; +} + +.vm-modal-grid{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:12px; + margin-bottom:12px; +} + +.vm-modal-block{margin-top:10px} + +.vm-inline{ + display:flex; + gap:10px; + align-items:center; +} + +.vm-mono{ + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; +} + +.vm-modal-actions{ + display:flex; + gap:10px; + justify-content:flex-end; + margin-top:12px; +} + +.vm-pre{ + background:#0b0f14; + border:1px solid rgba(255,255,255,.08); + border-radius:10px; + padding:12px; + color:var(--text-main); + max-height:260px; + overflow:auto; + font-size:.78rem; +} + +@media (max-width:768px){ + .vm-modal-grid{grid-template-columns:1fr} + .vm-modal-wide{max-width:420px} +} + +@media (max-width:576px){ + .event-item{flex-direction:column;align-items:flex-start} + .event-content{flex-wrap:wrap;gap:6px;width:100%} + .event-desc{width:100%;margin-bottom:6px} + + .time-selector-wrapper{ + display:grid; + grid-template-columns:repeat(5,1fr)!important; + gap:10px!important; + padding:0 10px; + } + + .time-btn{ + width:80%!important; + height:30px!important; + font-size:.75rem!important; + } + + .zoom-btn-mobile{ + width:100%; + margin-left:0; + margin-top:8px; + text-align:center; + padding:8px 10px; + font-size:.8rem; + } + + .zoom-btn-mobile:active{ + background:rgba(88,166,255,.2); + transform:scale(.98); + } + + .chart-range-badge{ + font-size:.65rem; + padding:3px 8px; + top:8px; + right:8px; + } +} + +.vm-range-quick{ + display:flex; + gap:6px; + flex-wrap:wrap; + margin-bottom:6px; +} + +.vm-range-btn{ + padding:4px 10px; + font-size:.75rem; + border-radius:6px; + border:1px solid var(--border-color); + background:transparent; + color:var(--blue-accent); + cursor:pointer; + transition:.15s; +} + +.vm-range-btn:hover{ + background:rgba(88,166,255,.1); +} + +.vm-range-btn.active{ + background:#1f6feb; + border-color:#1f6feb; + color:#fff; +} diff --git a/static/js/apiHelper.js b/static/js/apiHelper.js new file mode 100644 index 0000000..e9b1528 --- /dev/null +++ b/static/js/apiHelper.js @@ -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; + }); +}); \ No newline at end of file diff --git a/static/js/chart.js b/static/js/chart.js new file mode 100644 index 0000000..b3b873e --- /dev/null +++ b/static/js/chart.js @@ -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)}`; + } + } +}; \ No newline at end of file diff --git a/static/js/data.js b/static/js/data.js new file mode 100644 index 0000000..0652de0 --- /dev/null +++ b/static/js/data.js @@ -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); +}; \ No newline at end of file diff --git a/static/js/events.js b/static/js/events.js new file mode 100644 index 0000000..6a2ff9c --- /dev/null +++ b/static/js/events.js @@ -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 = ` +
${events.message}
+