changes
This commit is contained in:
18
docker-compose.sqlite.yml
Normal file
18
docker-compose.sqlite.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
FLASK_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 5000
|
||||||
|
DATABASE_URL: sqlite:////app/instance/mikromon.db
|
||||||
|
DEV_INPROCESS_POLLER: "1"
|
||||||
|
SECRET_KEY: change-me
|
||||||
|
CRED_ENC_KEY: REPLACE_WITH_FERNET_KEY
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- sqlite_data:/app/instance
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sqlite_data:
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""remove ssh fields and add rest scheme
|
||||||
|
|
||||||
|
Revision ID: 0002_remove_ssh_add_rest_scheme
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2026-03-06 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0002_remove_ssh_add_rest_scheme"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table("devices", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("rest_scheme", sa.String(length=8), nullable=False, server_default="https"))
|
||||||
|
batch_op.drop_column("ssh_enabled")
|
||||||
|
batch_op.drop_column("ssh_port")
|
||||||
|
op.execute("UPDATE devices SET rest_scheme = 'https' WHERE rest_scheme IS NULL OR rest_scheme = ''")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("devices", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("ssh_port", sa.Integer(), nullable=False, server_default="22"))
|
||||||
|
batch_op.add_column(sa.Column("ssh_enabled", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch_op.drop_column("rest_scheme")
|
||||||
@@ -1,78 +1,93 @@
|
|||||||
import json
|
import json
|
||||||
from flask import Blueprint, jsonify, request, abort, render_template, url_for
|
import datetime as dt
|
||||||
|
from flask import Blueprint, jsonify, render_template, request
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from .. import db
|
from .. import db
|
||||||
from ..models import Device, Dashboard, Widget, Share, PublicLink
|
from ..models import Device, Dashboard, Widget, Share, PublicLink, MetricLastValue
|
||||||
from ..services.acl import has_permission
|
from ..services.acl import has_permission
|
||||||
from ..services.presets import load_presets
|
from ..services.presets import load_presets
|
||||||
from ..connectors.registry import get_connector
|
|
||||||
from ..security.crypto import decrypt_json
|
from ..security.crypto import decrypt_json
|
||||||
|
from ..connectors.registry import get_connector
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
|
||||||
from .. import csrf
|
|
||||||
csrf.exempt(bp)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_ok(data=None):
|
def _json_ok(data=None):
|
||||||
return jsonify({"ok": True, "data": data})
|
return jsonify({"ok": True, "data": data})
|
||||||
|
|
||||||
|
|
||||||
def _json_err(msg, code=400):
|
def _json_err(msg, code=400):
|
||||||
return jsonify({"ok": False, "error": msg}), code
|
return jsonify({"ok": False, "error": msg}), code
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/docs")
|
@bp.get("/docs")
|
||||||
def docs():
|
def docs():
|
||||||
routes = [
|
routes = [
|
||||||
{"method":"GET","path":"/api/v1/me","desc":"Current user"},
|
{"method": "GET", "path": "/api/v1/me", "desc": "Current user"},
|
||||||
{"method":"GET","path":"/api/v1/devices","desc":"List devices you can view"},
|
{"method": "GET", "path": "/api/v1/devices", "desc": "List devices you can view"},
|
||||||
{"method":"POST","path":"/api/v1/devices/<id>/test","desc":"Test REST connection"},
|
{"method": "POST", "path": "/api/v1/devices/<id>/test", "desc": "Test REST connection"},
|
||||||
{"method":"GET","path":"/api/v1/dashboards","desc":"List dashboards you can view"},
|
{"method": "POST", "path": "/api/v1/devices", "desc": "Create device (JSON)"},
|
||||||
{"method":"GET","path":"/api/v1/dashboards/<id>","desc":"Dashboard detail + widgets"},
|
{"method": "GET", "path": "/api/v1/dashboards", "desc": "List dashboards you can view"},
|
||||||
{"method":"GET","path":"/api/v1/presets/widgets","desc":"Widget presets"},
|
{"method": "GET", "path": "/api/v1/dashboards/<id>", "desc": "Dashboard detail + widgets"},
|
||||||
{"method":"GET","path":"/api/v1/public/<token>","desc":"Public dashboard (read-only)"},
|
{"method": "POST", "path": "/api/v1/dashboards", "desc": "Create dashboard (JSON)"},
|
||||||
{"method":"POST","path":"/api/v1/devices","desc":"Create device (JSON)"},
|
{"method": "POST", "path": "/api/v1/dashboards/<id>/widgets", "desc": "Add widget (JSON)"},
|
||||||
{"method":"POST","path":"/api/v1/dashboards","desc":"Create dashboard (JSON)"},
|
{"method": "GET", "path": "/api/v1/presets/widgets", "desc": "Widget presets"},
|
||||||
{"method":"POST","path":"/api/v1/dashboards/<id>/widgets","desc":"Add widget (JSON)"},
|
{"method": "GET", "path": "/api/v1/widgets/<id>/last", "desc": "Last value (cache)"},
|
||||||
{"method":"GET","path":"/api/v1/widgets/<id>/last","desc":"Last value (cache)"},
|
{"method": "GET", "path": "/api/v1/public/<token>", "desc": "Public dashboard (read-only)"},
|
||||||
]
|
]
|
||||||
return render_template("api/docs.html", routes=routes)
|
return render_template("api/docs.html", routes=routes)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/v1/me")
|
@bp.get("/v1/me")
|
||||||
@login_required
|
@login_required
|
||||||
def me():
|
def me():
|
||||||
return _json_ok({"id": current_user.id, "email": current_user.email, "role": current_user.role.name})
|
return _json_ok({"id": current_user.id, "email": current_user.email, "role": current_user.role.name})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/v1/presets/widgets")
|
@bp.get("/v1/presets/widgets")
|
||||||
@login_required
|
@login_required
|
||||||
def widget_presets():
|
def widget_presets():
|
||||||
return _json_ok(load_presets())
|
return _json_ok(load_presets())
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/v1/devices")
|
@bp.get("/v1/devices")
|
||||||
@login_required
|
@login_required
|
||||||
def devices():
|
def devices():
|
||||||
# owner + shares
|
|
||||||
owned = Device.query.filter_by(owner_id=current_user.id).all()
|
owned = Device.query.filter_by(owner_id=current_user.id).all()
|
||||||
shared_ids = [s.target_id for s in Share.query.filter_by(target_type="device", user_id=current_user.id).all()]
|
shared_ids = [s.target_id for s in Share.query.filter_by(target_type="device", user_id=current_user.id).all()]
|
||||||
shared = Device.query.filter(Device.id.in_(shared_ids)).all() if shared_ids else []
|
shared = Device.query.filter(Device.id.in_(shared_ids)).all() if shared_ids else []
|
||||||
uniq = {d.id: d for d in owned + shared}
|
uniq = {d.id: d for d in owned + shared}
|
||||||
data = [{"id": d.id, "name": d.name, "host": d.host, "last_seen_at": d.last_seen_at, "last_error": d.last_error} for d in uniq.values()]
|
data = [
|
||||||
|
{
|
||||||
|
"id": d.id,
|
||||||
|
"name": d.name,
|
||||||
|
"host": d.host,
|
||||||
|
"rest_scheme": d.rest_scheme,
|
||||||
|
"rest_port": d.rest_port,
|
||||||
|
"rest_base_path": d.rest_base_path,
|
||||||
|
"last_seen_at": d.last_seen_at,
|
||||||
|
"last_error": d.last_error,
|
||||||
|
}
|
||||||
|
for d in uniq.values()
|
||||||
|
]
|
||||||
return _json_ok(data)
|
return _json_ok(data)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/v1/devices/<int:device_id>/test")
|
@bp.post("/v1/devices/<int:device_id>/test")
|
||||||
@login_required
|
@login_required
|
||||||
def device_test(device_id):
|
def device_test(device_id):
|
||||||
from ..connectors.registry import get_connector
|
|
||||||
from ..security.crypto import decrypt_json
|
|
||||||
|
|
||||||
d = db.session.get(Device, device_id)
|
d = db.session.get(Device, device_id)
|
||||||
if not d:
|
if not d:
|
||||||
return _json_err("Not found", 404)
|
return _json_err("Not found", 404)
|
||||||
if not has_permission(current_user, "device", d.id, "manage", d.owner_id):
|
if not has_permission(current_user, "device", d.id, "manage", d.owner_id):
|
||||||
return _json_err("Forbidden", 403)
|
return _json_err("Forbidden", 403)
|
||||||
creds = decrypt_json(d.enc_credentials) if d.enc_credentials else None
|
creds = decrypt_json(d.enc_credentials) if d.enc_credentials else None
|
||||||
|
if not creds:
|
||||||
|
return _json_err("Missing credentials", 400)
|
||||||
res = get_connector("rest").test(d, creds)
|
res = get_connector("rest").test(d, creds)
|
||||||
return _json_ok({"ok": True}) if res.ok else _json_err(res.error, 400)
|
return _json_ok({"ok": True}) if res.ok else _json_err(res.error, 400)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/v1/dashboards")
|
@bp.get("/v1/dashboards")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboards():
|
def dashboards():
|
||||||
@@ -80,9 +95,23 @@ def dashboards():
|
|||||||
shared_ids = [s.target_id for s in Share.query.filter_by(target_type="dashboard", user_id=current_user.id).all()]
|
shared_ids = [s.target_id for s in Share.query.filter_by(target_type="dashboard", user_id=current_user.id).all()]
|
||||||
shared = Dashboard.query.filter(Dashboard.id.in_(shared_ids)).all() if shared_ids else []
|
shared = Dashboard.query.filter(Dashboard.id.in_(shared_ids)).all() if shared_ids else []
|
||||||
uniq = {d.id: d for d in owned + shared}
|
uniq = {d.id: d for d in owned + shared}
|
||||||
data = [{"id": d.id, "name": d.name, "description": d.description} for d in uniq.values()]
|
data = []
|
||||||
|
for d in uniq.values():
|
||||||
|
widgets = Widget.query.filter_by(dashboard_id=d.id).all()
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"id": d.id,
|
||||||
|
"name": d.name,
|
||||||
|
"description": d.description,
|
||||||
|
"widgets_count": len(widgets),
|
||||||
|
"devices_count": len({w.device_id for w in widgets}),
|
||||||
|
"tables_count": len([w for w in widgets if w.widget_type == "table"]),
|
||||||
|
"charts_count": len([w for w in widgets if w.widget_type != "table"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
return _json_ok(data)
|
return _json_ok(data)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/v1/dashboards/<int:dashboard_id>")
|
@bp.get("/v1/dashboards/<int:dashboard_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard_detail(dashboard_id):
|
def dashboard_detail(dashboard_id):
|
||||||
@@ -96,11 +125,18 @@ def dashboard_detail(dashboard_id):
|
|||||||
"id": d.id,
|
"id": d.id,
|
||||||
"name": d.name,
|
"name": d.name,
|
||||||
"description": d.description,
|
"description": d.description,
|
||||||
"widgets": [{
|
"widgets": [
|
||||||
"id": w.id, "title": w.title, "type": w.widget_type, "device_id": w.device_id,
|
{
|
||||||
"refresh_seconds": w.refresh_seconds, "preset_key": w.preset_key,
|
"id": w.id,
|
||||||
"query": json.loads(w.query_json)
|
"title": w.title,
|
||||||
} for w in widgets]
|
"type": w.widget_type,
|
||||||
|
"device_id": w.device_id,
|
||||||
|
"refresh_seconds": w.refresh_seconds,
|
||||||
|
"preset_key": w.preset_key,
|
||||||
|
"query": json.loads(w.query_json),
|
||||||
|
}
|
||||||
|
for w in widgets
|
||||||
|
],
|
||||||
}
|
}
|
||||||
return _json_ok(data)
|
return _json_ok(data)
|
||||||
|
|
||||||
@@ -109,34 +145,44 @@ def dashboard_detail(dashboard_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def device_create():
|
def device_create():
|
||||||
from ..security.crypto import encrypt_json
|
from ..security.crypto import encrypt_json
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
required = ["name","host","username","password"]
|
required = ["name", "host", "username", "password"]
|
||||||
if any(k not in payload for k in required):
|
if any(k not in payload for k in required):
|
||||||
return _json_err("Missing fields: name, host, username, password", 400)
|
return _json_err("Missing fields: name, host, username, password", 400)
|
||||||
|
|
||||||
|
rest_scheme = str(payload.get("rest_scheme", "https")).strip().lower() or "https"
|
||||||
|
if rest_scheme not in ("http", "https"):
|
||||||
|
return _json_err("rest_scheme must be http or https", 400)
|
||||||
|
|
||||||
|
default_port = 80 if rest_scheme == "http" else 443
|
||||||
d = Device(
|
d = Device(
|
||||||
owner_id=current_user.id,
|
owner_id=current_user.id,
|
||||||
name=str(payload["name"]).strip(),
|
name=str(payload["name"]).strip(),
|
||||||
host=str(payload["host"]).strip(),
|
host=str(payload["host"]).strip(),
|
||||||
rest_port=int(payload.get("rest_port", 443)),
|
rest_scheme=rest_scheme,
|
||||||
rest_base_path=str(payload.get("rest_base_path","/rest")).strip(),
|
rest_port=int(payload.get("rest_port", default_port)),
|
||||||
allow_insecure_tls=bool(payload.get("allow_insecure_tls", False)),
|
rest_base_path=str(payload.get("rest_base_path", "/rest")).strip(),
|
||||||
ssh_enabled=bool(payload.get("ssh_enabled", False)),
|
allow_insecure_tls=bool(payload.get("allow_insecure_tls", False)) if rest_scheme == "https" else False,
|
||||||
ssh_port=int(payload.get("ssh_port", 22)),
|
|
||||||
)
|
)
|
||||||
d.enc_credentials = encrypt_json({"username": payload["username"], "password": payload["password"]})
|
d.enc_credentials = encrypt_json({"username": payload["username"], "password": payload["password"]})
|
||||||
db.session.add(d); db.session.commit()
|
db.session.add(d)
|
||||||
|
db.session.commit()
|
||||||
return _json_ok({"id": d.id})
|
return _json_ok({"id": d.id})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/v1/dashboards")
|
@bp.post("/v1/dashboards")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard_create():
|
def dashboard_create():
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
if "name" not in payload:
|
if "name" not in payload:
|
||||||
return _json_err("Missing field: name", 400)
|
return _json_err("Missing field: name", 400)
|
||||||
d = Dashboard(owner_id=current_user.id, name=str(payload["name"]).strip(), description=str(payload.get("description","")).strip())
|
d = Dashboard(owner_id=current_user.id, name=str(payload["name"]).strip(), description=str(payload.get("description", "")).strip())
|
||||||
db.session.add(d); db.session.commit()
|
db.session.add(d)
|
||||||
|
db.session.commit()
|
||||||
return _json_ok({"id": d.id})
|
return _json_ok({"id": d.id})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/v1/dashboards/<int:dashboard_id>/widgets")
|
@bp.post("/v1/dashboards/<int:dashboard_id>/widgets")
|
||||||
@login_required
|
@login_required
|
||||||
def widget_create(dashboard_id):
|
def widget_create(dashboard_id):
|
||||||
@@ -157,11 +203,11 @@ def widget_create(dashboard_id):
|
|||||||
q = payload.get("query")
|
q = payload.get("query")
|
||||||
if not q and preset:
|
if not q and preset:
|
||||||
q = {
|
q = {
|
||||||
"connector": preset.get("connector","rest"),
|
"connector": preset.get("connector", "rest"),
|
||||||
"endpoint": preset.get("endpoint"),
|
"endpoint": preset.get("endpoint"),
|
||||||
"params": preset.get("params") or {},
|
"params": preset.get("params") or {},
|
||||||
"extract": preset.get("extract") or {},
|
"extract": preset.get("extract") or {},
|
||||||
"preset_key": preset_key
|
"preset_key": preset_key,
|
||||||
}
|
}
|
||||||
if not q:
|
if not q:
|
||||||
return _json_err("Missing preset_key or query", 400)
|
return _json_err("Missing preset_key or query", 400)
|
||||||
@@ -180,23 +226,21 @@ def widget_create(dashboard_id):
|
|||||||
col_span=int(payload.get("col_span", 6)),
|
col_span=int(payload.get("col_span", 6)),
|
||||||
height_px=int(payload.get("height_px", 260)),
|
height_px=int(payload.get("height_px", 260)),
|
||||||
)
|
)
|
||||||
db.session.add(w); db.session.commit()
|
db.session.add(w)
|
||||||
|
db.session.commit()
|
||||||
return _json_ok({"id": w.id})
|
return _json_ok({"id": w.id})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/v1/widgets/<int:widget_id>/last")
|
@bp.get("/v1/widgets/<int:widget_id>/last")
|
||||||
@login_required
|
@login_required
|
||||||
def widget_last(widget_id):
|
def widget_last(widget_id):
|
||||||
from ..models import MetricLastValue
|
|
||||||
w = db.session.get(Widget, widget_id)
|
w = db.session.get(Widget, widget_id)
|
||||||
if not w:
|
if not w:
|
||||||
return _json_err("Not found", 404)
|
return _json_err("Not found", 404)
|
||||||
d = db.session.get(Dashboard, w.dashboard_id)
|
d = db.session.get(Dashboard, w.dashboard_id)
|
||||||
if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id):
|
if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id):
|
||||||
return _json_err("Forbidden", 403)
|
return _json_err("Forbidden", 403)
|
||||||
last = (MetricLastValue.query
|
last = MetricLastValue.query.filter_by(widget_id=w.id).first()
|
||||||
.filter_by(widget_id=w.id)
|
|
||||||
.order_by(MetricLastValue.ts.desc())
|
|
||||||
.first())
|
|
||||||
if not last:
|
if not last:
|
||||||
return _json_ok(None)
|
return _json_ok(None)
|
||||||
return _json_ok({"ts": last.ts.isoformat(), "value": json.loads(last.value_json)})
|
return _json_ok({"ts": last.ts.isoformat(), "value": json.loads(last.value_json)})
|
||||||
@@ -205,7 +249,6 @@ def widget_last(widget_id):
|
|||||||
@bp.get("/v1/preset-items")
|
@bp.get("/v1/preset-items")
|
||||||
@login_required
|
@login_required
|
||||||
def preset_items():
|
def preset_items():
|
||||||
"""List item names for presets returning lists (interfaces, queues, ...)."""
|
|
||||||
device_id = int(request.args.get("device_id") or 0)
|
device_id = int(request.args.get("device_id") or 0)
|
||||||
preset_key = (request.args.get("preset_key") or "").strip()
|
preset_key = (request.args.get("preset_key") or "").strip()
|
||||||
if not device_id or not preset_key:
|
if not device_id or not preset_key:
|
||||||
@@ -217,10 +260,7 @@ def preset_items():
|
|||||||
|
|
||||||
presets = load_presets()
|
presets = load_presets()
|
||||||
preset = presets.get(preset_key)
|
preset = presets.get(preset_key)
|
||||||
if not preset:
|
if not preset or not device.enc_credentials:
|
||||||
return _json_ok({"items": []})
|
|
||||||
|
|
||||||
if not device.enc_credentials:
|
|
||||||
return _json_ok({"items": []})
|
return _json_ok({"items": []})
|
||||||
try:
|
try:
|
||||||
creds = decrypt_json(device.enc_credentials)
|
creds = decrypt_json(device.enc_credentials)
|
||||||
@@ -234,7 +274,6 @@ def preset_items():
|
|||||||
"extract": preset.get("extract") or {},
|
"extract": preset.get("extract") or {},
|
||||||
"preset_key": preset_key,
|
"preset_key": preset_key,
|
||||||
}
|
}
|
||||||
# ensure list call (remove default filter)
|
|
||||||
q.get("params", {}).pop("name", None)
|
q.get("params", {}).pop("name", None)
|
||||||
|
|
||||||
connector = get_connector(q.get("connector", "rest"))
|
connector = get_connector(q.get("connector", "rest"))
|
||||||
@@ -250,19 +289,36 @@ def preset_items():
|
|||||||
items = sorted(set(items))[:200]
|
items = sorted(set(items))[:200]
|
||||||
return _json_ok({"items": items})
|
return _json_ok({"items": items})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/v1/public/<token>")
|
@bp.get("/v1/public/<token>")
|
||||||
def public_dashboard(token):
|
def public_dashboard(token):
|
||||||
import datetime as dt
|
|
||||||
pl = PublicLink.query.filter_by(token=token, target_type="dashboard").first()
|
pl = PublicLink.query.filter_by(token=token, target_type="dashboard").first()
|
||||||
if not pl:
|
if not pl:
|
||||||
return _json_err("Not found", 404)
|
return _json_err("Not found", 404)
|
||||||
if pl.expires_at and dt.datetime.utcnow() > pl.expires_at:
|
if pl.expires_at and dt.datetime.utcnow() > pl.expires_at:
|
||||||
return _json_err("Expired", 404)
|
return _json_err("Expired", 404)
|
||||||
|
|
||||||
d = db.session.get(Dashboard, pl.target_id)
|
d = db.session.get(Dashboard, pl.target_id)
|
||||||
if not d:
|
if not d:
|
||||||
return _json_err("Not found", 404)
|
return _json_err("Not found", 404)
|
||||||
|
|
||||||
widgets = Widget.query.filter_by(dashboard_id=d.id).all()
|
widgets = Widget.query.filter_by(dashboard_id=d.id).all()
|
||||||
return _json_ok({
|
return _json_ok(
|
||||||
"dashboard": {"id": d.id, "name": d.name, "description": d.description},
|
{
|
||||||
"widgets": [{"id": w.id, "title": w.title, "type": w.widget_type, "device_id": w.device_id} for w in widgets]
|
"id": d.id,
|
||||||
})
|
"name": d.name,
|
||||||
|
"description": d.description,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"id": w.id,
|
||||||
|
"title": w.title,
|
||||||
|
"type": w.widget_type,
|
||||||
|
"device_id": w.device_id,
|
||||||
|
"refresh_seconds": w.refresh_seconds,
|
||||||
|
"preset_key": w.preset_key,
|
||||||
|
"query": json.loads(w.query_json),
|
||||||
|
}
|
||||||
|
for w in widgets
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import json, copy
|
import json
|
||||||
|
import copy
|
||||||
import secrets
|
import secrets
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
from sqlalchemy import func, case, distinct
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, abort, request, jsonify
|
from flask import Blueprint, render_template, redirect, url_for, flash, abort, request, jsonify
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from .. import db
|
from .. import db
|
||||||
@@ -17,7 +19,27 @@ bp = Blueprint("dashboards", __name__, url_prefix="/")
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
dashboards = Dashboard.query.filter_by(owner_id=current_user.id).order_by(Dashboard.created_at.desc()).all()
|
stats_rows = (
|
||||||
|
db.session.query(
|
||||||
|
Dashboard,
|
||||||
|
func.count(Widget.id).label("widgets_count"),
|
||||||
|
func.count(distinct(Widget.device_id)).label("devices_count"),
|
||||||
|
func.sum(case((Widget.widget_type == "table", 1), else_=0)).label("tables_count"),
|
||||||
|
func.sum(case((Widget.widget_type != "table", 1), else_=0)).label("charts_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(Widget, Widget.dashboard_id == Dashboard.id)
|
||||||
|
.filter(Dashboard.owner_id == current_user.id)
|
||||||
|
.group_by(Dashboard.id)
|
||||||
|
.order_by(Dashboard.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
dashboards = []
|
||||||
|
for dashboard, widgets_count, devices_count, tables_count, charts_count in stats_rows:
|
||||||
|
dashboard.widgets_count = int(widgets_count or 0)
|
||||||
|
dashboard.devices_count = int(devices_count or 0)
|
||||||
|
dashboard.tables_count = int(tables_count or 0)
|
||||||
|
dashboard.charts_count = int(charts_count or 0)
|
||||||
|
dashboards.append(dashboard)
|
||||||
return render_template("dashboards/index.html", dashboards=dashboards)
|
return render_template("dashboards/index.html", dashboards=dashboards)
|
||||||
|
|
||||||
|
|
||||||
@@ -103,10 +125,7 @@ def widget_new_post(dashboard_id):
|
|||||||
flash("Unknown preset.", "danger")
|
flash("Unknown preset.", "danger")
|
||||||
return redirect(url_for("dashboards.widget_new", dashboard_id=d.id))
|
return redirect(url_for("dashboards.widget_new", dashboard_id=d.id))
|
||||||
|
|
||||||
# IMPORTANT: deepcopy to prevent cross-request / cross-widget mutation of dicts
|
|
||||||
preset = copy.deepcopy(preset)
|
preset = copy.deepcopy(preset)
|
||||||
|
|
||||||
# base query from preset (always dict copies)
|
|
||||||
q = {
|
q = {
|
||||||
"connector": preset.get("connector", "rest"),
|
"connector": preset.get("connector", "rest"),
|
||||||
"endpoint": preset.get("endpoint"),
|
"endpoint": preset.get("endpoint"),
|
||||||
@@ -115,10 +134,7 @@ def widget_new_post(dashboard_id):
|
|||||||
"preset_key": preset_key,
|
"preset_key": preset_key,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional selector (interfaces/queues etc.)
|
|
||||||
item_name = (form.item_name.data or "").strip()
|
item_name = (form.item_name.data or "").strip()
|
||||||
|
|
||||||
# If user typed raw query_json: merge it, don't fully replace (keeps preset_key etc.)
|
|
||||||
raw_q = (form.query_json.data or "").strip()
|
raw_q = (form.query_json.data or "").strip()
|
||||||
if raw_q:
|
if raw_q:
|
||||||
try:
|
try:
|
||||||
@@ -129,21 +145,15 @@ def widget_new_post(dashboard_id):
|
|||||||
flash("Query JSON invalid.", "danger")
|
flash("Query JSON invalid.", "danger")
|
||||||
return redirect(url_for("dashboards.widget_new", dashboard_id=d.id))
|
return redirect(url_for("dashboards.widget_new", dashboard_id=d.id))
|
||||||
|
|
||||||
# Merge: user_q overrides base q, but ensure required keys exist
|
|
||||||
merged = dict(q)
|
merged = dict(q)
|
||||||
merged.update(user_q)
|
merged.update(user_q)
|
||||||
|
|
||||||
merged.setdefault("params", {})
|
merged.setdefault("params", {})
|
||||||
merged.setdefault("extract", {})
|
merged.setdefault("extract", {})
|
||||||
merged["preset_key"] = preset_key
|
merged["preset_key"] = preset_key
|
||||||
|
|
||||||
# if user forgot connector/endpoint -> fallback to preset
|
|
||||||
merged["connector"] = merged.get("connector") or q["connector"]
|
merged["connector"] = merged.get("connector") or q["connector"]
|
||||||
merged["endpoint"] = merged.get("endpoint") or q["endpoint"]
|
merged["endpoint"] = merged.get("endpoint") or q["endpoint"]
|
||||||
|
|
||||||
q = merged
|
q = merged
|
||||||
else:
|
else:
|
||||||
# Only apply item_name convention when user did not provide custom JSON
|
|
||||||
if item_name:
|
if item_name:
|
||||||
q.setdefault("params", {})
|
q.setdefault("params", {})
|
||||||
q["params"]["name"] = item_name
|
q["params"]["name"] = item_name
|
||||||
@@ -190,7 +200,6 @@ def widget_delete(dashboard_id, widget_id):
|
|||||||
return redirect(url_for("dashboards.view", dashboard_id=d.id))
|
return redirect(url_for("dashboards.view", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
# Sharing
|
|
||||||
@bp.get("/dashboards/<int:dashboard_id>/share")
|
@bp.get("/dashboards/<int:dashboard_id>/share")
|
||||||
@login_required
|
@login_required
|
||||||
def share(dashboard_id):
|
def share(dashboard_id):
|
||||||
@@ -198,54 +207,83 @@ def share(dashboard_id):
|
|||||||
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
||||||
abort(403)
|
abort(403)
|
||||||
shares = Share.query.filter_by(target_type="dashboard", target_id=d.id).all()
|
shares = Share.query.filter_by(target_type="dashboard", target_id=d.id).all()
|
||||||
public = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first()
|
public_link = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first()
|
||||||
form = ShareForm()
|
form = ShareForm()
|
||||||
return render_template("dashboards/share.html", dashboard=d, shares=shares, public=public, form=form)
|
return render_template("dashboards/share.html", dashboard=d, shares=shares, public_link=public_link, form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/dashboards/<int:dashboard_id>/share")
|
@bp.post("/dashboards/<int:dashboard_id>/share")
|
||||||
@login_required
|
@login_required
|
||||||
def share_post(dashboard_id):
|
def share_add(dashboard_id):
|
||||||
d = _load_dashboard_or_404(dashboard_id)
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
||||||
abort(403)
|
abort(403)
|
||||||
from ..models import User, Share
|
|
||||||
form = ShareForm()
|
form = ShareForm()
|
||||||
if not form.validate_on_submit():
|
if not form.validate_on_submit():
|
||||||
flash("Invalid input.", "danger")
|
flash("Invalid input.", "danger")
|
||||||
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
u = User.query.filter_by(email=form.email.data.lower().strip()).first()
|
|
||||||
if not u:
|
from ..models import User
|
||||||
|
user = User.query.filter_by(email=form.email.data.strip().lower()).first()
|
||||||
|
if not user:
|
||||||
flash("User not found.", "warning")
|
flash("User not found.", "warning")
|
||||||
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
s = Share.query.filter_by(target_type="dashboard", target_id=d.id, user_id=u.id).first()
|
|
||||||
if not s:
|
existing = Share.query.filter_by(target_type="dashboard", target_id=d.id, user_id=user.id).first()
|
||||||
s = Share(target_type="dashboard", target_id=d.id, user_id=u.id, permission=form.permission.data)
|
if existing:
|
||||||
db.session.add(s)
|
existing.permission = form.permission.data
|
||||||
else:
|
else:
|
||||||
s.permission = form.permission.data
|
db.session.add(Share(target_type="dashboard", target_id=d.id, user_id=user.id, permission=form.permission.data))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
audit.log("dashboard.shared_user", "dashboard", d.id, f"{u.email}:{s.permission}")
|
audit.log("dashboard.shared", "dashboard", d.id, f"user_id={user.id},permission={form.permission.data}")
|
||||||
flash("Shared updated.", "success")
|
flash("Share updated.", "success")
|
||||||
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/dashboards/<int:dashboard_id>/share/public")
|
@bp.post("/dashboards/<int:dashboard_id>/share/<int:share_id>/delete")
|
||||||
@login_required
|
@login_required
|
||||||
def share_public(dashboard_id):
|
def share_delete(dashboard_id, share_id):
|
||||||
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
s = db.session.get(Share, share_id)
|
||||||
|
if not s or s.target_type != "dashboard" or s.target_id != d.id:
|
||||||
|
abort(404)
|
||||||
|
db.session.delete(s)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("dashboard.share_deleted", "dashboard", d.id, f"share_id={share_id}")
|
||||||
|
flash("Share removed.", "success")
|
||||||
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/public-link")
|
||||||
|
@login_required
|
||||||
|
def public_link_create(dashboard_id):
|
||||||
d = _load_dashboard_or_404(dashboard_id)
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
||||||
abort(403)
|
abort(403)
|
||||||
token = secrets.token_urlsafe(24)
|
|
||||||
pl = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first()
|
pl = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first()
|
||||||
if not pl:
|
if not pl:
|
||||||
pl = PublicLink(token=token, target_type="dashboard", target_id=d.id, read_only=True)
|
pl = PublicLink(target_type="dashboard", target_id=d.id, token=secrets.token_urlsafe(24), read_only=True)
|
||||||
db.session.add(pl)
|
db.session.add(pl)
|
||||||
else:
|
|
||||||
pl.token = token
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
audit.log("dashboard.public_link_created", "dashboard", d.id)
|
audit.log("dashboard.public_link_created", "dashboard", d.id)
|
||||||
flash("Public link created/refreshed.", "success")
|
flash("Public link ready.", "success")
|
||||||
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/public-link/delete")
|
||||||
|
@login_required
|
||||||
|
def public_link_delete(dashboard_id):
|
||||||
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
pl = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first()
|
||||||
|
if pl:
|
||||||
|
db.session.delete(pl)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("dashboard.public_link_deleted", "dashboard", d.id)
|
||||||
|
flash("Public link deleted.", "success")
|
||||||
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
@@ -263,20 +301,22 @@ def public_view(token):
|
|||||||
return render_template("dashboards/public_view.html", dashboard=d, widgets=widgets, token=token)
|
return render_template("dashboards/public_view.html", dashboard=d, widgets=widgets, token=token)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/dashboards/<int:dashboard_id>/delete")
|
@bp.get("/dashboards/<int:dashboard_id>/layout")
|
||||||
@login_required
|
@login_required
|
||||||
def delete(dashboard_id):
|
def layout_get(dashboard_id):
|
||||||
d = _load_dashboard_or_404(dashboard_id)
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id):
|
return jsonify({"ok": True, "layout": json.loads(d.layout) if d.layout else []})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/layout")
|
||||||
|
@login_required
|
||||||
|
def layout_save(dashboard_id):
|
||||||
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
Widget.query.filter_by(dashboard_id=d.id).delete(synchronize_session=False)
|
layout = payload.get("layout") or []
|
||||||
Share.query.filter_by(target_type="dashboard", target_id=d.id).delete(synchronize_session=False)
|
d.layout = json.dumps(layout)
|
||||||
PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).delete(synchronize_session=False)
|
|
||||||
|
|
||||||
db.session.delete(d)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
audit.log("dashboard.deleted", "dashboard", dashboard_id)
|
audit.log("dashboard.layout_saved", "dashboard", d.id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
flash("Dashboard deleted.", "success")
|
|
||||||
return redirect(url_for("dashboards.index"))
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import json
|
|
||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from .. import db
|
from .. import db
|
||||||
from ..models import Device, ShareTarget
|
from ..models import Device
|
||||||
from ..forms import DeviceForm, EditDeviceForm
|
from ..forms import DeviceForm, EditDeviceForm
|
||||||
from ..security.crypto import encrypt_json, decrypt_json
|
from ..security.crypto import encrypt_json, decrypt_json
|
||||||
from ..connectors.registry import get_connector
|
from ..connectors.registry import get_connector
|
||||||
@@ -11,23 +10,24 @@ from ..services import audit
|
|||||||
|
|
||||||
bp = Blueprint("devices", __name__, url_prefix="/devices")
|
bp = Blueprint("devices", __name__, url_prefix="/devices")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
devices = Device.query.filter_by(owner_id=current_user.id).order_by(Device.created_at.desc()).all()
|
devices = Device.query.filter_by(owner_id=current_user.id).order_by(Device.created_at.desc()).all()
|
||||||
return render_template("devices/index.html", devices=devices)
|
return render_template("devices/index.html", devices=devices)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/new")
|
@bp.get("/new")
|
||||||
@login_required
|
@login_required
|
||||||
def new():
|
def new():
|
||||||
if Device.query.filter_by(owner_id=current_user.id).count() >= int(request.app.config.get("USER_MAX_DEVICES", 20)) if hasattr(request, "app") else 10:
|
|
||||||
pass
|
|
||||||
form = DeviceForm()
|
form = DeviceForm()
|
||||||
|
form.rest_scheme.data = "https"
|
||||||
form.rest_port.data = 443
|
form.rest_port.data = 443
|
||||||
form.rest_base_path.data = "/rest"
|
form.rest_base_path.data = "/rest"
|
||||||
form.ssh_port.data = 22
|
|
||||||
return render_template("devices/new.html", form=form)
|
return render_template("devices/new.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/new")
|
@bp.post("/new")
|
||||||
@login_required
|
@login_required
|
||||||
def new_post():
|
def new_post():
|
||||||
@@ -36,22 +36,21 @@ def new_post():
|
|||||||
flash("Invalid input.", "danger")
|
flash("Invalid input.", "danger")
|
||||||
return render_template("devices/new.html", form=form), 400
|
return render_template("devices/new.html", form=form), 400
|
||||||
|
|
||||||
# per-user limit
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
max_devices = current_app.config.get("USER_MAX_DEVICES", 20)
|
max_devices = current_app.config.get("USER_MAX_DEVICES", 20)
|
||||||
if Device.query.filter_by(owner_id=current_user.id).count() >= max_devices:
|
if Device.query.filter_by(owner_id=current_user.id).count() >= max_devices:
|
||||||
flash(f"Device limit reached ({max_devices}).", "warning")
|
flash(f"Device limit reached ({max_devices}).", "warning")
|
||||||
return render_template("devices/new.html", form=form), 403
|
return render_template("devices/new.html", form=form), 403
|
||||||
|
|
||||||
|
scheme = (form.rest_scheme.data or "https").lower()
|
||||||
d = Device(
|
d = Device(
|
||||||
owner_id=current_user.id,
|
owner_id=current_user.id,
|
||||||
name=form.name.data.strip(),
|
name=form.name.data.strip(),
|
||||||
host=form.host.data.strip(),
|
host=form.host.data.strip(),
|
||||||
|
rest_scheme=scheme,
|
||||||
rest_port=form.rest_port.data,
|
rest_port=form.rest_port.data,
|
||||||
rest_base_path=form.rest_base_path.data.strip(),
|
rest_base_path=form.rest_base_path.data.strip(),
|
||||||
allow_insecure_tls=bool(form.allow_insecure_tls.data),
|
allow_insecure_tls=bool(form.allow_insecure_tls.data) if scheme == "https" else False,
|
||||||
ssh_enabled=bool(form.ssh_enabled.data),
|
|
||||||
ssh_port=form.ssh_port.data or 22,
|
|
||||||
)
|
)
|
||||||
d.enc_credentials = encrypt_json({"username": form.username.data, "password": form.password.data})
|
d.enc_credentials = encrypt_json({"username": form.username.data, "password": form.password.data})
|
||||||
db.session.add(d)
|
db.session.add(d)
|
||||||
@@ -61,6 +60,7 @@ def new_post():
|
|||||||
flash("Device created. You can test connection now.", "success")
|
flash("Device created. You can test connection now.", "success")
|
||||||
return redirect(url_for("devices.view", device_id=d.id))
|
return redirect(url_for("devices.view", device_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<int:device_id>")
|
@bp.get("/<int:device_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def view(device_id):
|
def view(device_id):
|
||||||
@@ -71,6 +71,7 @@ def view(device_id):
|
|||||||
abort(403)
|
abort(403)
|
||||||
return render_template("devices/view.html", device=d)
|
return render_template("devices/view.html", device=d)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/test")
|
@bp.post("/<int:device_id>/test")
|
||||||
@login_required
|
@login_required
|
||||||
def test(device_id):
|
def test(device_id):
|
||||||
@@ -90,6 +91,7 @@ def test(device_id):
|
|||||||
audit.log("device.test_failed", "device", d.id, res.error)
|
audit.log("device.test_failed", "device", d.id, res.error)
|
||||||
return jsonify({"ok": False, "error": res.error}), 400
|
return jsonify({"ok": False, "error": res.error}), 400
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<int:device_id>/discover")
|
@bp.get("/<int:device_id>/discover")
|
||||||
@login_required
|
@login_required
|
||||||
def discover(device_id):
|
def discover(device_id):
|
||||||
@@ -105,6 +107,7 @@ def discover(device_id):
|
|||||||
return jsonify({"ok": True, "data": res.data})
|
return jsonify({"ok": True, "data": res.data})
|
||||||
return jsonify({"ok": False, "error": res.error}), 400
|
return jsonify({"ok": False, "error": res.error}), 400
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<int:device_id>/edit")
|
@bp.get("/<int:device_id>/edit")
|
||||||
@login_required
|
@login_required
|
||||||
def edit(device_id):
|
def edit(device_id):
|
||||||
@@ -115,15 +118,12 @@ def edit(device_id):
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
form = EditDeviceForm()
|
form = EditDeviceForm()
|
||||||
|
|
||||||
# preload values
|
|
||||||
form.name.data = d.name
|
form.name.data = d.name
|
||||||
form.host.data = d.host
|
form.host.data = d.host
|
||||||
|
form.rest_scheme.data = d.rest_scheme or "https"
|
||||||
form.rest_port.data = d.rest_port
|
form.rest_port.data = d.rest_port
|
||||||
form.rest_base_path.data = d.rest_base_path
|
form.rest_base_path.data = d.rest_base_path
|
||||||
form.allow_insecure_tls.data = bool(d.allow_insecure_tls)
|
form.allow_insecure_tls.data = bool(d.allow_insecure_tls)
|
||||||
form.ssh_enabled.data = bool(d.ssh_enabled)
|
|
||||||
form.ssh_port.data = d.ssh_port
|
|
||||||
|
|
||||||
creds = decrypt_json(d.enc_credentials) if d.enc_credentials else {}
|
creds = decrypt_json(d.enc_credentials) if d.enc_credentials else {}
|
||||||
form.username.data = creds.get("username", "")
|
form.username.data = creds.get("username", "")
|
||||||
@@ -145,19 +145,17 @@ def edit_post(device_id):
|
|||||||
flash("Invalid input.", "danger")
|
flash("Invalid input.", "danger")
|
||||||
return render_template("devices/edit.html", device=d, form=form), 400
|
return render_template("devices/edit.html", device=d, form=form), 400
|
||||||
|
|
||||||
|
scheme = (form.rest_scheme.data or "https").lower()
|
||||||
d.name = form.name.data.strip()
|
d.name = form.name.data.strip()
|
||||||
d.host = form.host.data.strip()
|
d.host = form.host.data.strip()
|
||||||
|
d.rest_scheme = scheme
|
||||||
d.rest_port = form.rest_port.data
|
d.rest_port = form.rest_port.data
|
||||||
d.rest_base_path = form.rest_base_path.data.strip()
|
d.rest_base_path = form.rest_base_path.data.strip()
|
||||||
d.allow_insecure_tls = bool(form.allow_insecure_tls.data)
|
d.allow_insecure_tls = bool(form.allow_insecure_tls.data) if scheme == "https" else False
|
||||||
d.ssh_enabled = bool(form.ssh_enabled.data)
|
|
||||||
d.ssh_port = form.ssh_port.data or 22
|
|
||||||
|
|
||||||
# credentials: keep old password if blank
|
|
||||||
old = decrypt_json(d.enc_credentials) if d.enc_credentials else {}
|
old = decrypt_json(d.enc_credentials) if d.enc_credentials else {}
|
||||||
username = (form.username.data or "").strip()
|
username = (form.username.data or "").strip()
|
||||||
password = (form.password.data or "").strip() or (old.get("password") or "")
|
password = (form.password.data or "").strip() or (old.get("password") or "")
|
||||||
|
|
||||||
d.enc_credentials = encrypt_json({"username": username, "password": password})
|
d.enc_credentials = encrypt_json({"username": username, "password": password})
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from .routeros_rest import RouterOSRestConnector
|
from .routeros_rest import RouterOSRestConnector
|
||||||
from .routeros_ssh import RouterOSSshConnector
|
|
||||||
|
|
||||||
REGISTRY = {
|
REGISTRY = {
|
||||||
"rest": RouterOSRestConnector(),
|
"rest": RouterOSRestConnector(),
|
||||||
"ssh": RouterOSSshConnector(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_connector(name: str):
|
def get_connector(name: str):
|
||||||
c = REGISTRY.get(name)
|
c = REGISTRY.get(name)
|
||||||
if not c:
|
if not c:
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import httpx
|
import httpx
|
||||||
from typing import Any
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from .base import Connector, FetchResult
|
from .base import Connector, FetchResult
|
||||||
|
|
||||||
|
|
||||||
class RouterOSRestConnector(Connector):
|
class RouterOSRestConnector(Connector):
|
||||||
name = "rest"
|
name = "rest"
|
||||||
|
|
||||||
def _client(self, device):
|
def _client(self, device):
|
||||||
verify = not device.allow_insecure_tls
|
|
||||||
timeout = current_app.config.get("ROUTEROS_REST_TIMEOUT", 6)
|
timeout = current_app.config.get("ROUTEROS_REST_TIMEOUT", 6)
|
||||||
return httpx.Client(verify=verify, timeout=timeout)
|
kwargs = {"timeout": timeout}
|
||||||
|
if (device.rest_scheme or "https").lower() == "https":
|
||||||
|
kwargs["verify"] = not device.allow_insecure_tls
|
||||||
|
return httpx.Client(**kwargs)
|
||||||
|
|
||||||
def _url(self, device, endpoint: str) -> str:
|
def _url(self, device, endpoint: str) -> str:
|
||||||
|
scheme = (device.rest_scheme or "https").lower()
|
||||||
base = device.rest_base_path.strip("/")
|
base = device.rest_base_path.strip("/")
|
||||||
ep = endpoint.strip("/")
|
ep = endpoint.strip("/")
|
||||||
return f"https://{device.host}:{device.rest_port}/{base}/{ep}"
|
base_url = f"{scheme}://{device.host}:{device.rest_port}"
|
||||||
|
return f"{base_url}/{base}/{ep}" if base else f"{base_url}/{ep}"
|
||||||
|
|
||||||
def test(self, device, creds: dict) -> FetchResult:
|
def test(self, device, creds: dict) -> FetchResult:
|
||||||
try:
|
try:
|
||||||
@@ -43,11 +47,10 @@ class RouterOSRestConnector(Connector):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
res = r.json()
|
res = r.json()
|
||||||
version = res.get("version") or res.get("routeros-version")
|
version = res.get("version") or res.get("routeros-version")
|
||||||
# lightweight capabilities (extend later)
|
|
||||||
caps = {
|
caps = {
|
||||||
"routeros_version": version,
|
"routeros_version": version,
|
||||||
"has_rest": True,
|
"has_rest": True,
|
||||||
"resources": ["system/resource","interface/*","queue/*","ip/firewall/*","ip/dhcp-server/*","routing/*"],
|
"resources": ["system/resource", "interface/*", "queue/*", "ip/firewall/*", "ip/dhcp-server/*", "routing/*"],
|
||||||
}
|
}
|
||||||
return FetchResult(ok=True, data=caps)
|
return FetchResult(ok=True, data=caps)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, PasswordField, BooleanField, IntegerField, TextAreaField, SelectField
|
from wtforms import StringField, PasswordField, BooleanField, IntegerField, TextAreaField, SelectField
|
||||||
from wtforms.validators import DataRequired, Email, Length, NumberRange, Optional
|
from wtforms.validators import DataRequired, Email, Length, NumberRange, Optional
|
||||||
from wtforms.validators import Optional, Length
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm(FlaskForm):
|
class LoginForm(FlaskForm):
|
||||||
email = StringField("Email", validators=[DataRequired(), Email()])
|
email = StringField("Email", validators=[DataRequired(), Email()])
|
||||||
password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)])
|
password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)])
|
||||||
|
|
||||||
|
|
||||||
class RegisterForm(FlaskForm):
|
class RegisterForm(FlaskForm):
|
||||||
email = StringField("Email", validators=[DataRequired(), Email()])
|
email = StringField("Email", validators=[DataRequired(), Email()])
|
||||||
password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)])
|
password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)])
|
||||||
|
|
||||||
|
|
||||||
class DeviceForm(FlaskForm):
|
class DeviceForm(FlaskForm):
|
||||||
name = StringField("Name", validators=[DataRequired(), Length(max=120)])
|
name = StringField("Name", validators=[DataRequired(), Length(max=120)])
|
||||||
host = StringField("Host", validators=[DataRequired(), Length(max=255)])
|
host = StringField("Host", validators=[DataRequired(), Length(max=255)])
|
||||||
|
rest_scheme = SelectField(
|
||||||
|
"REST Protocol",
|
||||||
|
validators=[DataRequired()],
|
||||||
|
choices=[("https", "HTTPS"), ("http", "HTTP")],
|
||||||
|
default="https",
|
||||||
|
)
|
||||||
rest_port = IntegerField("REST Port", validators=[DataRequired(), NumberRange(min=1, max=65535)])
|
rest_port = IntegerField("REST Port", validators=[DataRequired(), NumberRange(min=1, max=65535)])
|
||||||
rest_base_path = StringField("REST Base Path", validators=[DataRequired(), Length(max=64)])
|
rest_base_path = StringField("REST Base Path", validators=[DataRequired(), Length(max=64)])
|
||||||
username = StringField("Username", validators=[DataRequired(), Length(max=128)])
|
username = StringField("Username", validators=[DataRequired(), Length(max=128)])
|
||||||
password = PasswordField("Password", validators=[DataRequired(), Length(max=128)])
|
password = PasswordField("Password", validators=[DataRequired(), Length(max=128)])
|
||||||
allow_insecure_tls = BooleanField("Allow insecure TLS (self-signed)")
|
allow_insecure_tls = BooleanField("Allow insecure TLS (self-signed)")
|
||||||
ssh_enabled = BooleanField("Enable SSH connector")
|
|
||||||
ssh_port = IntegerField("SSH Port", validators=[Optional(), NumberRange(min=1, max=65535)])
|
|
||||||
|
|
||||||
class EditDeviceForm(DeviceForm):
|
class EditDeviceForm(DeviceForm):
|
||||||
password = PasswordField("Password", validators=[Optional(), Length(max=128)])
|
password = PasswordField("Password", validators=[Optional(), Length(max=128)])
|
||||||
|
|
||||||
|
|
||||||
class DashboardForm(FlaskForm):
|
class DashboardForm(FlaskForm):
|
||||||
name = StringField("Name", validators=[DataRequired(), Length(max=120)])
|
name = StringField("Name", validators=[DataRequired(), Length(max=120)])
|
||||||
description = StringField("Description", validators=[Optional(), Length(max=500)])
|
description = StringField("Description", validators=[Optional(), Length(max=500)])
|
||||||
|
|
||||||
|
|
||||||
class WidgetWizardForm(FlaskForm):
|
class WidgetWizardForm(FlaskForm):
|
||||||
preset_key = SelectField("Preset", validators=[DataRequired()], choices=[])
|
preset_key = SelectField("Preset", validators=[DataRequired()], choices=[])
|
||||||
title = StringField("Title", validators=[DataRequired(), Length(max=120)])
|
title = StringField("Title", validators=[DataRequired(), Length(max=120)])
|
||||||
refresh_seconds = IntegerField("Refresh (seconds)", validators=[DataRequired(), NumberRange(min=1, max=3600)])
|
refresh_seconds = IntegerField("Refresh (seconds)", validators=[DataRequired(), NumberRange(min=1, max=3600)])
|
||||||
|
|
||||||
# Optional selector for presets that support it (interfaces/queues etc.).
|
|
||||||
item_name = SelectField("Item", validators=[Optional()], choices=[])
|
item_name = SelectField("Item", validators=[Optional()], choices=[])
|
||||||
|
|
||||||
# Layout controls
|
|
||||||
col_span = SelectField(
|
col_span = SelectField(
|
||||||
"Width",
|
"Width",
|
||||||
validators=[DataRequired()],
|
validators=[DataRequired()],
|
||||||
@@ -46,18 +50,25 @@ class WidgetWizardForm(FlaskForm):
|
|||||||
default="6",
|
default="6",
|
||||||
)
|
)
|
||||||
height_px = IntegerField("Height (px)", validators=[DataRequired(), NumberRange(min=160, max=1000)], default=260)
|
height_px = IntegerField("Height (px)", validators=[DataRequired(), NumberRange(min=160, max=1000)], default=260)
|
||||||
# JSON advanced override
|
|
||||||
query_json = TextAreaField("Query JSON (advanced)", validators=[Optional()])
|
query_json = TextAreaField("Query JSON (advanced)", validators=[Optional()])
|
||||||
|
|
||||||
|
|
||||||
class ForgotPasswordForm(FlaskForm):
|
class ForgotPasswordForm(FlaskForm):
|
||||||
email = StringField("Email", validators=[DataRequired(), Email()])
|
email = StringField("Email", validators=[DataRequired(), Email()])
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordForm(FlaskForm):
|
class ResetPasswordForm(FlaskForm):
|
||||||
password = PasswordField("New password", validators=[DataRequired(), Length(min=8, max=128)])
|
password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)])
|
||||||
|
|
||||||
|
|
||||||
class ShareForm(FlaskForm):
|
class ShareForm(FlaskForm):
|
||||||
email = StringField("User email", validators=[DataRequired(), Email()])
|
email = StringField("User email", validators=[DataRequired(), Email(), Length(max=255)])
|
||||||
permission = SelectField("Permission", validators=[DataRequired()], choices=[("view","View"),("edit","Edit"),("manage","Manage")])
|
permission = SelectField(
|
||||||
|
"Permission",
|
||||||
|
validators=[DataRequired()],
|
||||||
|
choices=[("view", "View"), ("edit", "Edit"), ("manage", "Manage")],
|
||||||
|
default="view",
|
||||||
|
)
|
||||||
|
|
||||||
class SmtpTestForm(FlaskForm):
|
class SmtpTestForm(FlaskForm):
|
||||||
to_email = StringField("To", validators=[DataRequired(), Email()])
|
to_email = StringField("To", validators=[DataRequired(), Email()])
|
||||||
@@ -4,24 +4,29 @@ from sqlalchemy import UniqueConstraint, Index
|
|||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from . import db
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
class RoleName(str, enum.Enum):
|
class RoleName(str, enum.Enum):
|
||||||
USER = "user"
|
USER = "user"
|
||||||
ADMIN = "admin"
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
class ShareTarget(str, enum.Enum):
|
class ShareTarget(str, enum.Enum):
|
||||||
DEVICE = "device"
|
DEVICE = "device"
|
||||||
DASHBOARD = "dashboard"
|
DASHBOARD = "dashboard"
|
||||||
|
|
||||||
|
|
||||||
class Permission(str, enum.Enum):
|
class Permission(str, enum.Enum):
|
||||||
VIEW = "view"
|
VIEW = "view"
|
||||||
EDIT = "edit"
|
EDIT = "edit"
|
||||||
MANAGE = "manage"
|
MANAGE = "manage"
|
||||||
|
|
||||||
|
|
||||||
class Role(db.Model):
|
class Role(db.Model):
|
||||||
__tablename__ = "roles"
|
__tablename__ = "roles"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(32), unique=True, nullable=False)
|
name = db.Column(db.String(32), unique=True, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class User(db.Model, UserMixin):
|
class User(db.Model, UserMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -36,6 +41,7 @@ class User(db.Model, UserMixin):
|
|||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
return self.role and self.role.name == RoleName.ADMIN.value
|
return self.role and self.role.name == RoleName.ADMIN.value
|
||||||
|
|
||||||
|
|
||||||
class Device(db.Model):
|
class Device(db.Model):
|
||||||
__tablename__ = "devices"
|
__tablename__ = "devices"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -43,14 +49,12 @@ class Device(db.Model):
|
|||||||
|
|
||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
host = db.Column(db.String(255), nullable=False)
|
host = db.Column(db.String(255), nullable=False)
|
||||||
|
rest_scheme = db.Column(db.String(8), default="https", nullable=False)
|
||||||
rest_port = db.Column(db.Integer, default=443, nullable=False)
|
rest_port = db.Column(db.Integer, default=443, nullable=False)
|
||||||
rest_base_path = db.Column(db.String(64), default="/rest", nullable=False) # allow custom www path
|
rest_base_path = db.Column(db.String(64), default="/rest", nullable=False)
|
||||||
api_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
api_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
ssh_enabled = db.Column(db.Boolean, default=False, nullable=False)
|
|
||||||
ssh_port = db.Column(db.Integer, default=22, nullable=False)
|
|
||||||
allow_insecure_tls = db.Column(db.Boolean, default=False, nullable=False)
|
allow_insecure_tls = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
# encrypted payload: json {"username": "...", "password":"..."}
|
|
||||||
enc_credentials = db.Column(db.Text, nullable=True)
|
enc_credentials = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
last_seen_at = db.Column(db.DateTime, nullable=True)
|
last_seen_at = db.Column(db.DateTime, nullable=True)
|
||||||
@@ -61,17 +65,19 @@ class Device(db.Model):
|
|||||||
|
|
||||||
__table_args__ = (Index("ix_devices_owner_name", "owner_id", "name"),)
|
__table_args__ = (Index("ix_devices_owner_name", "owner_id", "name"),)
|
||||||
|
|
||||||
|
|
||||||
class Dashboard(db.Model):
|
class Dashboard(db.Model):
|
||||||
__tablename__ = "dashboards"
|
__tablename__ = "dashboards"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
description = db.Column(db.String(500), nullable=True)
|
description = db.Column(db.String(500), nullable=True)
|
||||||
layout = db.Column(db.Text, nullable=True) # json
|
layout = db.Column(db.Text, nullable=True)
|
||||||
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
owner = db.relationship("User")
|
owner = db.relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class Widget(db.Model):
|
class Widget(db.Model):
|
||||||
__tablename__ = "widgets"
|
__tablename__ = "widgets"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -79,14 +85,13 @@ class Widget(db.Model):
|
|||||||
device_id = db.Column(db.Integer, db.ForeignKey("devices.id"), nullable=False, index=True)
|
device_id = db.Column(db.Integer, db.ForeignKey("devices.id"), nullable=False, index=True)
|
||||||
|
|
||||||
title = db.Column(db.String(120), nullable=False)
|
title = db.Column(db.String(120), nullable=False)
|
||||||
widget_type = db.Column(db.String(64), nullable=False) # e.g. timeseries
|
widget_type = db.Column(db.String(64), nullable=False)
|
||||||
preset_key = db.Column(db.String(64), nullable=True) # cpu, ram...
|
preset_key = db.Column(db.String(64), nullable=True)
|
||||||
query_json = db.Column(db.Text, nullable=False) # json with connector + endpoint + params
|
query_json = db.Column(db.Text, nullable=False)
|
||||||
refresh_seconds = db.Column(db.Integer, default=2, nullable=False)
|
refresh_seconds = db.Column(db.Integer, default=2, nullable=False)
|
||||||
series_config = db.Column(db.Text, nullable=True) # json
|
series_config = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
# UI/layout
|
col_span = db.Column(db.Integer, default=6, nullable=False)
|
||||||
col_span = db.Column(db.Integer, default=6, nullable=False) # Bootstrap grid 1..12
|
|
||||||
height_px = db.Column(db.Integer, default=260, nullable=False)
|
height_px = db.Column(db.Integer, default=260, nullable=False)
|
||||||
|
|
||||||
is_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
is_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
@@ -95,13 +100,14 @@ class Widget(db.Model):
|
|||||||
dashboard = db.relationship("Dashboard")
|
dashboard = db.relationship("Dashboard")
|
||||||
device = db.relationship("Device")
|
device = db.relationship("Device")
|
||||||
|
|
||||||
|
|
||||||
class Share(db.Model):
|
class Share(db.Model):
|
||||||
__tablename__ = "shares"
|
__tablename__ = "shares"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
target_type = db.Column(db.String(16), nullable=False) # device/dashboard
|
target_type = db.Column(db.String(16), nullable=False)
|
||||||
target_id = db.Column(db.Integer, nullable=False)
|
target_id = db.Column(db.Integer, nullable=False)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
||||||
permission = db.Column(db.String(16), nullable=False) # view/edit/manage
|
permission = db.Column(db.String(16), nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
user = db.relationship("User")
|
user = db.relationship("User")
|
||||||
@@ -111,6 +117,7 @@ class Share(db.Model):
|
|||||||
Index("ix_shares_target", "target_type", "target_id"),
|
Index("ix_shares_target", "target_type", "target_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PublicLink(db.Model):
|
class PublicLink(db.Model):
|
||||||
__tablename__ = "public_links"
|
__tablename__ = "public_links"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -123,6 +130,7 @@ class PublicLink(db.Model):
|
|||||||
|
|
||||||
__table_args__ = (Index("ix_public_links_target", "target_type", "target_id"),)
|
__table_args__ = (Index("ix_public_links_target", "target_type", "target_id"),)
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetToken(db.Model):
|
class PasswordResetToken(db.Model):
|
||||||
__tablename__ = "password_reset_tokens"
|
__tablename__ = "password_reset_tokens"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -137,6 +145,7 @@ class PasswordResetToken(db.Model):
|
|||||||
def is_valid(self) -> bool:
|
def is_valid(self) -> bool:
|
||||||
return self.used_at is None and dt.datetime.utcnow() < self.expires_at
|
return self.used_at is None and dt.datetime.utcnow() < self.expires_at
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(db.Model):
|
class AuditLog(db.Model):
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -151,12 +160,13 @@ class AuditLog(db.Model):
|
|||||||
|
|
||||||
actor = db.relationship("User")
|
actor = db.relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class MetricLastValue(db.Model):
|
class MetricLastValue(db.Model):
|
||||||
__tablename__ = "metrics_last_values"
|
__tablename__ = "metrics_last_values"
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
device_id = db.Column(db.Integer, db.ForeignKey("devices.id"), nullable=False, index=True)
|
device_id = db.Column(db.Integer, db.ForeignKey("devices.id"), nullable=False, index=True)
|
||||||
widget_id = db.Column(db.Integer, db.ForeignKey("widgets.id"), nullable=True, index=True)
|
widget_id = db.Column(db.Integer, db.ForeignKey("widgets.id"), nullable=True, index=True)
|
||||||
key = db.Column(db.String(128), nullable=False) # e.g. "system.cpu.load"
|
key = db.Column(db.String(128), nullable=False)
|
||||||
value_json = db.Column(db.Text, nullable=False)
|
value_json = db.Column(db.Text, nullable=False)
|
||||||
ts = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
ts = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -4,29 +4,35 @@
|
|||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
"connector": "rest",
|
"connector": "rest",
|
||||||
"endpoint": "/system/resource",
|
"endpoint": "/system/resource",
|
||||||
"extract": { "path": "cpu-load", "unit": "%" },
|
"extract": {
|
||||||
|
"path": "cpu-load",
|
||||||
|
"unit": "%"
|
||||||
|
},
|
||||||
"refresh_seconds": 2
|
"refresh_seconds": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
"ram": {
|
"ram": {
|
||||||
"title": "RAM Usage",
|
"title": "RAM Usage",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
"connector": "rest",
|
"connector": "rest",
|
||||||
"endpoint": "/system/resource",
|
"endpoint": "/system/resource",
|
||||||
"extract": {
|
"extract": {
|
||||||
"needs": ["total-memory", "free-memory"],
|
"needs": [
|
||||||
|
"total-memory",
|
||||||
|
"free-memory"
|
||||||
|
],
|
||||||
"calc": "100*(total_memory-free_memory)/total_memory",
|
"calc": "100*(total_memory-free_memory)/total_memory",
|
||||||
"unit": "%"
|
"unit": "%"
|
||||||
},
|
},
|
||||||
"refresh_seconds": 2
|
"refresh_seconds": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
"iface_traffic": {
|
"iface_traffic": {
|
||||||
"title": "Interface Traffic (Rx/Tx)",
|
"title": "Interface Traffic (Rx/Tx)",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
"connector": "rest",
|
"connector": "rest",
|
||||||
"endpoint": "/interface",
|
"endpoint": "/interface",
|
||||||
"params": { "name": "" },
|
"params": {
|
||||||
|
"name": ""
|
||||||
|
},
|
||||||
"extract": {
|
"extract": {
|
||||||
"rx_counter": "rx-byte",
|
"rx_counter": "rx-byte",
|
||||||
"tx_counter": "tx-byte",
|
"tx_counter": "tx-byte",
|
||||||
@@ -34,7 +40,6 @@
|
|||||||
},
|
},
|
||||||
"refresh_seconds": 2
|
"refresh_seconds": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
"firewall_rates": {
|
"firewall_rates": {
|
||||||
"title": "Firewall Traffic (bytes/s)",
|
"title": "Firewall Traffic (bytes/s)",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
@@ -52,7 +57,6 @@
|
|||||||
},
|
},
|
||||||
"refresh_seconds": 5
|
"refresh_seconds": 5
|
||||||
},
|
},
|
||||||
|
|
||||||
"firewall_bytes": {
|
"firewall_bytes": {
|
||||||
"title": "Firewall Counters (bytes)",
|
"title": "Firewall Counters (bytes)",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
@@ -70,7 +74,6 @@
|
|||||||
},
|
},
|
||||||
"refresh_seconds": 30
|
"refresh_seconds": 30
|
||||||
},
|
},
|
||||||
|
|
||||||
"queue_simple_rate": {
|
"queue_simple_rate": {
|
||||||
"title": "Simple Queues (RX/TX bytes/s)",
|
"title": "Simple Queues (RX/TX bytes/s)",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
@@ -81,7 +84,10 @@
|
|||||||
"series_key": "name",
|
"series_key": "name",
|
||||||
"value_key": "bytes",
|
"value_key": "bytes",
|
||||||
"split_duplex": true,
|
"split_duplex": true,
|
||||||
"duplex_labels": ["rx", "tx"],
|
"duplex_labels": [
|
||||||
|
"rx",
|
||||||
|
"tx"
|
||||||
|
],
|
||||||
"rate": true,
|
"rate": true,
|
||||||
"unit": "B/s",
|
"unit": "B/s",
|
||||||
"max_series": 12,
|
"max_series": 12,
|
||||||
@@ -90,7 +96,6 @@
|
|||||||
},
|
},
|
||||||
"refresh_seconds": 5
|
"refresh_seconds": 5
|
||||||
},
|
},
|
||||||
|
|
||||||
"queue_simple_bytes": {
|
"queue_simple_bytes": {
|
||||||
"title": "Simple Queues (RX/TX bytes)",
|
"title": "Simple Queues (RX/TX bytes)",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
@@ -101,7 +106,10 @@
|
|||||||
"series_key": "name",
|
"series_key": "name",
|
||||||
"value_key": "bytes",
|
"value_key": "bytes",
|
||||||
"split_duplex": true,
|
"split_duplex": true,
|
||||||
"duplex_labels": ["rx", "tx"],
|
"duplex_labels": [
|
||||||
|
"rx",
|
||||||
|
"tx"
|
||||||
|
],
|
||||||
"rate": false,
|
"rate": false,
|
||||||
"unit": "B",
|
"unit": "B",
|
||||||
"max_series": 12,
|
"max_series": 12,
|
||||||
@@ -110,7 +118,6 @@
|
|||||||
},
|
},
|
||||||
"refresh_seconds": 30
|
"refresh_seconds": 30
|
||||||
},
|
},
|
||||||
|
|
||||||
"queue_tree_rate": {
|
"queue_tree_rate": {
|
||||||
"title": "Queue Tree (bytes/s)",
|
"title": "Queue Tree (bytes/s)",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
@@ -128,7 +135,6 @@
|
|||||||
},
|
},
|
||||||
"refresh_seconds": 5
|
"refresh_seconds": 5
|
||||||
},
|
},
|
||||||
|
|
||||||
"queue_tree_bytes": {
|
"queue_tree_bytes": {
|
||||||
"title": "Queue Tree (bytes)",
|
"title": "Queue Tree (bytes)",
|
||||||
"widget_type": "timeseries",
|
"widget_type": "timeseries",
|
||||||
@@ -145,5 +151,236 @@
|
|||||||
"ignore_disabled": true
|
"ignore_disabled": true
|
||||||
},
|
},
|
||||||
"refresh_seconds": 30
|
"refresh_seconds": 30
|
||||||
|
},
|
||||||
|
"health_metric": {
|
||||||
|
"title": "System Health Metric",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/system/health",
|
||||||
|
"params": {
|
||||||
|
"name": ""
|
||||||
|
},
|
||||||
|
"extract": {
|
||||||
|
"path": "value",
|
||||||
|
"unit_path": "type"
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5,
|
||||||
|
"item_label": "Health metric"
|
||||||
|
},
|
||||||
|
"health_snapshot": {
|
||||||
|
"title": "System Health Snapshot",
|
||||||
|
"widget_type": "table",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/system/health",
|
||||||
|
"extract": {
|
||||||
|
"columns": [
|
||||||
|
"name",
|
||||||
|
"type",
|
||||||
|
"value"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5
|
||||||
|
},
|
||||||
|
"cpu_per_core": {
|
||||||
|
"title": "CPU Per Core",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/system/resource/cpu",
|
||||||
|
"extract": {
|
||||||
|
"series_key": "cpu",
|
||||||
|
"value_key": "load",
|
||||||
|
"rate": false,
|
||||||
|
"unit": "%",
|
||||||
|
"max_series": 32,
|
||||||
|
"ignore_dynamic": false,
|
||||||
|
"ignore_disabled": false
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5
|
||||||
|
},
|
||||||
|
"device_uptime": {
|
||||||
|
"title": "Device Uptime",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/system/resource",
|
||||||
|
"extract": {
|
||||||
|
"path": "uptime",
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"refresh_seconds": 10
|
||||||
|
},
|
||||||
|
"system_resource_snapshot": {
|
||||||
|
"title": "System Resource Snapshot",
|
||||||
|
"widget_type": "table",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/system/resource",
|
||||||
|
"extract": {
|
||||||
|
"columns": [
|
||||||
|
"platform",
|
||||||
|
"board-name",
|
||||||
|
"version",
|
||||||
|
"uptime",
|
||||||
|
"cpu-load",
|
||||||
|
"free-memory",
|
||||||
|
"total-memory",
|
||||||
|
"free-hdd-space",
|
||||||
|
"total-hdd-space"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"refresh_seconds": 15
|
||||||
|
},
|
||||||
|
"iface_errors": {
|
||||||
|
"title": "Interface Errors/s",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/interface",
|
||||||
|
"params": {
|
||||||
|
"name": ""
|
||||||
|
},
|
||||||
|
"extract": {
|
||||||
|
"counter_groups": [
|
||||||
|
{
|
||||||
|
"name": "errors",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"label": "rx-errors",
|
||||||
|
"key": "rx-error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "tx-errors",
|
||||||
|
"key": "tx-error"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rate": true,
|
||||||
|
"unit": "err/s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5,
|
||||||
|
"item_label": "Interface"
|
||||||
|
},
|
||||||
|
"iface_drops": {
|
||||||
|
"title": "Interface Drops/s",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/interface",
|
||||||
|
"params": {
|
||||||
|
"name": ""
|
||||||
|
},
|
||||||
|
"extract": {
|
||||||
|
"counter_groups": [
|
||||||
|
{
|
||||||
|
"name": "drops",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"label": "rx-drops",
|
||||||
|
"key": "rx-drop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "tx-drops",
|
||||||
|
"key": "tx-drop"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rate": true,
|
||||||
|
"unit": "drop/s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5,
|
||||||
|
"item_label": "Interface"
|
||||||
|
},
|
||||||
|
"iface_packets": {
|
||||||
|
"title": "Interface Packets/s",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/interface",
|
||||||
|
"params": {
|
||||||
|
"name": ""
|
||||||
|
},
|
||||||
|
"extract": {
|
||||||
|
"counter_groups": [
|
||||||
|
{
|
||||||
|
"name": "pps",
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"label": "rx-pps",
|
||||||
|
"key": "rx-packet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "tx-pps",
|
||||||
|
"key": "tx-packet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rate": true,
|
||||||
|
"unit": "pkt/s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5,
|
||||||
|
"item_label": "Interface"
|
||||||
|
},
|
||||||
|
"switch_total_throughput": {
|
||||||
|
"title": "Switch Total Throughput",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/interface",
|
||||||
|
"extract": {
|
||||||
|
"sum_counters": [
|
||||||
|
{
|
||||||
|
"label": "rx",
|
||||||
|
"key": "rx-byte"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "tx",
|
||||||
|
"key": "tx-byte"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sum_rate": true,
|
||||||
|
"sum_unit": "bps",
|
||||||
|
"sum_multiplier": 8,
|
||||||
|
"sum_ignore_dynamic": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5
|
||||||
|
},
|
||||||
|
"switch_total_packets": {
|
||||||
|
"title": "Switch Total Packets/s",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/interface",
|
||||||
|
"extract": {
|
||||||
|
"sum_counters": [
|
||||||
|
{
|
||||||
|
"label": "rx-pps",
|
||||||
|
"key": "rx-packet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "tx-pps",
|
||||||
|
"key": "tx-packet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sum_rate": true,
|
||||||
|
"sum_unit": "pkt/s",
|
||||||
|
"sum_ignore_dynamic": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5
|
||||||
|
},
|
||||||
|
"ethernet_port_snapshot": {
|
||||||
|
"title": "Ethernet Port Snapshot",
|
||||||
|
"widget_type": "table",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/interface/ethernet",
|
||||||
|
"extract": {
|
||||||
|
"columns": [
|
||||||
|
"name",
|
||||||
|
"running",
|
||||||
|
"disabled",
|
||||||
|
"comment",
|
||||||
|
"speed",
|
||||||
|
"full-duplex",
|
||||||
|
"auto-negotiation",
|
||||||
|
"tx-flow-control",
|
||||||
|
"rx-flow-control"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"refresh_seconds": 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
373
mikromon/services/mikromon/services/poller.py
Normal file
373
mikromon/services/mikromon/services/poller.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
import datetime as dt
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from flask import current_app
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from .. import db, socketio
|
||||||
|
from ..models import Widget, MetricLastValue
|
||||||
|
from ..security.crypto import decrypt_json
|
||||||
|
from ..connectors.registry import get_connector
|
||||||
|
|
||||||
|
_scheduler = None
|
||||||
|
_last_emit = {}
|
||||||
|
_last_counters = {}
|
||||||
|
_last_series_counters = {}
|
||||||
|
|
||||||
|
|
||||||
|
def start_dev_scheduler(app):
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler:
|
||||||
|
return
|
||||||
|
_scheduler = BackgroundScheduler(daemon=True)
|
||||||
|
_scheduler.add_job(lambda: _tick(app), "interval", seconds=1, max_instances=1, coalesce=True)
|
||||||
|
_scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _tick(app):
|
||||||
|
with app.app_context():
|
||||||
|
now = dt.datetime.utcnow()
|
||||||
|
widgets = (
|
||||||
|
db.session.query(Widget)
|
||||||
|
.options(joinedload(Widget.device), joinedload(Widget.dashboard))
|
||||||
|
.filter(Widget.is_enabled.is_(True))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
min_refresh = current_app.config.get("USER_MIN_REFRESH_SECONDS", 2)
|
||||||
|
|
||||||
|
for w in widgets:
|
||||||
|
if w.refresh_seconds < min_refresh:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (w.device_id, w.id)
|
||||||
|
last = _last_emit.get(key)
|
||||||
|
if last and (now - last).total_seconds() < w.refresh_seconds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_last_emit[key] = now
|
||||||
|
_poll_widget(w)
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_widget(widget: Widget):
|
||||||
|
device = widget.device
|
||||||
|
if not device or not device.enc_credentials:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
creds = decrypt_json(device.enc_credentials)
|
||||||
|
except Exception:
|
||||||
|
device.last_error = "Credential decrypt failed (check CRED_ENC_KEY)"
|
||||||
|
db.session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
q = json.loads(widget.query_json or "{}")
|
||||||
|
if not isinstance(q, dict):
|
||||||
|
raise ValueError()
|
||||||
|
except Exception:
|
||||||
|
device.last_error = "Invalid widget query JSON"
|
||||||
|
db.session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
connector = get_connector(q.get("connector", "rest"))
|
||||||
|
|
||||||
|
res = connector.fetch(device, creds, q)
|
||||||
|
if not res.ok:
|
||||||
|
device.last_error = res.error
|
||||||
|
db.session.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
device.last_error = None
|
||||||
|
device.last_seen_at = dt.datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
payload = _transform(widget, res.data, q)
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = dt.datetime.utcnow()
|
||||||
|
metric = MetricLastValue.query.filter_by(widget_id=widget.id).first()
|
||||||
|
if metric is None:
|
||||||
|
metric = MetricLastValue(
|
||||||
|
device_id=device.id,
|
||||||
|
widget_id=widget.id,
|
||||||
|
key=f"widget.{widget.id}",
|
||||||
|
value_json=json.dumps(payload),
|
||||||
|
ts=now,
|
||||||
|
)
|
||||||
|
db.session.add(metric)
|
||||||
|
else:
|
||||||
|
metric.device_id = device.id
|
||||||
|
metric.key = f"widget.{widget.id}"
|
||||||
|
metric.value_json = json.dumps(payload)
|
||||||
|
metric.ts = now
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
socketio.emit("metric", payload, room=f"dashboard:{widget.dashboard_id}")
|
||||||
|
socketio.emit("metric", payload, room=f"device:{device.id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _transform(widget: Widget, data, query: dict):
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
extract = query.get("extract") or {}
|
||||||
|
|
||||||
|
if widget.widget_type == "table":
|
||||||
|
cols = extract.get("columns") or []
|
||||||
|
rows = []
|
||||||
|
if isinstance(data, list):
|
||||||
|
for item in data[:200]:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
rows.append({c: item.get(c) for c in cols})
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
rows.append({c: data.get(c) for c in cols})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "table",
|
||||||
|
"widget_id": widget.id,
|
||||||
|
"dashboard_id": widget.dashboard_id,
|
||||||
|
"device_id": widget.device_id,
|
||||||
|
"title": widget.title,
|
||||||
|
"ts": now_ms,
|
||||||
|
"columns": cols,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
points = []
|
||||||
|
|
||||||
|
def _add(series, value, unit):
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
points.append({"series": str(series), "value": float(value), "unit": unit})
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ctx_from_needs(src: dict, needs: list):
|
||||||
|
ctx = {}
|
||||||
|
for k in needs:
|
||||||
|
if not isinstance(k, str):
|
||||||
|
continue
|
||||||
|
val = _to_float(src.get(k))
|
||||||
|
ctx[k] = val
|
||||||
|
if "-" in k:
|
||||||
|
ctx[k.replace("-", "_")] = val
|
||||||
|
if "_" in k:
|
||||||
|
ctx[k.replace("_", "-")] = val
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def _ram_percent(src: dict):
|
||||||
|
total = _to_float(src.get("total-memory"))
|
||||||
|
free = _to_float(src.get("free-memory"))
|
||||||
|
if total is None or free is None:
|
||||||
|
total = _to_float(src.get("total_memory"))
|
||||||
|
free = _to_float(src.get("free_memory"))
|
||||||
|
if total is None or free is None or total <= 0:
|
||||||
|
return None
|
||||||
|
return 100.0 * (total - free) / total
|
||||||
|
|
||||||
|
def _parse_duplex_counter(v):
|
||||||
|
if v is None:
|
||||||
|
return (None, None)
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return (float(v), None)
|
||||||
|
s = str(v).strip()
|
||||||
|
if "/" not in s:
|
||||||
|
return (_to_float(s), None)
|
||||||
|
a, b = s.split("/", 1)
|
||||||
|
return (_to_float(a), _to_float(b))
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "path" in extract:
|
||||||
|
_add(widget.title, _to_float(data.get(extract["path"])), extract.get("unit"))
|
||||||
|
|
||||||
|
if "calc" in extract and "needs" in extract:
|
||||||
|
try:
|
||||||
|
ctx = _ctx_from_needs(data, extract.get("needs") or [])
|
||||||
|
v = _safe_calc(extract["calc"], ctx)
|
||||||
|
_add(widget.title, v, extract.get("unit"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not points and (extract.get("unit") == "%") and ("ram" in widget.title.lower() or "memory" in widget.title.lower()):
|
||||||
|
_add(widget.title, _ram_percent(data), "%")
|
||||||
|
|
||||||
|
elif isinstance(data, list):
|
||||||
|
if "series_key" in extract and "value_key" in extract:
|
||||||
|
label_key = extract["series_key"]
|
||||||
|
value_key = extract["value_key"]
|
||||||
|
id_key = extract.get("id_key", ".id")
|
||||||
|
want_rate = bool(extract.get("rate"))
|
||||||
|
unit = extract.get("unit") or ("B/s" if want_rate else "B")
|
||||||
|
max_series = int(extract.get("max_series", 20))
|
||||||
|
|
||||||
|
ignore_dynamic = bool(extract.get("ignore_dynamic", True))
|
||||||
|
ignore_disabled = bool(extract.get("ignore_disabled", False))
|
||||||
|
|
||||||
|
split_duplex = bool(extract.get("split_duplex", False))
|
||||||
|
duplex_labels = extract.get("duplex_labels") or ["rx", "tx"]
|
||||||
|
|
||||||
|
def _label(it: dict):
|
||||||
|
lab = it.get(label_key)
|
||||||
|
if lab is not None and str(lab).strip():
|
||||||
|
return str(lab).strip()
|
||||||
|
ch = str(it.get("chain") or "").strip()
|
||||||
|
ac = str(it.get("action") or "").strip()
|
||||||
|
rid = str(it.get(id_key) or "").strip()
|
||||||
|
base = ":".join([x for x in [ch, ac, rid] if x])
|
||||||
|
return base or "unnamed"
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for it in data:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
if ignore_dynamic and str(it.get("dynamic", "false")).lower() == "true":
|
||||||
|
continue
|
||||||
|
if ignore_disabled and str(it.get("disabled", "false")).lower() == "true":
|
||||||
|
continue
|
||||||
|
|
||||||
|
rid = it.get(id_key)
|
||||||
|
if rid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
v_raw = it.get(value_key)
|
||||||
|
lab = _label(it)
|
||||||
|
|
||||||
|
if split_duplex:
|
||||||
|
a, b = _parse_duplex_counter(v_raw)
|
||||||
|
if a is not None:
|
||||||
|
items.append((f"{rid}:a", f"{lab} {duplex_labels[0]}", a))
|
||||||
|
if b is not None:
|
||||||
|
items.append((f"{rid}:b", f"{lab} {duplex_labels[1]}", b))
|
||||||
|
else:
|
||||||
|
v = _to_float(v_raw)
|
||||||
|
if v is not None:
|
||||||
|
items.append((str(rid), lab, v))
|
||||||
|
|
||||||
|
items.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
items = items[:max_series]
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if want_rate:
|
||||||
|
for sid, lab, v in items:
|
||||||
|
key = (widget.device_id, widget.id, sid)
|
||||||
|
prev = _last_series_counters.get(key)
|
||||||
|
_last_series_counters[key] = {"ts": now, "v": v}
|
||||||
|
if not prev:
|
||||||
|
continue
|
||||||
|
dt_s = max(now - prev["ts"], 0.001)
|
||||||
|
dv = max(v - prev["v"], 0.0)
|
||||||
|
_add(lab, dv / dt_s, unit)
|
||||||
|
else:
|
||||||
|
for _, lab, v in items:
|
||||||
|
_add(lab, v, unit)
|
||||||
|
|
||||||
|
# B) Select one element (interfaces)
|
||||||
|
params = query.get("params") or {}
|
||||||
|
name = params.get("name")
|
||||||
|
target = None
|
||||||
|
|
||||||
|
if name:
|
||||||
|
for it in data:
|
||||||
|
if isinstance(it, dict) and it.get("name") == name:
|
||||||
|
target = it
|
||||||
|
break
|
||||||
|
target = target or (data[0] if data else None)
|
||||||
|
|
||||||
|
if target and isinstance(target, dict):
|
||||||
|
if "rx_counter" in extract or "tx_counter" in extract:
|
||||||
|
iface = target.get("name") or name or "iface"
|
||||||
|
rx_c = _to_int(target.get(extract.get("rx_counter", "")))
|
||||||
|
tx_c = _to_int(target.get(extract.get("tx_counter", "")))
|
||||||
|
now = time.time()
|
||||||
|
key = (widget.device_id, str(iface))
|
||||||
|
prev = _last_counters.get(key)
|
||||||
|
|
||||||
|
rate_unit = extract.get("rate_unit", "B/s")
|
||||||
|
|
||||||
|
def _to_unit(bytes_per_s, unit_str):
|
||||||
|
if bytes_per_s is None:
|
||||||
|
return None
|
||||||
|
if str(unit_str).lower() == "bps":
|
||||||
|
return bytes_per_s * 8.0
|
||||||
|
return bytes_per_s
|
||||||
|
|
||||||
|
if prev and prev.get("ts") and now > prev["ts"] and rx_c is not None and tx_c is not None:
|
||||||
|
dt_s = now - prev["ts"]
|
||||||
|
rx_rate_b = (rx_c - prev["rx"]) / dt_s
|
||||||
|
tx_rate_b = (tx_c - prev["tx"]) / dt_s
|
||||||
|
if rx_rate_b >= 0:
|
||||||
|
_add("rx", _to_unit(rx_rate_b, rate_unit), rate_unit)
|
||||||
|
if tx_rate_b >= 0:
|
||||||
|
_add("tx", _to_unit(tx_rate_b, rate_unit), rate_unit)
|
||||||
|
|
||||||
|
if rx_c is not None and tx_c is not None:
|
||||||
|
_last_counters[key] = {"ts": now, "rx": rx_c, "tx": tx_c}
|
||||||
|
|
||||||
|
elif "path" in extract:
|
||||||
|
key_name = extract["path"]
|
||||||
|
unit = extract.get("unit")
|
||||||
|
max_items = int(extract.get("max_items", 30))
|
||||||
|
for item in data[:max_items]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
label = item.get("name") or item.get("comment") or item.get(".id") or widget.title
|
||||||
|
_add(label, _to_float(item.get(key_name)), unit)
|
||||||
|
|
||||||
|
elif data and isinstance(data[0], dict):
|
||||||
|
first = data[0]
|
||||||
|
value = None
|
||||||
|
for candidate in ("value", "rx-bits-per-second", "tx-bits-per-second", "bytes", "packets"):
|
||||||
|
if candidate in first:
|
||||||
|
value = _to_float(first.get(candidate))
|
||||||
|
if value is not None:
|
||||||
|
break
|
||||||
|
_add(widget.title, value, extract.get("unit"))
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "timeseries",
|
||||||
|
"widget_id": widget.id,
|
||||||
|
"dashboard_id": widget.dashboard_id,
|
||||||
|
"device_id": widget.device_id,
|
||||||
|
"title": widget.title,
|
||||||
|
"ts": now_ms,
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float(v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
s = str(v).strip().replace(" ", "")
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(v):
|
||||||
|
try:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, int):
|
||||||
|
return v
|
||||||
|
if isinstance(v, float):
|
||||||
|
return int(v)
|
||||||
|
return int(str(v).strip())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_calc(expr: str, ctx: dict):
|
||||||
|
allowed = {k: v for k, v in ctx.items() if isinstance(v, (int, float, type(None)))}
|
||||||
|
if any(v is None for v in allowed.values()):
|
||||||
|
return None
|
||||||
|
return eval(expr, {"__builtins__": {}}, allowed)
|
||||||
@@ -12,7 +12,7 @@ from ..connectors.registry import get_connector
|
|||||||
|
|
||||||
_scheduler = None
|
_scheduler = None
|
||||||
_last_emit = {} # (device_id, widget_id) -> datetime
|
_last_emit = {} # (device_id, widget_id) -> datetime
|
||||||
_last_counters = {} # (device_id, iface_name) -> {"ts": float, "rx": int, "tx": int}
|
_last_counters = {} # (device_id, counter_key) -> {"ts": float, ...counter values...}
|
||||||
_last_series_counters = {} # (device_id, widget_id, series_id) -> {"ts": float, "v": float}
|
_last_series_counters = {} # (device_id, widget_id, series_id) -> {"ts": float, "v": float}
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +180,10 @@ def _transform(widget: Widget, data, query: dict):
|
|||||||
# ---- dict ----
|
# ---- dict ----
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
if "path" in extract:
|
if "path" in extract:
|
||||||
_add(widget.title, _to_float(data.get(extract["path"])), extract.get("unit"))
|
unit = extract.get("unit")
|
||||||
|
if not unit and extract.get("unit_path"):
|
||||||
|
unit = data.get(extract.get("unit_path"))
|
||||||
|
_add(widget.title, _to_float(data.get(extract["path"])), unit)
|
||||||
|
|
||||||
if "calc" in extract and "needs" in extract:
|
if "calc" in extract and "needs" in extract:
|
||||||
try:
|
try:
|
||||||
@@ -281,38 +284,107 @@ def _transform(widget: Widget, data, query: dict):
|
|||||||
target = target or (data[0] if data else None)
|
target = target or (data[0] if data else None)
|
||||||
|
|
||||||
if target and isinstance(target, dict):
|
if target and isinstance(target, dict):
|
||||||
# interface rate from counters
|
counter_groups = []
|
||||||
if "rx_counter" in extract or "tx_counter" in extract:
|
if "rx_counter" in extract or "tx_counter" in extract:
|
||||||
iface = target.get("name") or name or "iface"
|
counter_groups.append({
|
||||||
rx_c = _to_int(target.get(extract.get("rx_counter", "")))
|
"series": [
|
||||||
tx_c = _to_int(target.get(extract.get("tx_counter", "")))
|
{"label": "rx", "key": extract.get("rx_counter", "")},
|
||||||
|
{"label": "tx", "key": extract.get("tx_counter", "")},
|
||||||
|
],
|
||||||
|
"rate": True,
|
||||||
|
"unit": extract.get("rate_unit", "B/s"),
|
||||||
|
"counter_key": str(target.get("name") or name or widget.id),
|
||||||
|
})
|
||||||
|
counter_groups.extend(extract.get("counter_groups") or [])
|
||||||
|
|
||||||
|
for group in counter_groups:
|
||||||
|
series = []
|
||||||
|
for entry in group.get("series") or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
label = str(entry.get("label") or entry.get("key") or "value")
|
||||||
|
key_name = str(entry.get("key") or "")
|
||||||
|
val = _to_int(target.get(key_name))
|
||||||
|
if val is not None:
|
||||||
|
series.append((label, key_name, val))
|
||||||
|
if not series:
|
||||||
|
continue
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
key = (widget.device_id, str(iface))
|
key = (widget.device_id, str(group.get("counter_key") or target.get("name") or name or widget.id), str(group.get("name") or widget.id))
|
||||||
prev = _last_counters.get(key)
|
prev = _last_counters.get(key)
|
||||||
|
unit = group.get("unit") or "count"
|
||||||
|
want_rate = bool(group.get("rate", True))
|
||||||
|
multiply = float(group.get("multiplier", 1.0) or 1.0)
|
||||||
|
|
||||||
rate_unit = extract.get("rate_unit", "B/s")
|
if want_rate:
|
||||||
|
if prev and prev.get("ts") and now > prev["ts"]:
|
||||||
def _to_unit(bytes_per_s, unit_str):
|
|
||||||
if bytes_per_s is None:
|
|
||||||
return None
|
|
||||||
if str(unit_str).lower() == "bps":
|
|
||||||
return bytes_per_s * 8.0
|
|
||||||
return bytes_per_s
|
|
||||||
|
|
||||||
if prev and prev.get("ts") and now > prev["ts"] and rx_c is not None and tx_c is not None:
|
|
||||||
dt_s = now - prev["ts"]
|
dt_s = now - prev["ts"]
|
||||||
rx_rate_b = (rx_c - prev["rx"]) / dt_s
|
for label, key_name, val in series:
|
||||||
tx_rate_b = (tx_c - prev["tx"]) / dt_s
|
prev_val = prev.get(key_name)
|
||||||
if rx_rate_b >= 0:
|
if prev_val is None:
|
||||||
_add("rx", _to_unit(rx_rate_b, rate_unit), rate_unit)
|
continue
|
||||||
if tx_rate_b >= 0:
|
delta = val - prev_val
|
||||||
_add("tx", _to_unit(tx_rate_b, rate_unit), rate_unit)
|
if delta >= 0:
|
||||||
|
_add(label, (delta / dt_s) * multiply, unit)
|
||||||
|
state = {"ts": now}
|
||||||
|
for _, key_name, val in series:
|
||||||
|
state[key_name] = val
|
||||||
|
_last_counters[key] = state
|
||||||
|
else:
|
||||||
|
for label, _, val in series:
|
||||||
|
_add(label, val * multiply, unit)
|
||||||
|
|
||||||
if rx_c is not None and tx_c is not None:
|
if "sum_counters" in extract:
|
||||||
_last_counters[key] = {"ts": now, "rx": rx_c, "tx": tx_c}
|
entries = []
|
||||||
|
for entry in extract.get("sum_counters") or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
label = str(entry.get("label") or entry.get("key") or "value")
|
||||||
|
key_name = str(entry.get("key") or "")
|
||||||
|
total = 0
|
||||||
|
found = False
|
||||||
|
for it in data:
|
||||||
|
if not isinstance(it, dict):
|
||||||
|
continue
|
||||||
|
if str(it.get("dynamic", "false")).lower() == "true" and extract.get("sum_ignore_dynamic", True):
|
||||||
|
continue
|
||||||
|
val = _to_int(it.get(key_name))
|
||||||
|
if val is not None:
|
||||||
|
total += val
|
||||||
|
found = True
|
||||||
|
if found:
|
||||||
|
entries.append((label, key_name, total))
|
||||||
|
if entries:
|
||||||
|
now = time.time()
|
||||||
|
key = (widget.device_id, f"sum:{widget.id}", "sum_counters")
|
||||||
|
prev = _last_counters.get(key)
|
||||||
|
unit = extract.get("sum_unit") or "count/s"
|
||||||
|
want_rate = bool(extract.get("sum_rate", True))
|
||||||
|
multiply = float(extract.get("sum_multiplier", 1.0) or 1.0)
|
||||||
|
if want_rate:
|
||||||
|
if prev and prev.get("ts") and now > prev["ts"]:
|
||||||
|
dt_s = now - prev["ts"]
|
||||||
|
for label, key_name, total in entries:
|
||||||
|
prev_val = prev.get(key_name)
|
||||||
|
if prev_val is None:
|
||||||
|
continue
|
||||||
|
delta = total - prev_val
|
||||||
|
if delta >= 0:
|
||||||
|
_add(label, (delta / dt_s) * multiply, unit)
|
||||||
|
state = {"ts": now}
|
||||||
|
for _, key_name, total in entries:
|
||||||
|
state[key_name] = total
|
||||||
|
_last_counters[key] = state
|
||||||
|
else:
|
||||||
|
for label, _, total in entries:
|
||||||
|
_add(label, total * multiply, unit)
|
||||||
|
|
||||||
if "path" in extract:
|
if "path" in extract:
|
||||||
_add(widget.title, _to_float(target.get(extract["path"])), extract.get("unit"))
|
unit = extract.get("unit")
|
||||||
|
if not unit and extract.get("unit_path"):
|
||||||
|
unit = target.get(extract.get("unit_path"))
|
||||||
|
_add(widget.title, _to_float(target.get(extract["path"])), unit)
|
||||||
|
|
||||||
if "calc" in extract and "needs" in extract:
|
if "calc" in extract and "needs" in extract:
|
||||||
try:
|
try:
|
||||||
@@ -347,7 +419,7 @@ def _to_float(v):
|
|||||||
s = s[:-1]
|
s = s[:-1]
|
||||||
return float(s)
|
return float(s)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return _to_duration_seconds(v)
|
||||||
|
|
||||||
|
|
||||||
def _to_int(v):
|
def _to_int(v):
|
||||||
@@ -358,11 +430,36 @@ def _to_int(v):
|
|||||||
return v
|
return v
|
||||||
if isinstance(v, float):
|
if isinstance(v, float):
|
||||||
return int(v)
|
return int(v)
|
||||||
return int(str(v).strip())
|
return int(float(str(v).strip()))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_duration_seconds(v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
s = str(v).strip().lower()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
total = 0.0
|
||||||
|
buf = ""
|
||||||
|
units = {"w": 7 * 24 * 3600, "d": 24 * 3600, "h": 3600, "m": 60, "s": 1}
|
||||||
|
for ch in s:
|
||||||
|
if ch.isdigit() or ch == '.':
|
||||||
|
buf += ch
|
||||||
|
continue
|
||||||
|
if ch in units and buf:
|
||||||
|
total += float(buf) * units[ch]
|
||||||
|
buf = ""
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
if buf:
|
||||||
|
total += float(buf)
|
||||||
|
return total if total > 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_calc(expr: str, ctx: dict):
|
def _safe_calc(expr: str, ctx: dict):
|
||||||
import ast
|
import ast
|
||||||
import operator as op
|
import operator as op
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ argon2-cffi==23.1.0
|
|||||||
cryptography==43.0.1
|
cryptography==43.0.1
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
paramiko==3.5.0
|
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
redis==5.0.8
|
redis==5.0.8
|
||||||
rq==1.16.2
|
rq==1.16.2
|
||||||
|
|||||||
113
static/js/app.js
113
static/js/app.js
@@ -1,8 +1,4 @@
|
|||||||
(function(){
|
(function(){
|
||||||
function ringPush(arr, item, max){
|
|
||||||
arr.push(item);
|
|
||||||
if(arr.length > max) arr.splice(0, arr.length-max);
|
|
||||||
}
|
|
||||||
function ensureLibs(){
|
function ensureLibs(){
|
||||||
return new Promise((resolve)=>{
|
return new Promise((resolve)=>{
|
||||||
const tick=()=>{ if(window.io && window.Chart) return resolve(); setTimeout(tick,50); };
|
const tick=()=>{ if(window.io && window.Chart) return resolve(); setTimeout(tick,50); };
|
||||||
@@ -11,40 +7,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const charts=new Map();
|
const charts=new Map();
|
||||||
const seriesData=new Map(); // widgetId -> {name: [{x,y}]}
|
const seriesData=new Map();
|
||||||
const seriesUnit=new Map(); // widgetId -> {name: unit}
|
const seriesUnit=new Map();
|
||||||
const maxPoints=900;
|
const selectedRange=new Map();
|
||||||
|
const maxWindowMs=10*60*60*1000;
|
||||||
|
const rangeMap={
|
||||||
|
'1m': 1*60*1000,
|
||||||
|
'10m': 10*60*1000,
|
||||||
|
'1h': 60*60*1000,
|
||||||
|
'3h': 3*60*60*1000,
|
||||||
|
'10h': 10*60*60*1000,
|
||||||
|
};
|
||||||
|
|
||||||
function isNum(v){ return typeof v==='number' && Number.isFinite(v); }
|
function isNum(v){ return typeof v==='number' && Number.isFinite(v); }
|
||||||
|
|
||||||
function scale(v, unit){
|
function scale(v, unit){
|
||||||
if(!isNum(v)) return {v, u:''};
|
if(!isNum(v)) return {v, u:''};
|
||||||
const u=(unit||'').toLowerCase();
|
const u=(unit||'').toLowerCase();
|
||||||
|
|
||||||
if(u==='%' || u==='pct' || u==='percent') return {v, u:'%'};
|
if(u==='%' || u==='pct' || u==='percent') return {v, u:'%'};
|
||||||
|
|
||||||
if(u==='bps' || u==='bit/s' || u==='bits/s'){
|
if(u==='bps' || u==='bit/s' || u==='bits/s'){
|
||||||
const steps=['bps','Kbps','Mbps','Gbps','Tbps'];
|
const steps=['bps','Kbps','Mbps','Gbps','Tbps'];
|
||||||
let i=0, x=v;
|
let i=0, x=v;
|
||||||
while(Math.abs(x)>=1000 && i<steps.length-1){ x/=1000; i++; }
|
while(Math.abs(x)>=1000 && i<steps.length-1){ x/=1000; i++; }
|
||||||
return {v:x, u:steps[i]};
|
return {v:x, u:steps[i]};
|
||||||
}
|
}
|
||||||
|
|
||||||
if(u==='b/s' || u==='bytes/s' || u==='byte/s'){
|
if(u==='b/s' || u==='bytes/s' || u==='byte/s'){
|
||||||
const steps=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];
|
const steps=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];
|
||||||
let i=0, x=v;
|
let i=0, x=v;
|
||||||
while(Math.abs(x)>=1024 && i<steps.length-1){ x/=1024; i++; }
|
while(Math.abs(x)>=1024 && i<steps.length-1){ x/=1024; i++; }
|
||||||
return {v:x, u:steps[i]};
|
return {v:x, u:steps[i]};
|
||||||
}
|
}
|
||||||
|
|
||||||
if(u==='b' || u==='bytes'){
|
if(u==='b' || u==='bytes'){
|
||||||
const steps=['B','KiB','MiB','GiB','TiB'];
|
const steps=['B','KiB','MiB','GiB','TiB'];
|
||||||
let i=0, x=v;
|
let i=0, x=v;
|
||||||
while(Math.abs(x)>=1024 && i<steps.length-1){ x/=1024; i++; }
|
while(Math.abs(x)>=1024 && i<steps.length-1){ x/=1024; i++; }
|
||||||
return {v:x, u:steps[i]};
|
return {v:x, u:steps[i]};
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback
|
|
||||||
return {v, u:unit||''};
|
return {v, u:unit||''};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +59,7 @@
|
|||||||
for(let i=0;i<str.length;i++){ h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
for(let i=0;i<str.length;i++){ h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||||
return h>>>0;
|
return h>>>0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorFor(name){
|
function colorFor(name){
|
||||||
const hue = (hash32(String(name)) % 360);
|
const hue = (hash32(String(name)) % 360);
|
||||||
return {
|
return {
|
||||||
@@ -69,6 +68,17 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRangeMs(widgetId){
|
||||||
|
return rangeMap[selectedRange.get(widgetId) || '1h'] || rangeMap['1h'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneStore(store, latestTs){
|
||||||
|
const minTs=latestTs-maxWindowMs;
|
||||||
|
for(const name of Object.keys(store)){
|
||||||
|
store[name]=store[name].filter(p=>p.x.getTime()>=minTs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function upsertChart(widgetId){
|
function upsertChart(widgetId){
|
||||||
const el=document.getElementById('chart-'+widgetId);
|
const el=document.getElementById('chart-'+widgetId);
|
||||||
if(!el) return null;
|
if(!el) return null;
|
||||||
@@ -90,7 +100,6 @@
|
|||||||
beginAtZero:false,
|
beginAtZero:false,
|
||||||
ticks:{
|
ticks:{
|
||||||
callback:(tick)=>{
|
callback:(tick)=>{
|
||||||
// jeśli w widżecie jest 1 unit -> formatuj, jeśli różne -> surowo
|
|
||||||
const uMap = seriesUnit.get(widgetId) || {};
|
const uMap = seriesUnit.get(widgetId) || {};
|
||||||
const units = Object.values(uMap).filter(Boolean);
|
const units = Object.values(uMap).filter(Boolean);
|
||||||
const uniq = [...new Set(units)];
|
const uniq = [...new Set(units)];
|
||||||
@@ -120,6 +129,7 @@
|
|||||||
charts.set(widgetId, chart);
|
charts.set(widgetId, chart);
|
||||||
seriesData.set(widgetId, {});
|
seriesData.set(widgetId, {});
|
||||||
seriesUnit.set(widgetId, {});
|
seriesUnit.set(widgetId, {});
|
||||||
|
if(!selectedRange.has(widgetId)) selectedRange.set(widgetId, '1h');
|
||||||
return chart;
|
return chart;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,31 +157,19 @@
|
|||||||
tbody.innerHTML=(msg.rows||[]).map(r=>'<tr>'+cols.map(c=>'<td>'+escapeHtml(r[c] ?? '')+'</td>').join('')+'</tr>').join('');
|
tbody.innerHTML=(msg.rows||[]).map(r=>'<tr>'+cols.map(c=>'<td>'+escapeHtml(r[c] ?? '')+'</td>').join('')+'</tr>').join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTimeseries(msg){
|
function renderChart(widgetId, latestTs){
|
||||||
const chart=upsertChart(msg.widget_id);
|
const chart=upsertChart(widgetId);
|
||||||
if(!chart) return;
|
if(!chart) return;
|
||||||
|
const store=seriesData.get(widgetId) || {};
|
||||||
|
const rangeMs=getRangeMs(widgetId);
|
||||||
|
const minTs=latestTs-rangeMs;
|
||||||
|
|
||||||
const t=new Date(msg.ts);
|
chart.data.datasets = Object.keys(store).map(name=>{
|
||||||
const store=seriesData.get(msg.widget_id) || {};
|
|
||||||
const uMap=seriesUnit.get(msg.widget_id) || {};
|
|
||||||
|
|
||||||
for(const p of (msg.points||[])){
|
|
||||||
if(p.value===null || p.value===undefined) continue;
|
|
||||||
const s=p.series || 'value';
|
|
||||||
if(!store[s]) store[s]=[];
|
|
||||||
ringPush(store[s], {x:t, y:Number(p.value)}, maxPoints);
|
|
||||||
if(p.unit) uMap[s]=String(p.unit);
|
|
||||||
}
|
|
||||||
|
|
||||||
seriesData.set(msg.widget_id, store);
|
|
||||||
seriesUnit.set(msg.widget_id, uMap);
|
|
||||||
|
|
||||||
const names = Object.keys(store);
|
|
||||||
chart.data.datasets = names.map(name=>{
|
|
||||||
const c=colorFor(name);
|
const c=colorFor(name);
|
||||||
|
const data=(store[name]||[]).filter(p=>p.x.getTime()>=minTs);
|
||||||
return {
|
return {
|
||||||
label:name,
|
label:name,
|
||||||
data:store[name],
|
data,
|
||||||
borderWidth:2,
|
borderWidth:2,
|
||||||
pointRadius:0,
|
pointRadius:0,
|
||||||
tension:0.15,
|
tension:0.15,
|
||||||
@@ -181,13 +179,58 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chart.options.scales.x.min=minTs;
|
||||||
|
chart.options.scales.x.max=latestTs;
|
||||||
chart.update('none');
|
chart.update('none');
|
||||||
setMeta(msg.widget_id, 'Updated: '+t.toLocaleTimeString());
|
}
|
||||||
|
|
||||||
|
function handleTimeseries(msg){
|
||||||
|
const t=new Date(msg.ts);
|
||||||
|
const widgetId=msg.widget_id;
|
||||||
|
upsertChart(widgetId);
|
||||||
|
|
||||||
|
const store=seriesData.get(widgetId) || {};
|
||||||
|
const uMap=seriesUnit.get(widgetId) || {};
|
||||||
|
|
||||||
|
for(const p of (msg.points||[])){
|
||||||
|
if(p.value===null || p.value===undefined) continue;
|
||||||
|
const s=p.series || 'value';
|
||||||
|
if(!store[s]) store[s]=[];
|
||||||
|
store[s].push({x:t, y:Number(p.value)});
|
||||||
|
if(p.unit) uMap[s]=String(p.unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneStore(store, t.getTime());
|
||||||
|
seriesData.set(widgetId, store);
|
||||||
|
seriesUnit.set(widgetId, uMap);
|
||||||
|
renderChart(widgetId, t.getTime());
|
||||||
|
setMeta(widgetId, 'Updated: '+t.toLocaleTimeString()+' · Range: '+(selectedRange.get(widgetId)||'1h'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRangeSelectors(){
|
||||||
|
document.querySelectorAll('[data-range-widget]').forEach((el)=>{
|
||||||
|
const widgetId=Number(el.getAttribute('data-range-widget'));
|
||||||
|
selectedRange.set(widgetId, el.value || '1h');
|
||||||
|
el.addEventListener('change', ()=>{
|
||||||
|
selectedRange.set(widgetId, el.value || '1h');
|
||||||
|
const store=seriesData.get(widgetId) || {};
|
||||||
|
let latestTs=Date.now();
|
||||||
|
for(const points of Object.values(store)){
|
||||||
|
if(points.length){
|
||||||
|
const ts=points[points.length-1].x.getTime();
|
||||||
|
if(ts>latestTs) latestTs=ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderChart(widgetId, latestTs);
|
||||||
|
setMeta(widgetId, 'Updated: '+new Date(latestTs).toLocaleTimeString()+' · Range: '+(selectedRange.get(widgetId)||'1h'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(){
|
async function main(){
|
||||||
if(!window.MIKROMON || !window.MIKROMON.dashboardId) return;
|
if(!window.MIKROMON || !window.MIKROMON.dashboardId) return;
|
||||||
await ensureLibs();
|
await ensureLibs();
|
||||||
|
initRangeSelectors();
|
||||||
|
|
||||||
const socket=io({transports:['polling'], upgrade:false});
|
const socket=io({transports:['polling'], upgrade:false});
|
||||||
socket.on('connect', ()=>{
|
socket.on('connect', ()=>{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">Audit log</h1>
|
<h1 class="h3 mb-0">Audit log</h1>
|
||||||
<div class="text-muted">Last logs (limit 200).</div>
|
<div class="text-muted">Last logs (limit 200)</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">Admin panel</h1>
|
<h1 class="h3 mb-0">Admin panel</h1>
|
||||||
<div class="text-muted">Administrative tools and system overview.</div>
|
<div class="text-muted">Administrative tools and system overview</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin.users') }}"><i class="fa-solid fa-users me-1"></i>Users</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('admin.users') }}"><i class="fa-solid fa-users me-1"></i>Users</a>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">SMTP</h1>
|
<h1 class="h3 mb-0">SMTP</h1>
|
||||||
<div class="text-muted">Test email sending configuration.</div>
|
<div class="text-muted">Test email sending configuration</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,12 +30,12 @@
|
|||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="fw-semibold mb-2"><i class="fa-solid fa-gear me-2"></i>Wymagane zmienne</div>
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-gear me-2"></i>Variables</div>
|
||||||
<ul class="small mb-0">
|
<ul class="small mb-0">
|
||||||
<li><code>SMTP_HOST</code>, <code>SMTP_PORT</code></li>
|
<li><code>SMTP_HOST</code>, <code>SMTP_PORT</code></li>
|
||||||
<li><code>SMTP_FROM</code></li>
|
<li><code>SMTP_FROM</code></li>
|
||||||
<li>opcjonalnie: <code>SMTP_USER</code>, <code>SMTP_PASS</code></li>
|
<li>optional: <code>SMTP_USER</code>, <code>SMTP_PASS</code></li>
|
||||||
<li>opcjonalnie: <code>SMTP_USE_TLS</code> / <code>SMTP_USE_SSL</code></li>
|
<li>optional: <code>SMTP_USE_TLS</code> / <code>SMTP_USE_SSL</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">Users</h1>
|
<h1 class="h3 mb-0">Users</h1>
|
||||||
<div class="text-muted">Lista kont w systemie.</div>
|
<div class="text-muted">Accounts</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Rola</th>
|
<th>Role</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Utworzono</th>
|
<th>Created</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">API</h1>
|
<h1 class="h3 mb-0">API</h1>
|
||||||
<div class="text-muted">Endpoints overview (UI reference only).</div>
|
<div class="text-muted">Endpoints overview (UI reference only)</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('dashboards.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('dashboards.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">Dashboards</h1>
|
<h1 class="h3 mb-0">Dashboards</h1>
|
||||||
<div class="text-muted">Your monitoring dashboards.</div>
|
<div class="text-muted">Your monitoring dashboards</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary" href="{{ url_for('dashboards.new') }}"><i class="fa-solid fa-plus me-1"></i>New dashboard</a>
|
<a class="btn btn-primary" href="{{ url_for('dashboards.new') }}"><i class="fa-solid fa-plus me-1"></i>New dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,7 +15,13 @@
|
|||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="fw-semibold">{{ d.name }}</div>
|
<div class="fw-semibold">{{ d.name }}</div>
|
||||||
<div class="text-muted small">{{ d.description or '' }}</div>
|
<div class="text-muted small mb-3">{{ d.description or '' }}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 small">
|
||||||
|
<span class="badge text-bg-light">{{ d.widgets_count }} widgets</span>
|
||||||
|
<span class="badge text-bg-light">{{ d.devices_count }} devices</span>
|
||||||
|
<span class="badge text-bg-light">{{ d.charts_count }} charts</span>
|
||||||
|
<span class="badge text-bg-light">{{ d.tables_count }} tables</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer bg-white border-0 pt-0 pb-3 px-3">
|
<div class="card-footer bg-white border-0 pt-0 pb-3 px-3">
|
||||||
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('dashboards.view', dashboard_id=d.id) }}">Open <i class="fa-solid fa-arrow-right ms-1"></i></a>
|
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('dashboards.view', dashboard_id=d.id) }}">Open <i class="fa-solid fa-arrow-right ms-1"></i></a>
|
||||||
|
|||||||
@@ -31,13 +31,20 @@ window.MIKROMON = {
|
|||||||
{% for w in widgets %}
|
{% for w in widgets %}
|
||||||
<div class="col-12 col-lg-6">
|
<div class="col-12 col-lg-6">
|
||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white d-flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||||
<div class="card-header bg-white">
|
|
||||||
<div class="fw-semibold">{{ w.title }}</div>
|
<div class="fw-semibold">{{ w.title }}</div>
|
||||||
|
{% if w.widget_type != 'table' %}
|
||||||
|
<select class="form-select form-select-sm" data-range-widget="{{ w.id }}" style="width:auto">
|
||||||
|
<option value="1m">1m</option>
|
||||||
|
<option value="10m">10m</option>
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="3h">3h</option>
|
||||||
|
<option value="10h">10h</option>
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
{% if w.widget_type == 'table' %}
|
{% if w.widget_type == 'table' %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle mb-0" data-table-widget="{{ w.id }}">
|
<table class="table table-sm align-middle mb-0" data-table-widget="{{ w.id }}">
|
||||||
@@ -46,7 +53,7 @@ window.MIKROMON = {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap" style="height: {{ w.height_px or 260 }}px;">
|
||||||
<canvas id="chart-{{ w.id }}"></canvas>
|
<canvas id="chart-{{ w.id }}"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small mt-2" id="meta-{{ w.id }}">
|
<div class="text-muted small mt-2" id="meta-{{ w.id }}">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="fw-semibold mb-2"><i class="fa-solid fa-user-plus me-2"></i>Share with user</div>
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-user-plus me-2"></i>Share with user</div>
|
||||||
<form method="post" action="{{ url_for('dashboards.share_post', dashboard_id=dashboard.id) }}">
|
<form method="post" action="{{ url_for('dashboards.share_add', dashboard_id=dashboard.id) }}">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="row g-2">
|
<div class="row g-2">
|
||||||
<div class="col-12 col-md-7">{{ form.email(class_="form-control", placeholder="email@example.com") }}</div>
|
<div class="col-12 col-md-7">{{ form.email(class_="form-control", placeholder="email@example.com") }}</div>
|
||||||
@@ -46,18 +46,21 @@
|
|||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="fw-semibold mb-2"><i class="fa-solid fa-link me-2"></i>Public link</div>
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-link me-2"></i>Public link</div>
|
||||||
{% if public %}
|
{% if public_link %}
|
||||||
<div class="alert alert-success small">
|
<div class="alert alert-success small">
|
||||||
<a href="{{ url_for('dashboards.public_view', token=public.token) }}" target="_blank">{{ url_for('dashboards.public_view', token=public.token, _external=true) }}</a>
|
<a href="{{ url_for('dashboards.public_view', token=public_link.token) }}" target="_blank">{{ url_for('dashboards.public_view', token=public_link.token, _external=true) }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('dashboards.public_link_delete', dashboard_id=dashboard.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-danger" type="submit"><i class="fa-solid fa-trash me-1"></i>Delete link</button>
|
||||||
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-secondary small">No active public link.</div>
|
<div class="alert alert-secondary small">No active public link.</div>
|
||||||
{% endif %}
|
<form method="post" action="{{ url_for('dashboards.public_link_create', dashboard_id=dashboard.id) }}">
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('dashboards.share_public', dashboard_id=dashboard.id) }}">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn-outline-primary" type="submit"><i class="fa-solid fa-rotate me-1"></i>Create / refresh</button>
|
<button class="btn btn-outline-primary" type="submit"><i class="fa-solid fa-link me-1"></i>Create link</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,13 +19,24 @@
|
|||||||
{% for w in widgets %}
|
{% for w in widgets %}
|
||||||
<div class="col-12 col-lg-{{ w.col_span or 6 }}">
|
<div class="col-12 col-lg-{{ w.col_span or 6 }}">
|
||||||
<div class="card shadow-sm h-100">
|
<div class="card shadow-sm h-100">
|
||||||
<div class="card-header bg-white d-flex align-items-center justify-content-between">
|
<div class="card-header bg-white d-flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||||
<div class="fw-semibold">{{ w.title }}</div>
|
<div class="fw-semibold">{{ w.title }}</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
{% if w.widget_type != 'table' %}
|
||||||
|
<select class="form-select form-select-sm" data-range-widget="{{ w.id }}" style="width:auto">
|
||||||
|
<option value="1m">1m</option>
|
||||||
|
<option value="10m">10m</option>
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="3h">3h</option>
|
||||||
|
<option value="10h">10h</option>
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('dashboards.widget_delete', dashboard_id=dashboard.id, widget_id=w.id) }}">
|
<form method="post" action="{{ url_for('dashboards.widget_delete', dashboard_id=dashboard.id, widget_id=w.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn-sm btn-outline-danger" type="submit" title="Delete"><i class="fa-solid fa-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" type="submit" title="Delete"><i class="fa-solid fa-trash"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if w.widget_type == 'table' %}
|
{% if w.widget_type == 'table' %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<select class="form-select" id="itemSelect">
|
<select class="form-select" id="itemSelect">
|
||||||
<option value="">— select —</option>
|
<option value="">— select —</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">E.g. interface / queue (depends on preset).</div>
|
<div class="form-text" id="itemHelp">E.g. interface / queue (depends on preset).</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -91,6 +91,7 @@
|
|||||||
const itemWrap = document.getElementById('itemWrap');
|
const itemWrap = document.getElementById('itemWrap');
|
||||||
const itemSelect = document.getElementById('itemSelect');
|
const itemSelect = document.getElementById('itemSelect');
|
||||||
const itemLabel = document.getElementById('itemLabel');
|
const itemLabel = document.getElementById('itemLabel');
|
||||||
|
const itemHelp = document.getElementById('itemHelp');
|
||||||
|
|
||||||
function presetNeedsItem(p){
|
function presetNeedsItem(p){
|
||||||
if(!p) return false;
|
if(!p) return false;
|
||||||
@@ -109,7 +110,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
itemWrap.classList.remove('d-none');
|
itemWrap.classList.remove('d-none');
|
||||||
itemLabel.textContent = String(presetKey).includes('queue') ? 'Queue' : 'Interface / item';
|
itemLabel.textContent = p.item_label || (String(presetKey).includes('queue') ? 'Queue' : 'Interface / item');
|
||||||
|
itemHelp.textContent = p.item_help || 'E.g. interface / queue / metric (depends on preset).';
|
||||||
itemSelect.innerHTML = '<option value="">Loading…</option>';
|
itemSelect.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
|
||||||
try{
|
try{
|
||||||
|
|||||||
@@ -26,11 +26,15 @@
|
|||||||
{{ form.host(class_="form-control") }}
|
{{ form.host(class_="form-control") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-4">
|
||||||
<label class="form-label">REST port</label>
|
<label class="form-label">Protocol</label>
|
||||||
{{ form.rest_port(class_="form-control") }}
|
{{ form.rest_scheme(class_="form-select", id="restScheme") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label">REST port</label>
|
||||||
|
{{ form.rest_port(class_="form-control", id="restPort") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
<label class="form-label">REST base path</label>
|
<label class="form-label">REST base path</label>
|
||||||
{{ form.rest_base_path(class_="form-control") }}
|
{{ form.rest_base_path(class_="form-control") }}
|
||||||
</div>
|
</div>
|
||||||
@@ -45,24 +49,12 @@
|
|||||||
<div class="form-text">Leave blank to keep the current password.</div>
|
<div class="form-text">Leave blank to keep the current password.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12" id="tlsRow">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
{{ form.allow_insecure_tls(class_="form-check-input") }}
|
{{ form.allow_insecure_tls(class_="form-check-input") }}
|
||||||
<label class="form-check-label">Allow insecure TLS (self-signed)</label>
|
<label class="form-check-label">Allow insecure TLS (self-signed)</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
{{ form.ssh_enabled(class_="form-check-input", id="sshEnabled") }}
|
|
||||||
<label class="form-check-label" for="sshEnabled">Enable SSH connector</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<label class="form-label">SSH port</label>
|
|
||||||
{{ form.ssh_port(class_="form-control") }}
|
|
||||||
<div class="form-text">Used only when SSH is enabled.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
@@ -78,10 +70,22 @@
|
|||||||
<div class="fw-semibold mb-2"><i class="fa-solid fa-circle-info me-2"></i>Notes</div>
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-circle-info me-2"></i>Notes</div>
|
||||||
<ul class="small mb-0">
|
<ul class="small mb-0">
|
||||||
<li>Changing credentials updates the encrypted secret stored in the database.</li>
|
<li>Changing credentials updates the encrypted secret stored in the database.</li>
|
||||||
<li>If REST fails, verify host/port/path and TLS setting.</li>
|
<li>For HTTP, TLS options are ignored.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const scheme=document.getElementById('restScheme');
|
||||||
|
const tlsRow=document.getElementById('tlsRow');
|
||||||
|
function sync(){
|
||||||
|
tlsRow.style.display=((scheme.value||'https')==='https')?'':'none';
|
||||||
|
}
|
||||||
|
scheme.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">Devices</h1>
|
<h1 class="h3 mb-0">Devices</h1>
|
||||||
<div class="text-muted">Routers / hosts to monitor.</div>
|
<div class="text-muted">Routers / hosts to monitor</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary" href="{{ url_for('devices.new') }}"><i class="fa-solid fa-plus me-1"></i>Add device</a>
|
<a class="btn btn-primary" href="{{ url_for('devices.new') }}"><i class="fa-solid fa-plus me-1"></i>Add device</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-0">Add device</h1>
|
<h1 class="h3 mb-0">Add device</h1>
|
||||||
<div class="text-muted">Configure REST/SSH access.</div>
|
<div class="text-muted">Configure RouterOS REST access</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-outline-secondary" href="{{ url_for('devices.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
<a class="btn btn-outline-secondary" href="{{ url_for('devices.index') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,11 +26,15 @@
|
|||||||
{{ form.host(class_="form-control", placeholder="192.168.1.1 or router.example.com") }}
|
{{ form.host(class_="form-control", placeholder="192.168.1.1 or router.example.com") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-4">
|
||||||
<label class="form-label">REST port</label>
|
<label class="form-label">Protocol</label>
|
||||||
{{ form.rest_port(class_="form-control") }}
|
{{ form.rest_scheme(class_="form-select", id="restScheme") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-4">
|
||||||
|
<label class="form-label">REST port</label>
|
||||||
|
{{ form.rest_port(class_="form-control", id="restPort") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
<label class="form-label">REST base path</label>
|
<label class="form-label">REST base path</label>
|
||||||
{{ form.rest_base_path(class_="form-control", placeholder="/rest") }}
|
{{ form.rest_base_path(class_="form-control", placeholder="/rest") }}
|
||||||
</div>
|
</div>
|
||||||
@@ -44,24 +48,12 @@
|
|||||||
{{ form.password(class_="form-control", placeholder="••••••••") }}
|
{{ form.password(class_="form-control", placeholder="••••••••") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
<div class="col-12" id="tlsRow">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
{{ form.allow_insecure_tls(class_="form-check-input") }}
|
{{ form.allow_insecure_tls(class_="form-check-input") }}
|
||||||
<label class="form-check-label">Allow insecure TLS (self-signed)</label>
|
<label class="form-check-label">Allow insecure TLS (self-signed)</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
{{ form.ssh_enabled(class_="form-check-input", id="sshEnabled") }}
|
|
||||||
<label class="form-check-label" for="sshEnabled">Enable SSH connector</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<label class="form-label">SSH port</label>
|
|
||||||
{{ form.ssh_port(class_="form-control") }}
|
|
||||||
<div class="form-text">Used only when SSH is enabled.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-3">
|
<hr class="my-3">
|
||||||
@@ -76,12 +68,31 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="fw-semibold mb-2"><i class="fa-solid fa-circle-info me-2"></i>Tips</div>
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-circle-info me-2"></i>Tips</div>
|
||||||
<ul class="small mb-0">
|
<ul class="small mb-0">
|
||||||
<li>REST uses the MikroTik API (<code>/rest</code>).</li>
|
<li>Use HTTPS for encrypted RouterOS REST.</li>
|
||||||
<li>If you use a self-signed cert, enable insecure TLS.</li>
|
<li>Use HTTP only for local trusted networks when needed.</li>
|
||||||
<li>SSH is optional (e.g. for commands/reads).</li>
|
<li>Default path is <code>/rest</code>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const scheme=document.getElementById('restScheme');
|
||||||
|
const port=document.getElementById('restPort');
|
||||||
|
const tlsRow=document.getElementById('tlsRow');
|
||||||
|
function sync(){
|
||||||
|
const isHttps=(scheme.value||'https')==='https';
|
||||||
|
tlsRow.style.display=isHttps?'':'none';
|
||||||
|
if(!port.value || port.dataset.autofill==='1'){
|
||||||
|
port.value=isHttps?'443':'80';
|
||||||
|
port.dataset.autofill='1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
port.addEventListener('input', ()=>{ port.dataset.autofill='0'; });
|
||||||
|
scheme.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head><meta charset="utf-8"></head>
|
<head><meta charset="utf-8"></head>
|
||||||
<body style="font-family:Arial,Helvetica,sans-serif;line-height:1.4">
|
<body style="font-family:Arial,Helvetica,sans-serif;line-height:1.4">
|
||||||
<h2 style="margin:0 0 12px 0">MikroMon — password reset</h2>
|
<h2 style="margin:0 0 12px 0">MikroMon — password reset</h2>
|
||||||
<p>We received a request to reset your password.</p>
|
<p>We received a request to reset your password</p>
|
||||||
<p><a href="{{ reset_url }}" style="display:inline-block;padding:10px 14px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:6px">Set a new password</a></p>
|
<p><a href="{{ reset_url }}" style="display:inline-block;padding:10px 14px;background:#0d6efd;color:#fff;text-decoration:none;border-radius:6px">Set a new password</a></p>
|
||||||
<p style="color:#666;font-size:12px">This link expires in {{ ttl_minutes }} minutes. If this wasn't you, ignore this email.</p>
|
<p style="color:#666;font-size:12px">This link expires in {{ ttl_minutes }} minutes. If this wasn't you, ignore this email.</p>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user