poprawki i eventy
This commit is contained in:
204
app.py
204
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/<int:phase_id>')
|
||||
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/<int:phase_id>')
|
||||
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'])
|
||||
|
||||
19
config.py
19
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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
Flask
|
||||
influxdb
|
||||
influxdb-client
|
||||
python-socketio
|
||||
flask-socketio
|
||||
gunicorn
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
.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; }
|
||||
|
||||
@@ -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 = `
|
||||
<div class="event-badge" style="background-color: ${phase.color}"></div>
|
||||
<div class="event-time">${start.toLocaleString('pl-PL', dateOpt)} - ${end.toLocaleTimeString('pl-PL', {hour:'2-digit', minute:'2-digit'})}</div>
|
||||
<div class="event-desc" style="flex-grow: 1;">Faza <strong>${phase.label}</strong>: brak przez ${dur} min.</div>
|
||||
<button class="btn btn-sm btn-outline-info show-event-btn" title="Pokaż 3h wokół zdarzenia"
|
||||
style="padding: 2px 8px; font-size: 11px; height: 24px; color: #58a6ff; border-color: #30363d; min-width: 50px;">
|
||||
Pokaż
|
||||
</button>
|
||||
`;
|
||||
|
||||
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 = '<div class="no-events">Brak zarejestrowanych zaników.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>VoltMonitor</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_v('css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid py-3 px-2">
|
||||
@@ -19,6 +19,7 @@
|
||||
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,24 +26,39 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Time Selector -->
|
||||
<div class="time-btn-container mb-3">
|
||||
{% for key, r in time_ranges.items() %}
|
||||
<button class="btn btn-sm btn-outline-primary time-btn {% if key == default_range %}active{% endif %}"
|
||||
data-range="{{ key }}" onclick="changeTimeRange('{{ key }}')">
|
||||
{{ key }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
<!-- Time & Reset Selector -->
|
||||
<div class="d-flex justify-content-center align-items-center gap-2 mb-3 flex-wrap">
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
{% for key, r in time_ranges.items() %}
|
||||
<button class="btn btn-sm btn-outline-primary time-btn {% if key == default_range %}active{% endif %}"
|
||||
style="min-width: 45px; height: 31px; display: flex; align-items: center; justify-content: center;"
|
||||
data-range="{{ key }}" onclick="changeTimeRange('{{ key }}')">
|
||||
{{ key }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Chart -->
|
||||
<div class="main-chart-card mb-3">
|
||||
<canvas id="voltageChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="events-card mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Dziennik zdarzeń (zaniki faz)</h6>
|
||||
<span id="eventRangeLabel" class="text-muted" style="font-size: 0.75rem;">Ładowanie...</span>
|
||||
</div>
|
||||
<div id="eventLogContainer">
|
||||
<div class="no-events">Ładowanie zdarzeń...</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/monitor.js') }}"></script>
|
||||
<script src="{{ static_v('js/monitor.js') }}"></script>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMonitor({{ phases|tojson }}, '{{ default_range }}');
|
||||
|
||||
Reference in New Issue
Block a user