From 016b2f53211e274c45f0070f756647bd7234e70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 2 Mar 2026 09:41:50 +0100 Subject: [PATCH] refactor v2 --- Dockerfile | 8 +- app.py | 37 ++- config.py | 2 +- docker-compose.yml | 2 + requirements.txt | 5 +- static/css/style.css | 654 ++++++++++++++++++++++---------------- static/js/apiHelper.js | 94 ++++++ static/js/chart.js | 134 ++++++++ static/js/data.js | 121 +++++++ static/js/events.js | 74 +++++ static/js/index.js | 25 ++ static/js/modal.js | 14 - static/js/monitor.js | 4 +- static/js/pageInit.js | 5 + static/js/socket.js | 39 +++ static/js/socketClient.js | 1 + static/js/state.js | 16 + static/js/topbarStatus.js | 35 ++ templates/base.html | 78 +++-- templates/index.html | 259 ++++++++++----- 20 files changed, 1210 insertions(+), 397 deletions(-) create mode 100644 static/js/apiHelper.js create mode 100644 static/js/chart.js create mode 100644 static/js/data.js create mode 100644 static/js/events.js create mode 100644 static/js/index.js create mode 100644 static/js/pageInit.js create mode 100644 static/js/socket.js create mode 100644 static/js/socketClient.js create mode 100644 static/js/state.js create mode 100644 static/js/topbarStatus.js 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}

+
`; + 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 = ` +
+
+
${start.toLocaleDateString('pl-PL', {day:'2-digit', month:'2-digit'})}, ${timeRangeStr}
+
+ ${phase.label}: + ${cfg.label} przez ${dur} min. +
+
+ + `; + + container.appendChild(item); + }); + } else { + container.innerHTML = '
Brak zarejestrowanych zdarzeń w tym zakresie.
'; + } +}; + +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); + } +}; \ No newline at end of file diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..4e63c19 --- /dev/null +++ b/static/js/index.js @@ -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); +}; \ No newline at end of file diff --git a/static/js/modal.js b/static/js/modal.js index 65a0f47..cf186fc 100644 --- a/static/js/modal.js +++ b/static/js/modal.js @@ -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) { diff --git a/static/js/monitor.js b/static/js/monitor.js index 3b7b4da..9f9b1fd 100644 --- a/static/js/monitor.js +++ b/static/js/monitor.js @@ -1,4 +1,6 @@ -const socket = io(); +window.socket = io(); +const socket = window.socket; + let currentTimeRange = '6h'; let phasesConfig = {}; const gauges = {}; diff --git a/static/js/pageInit.js b/static/js/pageInit.js new file mode 100644 index 0000000..2382b21 --- /dev/null +++ b/static/js/pageInit.js @@ -0,0 +1,5 @@ +function initPage(phases, defaultRange) { + if (typeof initMonitor === 'function') { + initMonitor(phases, defaultRange); + } +} \ No newline at end of file diff --git a/static/js/socket.js b/static/js/socket.js new file mode 100644 index 0000000..eb31f2c --- /dev/null +++ b/static/js/socket.js @@ -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); + }); +}; diff --git a/static/js/socketClient.js b/static/js/socketClient.js new file mode 100644 index 0000000..acb6fa5 --- /dev/null +++ b/static/js/socketClient.js @@ -0,0 +1 @@ +window.socket = window.socket || io(); \ No newline at end of file diff --git a/static/js/state.js b/static/js/state.js new file mode 100644 index 0000000..da7cb0d --- /dev/null +++ b/static/js/state.js @@ -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; +}; \ No newline at end of file diff --git a/static/js/topbarStatus.js b/static/js/topbarStatus.js new file mode 100644 index 0000000..103f98d --- /dev/null +++ b/static/js/topbarStatus.js @@ -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); +})(); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index f89ebbc..2960368 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,37 +1,61 @@ - - - VoltMonitor - - + + + VoltMonitor + + + + + -
- {% block content %}{% endblock %} -
- - © {{ footer.year }} {{ footer.project }} | - Autor: - - {{ footer.owner.name }} - - {% if footer.version %} - | v{{ footer.version }} - {% endif %} - -
+
+ + +
+
+ + Łączenie… +
+ +
+ Aktualizacja: — + + + + +
- - - - - - - + {% block content %}{% endblock %} - {% block scripts %}{% endblock %} +
+ + © {{ footer.year }} {{ footer.project }} | + Autor: + {{ footer.owner.name }} + {% if footer.version %}| v{{ footer.version }}{% endif %} + +
+
+ + + + + + + + + + + + + {% block scripts %}{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 9058c2e..9430db5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,101 +1,218 @@ {% extends 'base.html' %} {% block content %} -
-

Sieć Trójfazowa / Rokietnica, Gajowa

-
-
-
Dane chwilowe:
- Ładowanie... + + +
+
+
+
+ +

VoltMonitor

+
+
Sieć trójfazowa • Rokietnica, Gajowa
+
+ +
+ Ładowanie… + + PN-EN 50160: 207–253V + +
+
- -
+ +
+
+
Dane chwilowe
+ live +
+ +
{% for id, phase in phases.items() %}
-
- -
-
{{ phase.label }}
-
---
+
+
{{ phase.label }}
+
---
{% endfor %} +
- -
- - Norma PN-EN 50160: 230V ±10% (207V - 253V) - -
-
+ +
+
+
Wykres
+
Zakres • zoom/pan • przeciągnięcie = precyzyjny wybór
+
-
-
Wybierz zakres wykresu:
-
- -
-
- {% for key, r in time_ranges.items() %} - - {% endfor %} - -
+
+ {% for key, r in time_ranges.items() %} + + {% endfor %} + + +
- +
- - -
- - + +
+ +
+
-
-
Dziennik zdarzeń
- Ładowanie... -
-
-
Ładowanie zdarzeń...
-
+
+
Dziennik zdarzeń
+ Ładowanie… +
+ +
+
Ładowanie zdarzeń…
+
+ + +
+ +
+ {% endblock %} {% block scripts %} - + + + + + + + + + + -{% endblock %} +{% endblock %} \ No newline at end of file