From 7b8a81dc3b7f27d93c88b3b3d734f3fe06a7ee13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 6 Mar 2026 10:06:14 +0100 Subject: [PATCH] changes --- docker-compose.sqlite.yml | 18 + .../0002_remove_ssh_add_rest_scheme.py | 29 ++ mikromon/blueprints/api.py | 168 +++++--- mikromon/blueprints/dashboards.py | 138 ++++--- mikromon/blueprints/devices.py | 36 +- mikromon/connectors/registry.py | 4 +- mikromon/connectors/routeros_rest.py | 15 +- mikromon/forms.py | 35 +- mikromon/models.py | 40 +- mikromon/presets/widget_presets.json | 265 ++++++++++++- mikromon/services/mikromon/services/poller.py | 373 ++++++++++++++++++ mikromon/services/poller.py | 155 ++++++-- requirements.txt | 3 +- static/js/app.js | 115 ++++-- templates/admin/audit.html | 2 +- templates/admin/index.html | 2 +- templates/admin/smtp.html | 8 +- templates/admin/users.html | 6 +- templates/api/docs.html | 2 +- templates/dashboards/index.html | 10 +- templates/dashboards/public_view.html | 15 +- templates/dashboards/share.html | 19 +- templates/dashboards/view.html | 21 +- templates/dashboards/widget_new.html | 6 +- templates/devices/edit.html | 40 +- templates/devices/index.html | 2 +- templates/devices/new.html | 53 ++- templates/emails/reset_password.html | 2 +- 28 files changed, 1270 insertions(+), 312 deletions(-) create mode 100644 docker-compose.sqlite.yml create mode 100644 migrations/versions/migrations/versions/0002_remove_ssh_add_rest_scheme.py create mode 100644 mikromon/services/mikromon/services/poller.py diff --git a/docker-compose.sqlite.yml b/docker-compose.sqlite.yml new file mode 100644 index 0000000..83f4543 --- /dev/null +++ b/docker-compose.sqlite.yml @@ -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: diff --git a/migrations/versions/migrations/versions/0002_remove_ssh_add_rest_scheme.py b/migrations/versions/migrations/versions/0002_remove_ssh_add_rest_scheme.py new file mode 100644 index 0000000..fb7a231 --- /dev/null +++ b/migrations/versions/migrations/versions/0002_remove_ssh_add_rest_scheme.py @@ -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") diff --git a/mikromon/blueprints/api.py b/mikromon/blueprints/api.py index 3a55271..c9a511c 100644 --- a/mikromon/blueprints/api.py +++ b/mikromon/blueprints/api.py @@ -1,78 +1,93 @@ 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 .. 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.presets import load_presets -from ..connectors.registry import get_connector from ..security.crypto import decrypt_json +from ..connectors.registry import get_connector bp = Blueprint("api", __name__, url_prefix="/api") -from .. import csrf -csrf.exempt(bp) - def _json_ok(data=None): return jsonify({"ok": True, "data": data}) + def _json_err(msg, code=400): return jsonify({"ok": False, "error": msg}), code + @bp.get("/docs") def docs(): routes = [ - {"method":"GET","path":"/api/v1/me","desc":"Current user"}, - {"method":"GET","path":"/api/v1/devices","desc":"List devices you can view"}, - {"method":"POST","path":"/api/v1/devices//test","desc":"Test REST connection"}, - {"method":"GET","path":"/api/v1/dashboards","desc":"List dashboards you can view"}, - {"method":"GET","path":"/api/v1/dashboards/","desc":"Dashboard detail + widgets"}, - {"method":"GET","path":"/api/v1/presets/widgets","desc":"Widget presets"}, - {"method":"GET","path":"/api/v1/public/","desc":"Public dashboard (read-only)"}, - {"method":"POST","path":"/api/v1/devices","desc":"Create device (JSON)"}, - {"method":"POST","path":"/api/v1/dashboards","desc":"Create dashboard (JSON)"}, - {"method":"POST","path":"/api/v1/dashboards//widgets","desc":"Add widget (JSON)"}, - {"method":"GET","path":"/api/v1/widgets//last","desc":"Last value (cache)"}, + {"method": "GET", "path": "/api/v1/me", "desc": "Current user"}, + {"method": "GET", "path": "/api/v1/devices", "desc": "List devices you can view"}, + {"method": "POST", "path": "/api/v1/devices//test", "desc": "Test REST connection"}, + {"method": "POST", "path": "/api/v1/devices", "desc": "Create device (JSON)"}, + {"method": "GET", "path": "/api/v1/dashboards", "desc": "List dashboards you can view"}, + {"method": "GET", "path": "/api/v1/dashboards/", "desc": "Dashboard detail + widgets"}, + {"method": "POST", "path": "/api/v1/dashboards", "desc": "Create dashboard (JSON)"}, + {"method": "POST", "path": "/api/v1/dashboards//widgets", "desc": "Add widget (JSON)"}, + {"method": "GET", "path": "/api/v1/presets/widgets", "desc": "Widget presets"}, + {"method": "GET", "path": "/api/v1/widgets//last", "desc": "Last value (cache)"}, + {"method": "GET", "path": "/api/v1/public/", "desc": "Public dashboard (read-only)"}, ] return render_template("api/docs.html", routes=routes) + @bp.get("/v1/me") @login_required def me(): return _json_ok({"id": current_user.id, "email": current_user.email, "role": current_user.role.name}) + @bp.get("/v1/presets/widgets") @login_required def widget_presets(): return _json_ok(load_presets()) + @bp.get("/v1/devices") @login_required def devices(): - # owner + shares 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 = Device.query.filter(Device.id.in_(shared_ids)).all() if shared_ids else [] 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) + @bp.post("/v1/devices//test") @login_required def device_test(device_id): - from ..connectors.registry import get_connector - from ..security.crypto import decrypt_json - d = db.session.get(Device, device_id) if not d: return _json_err("Not found", 404) if not has_permission(current_user, "device", d.id, "manage", d.owner_id): return _json_err("Forbidden", 403) 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) return _json_ok({"ok": True}) if res.ok else _json_err(res.error, 400) + @bp.get("/v1/dashboards") @login_required 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 = Dashboard.query.filter(Dashboard.id.in_(shared_ids)).all() if shared_ids else [] 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) + @bp.get("/v1/dashboards/") @login_required def dashboard_detail(dashboard_id): @@ -96,11 +125,18 @@ def dashboard_detail(dashboard_id): "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] + "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 + ], } return _json_ok(data) @@ -109,34 +145,44 @@ def dashboard_detail(dashboard_id): @login_required def device_create(): from ..security.crypto import encrypt_json + 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): 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( owner_id=current_user.id, name=str(payload["name"]).strip(), host=str(payload["host"]).strip(), - rest_port=int(payload.get("rest_port", 443)), - rest_base_path=str(payload.get("rest_base_path","/rest")).strip(), - allow_insecure_tls=bool(payload.get("allow_insecure_tls", False)), - ssh_enabled=bool(payload.get("ssh_enabled", False)), - ssh_port=int(payload.get("ssh_port", 22)), + rest_scheme=rest_scheme, + rest_port=int(payload.get("rest_port", default_port)), + rest_base_path=str(payload.get("rest_base_path", "/rest")).strip(), + allow_insecure_tls=bool(payload.get("allow_insecure_tls", False)) if rest_scheme == "https" else False, ) 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}) + @bp.post("/v1/dashboards") @login_required def dashboard_create(): payload = request.get_json(silent=True) or {} if "name" not in payload: 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()) - db.session.add(d); db.session.commit() + 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() return _json_ok({"id": d.id}) + @bp.post("/v1/dashboards//widgets") @login_required def widget_create(dashboard_id): @@ -157,11 +203,11 @@ def widget_create(dashboard_id): q = payload.get("query") if not q and preset: q = { - "connector": preset.get("connector","rest"), + "connector": preset.get("connector", "rest"), "endpoint": preset.get("endpoint"), "params": preset.get("params") or {}, "extract": preset.get("extract") or {}, - "preset_key": preset_key + "preset_key": preset_key, } if not q: 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)), 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}) + @bp.get("/v1/widgets//last") @login_required def widget_last(widget_id): - from ..models import MetricLastValue w = db.session.get(Widget, widget_id) if not w: return _json_err("Not found", 404) d = db.session.get(Dashboard, w.dashboard_id) if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id): return _json_err("Forbidden", 403) - last = (MetricLastValue.query - .filter_by(widget_id=w.id) - .order_by(MetricLastValue.ts.desc()) - .first()) + last = MetricLastValue.query.filter_by(widget_id=w.id).first() if not last: return _json_ok(None) 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") @login_required def preset_items(): - """List item names for presets returning lists (interfaces, queues, ...).""" device_id = int(request.args.get("device_id") or 0) preset_key = (request.args.get("preset_key") or "").strip() if not device_id or not preset_key: @@ -217,10 +260,7 @@ def preset_items(): presets = load_presets() preset = presets.get(preset_key) - if not preset: - return _json_ok({"items": []}) - - if not device.enc_credentials: + if not preset or not device.enc_credentials: return _json_ok({"items": []}) try: creds = decrypt_json(device.enc_credentials) @@ -234,7 +274,6 @@ def preset_items(): "extract": preset.get("extract") or {}, "preset_key": preset_key, } - # ensure list call (remove default filter) q.get("params", {}).pop("name", None) connector = get_connector(q.get("connector", "rest")) @@ -250,19 +289,36 @@ def preset_items(): items = sorted(set(items))[:200] return _json_ok({"items": items}) + @bp.get("/v1/public/") def public_dashboard(token): - import datetime as dt pl = PublicLink.query.filter_by(token=token, target_type="dashboard").first() if not pl: return _json_err("Not found", 404) if pl.expires_at and dt.datetime.utcnow() > pl.expires_at: return _json_err("Expired", 404) + d = db.session.get(Dashboard, pl.target_id) if not d: return _json_err("Not found", 404) + widgets = Widget.query.filter_by(dashboard_id=d.id).all() - 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] - }) + return _json_ok( + { + "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 + ], + } + ) diff --git a/mikromon/blueprints/dashboards.py b/mikromon/blueprints/dashboards.py index 31fc7b8..a8220a2 100644 --- a/mikromon/blueprints/dashboards.py +++ b/mikromon/blueprints/dashboards.py @@ -1,6 +1,8 @@ -import json, copy +import json +import copy import secrets 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_login import login_required, current_user from .. import db @@ -17,7 +19,27 @@ bp = Blueprint("dashboards", __name__, url_prefix="/") @bp.get("/") @login_required 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) @@ -103,10 +125,7 @@ def widget_new_post(dashboard_id): flash("Unknown preset.", "danger") 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) - - # base query from preset (always dict copies) q = { "connector": preset.get("connector", "rest"), "endpoint": preset.get("endpoint"), @@ -115,10 +134,7 @@ def widget_new_post(dashboard_id): "preset_key": preset_key, } - # Optional selector (interfaces/queues etc.) 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() if raw_q: try: @@ -129,21 +145,15 @@ def widget_new_post(dashboard_id): flash("Query JSON invalid.", "danger") 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.update(user_q) - merged.setdefault("params", {}) merged.setdefault("extract", {}) merged["preset_key"] = preset_key - - # if user forgot connector/endpoint -> fallback to preset merged["connector"] = merged.get("connector") or q["connector"] merged["endpoint"] = merged.get("endpoint") or q["endpoint"] - q = merged else: - # Only apply item_name convention when user did not provide custom JSON if item_name: q.setdefault("params", {}) 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)) -# Sharing @bp.get("/dashboards//share") @login_required 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): abort(403) 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() - 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//share") @login_required -def share_post(dashboard_id): +def share_add(dashboard_id): d = _load_dashboard_or_404(dashboard_id) if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id): abort(403) - from ..models import User, Share form = ShareForm() if not form.validate_on_submit(): flash("Invalid input.", "danger") 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") 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: - s = Share(target_type="dashboard", target_id=d.id, user_id=u.id, permission=form.permission.data) - db.session.add(s) + + existing = Share.query.filter_by(target_type="dashboard", target_id=d.id, user_id=user.id).first() + if existing: + existing.permission = form.permission.data 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() - audit.log("dashboard.shared_user", "dashboard", d.id, f"{u.email}:{s.permission}") - flash("Shared updated.", "success") + audit.log("dashboard.shared", "dashboard", d.id, f"user_id={user.id},permission={form.permission.data}") + flash("Share updated.", "success") return redirect(url_for("dashboards.share", dashboard_id=d.id)) -@bp.post("/dashboards//share/public") +@bp.post("/dashboards//share//delete") @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//public-link") +@login_required +def public_link_create(dashboard_id): d = _load_dashboard_or_404(dashboard_id) if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id): abort(403) - token = secrets.token_urlsafe(24) pl = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first() 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) - else: - pl.token = token - db.session.commit() + db.session.commit() 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//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)) @@ -263,20 +301,22 @@ def public_view(token): return render_template("dashboards/public_view.html", dashboard=d, widgets=widgets, token=token) -@bp.post("/dashboards//delete") +@bp.get("/dashboards//layout") @login_required -def delete(dashboard_id): +def layout_get(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//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) - - Widget.query.filter_by(dashboard_id=d.id).delete(synchronize_session=False) - Share.query.filter_by(target_type="dashboard", target_id=d.id).delete(synchronize_session=False) - PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).delete(synchronize_session=False) - - db.session.delete(d) + payload = request.get_json(silent=True) or {} + layout = payload.get("layout") or [] + d.layout = json.dumps(layout) db.session.commit() - audit.log("dashboard.deleted", "dashboard", dashboard_id) - - flash("Dashboard deleted.", "success") - return redirect(url_for("dashboards.index")) \ No newline at end of file + audit.log("dashboard.layout_saved", "dashboard", d.id) + return jsonify({"ok": True}) diff --git a/mikromon/blueprints/devices.py b/mikromon/blueprints/devices.py index a67cfc1..be33228 100644 --- a/mikromon/blueprints/devices.py +++ b/mikromon/blueprints/devices.py @@ -1,8 +1,7 @@ -import json from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify from flask_login import login_required, current_user from .. import db -from ..models import Device, ShareTarget +from ..models import Device from ..forms import DeviceForm, EditDeviceForm from ..security.crypto import encrypt_json, decrypt_json from ..connectors.registry import get_connector @@ -11,23 +10,24 @@ from ..services import audit bp = Blueprint("devices", __name__, url_prefix="/devices") + @bp.get("/") @login_required def index(): 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) + @bp.get("/new") @login_required 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.rest_scheme.data = "https" form.rest_port.data = 443 form.rest_base_path.data = "/rest" - form.ssh_port.data = 22 return render_template("devices/new.html", form=form) + @bp.post("/new") @login_required def new_post(): @@ -36,22 +36,21 @@ def new_post(): flash("Invalid input.", "danger") return render_template("devices/new.html", form=form), 400 - # per-user limit from flask import current_app max_devices = current_app.config.get("USER_MAX_DEVICES", 20) if Device.query.filter_by(owner_id=current_user.id).count() >= max_devices: flash(f"Device limit reached ({max_devices}).", "warning") return render_template("devices/new.html", form=form), 403 + scheme = (form.rest_scheme.data or "https").lower() d = Device( owner_id=current_user.id, name=form.name.data.strip(), host=form.host.data.strip(), + rest_scheme=scheme, rest_port=form.rest_port.data, rest_base_path=form.rest_base_path.data.strip(), - allow_insecure_tls=bool(form.allow_insecure_tls.data), - ssh_enabled=bool(form.ssh_enabled.data), - ssh_port=form.ssh_port.data or 22, + allow_insecure_tls=bool(form.allow_insecure_tls.data) if scheme == "https" else False, ) d.enc_credentials = encrypt_json({"username": form.username.data, "password": form.password.data}) db.session.add(d) @@ -61,6 +60,7 @@ def new_post(): flash("Device created. You can test connection now.", "success") return redirect(url_for("devices.view", device_id=d.id)) + @bp.get("/") @login_required def view(device_id): @@ -71,6 +71,7 @@ def view(device_id): abort(403) return render_template("devices/view.html", device=d) + @bp.post("//test") @login_required def test(device_id): @@ -90,6 +91,7 @@ def test(device_id): audit.log("device.test_failed", "device", d.id, res.error) return jsonify({"ok": False, "error": res.error}), 400 + @bp.get("//discover") @login_required def discover(device_id): @@ -105,6 +107,7 @@ def discover(device_id): return jsonify({"ok": True, "data": res.data}) return jsonify({"ok": False, "error": res.error}), 400 + @bp.get("//edit") @login_required def edit(device_id): @@ -115,15 +118,12 @@ def edit(device_id): abort(403) form = EditDeviceForm() - - # preload values form.name.data = d.name form.host.data = d.host + form.rest_scheme.data = d.rest_scheme or "https" form.rest_port.data = d.rest_port form.rest_base_path.data = d.rest_base_path 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 {} form.username.data = creds.get("username", "") @@ -145,19 +145,17 @@ def edit_post(device_id): flash("Invalid input.", "danger") 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.host = form.host.data.strip() + d.rest_scheme = scheme d.rest_port = form.rest_port.data d.rest_base_path = form.rest_base_path.data.strip() - d.allow_insecure_tls = bool(form.allow_insecure_tls.data) - d.ssh_enabled = bool(form.ssh_enabled.data) - d.ssh_port = form.ssh_port.data or 22 + d.allow_insecure_tls = bool(form.allow_insecure_tls.data) if scheme == "https" else False - # credentials: keep old password if blank old = decrypt_json(d.enc_credentials) if d.enc_credentials else {} username = (form.username.data or "").strip() password = (form.password.data or "").strip() or (old.get("password") or "") - d.enc_credentials = encrypt_json({"username": username, "password": password}) db.session.commit() @@ -181,4 +179,4 @@ def delete(device_id): audit.log("device.deleted", "device", device_id) flash("Device deleted.", "success") - return redirect(url_for("devices.index")) \ No newline at end of file + return redirect(url_for("devices.index")) diff --git a/mikromon/connectors/registry.py b/mikromon/connectors/registry.py index 4280679..83a18aa 100644 --- a/mikromon/connectors/registry.py +++ b/mikromon/connectors/registry.py @@ -1,11 +1,11 @@ from .routeros_rest import RouterOSRestConnector -from .routeros_ssh import RouterOSSshConnector + REGISTRY = { "rest": RouterOSRestConnector(), - "ssh": RouterOSSshConnector(), } + def get_connector(name: str): c = REGISTRY.get(name) if not c: diff --git a/mikromon/connectors/routeros_rest.py b/mikromon/connectors/routeros_rest.py index 25afcd6..3e1bc9e 100644 --- a/mikromon/connectors/routeros_rest.py +++ b/mikromon/connectors/routeros_rest.py @@ -1,20 +1,24 @@ import httpx -from typing import Any from flask import current_app from .base import Connector, FetchResult + class RouterOSRestConnector(Connector): name = "rest" def _client(self, device): - verify = not device.allow_insecure_tls 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: + scheme = (device.rest_scheme or "https").lower() base = device.rest_base_path.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: try: @@ -43,11 +47,10 @@ class RouterOSRestConnector(Connector): r.raise_for_status() res = r.json() version = res.get("version") or res.get("routeros-version") - # lightweight capabilities (extend later) caps = { "routeros_version": version, "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) except Exception as e: diff --git a/mikromon/forms.py b/mikromon/forms.py index 002412c..7b664f2 100644 --- a/mikromon/forms.py +++ b/mikromon/forms.py @@ -1,44 +1,48 @@ from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, IntegerField, TextAreaField, SelectField from wtforms.validators import DataRequired, Email, Length, NumberRange, Optional -from wtforms.validators import Optional, Length class LoginForm(FlaskForm): email = StringField("Email", validators=[DataRequired(), Email()]) password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)]) + class RegisterForm(FlaskForm): email = StringField("Email", validators=[DataRequired(), Email()]) password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)]) + class DeviceForm(FlaskForm): name = StringField("Name", validators=[DataRequired(), Length(max=120)]) 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_base_path = StringField("REST Base Path", validators=[DataRequired(), Length(max=64)]) username = StringField("Username", validators=[DataRequired(), Length(max=128)]) password = PasswordField("Password", validators=[DataRequired(), Length(max=128)]) 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): password = PasswordField("Password", validators=[Optional(), Length(max=128)]) + class DashboardForm(FlaskForm): name = StringField("Name", validators=[DataRequired(), Length(max=120)]) description = StringField("Description", validators=[Optional(), Length(max=500)]) + class WidgetWizardForm(FlaskForm): preset_key = SelectField("Preset", validators=[DataRequired()], choices=[]) title = StringField("Title", validators=[DataRequired(), Length(max=120)]) 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=[]) - - # Layout controls col_span = SelectField( "Width", validators=[DataRequired()], @@ -46,18 +50,25 @@ class WidgetWizardForm(FlaskForm): default="6", ) 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()]) + class ForgotPasswordForm(FlaskForm): email = StringField("Email", validators=[DataRequired(), Email()]) + 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): - email = StringField("User email", validators=[DataRequired(), Email()]) - permission = SelectField("Permission", validators=[DataRequired()], choices=[("view","View"),("edit","Edit"),("manage","Manage")]) + email = StringField("User email", validators=[DataRequired(), Email(), Length(max=255)]) + permission = SelectField( + "Permission", + validators=[DataRequired()], + choices=[("view", "View"), ("edit", "Edit"), ("manage", "Manage")], + default="view", + ) class SmtpTestForm(FlaskForm): - to_email = StringField("To", validators=[DataRequired(), Email()]) + to_email = StringField("To", validators=[DataRequired(), Email()]) \ No newline at end of file diff --git a/mikromon/models.py b/mikromon/models.py index a8984c9..72d1ec2 100644 --- a/mikromon/models.py +++ b/mikromon/models.py @@ -4,24 +4,29 @@ from sqlalchemy import UniqueConstraint, Index from flask_login import UserMixin from . import db + class RoleName(str, enum.Enum): USER = "user" ADMIN = "admin" + class ShareTarget(str, enum.Enum): DEVICE = "device" DASHBOARD = "dashboard" + class Permission(str, enum.Enum): VIEW = "view" EDIT = "edit" MANAGE = "manage" + class Role(db.Model): __tablename__ = "roles" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(32), unique=True, nullable=False) + class User(db.Model, UserMixin): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) @@ -36,6 +41,7 @@ class User(db.Model, UserMixin): def is_admin(self) -> bool: return self.role and self.role.name == RoleName.ADMIN.value + class Device(db.Model): __tablename__ = "devices" id = db.Column(db.Integer, primary_key=True) @@ -43,14 +49,12 @@ class Device(db.Model): name = db.Column(db.String(120), 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_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) - 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) - # encrypted payload: json {"username": "...", "password":"..."} enc_credentials = db.Column(db.Text, 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"),) + class Dashboard(db.Model): __tablename__ = "dashboards" id = db.Column(db.Integer, primary_key=True) owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) name = db.Column(db.String(120), nullable=False) 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) owner = db.relationship("User") + class Widget(db.Model): __tablename__ = "widgets" 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) title = db.Column(db.String(120), nullable=False) - widget_type = db.Column(db.String(64), nullable=False) # e.g. timeseries - preset_key = db.Column(db.String(64), nullable=True) # cpu, ram... - query_json = db.Column(db.Text, nullable=False) # json with connector + endpoint + params + widget_type = db.Column(db.String(64), nullable=False) + preset_key = db.Column(db.String(64), nullable=True) + query_json = db.Column(db.Text, 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) # Bootstrap grid 1..12 + col_span = db.Column(db.Integer, default=6, nullable=False) height_px = db.Column(db.Integer, default=260, nullable=False) is_enabled = db.Column(db.Boolean, default=True, nullable=False) @@ -94,14 +99,15 @@ class Widget(db.Model): dashboard = db.relationship("Dashboard") device = db.relationship("Device") - + + class Share(db.Model): __tablename__ = "shares" 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) 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) user = db.relationship("User") @@ -111,6 +117,7 @@ class Share(db.Model): Index("ix_shares_target", "target_type", "target_id"), ) + class PublicLink(db.Model): __tablename__ = "public_links" 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"),) + class PasswordResetToken(db.Model): __tablename__ = "password_reset_tokens" id = db.Column(db.Integer, primary_key=True) @@ -137,6 +145,7 @@ class PasswordResetToken(db.Model): def is_valid(self) -> bool: return self.used_at is None and dt.datetime.utcnow() < self.expires_at + class AuditLog(db.Model): __tablename__ = "audit_logs" id = db.Column(db.Integer, primary_key=True) @@ -151,12 +160,13 @@ class AuditLog(db.Model): actor = db.relationship("User") + class MetricLastValue(db.Model): __tablename__ = "metrics_last_values" id = db.Column(db.Integer, primary_key=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) - 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) ts = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) diff --git a/mikromon/presets/widget_presets.json b/mikromon/presets/widget_presets.json index f106baa..f1b12bb 100644 --- a/mikromon/presets/widget_presets.json +++ b/mikromon/presets/widget_presets.json @@ -4,29 +4,35 @@ "widget_type": "timeseries", "connector": "rest", "endpoint": "/system/resource", - "extract": { "path": "cpu-load", "unit": "%" }, + "extract": { + "path": "cpu-load", + "unit": "%" + }, "refresh_seconds": 2 }, - "ram": { "title": "RAM Usage", "widget_type": "timeseries", "connector": "rest", "endpoint": "/system/resource", "extract": { - "needs": ["total-memory", "free-memory"], + "needs": [ + "total-memory", + "free-memory" + ], "calc": "100*(total_memory-free_memory)/total_memory", "unit": "%" }, "refresh_seconds": 2 }, - "iface_traffic": { "title": "Interface Traffic (Rx/Tx)", "widget_type": "timeseries", "connector": "rest", "endpoint": "/interface", - "params": { "name": "" }, + "params": { + "name": "" + }, "extract": { "rx_counter": "rx-byte", "tx_counter": "tx-byte", @@ -34,7 +40,6 @@ }, "refresh_seconds": 2 }, - "firewall_rates": { "title": "Firewall Traffic (bytes/s)", "widget_type": "timeseries", @@ -52,7 +57,6 @@ }, "refresh_seconds": 5 }, - "firewall_bytes": { "title": "Firewall Counters (bytes)", "widget_type": "timeseries", @@ -70,7 +74,6 @@ }, "refresh_seconds": 30 }, - "queue_simple_rate": { "title": "Simple Queues (RX/TX bytes/s)", "widget_type": "timeseries", @@ -81,7 +84,10 @@ "series_key": "name", "value_key": "bytes", "split_duplex": true, - "duplex_labels": ["rx", "tx"], + "duplex_labels": [ + "rx", + "tx" + ], "rate": true, "unit": "B/s", "max_series": 12, @@ -90,7 +96,6 @@ }, "refresh_seconds": 5 }, - "queue_simple_bytes": { "title": "Simple Queues (RX/TX bytes)", "widget_type": "timeseries", @@ -101,7 +106,10 @@ "series_key": "name", "value_key": "bytes", "split_duplex": true, - "duplex_labels": ["rx", "tx"], + "duplex_labels": [ + "rx", + "tx" + ], "rate": false, "unit": "B", "max_series": 12, @@ -110,7 +118,6 @@ }, "refresh_seconds": 30 }, - "queue_tree_rate": { "title": "Queue Tree (bytes/s)", "widget_type": "timeseries", @@ -128,7 +135,6 @@ }, "refresh_seconds": 5 }, - "queue_tree_bytes": { "title": "Queue Tree (bytes)", "widget_type": "timeseries", @@ -145,5 +151,236 @@ "ignore_disabled": true }, "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 } -} \ No newline at end of file +} diff --git a/mikromon/services/mikromon/services/poller.py b/mikromon/services/mikromon/services/poller.py new file mode 100644 index 0000000..03ffbfc --- /dev/null +++ b/mikromon/services/mikromon/services/poller.py @@ -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) diff --git a/mikromon/services/poller.py b/mikromon/services/poller.py index d940134..7d89516 100644 --- a/mikromon/services/poller.py +++ b/mikromon/services/poller.py @@ -12,7 +12,7 @@ from ..connectors.registry import get_connector _scheduler = None _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} @@ -180,7 +180,10 @@ def _transform(widget: Widget, data, query: dict): # ---- dict ---- if isinstance(data, dict): 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: try: @@ -281,38 +284,107 @@ def _transform(widget: Widget, data, query: dict): target = target or (data[0] if data else None) if target and isinstance(target, dict): - # interface rate from counters + counter_groups = [] 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", ""))) + counter_groups.append({ + "series": [ + {"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() - 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) + 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 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} + if "sum_counters" in extract: + entries = [] + for entry in extract.get("sum_counters") or []: + if not isinstance(entry, dict): + continue + label = str(entry.get("label") or entry.get("key") or "value") + key_name = str(entry.get("key") or "") + total = 0 + found = False + for it in data: + if not isinstance(it, dict): + continue + if str(it.get("dynamic", "false")).lower() == "true" and extract.get("sum_ignore_dynamic", True): + continue + val = _to_int(it.get(key_name)) + if val is not None: + total += val + found = True + if found: + entries.append((label, key_name, total)) + if entries: + now = time.time() + key = (widget.device_id, f"sum:{widget.id}", "sum_counters") + prev = _last_counters.get(key) + unit = extract.get("sum_unit") or "count/s" + want_rate = bool(extract.get("sum_rate", True)) + multiply = float(extract.get("sum_multiplier", 1.0) or 1.0) + if want_rate: + if prev and prev.get("ts") and now > prev["ts"]: + dt_s = now - prev["ts"] + for label, key_name, total in entries: + prev_val = prev.get(key_name) + if prev_val is None: + continue + delta = total - prev_val + if delta >= 0: + _add(label, (delta / dt_s) * multiply, unit) + state = {"ts": now} + for _, key_name, total in entries: + state[key_name] = total + _last_counters[key] = state + else: + for label, _, total in entries: + _add(label, total * multiply, unit) if "path" in extract: - _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: try: @@ -347,7 +419,7 @@ def _to_float(v): s = s[:-1] return float(s) except Exception: - return None + return _to_duration_seconds(v) def _to_int(v): @@ -358,11 +430,36 @@ def _to_int(v): return v if isinstance(v, float): return int(v) - return int(str(v).strip()) + return int(float(str(v).strip())) except Exception: 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): import ast import operator as op diff --git a/requirements.txt b/requirements.txt index ea91d12..d0b5daa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,6 @@ argon2-cffi==23.1.0 cryptography==43.0.1 requests==2.32.3 httpx==0.27.2 -paramiko==3.5.0 APScheduler==3.10.4 redis==5.0.8 rq==1.16.2 @@ -20,4 +19,4 @@ email-validator==2.2.0 itsdangerous==2.2.0 pytest==8.3.2 pytest-flask==1.3.0 -coverage==7.6.1 \ No newline at end of file +coverage==7.6.1 diff --git a/static/js/app.js b/static/js/app.js index d3beb7c..bd47b06 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,8 +1,4 @@ (function(){ - function ringPush(arr, item, max){ - arr.push(item); - if(arr.length > max) arr.splice(0, arr.length-max); - } function ensureLibs(){ return new Promise((resolve)=>{ const tick=()=>{ if(window.io && window.Chart) return resolve(); setTimeout(tick,50); }; @@ -11,40 +7,42 @@ } const charts=new Map(); - const seriesData=new Map(); // widgetId -> {name: [{x,y}]} - const seriesUnit=new Map(); // widgetId -> {name: unit} - const maxPoints=900; + const seriesData=new Map(); + const seriesUnit=new Map(); + 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 scale(v, unit){ if(!isNum(v)) return {v, u:''}; const u=(unit||'').toLowerCase(); - if(u==='%' || u==='pct' || u==='percent') return {v, u:'%'}; - if(u==='bps' || u==='bit/s' || u==='bits/s'){ const steps=['bps','Kbps','Mbps','Gbps','Tbps']; let i=0, x=v; while(Math.abs(x)>=1000 && i=1024 && i=1024 && i>>0; } + function colorFor(name){ const hue = (hash32(String(name)) % 360); 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){ const el=document.getElementById('chart-'+widgetId); if(!el) return null; @@ -90,7 +100,6 @@ beginAtZero:false, ticks:{ callback:(tick)=>{ - // jeśli w widżecie jest 1 unit -> formatuj, jeśli różne -> surowo const uMap = seriesUnit.get(widgetId) || {}; const units = Object.values(uMap).filter(Boolean); const uniq = [...new Set(units)]; @@ -120,6 +129,7 @@ charts.set(widgetId, chart); seriesData.set(widgetId, {}); seriesUnit.set(widgetId, {}); + if(!selectedRange.has(widgetId)) selectedRange.set(widgetId, '1h'); return chart; } @@ -147,31 +157,19 @@ tbody.innerHTML=(msg.rows||[]).map(r=>''+cols.map(c=>''+escapeHtml(r[c] ?? '')+'').join('')+'').join(''); } - function handleTimeseries(msg){ - const chart=upsertChart(msg.widget_id); + function renderChart(widgetId, latestTs){ + const chart=upsertChart(widgetId); if(!chart) return; + const store=seriesData.get(widgetId) || {}; + const rangeMs=getRangeMs(widgetId); + const minTs=latestTs-rangeMs; - const t=new Date(msg.ts); - 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=>{ + chart.data.datasets = Object.keys(store).map(name=>{ const c=colorFor(name); + const data=(store[name]||[]).filter(p=>p.x.getTime()>=minTs); return { label:name, - data:store[name], + data, borderWidth:2, pointRadius:0, tension:0.15, @@ -181,13 +179,58 @@ }; }); + chart.options.scales.x.min=minTs; + chart.options.scales.x.max=latestTs; 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(){ if(!window.MIKROMON || !window.MIKROMON.dashboardId) return; await ensureLibs(); + initRangeSelectors(); const socket=io({transports:['polling'], upgrade:false}); socket.on('connect', ()=>{ @@ -201,4 +244,4 @@ } document.addEventListener('DOMContentLoaded', main); -})(); \ No newline at end of file +})(); diff --git a/templates/admin/audit.html b/templates/admin/audit.html index 63fc527..35fca73 100644 --- a/templates/admin/audit.html +++ b/templates/admin/audit.html @@ -4,7 +4,7 @@

