This commit is contained in:
Mateusz Gruszczyński
2026-03-06 10:06:14 +01:00
parent e8f6c4c609
commit 7b8a81dc3b
28 changed files with 1270 additions and 312 deletions

18
docker-compose.sqlite.yml Normal file
View 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:

View File

@@ -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")

View File

@@ -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
],
}
)

View File

@@ -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: db.session.commit()
pl.token = token
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"))

View File

@@ -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()

View File

@@ -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:

View File

@@ -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:

View File

@@ -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()])

View File

@@ -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)

View File

@@ -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
} }
} }

View 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)

View File

@@ -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"]:
dt_s = now - prev["ts"]
for label, key_name, val in series:
prev_val = prev.get(key_name)
if prev_val is None:
continue
delta = val - prev_val
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)
def _to_unit(bytes_per_s, unit_str): if "sum_counters" in extract:
if bytes_per_s is None: entries = []
return None for entry in extract.get("sum_counters") or []:
if str(unit_str).lower() == "bps": if not isinstance(entry, dict):
return bytes_per_s * 8.0 continue
return bytes_per_s label = str(entry.get("label") or entry.get("key") or "value")
key_name = str(entry.get("key") or "")
if prev and prev.get("ts") and now > prev["ts"] and rx_c is not None and tx_c is not None: total = 0
dt_s = now - prev["ts"] found = False
rx_rate_b = (rx_c - prev["rx"]) / dt_s for it in data:
tx_rate_b = (tx_c - prev["tx"]) / dt_s if not isinstance(it, dict):
if rx_rate_b >= 0: continue
_add("rx", _to_unit(rx_rate_b, rate_unit), rate_unit) if str(it.get("dynamic", "false")).lower() == "true" and extract.get("sum_ignore_dynamic", True):
if tx_rate_b >= 0: continue
_add("tx", _to_unit(tx_rate_b, rate_unit), rate_unit) val = _to_int(it.get(key_name))
if val is not None:
if rx_c is not None and tx_c is not None: total += val
_last_counters[key] = {"ts": now, "rx": rx_c, "tx": tx_c} 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

View File

@@ -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

View File

@@ -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', ()=>{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }}">

View File

@@ -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>
<form method="post" action="{{ url_for('dashboards.public_link_create', dashboard_id=dashboard.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-primary" type="submit"><i class="fa-solid fa-link me-1"></i>Create link</button>
</form>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('dashboards.share_public', dashboard_id=dashboard.id) }}">
<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>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -19,12 +19,23 @@
{% 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>
<form method="post" action="{{ url_for('dashboards.widget_delete', dashboard_id=dashboard.id, widget_id=w.id) }}"> <div class="d-flex align-items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> {% if w.widget_type != 'table' %}
<button class="btn btn-sm btn-outline-danger" type="submit" title="Delete"><i class="fa-solid fa-trash"></i></button> <select class="form-select form-select-sm" data-range-widget="{{ w.id }}" style="width:auto">
</form> <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) }}">
<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>
</form>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if w.widget_type == 'table' %} {% if w.widget_type == 'table' %}

View File

@@ -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{

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>