Audit log

-
Last logs (limit 200).
+
Last logs (limit 200)
Back
diff --git a/templates/admin/index.html b/templates/admin/index.html index 6443e10..7ec6ceb 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -4,7 +4,7 @@

Admin panel

-
Administrative tools and system overview.
+
Administrative tools and system overview
Users diff --git a/templates/admin/smtp.html b/templates/admin/smtp.html index c263433..93eb053 100644 --- a/templates/admin/smtp.html +++ b/templates/admin/smtp.html @@ -4,7 +4,7 @@

SMTP

-
Test email sending configuration.
+
Test email sending configuration
Back
@@ -30,12 +30,12 @@
-
Wymagane zmienne
+
Variables
  • SMTP_HOST, SMTP_PORT
  • SMTP_FROM
  • -
  • opcjonalnie: SMTP_USER, SMTP_PASS
  • -
  • opcjonalnie: SMTP_USE_TLS / SMTP_USE_SSL
  • +
  • optional: SMTP_USER, SMTP_PASS
  • +
  • optional: SMTP_USE_TLS / SMTP_USE_SSL
diff --git a/templates/admin/users.html b/templates/admin/users.html index c17d237..b4a347c 100644 --- a/templates/admin/users.html +++ b/templates/admin/users.html @@ -4,7 +4,7 @@

Users

-
Lista kont w systemie.
+
Accounts
Back
@@ -16,9 +16,9 @@ ID Email - Rola + Role Status - Utworzono + Created diff --git a/templates/api/docs.html b/templates/api/docs.html index e99ac8b..7778774 100644 --- a/templates/api/docs.html +++ b/templates/api/docs.html @@ -4,7 +4,7 @@

API

-
Endpoints overview (UI reference only).
+
Endpoints overview (UI reference only)
{% if current_user.is_authenticated %} Back diff --git a/templates/dashboards/index.html b/templates/dashboards/index.html index c125a1b..9b22895 100644 --- a/templates/dashboards/index.html +++ b/templates/dashboards/index.html @@ -4,7 +4,7 @@

Dashboards

-
Your monitoring dashboards.
+
Your monitoring dashboards
New dashboard
@@ -15,7 +15,13 @@
{{ d.name }}
-
{{ d.description or '' }}
+
{{ d.description or '' }}
+
+ {{ d.widgets_count }} widgets + {{ d.devices_count }} devices + {{ d.charts_count }} charts + {{ d.tables_count }} tables +