push
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
instance
|
||||||
|
*.db
|
||||||
|
*.zip
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
instance/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV FLASK_APP=app.py
|
||||||
|
RUN mkdir -p instance && chmod +x docker/entrypoint.sh
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import requests; requests.get('http://127.0.0.1:5000/realtime/health', timeout=3).raise_for_status()"
|
||||||
|
|
||||||
|
CMD ["docker/entrypoint.sh"]
|
||||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# MikroMon - MikroTik RouterOS Realtime Monitoring (Flask MVP)
|
||||||
|
|
||||||
|
Minimalist, dark-themed web UI + full JSON API + realtime charts via Socket.IO.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Multi-user accounts (Argon2 password hashing)
|
||||||
|
- Devices: MikroTik RouterOS REST + optional SSH (paramiko)
|
||||||
|
- Dashboards with widgets (presets + configurable)
|
||||||
|
- Realtime streaming (polling workers -> Socket.IO rooms)
|
||||||
|
- Sharing: per-user ACL (view/edit/manage) + public read-only links
|
||||||
|
- Admin panel (master role)
|
||||||
|
- SMTP password reset (token + TTL) + admin SMTP test
|
||||||
|
- Audit log
|
||||||
|
- Server-side sessions (DB)
|
||||||
|
- CSRF (forms), rate-limiting for auth endpoints
|
||||||
|
- Static cache-busting with MD5 hash query param and long cache headers
|
||||||
|
|
||||||
|
## Quick start (dev, no Docker)
|
||||||
|
### 1) Create venv + install
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) Configure env
|
||||||
|
Copy `.env.example` -> `.env` and edit.
|
||||||
|
|
||||||
|
### 3) Init DB (SQLite by default)
|
||||||
|
```bash
|
||||||
|
export FLASK_APP=app.py
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4) Run
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
Open: http://127.0.0.1:5000
|
||||||
|
|
||||||
|
Default admin:
|
||||||
|
- email: `admin@example.com`
|
||||||
|
- password: `Admin123!` (change immediately)
|
||||||
|
|
||||||
|
## Realtime
|
||||||
|
- Browser joins rooms per dashboard/device
|
||||||
|
- Poller runs in-process by default (APScheduler) for dev.
|
||||||
|
- Production: use Redis + RQ worker (see Docker).
|
||||||
|
|
||||||
|
## API
|
||||||
|
- JSON API lives under `/api/v1/...`
|
||||||
|
- API explorer: `/api/docs` (lists endpoints + basic try-it)
|
||||||
|
- Auth: session cookie (same as UI) + optional API token (personal token) can be added later.
|
||||||
|
|
||||||
|
## Insecure TLS (self-signed)
|
||||||
|
Per-device `allow_insecure_tls` flag allows `verify=False` for REST.
|
||||||
|
UI shows a warning. Use only if you understand the risk.
|
||||||
|
|
||||||
|
## Credentials encryption
|
||||||
|
Device credentials are encrypted using Fernet symmetric encryption.
|
||||||
|
Key comes from `CRED_ENC_KEY` env (base64).
|
||||||
|
### Rotate key
|
||||||
|
1) Set `CRED_ENC_KEY_OLD` to old key, `CRED_ENC_KEY` to new key.
|
||||||
|
2) Run:
|
||||||
|
```bash
|
||||||
|
flask devices rotate-cred-key
|
||||||
|
```
|
||||||
|
3) Remove `CRED_ENC_KEY_OLD`.
|
||||||
|
|
||||||
|
## Reset admin password (CLI)
|
||||||
|
### Option A (Flask CLI)
|
||||||
|
```bash
|
||||||
|
flask users set-password admin@example.com "NewStrongPassword123!"
|
||||||
|
```
|
||||||
|
### Option B (script)
|
||||||
|
```bash
|
||||||
|
python scripts/set_admin_password.py admin@example.com "NewStrongPassword123!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
```bash
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
See `docker-compose.yml`. It can run app + Postgres + Redis + RQ worker.
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
- Put behind HTTPS reverse proxy (nginx/Traefik/Caddy)
|
||||||
|
- Use Postgres/MySQL for multi-instance
|
||||||
|
- Run workers separately (RQ/Celery) + Redis
|
||||||
|
- Set `SECRET_KEY`, `SESSION_COOKIE_SECURE=1`, `PREFERRED_URL_SCHEME=https`
|
||||||
|
- Configure rate limits and global per-user limits
|
||||||
|
|
||||||
10
app.py
Normal file
10
app.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import os
|
||||||
|
from mikromon import create_app, socketio
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
host = os.getenv("HOST", "127.0.0.1")
|
||||||
|
port = int(os.getenv("PORT", "5000"))
|
||||||
|
debug = os.getenv("FLASK_ENV", "development") == "development"
|
||||||
|
socketio.run(app, host=host, port=port, debug=debug)
|
||||||
24
deploy/nginx.conf
Normal file
24
deploy/nginx.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /app/static/;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://app:5000/socket.io/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app:5000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
FLASK_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 5000
|
||||||
|
DATABASE_URL: postgresql+psycopg://mikromon:mikromon@db:5432/mikromon
|
||||||
|
SOCKETIO_MESSAGE_QUEUE: redis://redis:6379/0
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: change-me
|
||||||
|
CRED_ENC_KEY: REPLACE_WITH_FERNET_KEY
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
worker:
|
||||||
|
build: .
|
||||||
|
command: ["rq", "worker", "--with-scheduler", "-u", "redis://redis:6379/0"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://mikromon:mikromon@db:5432/mikromon
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
SECRET_KEY: change-me
|
||||||
|
CRED_ENC_KEY: REPLACE_WITH_FERNET_KEY
|
||||||
|
DEV_INPROCESS_POLLER: "0"
|
||||||
|
SOCKETIO_MESSAGE_QUEUE: redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: mikromon
|
||||||
|
POSTGRES_PASSWORD: mikromon
|
||||||
|
POSTGRES_DB: mikromon
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
32
docker/entrypoint.sh
Executable file
32
docker/entrypoint.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p instance
|
||||||
|
|
||||||
|
export FLASK_APP=app.py
|
||||||
|
|
||||||
|
if [[ "${DATABASE_URL:-}" != "" ]]; then
|
||||||
|
echo "[mikromon] waiting for database"
|
||||||
|
python - <<'PY'
|
||||||
|
import os, time
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
|
url = os.environ.get("DATABASE_URL")
|
||||||
|
engine = create_engine(url, pool_pre_ping=True)
|
||||||
|
for i in range(60):
|
||||||
|
try:
|
||||||
|
with engine.connect() as c:
|
||||||
|
c.exec_driver_sql("SELECT 1")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
raise SystemExit("DB not reachable")
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[mikromon] applying migrations"
|
||||||
|
MIKROMON_MIGRATING=1 flask db upgrade
|
||||||
|
|
||||||
|
echo "[mikromon] starting app"
|
||||||
|
exec python app.py
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generated by Flask-Migrate.
|
||||||
2
migrations/alembic.ini
Normal file
2
migrations/alembic.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
62
migrations/env.py
Normal file
62
migrations/env.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import with_statement
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Safe logging configuration
|
||||||
|
if config.config_file_name and os.path.exists(config.config_file_name):
|
||||||
|
try:
|
||||||
|
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger = logging.getLogger("alembic.env")
|
||||||
|
|
||||||
|
from mikromon import db
|
||||||
|
from mikromon.models import * # noqa
|
||||||
|
|
||||||
|
target_metadata = db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def get_url():
|
||||||
|
return current_app.config.get("SQLALCHEMY_DATABASE_URI")
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
url = get_url()
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
connectable = db.engine
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
0
migrations/script.py.mako
Normal file
0
migrations/script.py.mako
Normal file
177
migrations/versions/0001_initial.py
Normal file
177
migrations/versions/0001_initial.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""initial
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-05
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"roles",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(length=32), nullable=False, unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("role_id", sa.Integer(), sa.ForeignKey("roles.id"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("is_active_flag", sa.Boolean(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"devices",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("host", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("rest_port", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("rest_base_path", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("api_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("ssh_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("ssh_port", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("allow_insecure_tls", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("enc_credentials", sa.Text(), nullable=True),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_devices_owner_name", "devices", ["owner_id", "name"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"dashboards",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("layout", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"widgets",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("dashboard_id", sa.Integer(), sa.ForeignKey("dashboards.id"), nullable=False),
|
||||||
|
sa.Column("device_id", sa.Integer(), sa.ForeignKey("devices.id"), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("widget_type", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("preset_key", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("query_json", sa.Text(), nullable=False),
|
||||||
|
sa.Column("refresh_seconds", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("series_config", sa.Text(), nullable=True),
|
||||||
|
sa.Column("col_span", sa.Integer(), nullable=False, server_default="6"),
|
||||||
|
sa.Column("height_px", sa.Integer(), nullable=False, server_default="260"),
|
||||||
|
sa.Column("is_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_widgets_dashboard_id", "widgets", ["dashboard_id"])
|
||||||
|
op.create_index("ix_widgets_device_id", "widgets", ["device_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"shares",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("target_type", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("target_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("permission", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.UniqueConstraint("target_type","target_id","user_id",name="uq_share_target_user"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_shares_target", "shares", ["target_type","target_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"public_links",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("token", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("target_type", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("target_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_public_links_token", "public_links", ["token"], unique=True)
|
||||||
|
op.create_index("ix_public_links_target", "public_links", ["target_type","target_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"password_reset_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("token", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"])
|
||||||
|
op.create_index("ix_password_reset_tokens_token", "password_reset_tokens", ["token"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("actor_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True),
|
||||||
|
sa.Column("action", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("target_type", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("target_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("details", sa.Text(), nullable=True),
|
||||||
|
sa.Column("request_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("ip", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_audit_logs_actor_user_id", "audit_logs", ["actor_user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"metrics_last_values",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("device_id", sa.Integer(), sa.ForeignKey("devices.id"), nullable=False),
|
||||||
|
sa.Column("widget_id", sa.Integer(), sa.ForeignKey("widgets.id"), nullable=True),
|
||||||
|
sa.Column("key", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("value_json", sa.Text(), nullable=False),
|
||||||
|
sa.Column("ts", sa.DateTime(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_metrics_last_device_key", "metrics_last_values", ["device_id","key"])
|
||||||
|
|
||||||
|
# sessions table for Flask-Session (SQLAlchemy backend)
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column("session_id", sa.String(255), unique=True, nullable=False),
|
||||||
|
sa.Column("data", sa.LargeBinary, nullable=False),
|
||||||
|
sa.Column("expiry", sa.DateTime, nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_index("ix_metrics_last_device_key", table_name="metrics_last_values")
|
||||||
|
op.drop_table("metrics_last_values")
|
||||||
|
op.drop_index("ix_audit_logs_actor_user_id", table_name="audit_logs")
|
||||||
|
op.drop_table("audit_logs")
|
||||||
|
op.drop_index("ix_password_reset_tokens_token", table_name="password_reset_tokens")
|
||||||
|
op.drop_index("ix_password_reset_tokens_user_id", table_name="password_reset_tokens")
|
||||||
|
op.drop_table("password_reset_tokens")
|
||||||
|
op.drop_index("ix_public_links_target", table_name="public_links")
|
||||||
|
op.drop_index("ix_public_links_token", table_name="public_links")
|
||||||
|
op.drop_table("public_links")
|
||||||
|
op.drop_index("ix_shares_target", table_name="shares")
|
||||||
|
op.drop_table("shares")
|
||||||
|
op.drop_index("ix_widgets_device_id", table_name="widgets")
|
||||||
|
op.drop_index("ix_widgets_dashboard_id", table_name="widgets")
|
||||||
|
op.drop_table("widgets")
|
||||||
|
op.drop_table("dashboards")
|
||||||
|
op.drop_index("ix_devices_owner_name", table_name="devices")
|
||||||
|
op.drop_table("devices")
|
||||||
|
op.drop_index("ix_users_email", table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.drop_table("roles")
|
||||||
136
mikromon/__init__.py
Normal file
136
mikromon/__init__.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid # still used for SECRET_KEY fallback
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from flask import Flask
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_session import Session
|
||||||
|
from flask_socketio import SocketIO
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
default_limits=["200 per minute"],
|
||||||
|
# set RATELIMIT_STORAGE_URL=redis://... in prod to avoid in-memory
|
||||||
|
storage_uri=os.getenv("RATELIMIT_STORAGE_URL") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_ext = Session()
|
||||||
|
socketio = SocketIO(async_mode="threading", cors_allowed_origins=[])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_migration_mode() -> bool:
|
||||||
|
# Explicit flag (recommended for Docker entrypoint)
|
||||||
|
if os.getenv("MIKROMON_MIGRATING", "0") == "1":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Heuristic for local CLI: `flask db ...`
|
||||||
|
argv = " ".join(sys.argv).lower()
|
||||||
|
if "flask" in argv and " db " in f" {argv} ":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Also treat alembic direct runs as migration mode
|
||||||
|
if "alembic" in argv:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
instance_relative_config=True,
|
||||||
|
template_folder=str(BASE_DIR / "templates"),
|
||||||
|
static_folder=str(BASE_DIR / "static"),
|
||||||
|
)
|
||||||
|
app.config.from_object("mikromon.config.Config")
|
||||||
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
|
# --- FIX: always use absolute sqlite path inside instance/ ---
|
||||||
|
uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
||||||
|
if uri.startswith("sqlite:///") and not uri.startswith("sqlite:////"):
|
||||||
|
# uri like sqlite:///instance/mikromon.db or sqlite:///mikromon.db (relative)
|
||||||
|
rel = uri[len("sqlite:///"):]
|
||||||
|
# jeśli to nie jest ścieżka absolutna -> wpinamy do instance_path
|
||||||
|
if not os.path.isabs(rel):
|
||||||
|
fname = os.path.basename(rel) # mikromon.db
|
||||||
|
db_path = os.path.join(app.instance_path, fname)
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
|
||||||
|
# -----------------------------------------------------------
|
||||||
|
|
||||||
|
migrating = _is_migration_mode()
|
||||||
|
|
||||||
|
# extensions
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
limiter.init_app(app)
|
||||||
|
|
||||||
|
# IMPORTANT:
|
||||||
|
# Flask-Session (SQLAlchemy backend) auto-creates the `sessions` table on init.
|
||||||
|
# During `flask db upgrade` this causes conflicts with Alembic migrations.
|
||||||
|
# So we skip session init in migration mode.
|
||||||
|
if not migrating:
|
||||||
|
app.config["SESSION_SQLALCHEMY"] = db
|
||||||
|
session_ext.init_app(app)
|
||||||
|
|
||||||
|
socketio.init_app(app, message_queue=app.config.get("SOCKETIO_MESSAGE_QUEUE"))
|
||||||
|
|
||||||
|
login_manager.login_view = "auth.login"
|
||||||
|
login_manager.login_message_category = "warning"
|
||||||
|
|
||||||
|
# Request-ID removed (kept causing "Request ID: -" in UI and noise in logs)
|
||||||
|
# If you ever need it back, reintroduce g.request_id and set a header.
|
||||||
|
|
||||||
|
from .utils.static import init_static_cache_headers
|
||||||
|
init_static_cache_headers(app)
|
||||||
|
|
||||||
|
from .errors import register_error_handlers
|
||||||
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
from .template_helpers import register as register_template_helpers
|
||||||
|
register_template_helpers(app)
|
||||||
|
|
||||||
|
# blueprints
|
||||||
|
from .blueprints.auth import bp as auth_bp
|
||||||
|
from .blueprints.devices import bp as devices_bp
|
||||||
|
from .blueprints.dashboards import bp as dashboards_bp
|
||||||
|
from .blueprints.admin import bp as admin_bp
|
||||||
|
from .blueprints.api import bp as api_bp
|
||||||
|
from .blueprints.realtime import bp as realtime_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(devices_bp)
|
||||||
|
app.register_blueprint(dashboards_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(realtime_bp)
|
||||||
|
|
||||||
|
from .cli import register_cli
|
||||||
|
register_cli(app)
|
||||||
|
|
||||||
|
# bootstrap (skip during migrations)
|
||||||
|
if not migrating:
|
||||||
|
with app.app_context():
|
||||||
|
from .services.bootstrap import ensure_default_roles_and_admin
|
||||||
|
ensure_default_roles_and_admin()
|
||||||
|
|
||||||
|
from .services.poller import start_dev_scheduler
|
||||||
|
if app.config.get("DEV_INPROCESS_POLLER"):
|
||||||
|
start_dev_scheduler(app)
|
||||||
|
|
||||||
|
return app
|
||||||
10
mikromon/authn.py
Normal file
10
mikromon/authn.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from . import login_manager
|
||||||
|
from .models import User
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id: str):
|
||||||
|
try:
|
||||||
|
return db.session.get(User, int(user_id))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
59
mikromon/blueprints/admin.py
Normal file
59
mikromon/blueprints/admin.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from flask import Blueprint, render_template, abort, redirect, url_for, flash
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from ..models import User, Device, Dashboard, AuditLog
|
||||||
|
from ..forms import SmtpTestForm
|
||||||
|
from ..services.mail import send_mail
|
||||||
|
from ..services import audit
|
||||||
|
|
||||||
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
def _require_admin():
|
||||||
|
if not current_user.is_authenticated or not current_user.is_admin():
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
@bp.get("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
_require_admin()
|
||||||
|
return render_template("admin/index.html",
|
||||||
|
user_count=User.query.count(),
|
||||||
|
device_count=Device.query.count(),
|
||||||
|
dashboard_count=Dashboard.query.count())
|
||||||
|
|
||||||
|
@bp.get("/users")
|
||||||
|
@login_required
|
||||||
|
def users():
|
||||||
|
_require_admin()
|
||||||
|
users = User.query.order_by(User.created_at.desc()).all()
|
||||||
|
return render_template("admin/users.html", users=users)
|
||||||
|
|
||||||
|
@bp.get("/audit")
|
||||||
|
@login_required
|
||||||
|
def audit_logs():
|
||||||
|
_require_admin()
|
||||||
|
logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(200).all()
|
||||||
|
return render_template("admin/audit.html", logs=logs)
|
||||||
|
|
||||||
|
@bp.get("/smtp")
|
||||||
|
@login_required
|
||||||
|
def smtp():
|
||||||
|
_require_admin()
|
||||||
|
form = SmtpTestForm()
|
||||||
|
return render_template("admin/smtp.html", form=form)
|
||||||
|
|
||||||
|
@bp.post("/smtp/test")
|
||||||
|
@login_required
|
||||||
|
def smtp_test():
|
||||||
|
_require_admin()
|
||||||
|
form = SmtpTestForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid email.", "danger")
|
||||||
|
return redirect(url_for("admin.smtp"))
|
||||||
|
try:
|
||||||
|
send_mail(form.to_email.data, "MikroMon SMTP test", "emails/smtp_test.html")
|
||||||
|
audit.log("admin.smtp_test_ok")
|
||||||
|
flash("Email sent.", "success")
|
||||||
|
except Exception as e:
|
||||||
|
audit.log("admin.smtp_test_failed", details=str(e))
|
||||||
|
flash(f"SMTP error: {e}", "danger")
|
||||||
|
return redirect(url_for("admin.smtp"))
|
||||||
268
mikromon/blueprints/api.py
Normal file
268
mikromon/blueprints/api.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import json
|
||||||
|
from flask import Blueprint, jsonify, request, abort, render_template, url_for
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from .. import db
|
||||||
|
from ..models import Device, Dashboard, Widget, Share, PublicLink
|
||||||
|
from ..services.acl import has_permission
|
||||||
|
from ..services.presets import load_presets
|
||||||
|
from ..connectors.registry import get_connector
|
||||||
|
from ..security.crypto import decrypt_json
|
||||||
|
|
||||||
|
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/<id>/test","desc":"Test REST connection"},
|
||||||
|
{"method":"GET","path":"/api/v1/dashboards","desc":"List dashboards you can view"},
|
||||||
|
{"method":"GET","path":"/api/v1/dashboards/<id>","desc":"Dashboard detail + widgets"},
|
||||||
|
{"method":"GET","path":"/api/v1/presets/widgets","desc":"Widget presets"},
|
||||||
|
{"method":"GET","path":"/api/v1/public/<token>","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/<id>/widgets","desc":"Add widget (JSON)"},
|
||||||
|
{"method":"GET","path":"/api/v1/widgets/<id>/last","desc":"Last value (cache)"},
|
||||||
|
]
|
||||||
|
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()]
|
||||||
|
return _json_ok(data)
|
||||||
|
|
||||||
|
@bp.post("/v1/devices/<int:device_id>/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
|
||||||
|
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():
|
||||||
|
owned = Dashboard.query.filter_by(owner_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 []
|
||||||
|
uniq = {d.id: d for d in owned + shared}
|
||||||
|
data = [{"id": d.id, "name": d.name, "description": d.description} for d in uniq.values()]
|
||||||
|
return _json_ok(data)
|
||||||
|
|
||||||
|
@bp.get("/v1/dashboards/<int:dashboard_id>")
|
||||||
|
@login_required
|
||||||
|
def dashboard_detail(dashboard_id):
|
||||||
|
d = db.session.get(Dashboard, dashboard_id)
|
||||||
|
if not d:
|
||||||
|
return _json_err("Not found", 404)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id):
|
||||||
|
return _json_err("Forbidden", 403)
|
||||||
|
widgets = Widget.query.filter_by(dashboard_id=d.id).all()
|
||||||
|
data = {
|
||||||
|
"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]
|
||||||
|
}
|
||||||
|
return _json_ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/v1/devices")
|
||||||
|
@login_required
|
||||||
|
def device_create():
|
||||||
|
from ..security.crypto import encrypt_json
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
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)
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
d.enc_credentials = encrypt_json({"username": payload["username"], "password": payload["password"]})
|
||||||
|
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()
|
||||||
|
return _json_ok({"id": d.id})
|
||||||
|
|
||||||
|
@bp.post("/v1/dashboards/<int:dashboard_id>/widgets")
|
||||||
|
@login_required
|
||||||
|
def widget_create(dashboard_id):
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
d = db.session.get(Dashboard, dashboard_id)
|
||||||
|
if not d:
|
||||||
|
return _json_err("Not found", 404)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id):
|
||||||
|
return _json_err("Forbidden", 403)
|
||||||
|
if "device_id" not in payload or "title" not in payload:
|
||||||
|
return _json_err("Missing fields: device_id, title", 400)
|
||||||
|
device = db.session.get(Device, int(payload["device_id"]))
|
||||||
|
if not device or not has_permission(current_user, "device", device.id, "view", device.owner_id):
|
||||||
|
return _json_err("Invalid device", 400)
|
||||||
|
presets = load_presets()
|
||||||
|
preset_key = payload.get("preset_key")
|
||||||
|
preset = presets.get(preset_key) if preset_key else None
|
||||||
|
q = payload.get("query")
|
||||||
|
if not q and preset:
|
||||||
|
q = {
|
||||||
|
"connector": preset.get("connector","rest"),
|
||||||
|
"endpoint": preset.get("endpoint"),
|
||||||
|
"params": preset.get("params") or {},
|
||||||
|
"extract": preset.get("extract") or {},
|
||||||
|
"preset_key": preset_key
|
||||||
|
}
|
||||||
|
if not q:
|
||||||
|
return _json_err("Missing preset_key or query", 400)
|
||||||
|
refresh = int(payload.get("refresh_seconds", (preset.get("refresh_seconds", 2) if preset else 2)))
|
||||||
|
from flask import current_app
|
||||||
|
refresh = max(refresh, current_app.config.get("USER_MIN_REFRESH_SECONDS", 2))
|
||||||
|
w = Widget(
|
||||||
|
dashboard_id=d.id,
|
||||||
|
device_id=device.id,
|
||||||
|
title=str(payload["title"]).strip(),
|
||||||
|
widget_type=str(payload.get("widget_type", (preset.get("widget_type") if preset else "timeseries"))),
|
||||||
|
preset_key=preset_key,
|
||||||
|
query_json=json.dumps(q),
|
||||||
|
refresh_seconds=refresh,
|
||||||
|
series_config=json.dumps(payload.get("series_config") or (preset.get("series") if preset else [])),
|
||||||
|
col_span=int(payload.get("col_span", 6)),
|
||||||
|
height_px=int(payload.get("height_px", 260)),
|
||||||
|
)
|
||||||
|
db.session.add(w); db.session.commit()
|
||||||
|
return _json_ok({"id": w.id})
|
||||||
|
|
||||||
|
@bp.get("/v1/widgets/<int:widget_id>/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())
|
||||||
|
if not last:
|
||||||
|
return _json_ok(None)
|
||||||
|
return _json_ok({"ts": last.ts.isoformat(), "value": json.loads(last.value_json)})
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
return _json_ok({"items": []})
|
||||||
|
|
||||||
|
device = db.session.get(Device, device_id)
|
||||||
|
if not device or not has_permission(current_user, "device", device.id, "view", device.owner_id):
|
||||||
|
return _json_ok({"items": []})
|
||||||
|
|
||||||
|
presets = load_presets()
|
||||||
|
preset = presets.get(preset_key)
|
||||||
|
if not preset:
|
||||||
|
return _json_ok({"items": []})
|
||||||
|
|
||||||
|
if not device.enc_credentials:
|
||||||
|
return _json_ok({"items": []})
|
||||||
|
try:
|
||||||
|
creds = decrypt_json(device.enc_credentials)
|
||||||
|
except Exception:
|
||||||
|
return _json_ok({"items": []})
|
||||||
|
|
||||||
|
q = {
|
||||||
|
"connector": preset.get("connector", "rest"),
|
||||||
|
"endpoint": preset.get("endpoint"),
|
||||||
|
"params": dict(preset.get("params") or {}),
|
||||||
|
"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"))
|
||||||
|
res = connector.fetch(device, creds, q)
|
||||||
|
if not res.ok:
|
||||||
|
return _json_ok({"items": []})
|
||||||
|
|
||||||
|
items = []
|
||||||
|
if isinstance(res.data, list):
|
||||||
|
for it in res.data:
|
||||||
|
if isinstance(it, dict) and it.get("name"):
|
||||||
|
items.append(str(it.get("name")))
|
||||||
|
items = sorted(set(items))[:200]
|
||||||
|
return _json_ok({"items": items})
|
||||||
|
|
||||||
|
@bp.get("/v1/public/<token>")
|
||||||
|
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]
|
||||||
|
})
|
||||||
133
mikromon/blueprints/auth.py
Normal file
133
mikromon/blueprints/auth.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import datetime as dt
|
||||||
|
import secrets
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from .. import db, limiter
|
||||||
|
from ..models import User, Role, RoleName, PasswordResetToken
|
||||||
|
from ..forms import LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm
|
||||||
|
from ..security.passwords import verify_password, hash_password
|
||||||
|
from ..services import audit
|
||||||
|
from ..services.mail import send_mail
|
||||||
|
from ..authn import load_user # noqa
|
||||||
|
|
||||||
|
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
|
|
||||||
|
@bp.get("/login")
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("dashboards.index"))
|
||||||
|
form = LoginForm()
|
||||||
|
return render_template("auth/login.html", form=form)
|
||||||
|
|
||||||
|
@bp.post("/login")
|
||||||
|
@limiter.limit(lambda: current_app.config.get("LIMIT_LOGIN", "10 per minute"))
|
||||||
|
def login_post():
|
||||||
|
form = LoginForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid credentials.", "danger")
|
||||||
|
return render_template("auth/login.html", form=form), 400
|
||||||
|
u = User.query.filter_by(email=form.email.data.lower().strip()).first()
|
||||||
|
if not u or not verify_password(u.password_hash, form.password.data) or not u.is_active_flag:
|
||||||
|
flash("Invalid credentials.", "danger")
|
||||||
|
return render_template("auth/login.html", form=form), 401
|
||||||
|
login_user(u)
|
||||||
|
audit.log("auth.login", "user", u.id)
|
||||||
|
return redirect(url_for("dashboards.index"))
|
||||||
|
|
||||||
|
@bp.get("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
audit.log("auth.logout", "user", current_user.id)
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
@bp.get("/register")
|
||||||
|
def register():
|
||||||
|
# MVP: allow self-registration; in production you may disable.
|
||||||
|
form = RegisterForm()
|
||||||
|
return render_template("auth/register.html", form=form)
|
||||||
|
|
||||||
|
@bp.post("/register")
|
||||||
|
def register_post():
|
||||||
|
form = RegisterForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid input.", "danger")
|
||||||
|
return render_template("auth/register.html", form=form), 400
|
||||||
|
email = form.email.data.lower().strip()
|
||||||
|
if User.query.filter_by(email=email).first():
|
||||||
|
flash("Email already registered.", "warning")
|
||||||
|
return render_template("auth/register.html", form=form), 409
|
||||||
|
user_role = Role.query.filter_by(name=RoleName.USER.value).first()
|
||||||
|
u = User(email=email, password_hash=hash_password(form.password.data), role_id=user_role.id)
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("auth.register", "user", u.id)
|
||||||
|
flash("Account created. Please log in.", "success")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
@bp.get("/forgot")
|
||||||
|
def forgot():
|
||||||
|
form = ForgotPasswordForm()
|
||||||
|
return render_template("auth/forgot.html", form=form)
|
||||||
|
|
||||||
|
@bp.post("/forgot")
|
||||||
|
@limiter.limit(lambda: current_app.config.get("LIMIT_RESET", "5 per hour"))
|
||||||
|
def forgot_post():
|
||||||
|
form = ForgotPasswordForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid input.", "danger")
|
||||||
|
return render_template("auth/forgot.html", form=form), 400
|
||||||
|
|
||||||
|
email = form.email.data.lower().strip()
|
||||||
|
u = User.query.filter_by(email=email).first()
|
||||||
|
# always respond the same
|
||||||
|
if u:
|
||||||
|
token = secrets.token_urlsafe(48)
|
||||||
|
ttl_minutes = 30
|
||||||
|
prt = PasswordResetToken(
|
||||||
|
user_id=u.id,
|
||||||
|
token=token,
|
||||||
|
expires_at=dt.datetime.utcnow() + dt.timedelta(minutes=ttl_minutes),
|
||||||
|
)
|
||||||
|
db.session.add(prt)
|
||||||
|
db.session.commit()
|
||||||
|
reset_url = url_for("auth.reset", token=token, _external=True)
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
to_email=u.email,
|
||||||
|
subject="Password reset",
|
||||||
|
template="emails/reset_password.html",
|
||||||
|
reset_url=reset_url,
|
||||||
|
ttl_minutes=ttl_minutes
|
||||||
|
)
|
||||||
|
audit.log("auth.reset_email_sent", "user", u.id)
|
||||||
|
except Exception:
|
||||||
|
# do not leak SMTP issues to attacker
|
||||||
|
audit.log("auth.reset_email_failed", "user", u.id)
|
||||||
|
flash("If that email exists, a reset link has been sent.", "info")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
@bp.get("/reset/<token>")
|
||||||
|
def reset(token):
|
||||||
|
form = ResetPasswordForm()
|
||||||
|
return render_template("auth/reset.html", form=form, token=token)
|
||||||
|
|
||||||
|
@bp.post("/reset/<token>")
|
||||||
|
def reset_post(token):
|
||||||
|
form = ResetPasswordForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid input.", "danger")
|
||||||
|
return render_template("auth/reset.html", form=form, token=token), 400
|
||||||
|
|
||||||
|
prt = PasswordResetToken.query.filter_by(token=token).first()
|
||||||
|
if not prt or not prt.is_valid():
|
||||||
|
flash("Token invalid or expired.", "danger")
|
||||||
|
return render_template("auth/reset.html", form=form, token=token), 400
|
||||||
|
|
||||||
|
u = prt.user
|
||||||
|
u.password_hash = hash_password(form.password.data)
|
||||||
|
prt.used_at = dt.datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("auth.password_reset", "user", u.id)
|
||||||
|
flash("Password changed. Please log in.", "success")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
282
mikromon/blueprints/dashboards.py
Normal file
282
mikromon/blueprints/dashboards.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import json, copy
|
||||||
|
import secrets
|
||||||
|
import datetime as dt
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, abort, request, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from .. import db
|
||||||
|
from ..models import Dashboard, Widget, Device, Share, PublicLink
|
||||||
|
from ..forms import DashboardForm, WidgetWizardForm, ShareForm
|
||||||
|
from ..services.acl import has_permission
|
||||||
|
from ..services.presets import load_presets
|
||||||
|
from ..services import audit
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
return render_template("dashboards/index.html", dashboards=dashboards)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/dashboards/new")
|
||||||
|
@login_required
|
||||||
|
def new():
|
||||||
|
form = DashboardForm()
|
||||||
|
return render_template("dashboards/new.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/new")
|
||||||
|
@login_required
|
||||||
|
def new_post():
|
||||||
|
form = DashboardForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid input.", "danger")
|
||||||
|
return render_template("dashboards/new.html", form=form), 400
|
||||||
|
d = Dashboard(owner_id=current_user.id, name=form.name.data.strip(), description=form.description.data.strip())
|
||||||
|
db.session.add(d)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("dashboard.created", "dashboard", d.id)
|
||||||
|
return redirect(url_for("dashboards.view", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dashboard_or_404(dashboard_id):
|
||||||
|
d = db.session.get(Dashboard, dashboard_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/dashboards/<int:dashboard_id>")
|
||||||
|
@login_required
|
||||||
|
def view(dashboard_id):
|
||||||
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
|
widgets = Widget.query.filter_by(dashboard_id=d.id).order_by(Widget.created_at.asc()).all()
|
||||||
|
return render_template("dashboards/view.html", dashboard=d, widgets=widgets)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/dashboards/<int:dashboard_id>/widgets/new")
|
||||||
|
@login_required
|
||||||
|
def widget_new(dashboard_id):
|
||||||
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
presets = load_presets()
|
||||||
|
form = WidgetWizardForm()
|
||||||
|
form.preset_key.choices = [(k, f"{v['title']} ({k})") for k, v in presets.items()]
|
||||||
|
form.refresh_seconds.data = 2
|
||||||
|
|
||||||
|
devices = Device.query.filter_by(owner_id=current_user.id).all()
|
||||||
|
return render_template("dashboards/widget_new.html", dashboard=d, form=form, presets=presets, devices=devices)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/widgets/new")
|
||||||
|
@login_required
|
||||||
|
def widget_new_post(dashboard_id):
|
||||||
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
presets = load_presets()
|
||||||
|
form = WidgetWizardForm()
|
||||||
|
form.preset_key.choices = [(k, f"{v['title']} ({k})") for k, v in presets.items()]
|
||||||
|
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid input.", "danger")
|
||||||
|
devices = Device.query.filter_by(owner_id=current_user.id).all()
|
||||||
|
return render_template("dashboards/widget_new.html", dashboard=d, form=form, presets=presets, devices=devices), 400
|
||||||
|
|
||||||
|
device_id = int(request.form.get("device_id") or 0)
|
||||||
|
device = db.session.get(Device, device_id)
|
||||||
|
if not device or not has_permission(current_user, "device", device.id, "view", device.owner_id):
|
||||||
|
flash("Invalid device.", "danger")
|
||||||
|
return redirect(url_for("dashboards.widget_new", dashboard_id=d.id))
|
||||||
|
|
||||||
|
preset_key = form.preset_key.data
|
||||||
|
preset = presets.get(preset_key)
|
||||||
|
if not preset:
|
||||||
|
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"),
|
||||||
|
"params": copy.deepcopy(preset.get("params") or {}),
|
||||||
|
"extract": copy.deepcopy(preset.get("extract") or {}),
|
||||||
|
"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:
|
||||||
|
user_q = json.loads(raw_q)
|
||||||
|
if not isinstance(user_q, dict):
|
||||||
|
raise ValueError("query_json must be an object")
|
||||||
|
except Exception:
|
||||||
|
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
|
||||||
|
|
||||||
|
refresh = max(int(form.refresh_seconds.data or 1), 1)
|
||||||
|
from flask import current_app
|
||||||
|
refresh = max(refresh, int(current_app.config.get("USER_MIN_REFRESH_SECONDS", 2)))
|
||||||
|
|
||||||
|
title = (form.title.data or "").strip() or preset.get("title") or preset_key
|
||||||
|
|
||||||
|
wdg = Widget(
|
||||||
|
dashboard_id=d.id,
|
||||||
|
device_id=device.id,
|
||||||
|
title=title,
|
||||||
|
widget_type=preset.get("widget_type", "timeseries"),
|
||||||
|
preset_key=preset_key,
|
||||||
|
query_json=json.dumps(q),
|
||||||
|
refresh_seconds=refresh,
|
||||||
|
series_config=json.dumps(preset.get("series") or []),
|
||||||
|
col_span=int(form.col_span.data or 6),
|
||||||
|
height_px=int(form.height_px.data or 260),
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
db.session.add(wdg)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("widget.created", "widget", wdg.id)
|
||||||
|
flash("Widget added.", "success")
|
||||||
|
return redirect(url_for("dashboards.view", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/widgets/<int:widget_id>/delete")
|
||||||
|
@login_required
|
||||||
|
def widget_delete(dashboard_id, widget_id):
|
||||||
|
d = _load_dashboard_or_404(dashboard_id)
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
wdg = db.session.get(Widget, widget_id)
|
||||||
|
if not wdg or wdg.dashboard_id != d.id:
|
||||||
|
abort(404)
|
||||||
|
db.session.delete(wdg)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("widget.deleted", "widget", widget_id)
|
||||||
|
flash("Widget deleted.", "success")
|
||||||
|
return redirect(url_for("dashboards.view", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
# Sharing
|
||||||
|
@bp.get("/dashboards/<int:dashboard_id>/share")
|
||||||
|
@login_required
|
||||||
|
def share(dashboard_id):
|
||||||
|
d = _load_dashboard_or_404(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()
|
||||||
|
form = ShareForm()
|
||||||
|
return render_template("dashboards/share.html", dashboard=d, shares=shares, public=public, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/share")
|
||||||
|
@login_required
|
||||||
|
def share_post(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:
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
s.permission = form.permission.data
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("dashboard.shared_user", "dashboard", d.id, f"{u.email}:{s.permission}")
|
||||||
|
flash("Shared updated.", "success")
|
||||||
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/share/public")
|
||||||
|
@login_required
|
||||||
|
def share_public(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)
|
||||||
|
db.session.add(pl)
|
||||||
|
else:
|
||||||
|
pl.token = token
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("dashboard.public_link_created", "dashboard", d.id)
|
||||||
|
flash("Public link created/refreshed.", "success")
|
||||||
|
return redirect(url_for("dashboards.share", dashboard_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/public/<token>")
|
||||||
|
def public_view(token):
|
||||||
|
pl = PublicLink.query.filter_by(token=token, target_type="dashboard").first()
|
||||||
|
if not pl:
|
||||||
|
abort(404)
|
||||||
|
if pl.expires_at and dt.datetime.utcnow() > pl.expires_at:
|
||||||
|
abort(404)
|
||||||
|
d = db.session.get(Dashboard, pl.target_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
widgets = Widget.query.filter_by(dashboard_id=d.id).order_by(Widget.created_at.asc()).all()
|
||||||
|
return render_template("dashboards/public_view.html", dashboard=d, widgets=widgets, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dashboards/<int:dashboard_id>/delete")
|
||||||
|
@login_required
|
||||||
|
def 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("dashboard.deleted", "dashboard", dashboard_id)
|
||||||
|
|
||||||
|
flash("Dashboard deleted.", "success")
|
||||||
|
return redirect(url_for("dashboards.index"))
|
||||||
184
mikromon/blueprints/devices.py
Normal file
184
mikromon/blueprints/devices.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
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 ..forms import DeviceForm, EditDeviceForm
|
||||||
|
from ..security.crypto import encrypt_json, decrypt_json
|
||||||
|
from ..connectors.registry import get_connector
|
||||||
|
from ..services.acl import has_permission
|
||||||
|
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_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():
|
||||||
|
form = DeviceForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
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
|
||||||
|
|
||||||
|
d = Device(
|
||||||
|
owner_id=current_user.id,
|
||||||
|
name=form.name.data.strip(),
|
||||||
|
host=form.host.data.strip(),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
d.enc_credentials = encrypt_json({"username": form.username.data, "password": form.password.data})
|
||||||
|
db.session.add(d)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("device.created", "device", d.id)
|
||||||
|
|
||||||
|
flash("Device created. You can test connection now.", "success")
|
||||||
|
return redirect(url_for("devices.view", device_id=d.id))
|
||||||
|
|
||||||
|
@bp.get("/<int:device_id>")
|
||||||
|
@login_required
|
||||||
|
def view(device_id):
|
||||||
|
d = db.session.get(Device, device_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
if not has_permission(current_user, "device", d.id, "view", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
return render_template("devices/view.html", device=d)
|
||||||
|
|
||||||
|
@bp.post("/<int:device_id>/test")
|
||||||
|
@login_required
|
||||||
|
def test(device_id):
|
||||||
|
d = db.session.get(Device, device_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
if not has_permission(current_user, "device", d.id, "manage", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
creds = decrypt_json(d.enc_credentials) if d.enc_credentials else None
|
||||||
|
if not creds:
|
||||||
|
return jsonify({"ok": False, "error": "Missing credentials"}), 400
|
||||||
|
connector = get_connector("rest")
|
||||||
|
res = connector.test(d, creds)
|
||||||
|
if res.ok:
|
||||||
|
audit.log("device.test_ok", "device", d.id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
audit.log("device.test_failed", "device", d.id, res.error)
|
||||||
|
return jsonify({"ok": False, "error": res.error}), 400
|
||||||
|
|
||||||
|
@bp.get("/<int:device_id>/discover")
|
||||||
|
@login_required
|
||||||
|
def discover(device_id):
|
||||||
|
d = db.session.get(Device, device_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
if not has_permission(current_user, "device", d.id, "manage", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
creds = decrypt_json(d.enc_credentials) if d.enc_credentials else None
|
||||||
|
connector = get_connector("rest")
|
||||||
|
res = connector.discover(d, creds)
|
||||||
|
if res.ok:
|
||||||
|
return jsonify({"ok": True, "data": res.data})
|
||||||
|
return jsonify({"ok": False, "error": res.error}), 400
|
||||||
|
|
||||||
|
@bp.get("/<int:device_id>/edit")
|
||||||
|
@login_required
|
||||||
|
def edit(device_id):
|
||||||
|
d = db.session.get(Device, device_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
if not has_permission(current_user, "device", d.id, "manage", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
form = EditDeviceForm()
|
||||||
|
|
||||||
|
# preload values
|
||||||
|
form.name.data = d.name
|
||||||
|
form.host.data = d.host
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
return render_template("devices/edit.html", device=d, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<int:device_id>/edit")
|
||||||
|
@login_required
|
||||||
|
def edit_post(device_id):
|
||||||
|
d = db.session.get(Device, device_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
if not has_permission(current_user, "device", d.id, "manage", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
form = EditDeviceForm()
|
||||||
|
if not form.validate_on_submit():
|
||||||
|
flash("Invalid input.", "danger")
|
||||||
|
return render_template("devices/edit.html", device=d, form=form), 400
|
||||||
|
|
||||||
|
d.name = form.name.data.strip()
|
||||||
|
d.host = form.host.data.strip()
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
audit.log("device.updated", "device", d.id)
|
||||||
|
|
||||||
|
flash("Device updated.", "success")
|
||||||
|
return redirect(url_for("devices.view", device_id=d.id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<int:device_id>/delete")
|
||||||
|
@login_required
|
||||||
|
def delete(device_id):
|
||||||
|
d = db.session.get(Device, device_id)
|
||||||
|
if not d:
|
||||||
|
abort(404)
|
||||||
|
if not has_permission(current_user, "device", d.id, "manage", d.owner_id):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
db.session.delete(d)
|
||||||
|
db.session.commit()
|
||||||
|
audit.log("device.deleted", "device", device_id)
|
||||||
|
|
||||||
|
flash("Device deleted.", "success")
|
||||||
|
return redirect(url_for("devices.index"))
|
||||||
107
mikromon/blueprints/realtime.py
Normal file
107
mikromon/blueprints/realtime.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from flask import Blueprint, jsonify
|
||||||
|
from flask_login import current_user
|
||||||
|
from flask_socketio import join_room, leave_room, disconnect, emit
|
||||||
|
|
||||||
|
from .. import socketio, db
|
||||||
|
from ..models import Dashboard, PublicLink
|
||||||
|
from ..services.acl import has_permission
|
||||||
|
|
||||||
|
bp = Blueprint("realtime", __name__, url_prefix="/realtime")
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("connect")
|
||||||
|
def sio_connect(auth=None):
|
||||||
|
# Cookie-based session auth is validated later during join/subscribe.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("subscribe")
|
||||||
|
def sio_subscribe(data):
|
||||||
|
"""
|
||||||
|
data: {"dashboard_id": 123}
|
||||||
|
Auth required (no public token here). For public dashboards use join_dashboard.
|
||||||
|
"""
|
||||||
|
data = data or {}
|
||||||
|
dashboard_id = int(data.get("dashboard_id") or 0)
|
||||||
|
if not dashboard_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
emit("error", {"message": "unauthorized"})
|
||||||
|
disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
d = db.session.get(Dashboard, dashboard_id)
|
||||||
|
if not d:
|
||||||
|
emit("error", {"message": "not_found"})
|
||||||
|
disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id):
|
||||||
|
emit("error", {"message": "forbidden"})
|
||||||
|
disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
join_room(f"dashboard:{d.id}")
|
||||||
|
emit("subscribed", {"dashboard_id": d.id})
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("join_dashboard")
|
||||||
|
def sio_join_dashboard(data):
|
||||||
|
"""
|
||||||
|
data: {"dashboard_id": 123, "public_token": "..."|null}
|
||||||
|
Allows either:
|
||||||
|
- authenticated session (cookie)
|
||||||
|
- public token for read-only dashboards
|
||||||
|
"""
|
||||||
|
data = data or {}
|
||||||
|
dashboard_id = int(data.get("dashboard_id") or 0)
|
||||||
|
public_token = data.get("public_token")
|
||||||
|
|
||||||
|
if not dashboard_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
d = db.session.get(Dashboard, dashboard_id)
|
||||||
|
if not d:
|
||||||
|
emit("error", {"message": "not_found"})
|
||||||
|
disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
# public link path
|
||||||
|
if public_token:
|
||||||
|
pl = PublicLink.query.filter_by(token=public_token, target_type="dashboard").first()
|
||||||
|
if not pl or pl.target_id != d.id:
|
||||||
|
emit("error", {"message": "forbidden"})
|
||||||
|
disconnect()
|
||||||
|
return
|
||||||
|
join_room(f"dashboard:{d.id}")
|
||||||
|
emit("joined", {"dashboard_id": d.id, "mode": "public"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# authenticated path
|
||||||
|
if not current_user.is_authenticated:
|
||||||
|
emit("error", {"message": "unauthorized"})
|
||||||
|
disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id):
|
||||||
|
emit("error", {"message": "forbidden"})
|
||||||
|
disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
join_room(f"dashboard:{d.id}")
|
||||||
|
emit("joined", {"dashboard_id": d.id, "mode": "user"})
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("leave_dashboard")
|
||||||
|
def sio_leave_dashboard(data):
|
||||||
|
data = data or {}
|
||||||
|
dashboard_id = int(data.get("dashboard_id") or 0)
|
||||||
|
if dashboard_id:
|
||||||
|
leave_room(f"dashboard:{dashboard_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify({"ok": True})
|
||||||
43
mikromon/cli.py
Normal file
43
mikromon/cli.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
from . import db
|
||||||
|
from .models import User
|
||||||
|
from .security.passwords import hash_password
|
||||||
|
from .security.crypto import reencrypt
|
||||||
|
from .models import Device
|
||||||
|
|
||||||
|
def register_cli(app):
|
||||||
|
@app.cli.group("users")
|
||||||
|
def users():
|
||||||
|
"""User management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@users.command("set-password")
|
||||||
|
@click.argument("email")
|
||||||
|
@click.argument("password")
|
||||||
|
@with_appcontext
|
||||||
|
def set_password(email, password):
|
||||||
|
u = User.query.filter_by(email=email.lower().strip()).first()
|
||||||
|
if not u:
|
||||||
|
raise click.ClickException("User not found")
|
||||||
|
u.password_hash = hash_password(password)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo("Password updated.")
|
||||||
|
|
||||||
|
@app.cli.group("devices")
|
||||||
|
def devices():
|
||||||
|
"""Device maintenance."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@devices.command("rotate-cred-key")
|
||||||
|
@with_appcontext
|
||||||
|
def rotate_cred_key():
|
||||||
|
# re-encrypt all credentials with new CRED_ENC_KEY, accepting old key via CRED_ENC_KEY_OLD
|
||||||
|
cnt = 0
|
||||||
|
for d in Device.query.all():
|
||||||
|
if not d.enc_credentials:
|
||||||
|
continue
|
||||||
|
d.enc_credentials = reencrypt(d.enc_credentials)
|
||||||
|
cnt += 1
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f"Re-encrypted {cnt} device credential blobs.")
|
||||||
114
mikromon/config.py
Normal file
114
mikromon/config.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(name: str, default: bool = False) -> bool:
|
||||||
|
v = os.getenv(name)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
return str(v).lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _int(name: str, default: int) -> int:
|
||||||
|
v = os.getenv(name)
|
||||||
|
if v is None or v == "":
|
||||||
|
return default
|
||||||
|
return int(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _float(name: str, default: float) -> float:
|
||||||
|
v = os.getenv(name)
|
||||||
|
if v is None or v == "":
|
||||||
|
return default
|
||||||
|
return float(v)
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Core
|
||||||
|
# -------------------------------------------------
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
|
||||||
|
|
||||||
|
# prefer DATABASE_URL (Docker / production)
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
os.getenv("SQLALCHEMY_DATABASE_URI", "sqlite:///instance/mikromon.db"),
|
||||||
|
)
|
||||||
|
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = _bool("SQLALCHEMY_TRACK_MODIFICATIONS", False)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Session
|
||||||
|
# -------------------------------------------------
|
||||||
|
SESSION_TYPE = os.getenv("SESSION_TYPE", "sqlalchemy")
|
||||||
|
SESSION_SQLALCHEMY_TABLE = os.getenv("SESSION_SQLALCHEMY_TABLE", "sessions")
|
||||||
|
SESSION_SQLALCHEMY = None # set in app factory
|
||||||
|
SESSION_PERMANENT = _bool("SESSION_PERMANENT", False)
|
||||||
|
|
||||||
|
SESSION_COOKIE_SECURE = _bool("SESSION_COOKIE_SECURE", False)
|
||||||
|
SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "Lax")
|
||||||
|
|
||||||
|
SESSION_COOKIE_HTTPONLY = _bool("SESSION_COOKIE_HTTPONLY", True)
|
||||||
|
PERMANENT_SESSION_LIFETIME = _int("PERMANENT_SESSION_LIFETIME", 86400)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# SocketIO
|
||||||
|
# -------------------------------------------------
|
||||||
|
SOCKETIO_MESSAGE_QUEUE = os.getenv("SOCKETIO_MESSAGE_QUEUE")
|
||||||
|
SOCKETIO_CORS_ALLOWED_ORIGINS = os.getenv("SOCKETIO_CORS_ALLOWED_ORIGINS", "*")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Dev poller
|
||||||
|
# -------------------------------------------------
|
||||||
|
DEV_INPROCESS_POLLER = _bool("DEV_INPROCESS_POLLER", True)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# SMTP / Email
|
||||||
|
# -------------------------------------------------
|
||||||
|
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||||
|
SMTP_PORT = _int("SMTP_PORT", 587)
|
||||||
|
|
||||||
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||||
|
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
||||||
|
|
||||||
|
SMTP_USE_TLS = _bool("SMTP_USE_TLS", True)
|
||||||
|
SMTP_USE_SSL = _bool("SMTP_USE_SSL", False)
|
||||||
|
|
||||||
|
SMTP_FROM = os.getenv("SMTP_FROM", "no-reply@mikromon.local")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Limits / quotas
|
||||||
|
# -------------------------------------------------
|
||||||
|
USER_MAX_DEVICES = _int("USER_MAX_DEVICES", 20)
|
||||||
|
USER_MIN_REFRESH_SECONDS = _int("USER_MIN_REFRESH_SECONDS", 2)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# RouterOS
|
||||||
|
# -------------------------------------------------
|
||||||
|
ROUTEROS_REST_TIMEOUT = _float("ROUTEROS_REST_TIMEOUT", 6)
|
||||||
|
|
||||||
|
ROUTEROS_VERIFY_TLS = _bool("ROUTEROS_VERIFY_TLS", False)
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Encryption
|
||||||
|
# -------------------------------------------------
|
||||||
|
CRED_ENC_KEY = os.getenv("CRED_ENC_KEY", "")
|
||||||
|
CRED_ENC_KEY_OLD = os.getenv("CRED_ENC_KEY_OLD", "")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# Rate limiting
|
||||||
|
# -------------------------------------------------
|
||||||
|
LIMIT_LOGIN = os.getenv("LIMIT_LOGIN", "10 per minute")
|
||||||
|
LIMIT_RESET = os.getenv("LIMIT_RESET", "5 per hour")
|
||||||
|
|
||||||
|
RATELIMIT_STORAGE_URI = os.getenv("RATELIMIT_STORAGE_URI", "memory://")
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# App behaviour
|
||||||
|
# -------------------------------------------------
|
||||||
|
APP_NAME = os.getenv("APP_NAME", "MikroMon")
|
||||||
|
|
||||||
|
ENABLE_PUBLIC_DASHBOARDS = _bool("ENABLE_PUBLIC_DASHBOARDS", True)
|
||||||
|
|
||||||
|
DEFAULT_WIDGET_REFRESH = _int("DEFAULT_WIDGET_REFRESH", 2)
|
||||||
|
|
||||||
|
METRICS_HISTORY_LIMIT = _int("METRICS_HISTORY_LIMIT", 900)
|
||||||
21
mikromon/connectors/base.py
Normal file
21
mikromon/connectors/base.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FetchResult:
|
||||||
|
ok: bool
|
||||||
|
data: Any = None
|
||||||
|
error: str|None = None
|
||||||
|
|
||||||
|
class Connector:
|
||||||
|
name = "base"
|
||||||
|
|
||||||
|
def test(self, device, creds: dict) -> FetchResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def fetch(self, device, creds: dict, query: dict) -> FetchResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def discover(self, device, creds: dict) -> FetchResult:
|
||||||
|
"""Basic capabilities discovery: RouterOS version + common resources."""
|
||||||
|
raise NotImplementedError
|
||||||
13
mikromon/connectors/registry.py
Normal file
13
mikromon/connectors/registry.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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:
|
||||||
|
raise KeyError(f"Unknown connector: {name}")
|
||||||
|
return c
|
||||||
54
mikromon/connectors/routeros_rest.py
Normal file
54
mikromon/connectors/routeros_rest.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
def _url(self, device, endpoint: str) -> str:
|
||||||
|
base = device.rest_base_path.strip("/")
|
||||||
|
ep = endpoint.strip("/")
|
||||||
|
return f"https://{device.host}:{device.rest_port}/{base}/{ep}"
|
||||||
|
|
||||||
|
def test(self, device, creds: dict) -> FetchResult:
|
||||||
|
try:
|
||||||
|
with self._client(device) as c:
|
||||||
|
r = c.get(self._url(device, "/system/resource"), auth=(creds["username"], creds["password"]))
|
||||||
|
r.raise_for_status()
|
||||||
|
return FetchResult(ok=True, data={"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return FetchResult(ok=False, error=str(e))
|
||||||
|
|
||||||
|
def fetch(self, device, creds: dict, query: dict) -> FetchResult:
|
||||||
|
try:
|
||||||
|
endpoint = query.get("endpoint", "")
|
||||||
|
params = query.get("params") or {}
|
||||||
|
with self._client(device) as c:
|
||||||
|
r = c.get(self._url(device, endpoint), params=params, auth=(creds["username"], creds["password"]))
|
||||||
|
r.raise_for_status()
|
||||||
|
return FetchResult(ok=True, data=r.json())
|
||||||
|
except Exception as e:
|
||||||
|
return FetchResult(ok=False, error=str(e))
|
||||||
|
|
||||||
|
def discover(self, device, creds: dict) -> FetchResult:
|
||||||
|
try:
|
||||||
|
with self._client(device) as c:
|
||||||
|
r = c.get(self._url(device, "/system/resource"), auth=(creds["username"], creds["password"]))
|
||||||
|
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/*"],
|
||||||
|
}
|
||||||
|
return FetchResult(ok=True, data=caps)
|
||||||
|
except Exception as e:
|
||||||
|
return FetchResult(ok=False, error=str(e))
|
||||||
60
mikromon/connectors/routeros_ssh.py
Normal file
60
mikromon/connectors/routeros_ssh.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import paramiko
|
||||||
|
from flask import current_app
|
||||||
|
from .base import Connector, FetchResult
|
||||||
|
|
||||||
|
class RouterOSSshConnector(Connector):
|
||||||
|
name = "ssh"
|
||||||
|
|
||||||
|
def _connect(self, device, creds: dict):
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # user opted-in by enabling SSH
|
||||||
|
timeout = float(current_app.config.get("ROUTEROS_REST_TIMEOUT", 6))
|
||||||
|
client.connect(
|
||||||
|
hostname=device.host,
|
||||||
|
port=device.ssh_port,
|
||||||
|
username=creds["username"],
|
||||||
|
password=creds["password"],
|
||||||
|
timeout=timeout,
|
||||||
|
banner_timeout=timeout,
|
||||||
|
auth_timeout=timeout,
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
def test(self, device, creds: dict) -> FetchResult:
|
||||||
|
try:
|
||||||
|
c = self._connect(device, creds)
|
||||||
|
c.close()
|
||||||
|
return FetchResult(ok=True, data={"status":"ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return FetchResult(ok=False, error=str(e))
|
||||||
|
|
||||||
|
def fetch(self, device, creds: dict, query: dict) -> FetchResult:
|
||||||
|
# query: {"command": "/system/resource/print"}
|
||||||
|
cmd = query.get("command")
|
||||||
|
if not cmd:
|
||||||
|
return FetchResult(ok=False, error="Missing SSH command")
|
||||||
|
try:
|
||||||
|
c = self._connect(device, creds)
|
||||||
|
try:
|
||||||
|
_, stdout, stderr = c.exec_command(cmd)
|
||||||
|
out = stdout.read().decode("utf-8", errors="ignore")
|
||||||
|
err = stderr.read().decode("utf-8", errors="ignore")
|
||||||
|
if err.strip():
|
||||||
|
return FetchResult(ok=False, error=err.strip())
|
||||||
|
return FetchResult(ok=True, data={"raw": out})
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
except Exception as e:
|
||||||
|
return FetchResult(ok=False, error=str(e))
|
||||||
|
|
||||||
|
def discover(self, device, creds: dict) -> FetchResult:
|
||||||
|
try:
|
||||||
|
c = self._connect(device, creds)
|
||||||
|
try:
|
||||||
|
_, stdout, _ = c.exec_command(":put [/system/resource/get version]")
|
||||||
|
version = stdout.read().decode("utf-8", errors="ignore").strip()
|
||||||
|
return FetchResult(ok=True, data={"routeros_version": version, "has_ssh": True})
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
except Exception as e:
|
||||||
|
return FetchResult(ok=False, error=str(e))
|
||||||
24
mikromon/errors.py
Normal file
24
mikromon/errors.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from flask import render_template, g
|
||||||
|
|
||||||
|
def register_error_handlers(app):
|
||||||
|
for code in (400, 401, 403, 404, 405, 429, 500):
|
||||||
|
app.register_error_handler(code, _handler(code))
|
||||||
|
|
||||||
|
@app.route("/favicon.ico")
|
||||||
|
def favicon():
|
||||||
|
return ("", 204)
|
||||||
|
|
||||||
|
def _handler(code):
|
||||||
|
def handler(err):
|
||||||
|
title = {
|
||||||
|
400: "Bad Request",
|
||||||
|
401: "Unauthorized",
|
||||||
|
403: "Forbidden",
|
||||||
|
404: "Not Found",
|
||||||
|
405: "Method Not Allowed",
|
||||||
|
429: "Too Many Requests",
|
||||||
|
500: "Server Error",
|
||||||
|
}.get(code, "Error")
|
||||||
|
desc = getattr(err, "description", None) or "Something went wrong."
|
||||||
|
return render_template("error.html", code=code, title=title, description=desc, request_id=getattr(g, "request_id", "-")), code
|
||||||
|
return handler
|
||||||
63
mikromon/forms.py
Normal file
63
mikromon/forms.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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_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()],
|
||||||
|
choices=[("12", "Full"), ("6", "Half"), ("4", "1/3"), ("3", "1/4")],
|
||||||
|
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)])
|
||||||
|
|
||||||
|
class ShareForm(FlaskForm):
|
||||||
|
email = StringField("User email", validators=[DataRequired(), Email()])
|
||||||
|
permission = SelectField("Permission", validators=[DataRequired()], choices=[("view","View"),("edit","Edit"),("manage","Manage")])
|
||||||
|
|
||||||
|
class SmtpTestForm(FlaskForm):
|
||||||
|
to_email = StringField("To", validators=[DataRequired(), Email()])
|
||||||
163
mikromon/models.py
Normal file
163
mikromon/models.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import enum
|
||||||
|
import datetime as dt
|
||||||
|
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)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
|
role_id = db.Column(db.Integer, db.ForeignKey("roles.id"), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
is_active_flag = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
role = db.relationship("Role")
|
||||||
|
|
||||||
|
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)
|
||||||
|
owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
|
||||||
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
host = db.Column(db.String(255), 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
|
||||||
|
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)
|
||||||
|
last_error = db.Column(db.Text, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
owner = db.relationship("User")
|
||||||
|
|
||||||
|
__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
|
||||||
|
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)
|
||||||
|
dashboard_id = db.Column(db.Integer, db.ForeignKey("dashboards.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)
|
||||||
|
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
|
||||||
|
refresh_seconds = db.Column(db.Integer, default=2, nullable=False)
|
||||||
|
series_config = db.Column(db.Text, nullable=True) # json
|
||||||
|
|
||||||
|
# UI/layout
|
||||||
|
col_span = db.Column(db.Integer, default=6, nullable=False) # Bootstrap grid 1..12
|
||||||
|
height_px = db.Column(db.Integer, default=260, nullable=False)
|
||||||
|
|
||||||
|
is_enabled = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
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_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
|
||||||
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = db.relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("target_type", "target_id", "user_id", name="uq_share_target_user"),
|
||||||
|
Index("ix_shares_target", "target_type", "target_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class PublicLink(db.Model):
|
||||||
|
__tablename__ = "public_links"
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
target_type = db.Column(db.String(16), nullable=False)
|
||||||
|
target_id = db.Column(db.Integer, nullable=False)
|
||||||
|
read_only = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
expires_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
__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)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
token = db.Column(db.String(128), unique=True, nullable=False, index=True)
|
||||||
|
expires_at = db.Column(db.DateTime, nullable=False)
|
||||||
|
used_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = db.relationship("User")
|
||||||
|
|
||||||
|
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)
|
||||||
|
actor_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True)
|
||||||
|
action = db.Column(db.String(64), nullable=False)
|
||||||
|
target_type = db.Column(db.String(32), nullable=True)
|
||||||
|
target_id = db.Column(db.Integer, nullable=True)
|
||||||
|
details = db.Column(db.Text, nullable=True)
|
||||||
|
request_id = db.Column(db.String(64), nullable=True)
|
||||||
|
ip = db.Column(db.String(64), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
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"
|
||||||
|
value_json = db.Column(db.Text, nullable=False)
|
||||||
|
ts = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_metrics_last_device_key", "device_id", "key"),)
|
||||||
149
mikromon/presets/widget_presets.json
Normal file
149
mikromon/presets/widget_presets.json
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"cpu": {
|
||||||
|
"title": "CPU Load",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/system/resource",
|
||||||
|
"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"],
|
||||||
|
"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": "" },
|
||||||
|
"extract": {
|
||||||
|
"rx_counter": "rx-byte",
|
||||||
|
"tx_counter": "tx-byte",
|
||||||
|
"rate_unit": "bps"
|
||||||
|
},
|
||||||
|
"refresh_seconds": 2
|
||||||
|
},
|
||||||
|
|
||||||
|
"firewall_rates": {
|
||||||
|
"title": "Firewall Traffic (bytes/s)",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/ip/firewall/filter",
|
||||||
|
"extract": {
|
||||||
|
"id_key": ".id",
|
||||||
|
"series_key": "comment",
|
||||||
|
"value_key": "bytes",
|
||||||
|
"rate": true,
|
||||||
|
"unit": "B/s",
|
||||||
|
"max_series": 12,
|
||||||
|
"ignore_dynamic": true,
|
||||||
|
"ignore_disabled": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"firewall_bytes": {
|
||||||
|
"title": "Firewall Counters (bytes)",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/ip/firewall/filter",
|
||||||
|
"extract": {
|
||||||
|
"id_key": ".id",
|
||||||
|
"series_key": "comment",
|
||||||
|
"value_key": "bytes",
|
||||||
|
"rate": false,
|
||||||
|
"unit": "B",
|
||||||
|
"max_series": 12,
|
||||||
|
"ignore_dynamic": true,
|
||||||
|
"ignore_disabled": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 30
|
||||||
|
},
|
||||||
|
|
||||||
|
"queue_simple_rate": {
|
||||||
|
"title": "Simple Queues (RX/TX bytes/s)",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/queue/simple",
|
||||||
|
"extract": {
|
||||||
|
"id_key": ".id",
|
||||||
|
"series_key": "name",
|
||||||
|
"value_key": "bytes",
|
||||||
|
"split_duplex": true,
|
||||||
|
"duplex_labels": ["rx", "tx"],
|
||||||
|
"rate": true,
|
||||||
|
"unit": "B/s",
|
||||||
|
"max_series": 12,
|
||||||
|
"ignore_dynamic": true,
|
||||||
|
"ignore_disabled": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"queue_simple_bytes": {
|
||||||
|
"title": "Simple Queues (RX/TX bytes)",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/queue/simple",
|
||||||
|
"extract": {
|
||||||
|
"id_key": ".id",
|
||||||
|
"series_key": "name",
|
||||||
|
"value_key": "bytes",
|
||||||
|
"split_duplex": true,
|
||||||
|
"duplex_labels": ["rx", "tx"],
|
||||||
|
"rate": false,
|
||||||
|
"unit": "B",
|
||||||
|
"max_series": 12,
|
||||||
|
"ignore_dynamic": true,
|
||||||
|
"ignore_disabled": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 30
|
||||||
|
},
|
||||||
|
|
||||||
|
"queue_tree_rate": {
|
||||||
|
"title": "Queue Tree (bytes/s)",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/queue/tree",
|
||||||
|
"extract": {
|
||||||
|
"id_key": ".id",
|
||||||
|
"series_key": "name",
|
||||||
|
"value_key": "bytes",
|
||||||
|
"rate": true,
|
||||||
|
"unit": "B/s",
|
||||||
|
"max_series": 12,
|
||||||
|
"ignore_dynamic": true,
|
||||||
|
"ignore_disabled": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"queue_tree_bytes": {
|
||||||
|
"title": "Queue Tree (bytes)",
|
||||||
|
"widget_type": "timeseries",
|
||||||
|
"connector": "rest",
|
||||||
|
"endpoint": "/queue/tree",
|
||||||
|
"extract": {
|
||||||
|
"id_key": ".id",
|
||||||
|
"series_key": "name",
|
||||||
|
"value_key": "bytes",
|
||||||
|
"rate": false,
|
||||||
|
"unit": "B",
|
||||||
|
"max_series": 12,
|
||||||
|
"ignore_dynamic": true,
|
||||||
|
"ignore_disabled": true
|
||||||
|
},
|
||||||
|
"refresh_seconds": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
37
mikromon/security/crypto.py
Normal file
37
mikromon/security/crypto.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import json
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
def _fernet():
|
||||||
|
key = current_app.config.get("CRED_ENC_KEY", "")
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError("CRED_ENC_KEY is not set")
|
||||||
|
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||||
|
|
||||||
|
def _fernet_old():
|
||||||
|
key = current_app.config.get("CRED_ENC_KEY_OLD", "")
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||||
|
|
||||||
|
def encrypt_json(data: dict) -> str:
|
||||||
|
f = _fernet()
|
||||||
|
token = f.encrypt(json.dumps(data).encode("utf-8"))
|
||||||
|
return token.decode("utf-8")
|
||||||
|
|
||||||
|
def decrypt_json(token: str) -> dict:
|
||||||
|
f = _fernet()
|
||||||
|
try:
|
||||||
|
raw = f.decrypt(token.encode("utf-8"))
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except InvalidToken:
|
||||||
|
# allow old key during rotation
|
||||||
|
f_old = _fernet_old()
|
||||||
|
if not f_old:
|
||||||
|
raise
|
||||||
|
raw = f_old.decrypt(token.encode("utf-8"))
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def reencrypt(token: str) -> str:
|
||||||
|
data = decrypt_json(token)
|
||||||
|
return encrypt_json(data)
|
||||||
13
mikromon/security/passwords.py
Normal file
13
mikromon/security/passwords.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
_ph = PasswordHasher()
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _ph.hash(password)
|
||||||
|
|
||||||
|
def verify_password(hash_: str, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _ph.verify(hash_, password)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
1
mikromon/services/__init__.py
Normal file
1
mikromon/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import audit
|
||||||
16
mikromon/services/acl.py
Normal file
16
mikromon/services/acl.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from ..models import Share, Permission, ShareTarget
|
||||||
|
|
||||||
|
def permission_rank(p: str) -> int:
|
||||||
|
return {"view": 1, "edit": 2, "manage": 3}.get(p, 0)
|
||||||
|
|
||||||
|
def has_permission(user, target_type: str, target_id: int, required: str, owner_id: int) -> bool:
|
||||||
|
if user.is_authenticated and getattr(user, "is_admin", lambda: False)():
|
||||||
|
return True
|
||||||
|
if user.is_authenticated and user.id == owner_id:
|
||||||
|
return True
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
share = Share.query.filter_by(target_type=target_type, target_id=target_id, user_id=user.id).first()
|
||||||
|
if not share:
|
||||||
|
return False
|
||||||
|
return permission_rank(share.permission) >= permission_rank(required)
|
||||||
21
mikromon/services/audit.py
Normal file
21
mikromon/services/audit.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from flask import request, g
|
||||||
|
from flask_login import current_user
|
||||||
|
from .. import db
|
||||||
|
from ..models import AuditLog
|
||||||
|
|
||||||
|
def log(action: str, target_type: str|None=None, target_id: int|None=None, details: str|None=None):
|
||||||
|
try:
|
||||||
|
actor_id = current_user.id if current_user.is_authenticated else None
|
||||||
|
except Exception:
|
||||||
|
actor_id = None
|
||||||
|
entry = AuditLog(
|
||||||
|
actor_user_id=actor_id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
details=details,
|
||||||
|
request_id=getattr(g, "request_id", None),
|
||||||
|
ip=request.headers.get("X-Forwarded-For", request.remote_addr),
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
29
mikromon/services/bootstrap.py
Normal file
29
mikromon/services/bootstrap.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from .. import db
|
||||||
|
from ..models import Role, RoleName, User
|
||||||
|
from ..security.passwords import hash_password
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.exc import OperationalError
|
||||||
|
|
||||||
|
def ensure_default_roles_and_admin():
|
||||||
|
try:
|
||||||
|
insp = inspect(db.engine)
|
||||||
|
if not insp.has_table("roles"):
|
||||||
|
return
|
||||||
|
except OperationalError:
|
||||||
|
return
|
||||||
|
# roles
|
||||||
|
for rn in (RoleName.USER.value, RoleName.ADMIN.value):
|
||||||
|
if not Role.query.filter_by(name=rn).first():
|
||||||
|
db.session.add(Role(name=rn))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# admin
|
||||||
|
admin_role = Role.query.filter_by(name=RoleName.ADMIN.value).first()
|
||||||
|
if not User.query.filter_by(email="admin@example.com").first():
|
||||||
|
db.session.add(User(
|
||||||
|
email="admin@example.com",
|
||||||
|
password_hash=hash_password("Admin123!"),
|
||||||
|
role_id=admin_role.id
|
||||||
|
))
|
||||||
|
db.session.commit()
|
||||||
39
mikromon/services/mail.py
Normal file
39
mikromon/services/mail.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from flask import current_app, render_template
|
||||||
|
|
||||||
|
def send_mail(to_email: str, subject: str, template: str, **ctx):
|
||||||
|
app = current_app
|
||||||
|
host = app.config.get("SMTP_HOST")
|
||||||
|
if not host:
|
||||||
|
raise RuntimeError("SMTP_HOST not configured")
|
||||||
|
port = app.config.get("SMTP_PORT")
|
||||||
|
user = app.config.get("SMTP_USER")
|
||||||
|
pw = app.config.get("SMTP_PASS")
|
||||||
|
use_tls = app.config.get("SMTP_USE_TLS")
|
||||||
|
use_ssl = app.config.get("SMTP_USE_SSL")
|
||||||
|
from_email = app.config.get("SMTP_FROM")
|
||||||
|
|
||||||
|
html = render_template(template, **ctx)
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = from_email
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content("This email requires HTML support.")
|
||||||
|
msg.add_alternative(html, subtype="html")
|
||||||
|
|
||||||
|
if use_ssl:
|
||||||
|
smtp = smtplib.SMTP_SSL(host, port, timeout=10)
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(host, port, timeout=10)
|
||||||
|
try:
|
||||||
|
smtp.ehlo()
|
||||||
|
if use_tls and not use_ssl:
|
||||||
|
smtp.starttls()
|
||||||
|
smtp.ehlo()
|
||||||
|
if user:
|
||||||
|
smtp.login(user, pw)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
finally:
|
||||||
|
smtp.quit()
|
||||||
394
mikromon/services/poller.py
Normal file
394
mikromon/services/poller.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
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 = {} # (device_id, widget_id) -> datetime
|
||||||
|
_last_counters = {} # (device_id, iface_name) -> {"ts": float, "rx": int, "tx": int}
|
||||||
|
_last_series_counters = {} # (device_id, widget_id, series_id) -> {"ts": float, "v": float}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
db.session.add(
|
||||||
|
MetricLastValue(
|
||||||
|
device_id=device.id,
|
||||||
|
widget_id=widget.id,
|
||||||
|
key=f"widget.{widget.id}",
|
||||||
|
value_json=json.dumps(payload),
|
||||||
|
ts=dt.datetime.utcnow(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
# ---- TABLE ----
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- TIMESERIES ----
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
RouterOS potrafi dawać bytes jako "a/b" (simple queues).
|
||||||
|
Zwraca (a,b) jako float albo (None,None).
|
||||||
|
"""
|
||||||
|
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))
|
||||||
|
|
||||||
|
# ---- dict ----
|
||||||
|
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
|
||||||
|
|
||||||
|
# RAM fallback
|
||||||
|
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), "%")
|
||||||
|
|
||||||
|
# ---- list ----
|
||||||
|
elif isinstance(data, list):
|
||||||
|
|
||||||
|
# A) Multi-series (firewall/queues) with optional rate(delta)
|
||||||
|
if "series_key" in extract and "value_key" in extract:
|
||||||
|
label_key = extract["series_key"] # label e.g. comment/name
|
||||||
|
value_key = extract["value_key"] # bytes
|
||||||
|
id_key = extract.get("id_key", ".id") # stable 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"] # a/b
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
# top N po liczniku (dla duplex liczymy po wartości serii)
|
||||||
|
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)
|
||||||
|
if prev and now > prev["ts"]:
|
||||||
|
dt_s = now - prev["ts"]
|
||||||
|
dv = v - prev["v"]
|
||||||
|
if dv >= 0:
|
||||||
|
_add(lab, dv / dt_s, unit)
|
||||||
|
_last_series_counters[key] = {"ts": now, "v": v}
|
||||||
|
else:
|
||||||
|
for sid, 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):
|
||||||
|
# interface rate from counters
|
||||||
|
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}
|
||||||
|
|
||||||
|
if "path" in extract:
|
||||||
|
_add(widget.title, _to_float(target.get(extract["path"])), extract.get("unit"))
|
||||||
|
|
||||||
|
if "calc" in extract and "needs" in extract:
|
||||||
|
try:
|
||||||
|
ctx = _ctx_from_needs(target, extract.get("needs") or [])
|
||||||
|
v = _safe_calc(extract["calc"], ctx)
|
||||||
|
_add(widget.title, v, extract.get("unit"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
s = str(v).strip()
|
||||||
|
if s.endswith("%"):
|
||||||
|
s = s[:-1]
|
||||||
|
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):
|
||||||
|
import ast
|
||||||
|
import operator as op
|
||||||
|
|
||||||
|
allowed_ops = {
|
||||||
|
ast.Add: op.add,
|
||||||
|
ast.Sub: op.sub,
|
||||||
|
ast.Mult: op.mul,
|
||||||
|
ast.Div: op.truediv,
|
||||||
|
ast.Pow: op.pow,
|
||||||
|
ast.USub: op.neg,
|
||||||
|
ast.Mod: op.mod,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _eval(node):
|
||||||
|
if isinstance(node, ast.Num):
|
||||||
|
return node.n
|
||||||
|
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||||
|
return node.value
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return ctx[node.id]
|
||||||
|
if isinstance(node, ast.BinOp):
|
||||||
|
return allowed_ops[type(node.op)](_eval(node.left), _eval(node.right))
|
||||||
|
if isinstance(node, ast.UnaryOp):
|
||||||
|
return allowed_ops[type(node.op)](_eval(node.operand))
|
||||||
|
raise ValueError("Unsafe expression")
|
||||||
|
|
||||||
|
tree = ast.parse(expr, mode="eval")
|
||||||
|
return _eval(tree.body)
|
||||||
6
mikromon/services/presets.py
Normal file
6
mikromon/services/presets.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def load_presets() -> dict:
|
||||||
|
fp = Path(__file__).resolve().parents[1] / "presets" / "widget_presets.json"
|
||||||
|
return json.loads(fp.read_text(encoding="utf-8"))
|
||||||
34
mikromon/template_helpers.py
Normal file
34
mikromon/template_helpers.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import current_app, url_for
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_hash(filepath: Path) -> str:
|
||||||
|
"""Generate MD5 hash for cache busting"""
|
||||||
|
try:
|
||||||
|
if filepath.exists():
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
return hashlib.md5(f.read()).hexdigest()[:8]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "v1"
|
||||||
|
|
||||||
|
|
||||||
|
def static_url(path: str) -> str:
|
||||||
|
"""
|
||||||
|
Returns /static/<path>?v=<hash>
|
||||||
|
Works even if static folder is outside the package (symlink etc.).
|
||||||
|
"""
|
||||||
|
path = path.lstrip("/")
|
||||||
|
|
||||||
|
static_folder = Path(current_app.static_folder) # absolute path
|
||||||
|
file_path = static_folder / path
|
||||||
|
v = get_file_hash(file_path)
|
||||||
|
|
||||||
|
return url_for("static", filename=path, v=v)
|
||||||
|
|
||||||
|
|
||||||
|
def register(app):
|
||||||
|
app.jinja_env.globals["static_url"] = static_url
|
||||||
1
mikromon/templates
Symbolic link
1
mikromon/templates
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../templates
|
||||||
39
mikromon/utils/static.py
Normal file
39
mikromon/utils/static.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
def get_file_hash(filepath: Path) -> str:
|
||||||
|
"""Generate MD5 hash for cache busting"""
|
||||||
|
try:
|
||||||
|
if filepath.exists():
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
return hashlib.md5(f.read()).hexdigest()[:8]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "v1"
|
||||||
|
|
||||||
|
def static_url(path: str) -> str:
|
||||||
|
# path is like 'css/app.css'
|
||||||
|
static_folder = Path(__file__).resolve().parents[2] / "static"
|
||||||
|
fp = static_folder / path
|
||||||
|
v = get_file_hash(fp)
|
||||||
|
return url_for("static", filename=path, v=v)
|
||||||
|
|
||||||
|
def init_static_cache_headers(app):
|
||||||
|
@app.after_request
|
||||||
|
def _static_cache(resp):
|
||||||
|
# Long cache for hashed static. Safe because we add ?v=
|
||||||
|
if resp.direct_passthrough:
|
||||||
|
return resp
|
||||||
|
try:
|
||||||
|
if resp.status_code == 200 and (resp.mimetype.startswith("text/") or resp.mimetype in ("application/javascript","application/json","image/svg+xml")):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if request_is_static():
|
||||||
|
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def request_is_static() -> bool:
|
||||||
|
from flask import request
|
||||||
|
return request.path.startswith("/static/")
|
||||||
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
Flask-WTF==1.2.1
|
||||||
|
Flask-Limiter==3.7.0
|
||||||
|
Flask-Session==0.8.0
|
||||||
|
Flask-SocketIO==5.3.6
|
||||||
|
simple-websocket==1.0.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
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
|
||||||
|
email-validator==2.2.0
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
pytest==8.3.2
|
||||||
|
pytest-flask==1.3.0
|
||||||
|
coverage==7.6.1
|
||||||
30
scripts/set_admin_password.py
Normal file
30
scripts/set_admin_password.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from mikromon import create_app, db
|
||||||
|
from mikromon.models import User
|
||||||
|
from mikromon.security.passwords import hash_password
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("Usage: python scripts/set_admin_password.py <email> <new_password>")
|
||||||
|
sys.exit(2)
|
||||||
|
email = sys.argv[1].lower().strip()
|
||||||
|
pw = sys.argv[2]
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
u = User.query.filter_by(email=email).first()
|
||||||
|
if not u:
|
||||||
|
print("User not found")
|
||||||
|
sys.exit(1)
|
||||||
|
u.password_hash = hash_password(pw)
|
||||||
|
db.session.commit()
|
||||||
|
print("Password updated.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
static/css/app.css
Normal file
10
static/css/app.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.chart-wrap{position:relative;height:260px}
|
||||||
|
.chart-wrap canvas{width:100%!important;height:260px!important}
|
||||||
|
|
||||||
|
/* Layout helpers */
|
||||||
|
.chart-wrap{height:260px;}
|
||||||
|
.chart{width:100% !important; height:100% !important;}
|
||||||
|
.truncate-40{max-width:40ch;}
|
||||||
|
|
||||||
|
/* Small UI tweaks */
|
||||||
|
pre{white-space:pre-wrap;word-break:break-word;}
|
||||||
204
static/js/app.js
Normal file
204
static/js/app.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
(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); };
|
||||||
|
tick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const charts=new Map();
|
||||||
|
const seriesData=new Map(); // widgetId -> {name: [{x,y}]}
|
||||||
|
const seriesUnit=new Map(); // widgetId -> {name: unit}
|
||||||
|
const maxPoints=900;
|
||||||
|
|
||||||
|
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<steps.length-1){ x/=1000; i++; }
|
||||||
|
return {v:x, u:steps[i]};
|
||||||
|
}
|
||||||
|
|
||||||
|
if(u==='b/s' || u==='bytes/s' || u==='byte/s'){
|
||||||
|
const steps=['B/s','KiB/s','MiB/s','GiB/s','TiB/s'];
|
||||||
|
let i=0, x=v;
|
||||||
|
while(Math.abs(x)>=1024 && i<steps.length-1){ x/=1024; i++; }
|
||||||
|
return {v:x, u:steps[i]};
|
||||||
|
}
|
||||||
|
|
||||||
|
if(u==='b' || u==='bytes'){
|
||||||
|
const steps=['B','KiB','MiB','GiB','TiB'];
|
||||||
|
let i=0, x=v;
|
||||||
|
while(Math.abs(x)>=1024 && i<steps.length-1){ x/=1024; i++; }
|
||||||
|
return {v:x, u:steps[i]};
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback
|
||||||
|
return {v, u:unit||''};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v, unit){
|
||||||
|
if(!isNum(v)) return '';
|
||||||
|
const s=scale(v, unit);
|
||||||
|
const a=Math.abs(s.v);
|
||||||
|
const n = a>=100 ? s.v.toFixed(0) : a>=10 ? s.v.toFixed(1) : s.v.toFixed(2);
|
||||||
|
return n + (s.u ? ' '+s.u : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash32(str){
|
||||||
|
let h=2166136261;
|
||||||
|
for(let i=0;i<str.length;i++){ h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||||
|
return h>>>0;
|
||||||
|
}
|
||||||
|
function colorFor(name){
|
||||||
|
const hue = (hash32(String(name)) % 360);
|
||||||
|
return {
|
||||||
|
border:`hsl(${hue} 70% 45%)`,
|
||||||
|
fill:`hsl(${hue} 70% 45% / 0.20)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertChart(widgetId){
|
||||||
|
const el=document.getElementById('chart-'+widgetId);
|
||||||
|
if(!el) return null;
|
||||||
|
if(charts.has(widgetId)) return charts.get(widgetId);
|
||||||
|
|
||||||
|
const chart=new Chart(el.getContext('2d'),{
|
||||||
|
type:'line',
|
||||||
|
data:{datasets:[]},
|
||||||
|
options:{
|
||||||
|
animation:false,
|
||||||
|
parsing:false,
|
||||||
|
normalized:true,
|
||||||
|
responsive:true,
|
||||||
|
maintainAspectRatio:false,
|
||||||
|
interaction:{mode:'nearest', intersect:false},
|
||||||
|
scales:{
|
||||||
|
x:{type:'time', time:{unit:'minute'}},
|
||||||
|
y:{
|
||||||
|
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)];
|
||||||
|
const u = uniq.length===1 ? uniq[0] : '';
|
||||||
|
return u ? fmt(Number(tick), u) : tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins:{
|
||||||
|
legend:{display:true},
|
||||||
|
tooltip:{
|
||||||
|
callbacks:{
|
||||||
|
label:(ctx)=>{
|
||||||
|
const name=ctx.dataset.label||'';
|
||||||
|
const y=ctx.parsed && isNum(ctx.parsed.y) ? ctx.parsed.y : null;
|
||||||
|
const u=(seriesUnit.get(widgetId)||{})[name] || '';
|
||||||
|
return name+': '+(y===null?'':fmt(y,u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements:{point:{radius:0}}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
charts.set(widgetId, chart);
|
||||||
|
seriesData.set(widgetId, {});
|
||||||
|
seriesUnit.set(widgetId, {});
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeta(widgetId, txt){
|
||||||
|
const m=document.getElementById('meta-'+widgetId);
|
||||||
|
if(m) m.textContent=txt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s){
|
||||||
|
return String(s)
|
||||||
|
.replaceAll('&','&')
|
||||||
|
.replaceAll('<','<')
|
||||||
|
.replaceAll('>','>')
|
||||||
|
.replaceAll('"','"')
|
||||||
|
.replaceAll("'",''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTable(msg){
|
||||||
|
const tbl=document.querySelector("table[data-table-widget='"+msg.widget_id+"']");
|
||||||
|
if(!tbl) return;
|
||||||
|
const thead=tbl.querySelector('thead');
|
||||||
|
const tbody=tbl.querySelector('tbody');
|
||||||
|
const cols=msg.columns||[];
|
||||||
|
thead.innerHTML='<tr>'+cols.map(c=>'<th>'+escapeHtml(c)+'</th>').join('')+'</tr>';
|
||||||
|
tbody.innerHTML=(msg.rows||[]).map(r=>'<tr>'+cols.map(c=>'<td>'+escapeHtml(r[c] ?? '')+'</td>').join('')+'</tr>').join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimeseries(msg){
|
||||||
|
const chart=upsertChart(msg.widget_id);
|
||||||
|
if(!chart) return;
|
||||||
|
|
||||||
|
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=>{
|
||||||
|
const c=colorFor(name);
|
||||||
|
return {
|
||||||
|
label:name,
|
||||||
|
data:store[name],
|
||||||
|
borderWidth:2,
|
||||||
|
pointRadius:0,
|
||||||
|
tension:0.15,
|
||||||
|
fill:true,
|
||||||
|
borderColor:c.border,
|
||||||
|
backgroundColor:c.fill
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
chart.update('none');
|
||||||
|
setMeta(msg.widget_id, 'Updated: '+t.toLocaleTimeString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(){
|
||||||
|
if(!window.MIKROMON || !window.MIKROMON.dashboardId) return;
|
||||||
|
await ensureLibs();
|
||||||
|
|
||||||
|
const socket=io({transports:['polling'], upgrade:false});
|
||||||
|
socket.on('connect', ()=>{
|
||||||
|
socket.emit('join_dashboard', {dashboard_id: window.MIKROMON.dashboardId, public_token: window.MIKROMON.publicToken});
|
||||||
|
});
|
||||||
|
socket.on('metric', (msg)=>{
|
||||||
|
if(!msg || !msg.widget_id) return;
|
||||||
|
if(msg.type==='table') return handleTable(msg);
|
||||||
|
return handleTimeseries(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', main);
|
||||||
|
})();
|
||||||
40
templates/admin/audit.html
Normal file
40
templates/admin/audit.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Audit - Admin - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Audit log</h1>
|
||||||
|
<div class="text-muted">Last logs (limit 200).</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in logs %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small">{{ l.created_at }}</td>
|
||||||
|
<td class="fw-semibold">{{ l.action }}</td>
|
||||||
|
<td class="text-muted">{{ l.target_type or '-' }}</td>
|
||||||
|
<td class="text-muted">{{ l.target_id or '-' }}</td>
|
||||||
|
<td class="small">{{ l.details or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-muted">No data.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
42
templates/admin/index.html
Normal file
42
templates/admin/index.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Admin panel</h1>
|
||||||
|
<div class="text-muted">Administrative tools and system overview.</div>
|
||||||
|
</div>
|
||||||
|
<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.audit_logs') }}"><i class="fa-solid fa-list-check me-1"></i>Audit</a>
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('admin.smtp') }}"><i class="fa-solid fa-envelope me-1"></i>SMTP</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">Users</div>
|
||||||
|
<div class="display-6">{{ user_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">Devices</div>
|
||||||
|
<div class="display-6">{{ device_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small">Dashboards</div>
|
||||||
|
<div class="display-6">{{ dashboard_count }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
templates/admin/smtp.html
Normal file
44
templates/admin/smtp.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}SMTP - Admin - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">SMTP</h1>
|
||||||
|
<div class="text-muted">Test email sending configuration.</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-paper-plane me-2"></i>Send test</div>
|
||||||
|
<form method="post" action="{{ url_for('admin.smtp_test') }}">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Do</label>
|
||||||
|
{{ form.to_email(class_="form-control", placeholder="email@example.com") }}
|
||||||
|
<div class="form-text">Will send HTML email: <code>emails/smtp_test.html</code></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-gear me-2"></i>Wymagane zmienne</div>
|
||||||
|
<ul class="small mb-0">
|
||||||
|
<li><code>SMTP_HOST</code>, <code>SMTP_PORT</code></li>
|
||||||
|
<li><code>SMTP_FROM</code></li>
|
||||||
|
<li>opcjonalnie: <code>SMTP_USER</code>, <code>SMTP_PASS</code></li>
|
||||||
|
<li>opcjonalnie: <code>SMTP_USE_TLS</code> / <code>SMTP_USE_SSL</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
44
templates/admin/users.html
Normal file
44
templates/admin/users.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Users - Admin - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Users</h1>
|
||||||
|
<div class="text-muted">Lista kont w systemie.</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Rola</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Utworzono</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted">{{ u.id }}</td>
|
||||||
|
<td class="fw-semibold">{{ u.email }}</td>
|
||||||
|
<td><span class="badge text-bg-secondary">{{ u.role.name }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if u.is_active_flag %}
|
||||||
|
<span class="badge text-bg-success">active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge text-bg-danger">disabled</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ u.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
40
templates/api/docs.html
Normal file
40
templates/api/docs.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}API - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">API</h1>
|
||||||
|
<div class="text-muted">Endpoints overview (UI reference only).</div>
|
||||||
|
</div>
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in routes %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge text-bg-dark">{{ r.method }}</span></td>
|
||||||
|
<td><code>{{ r.path }}</code></td>
|
||||||
|
<td>{{ r.desc }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-3 mb-0">
|
||||||
|
Auth: most endpoints require a logged-in session (cookie). For external integrations use your own reverse-proxy / tokens (MVP has no dedicated API tokens).
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
25
templates/auth/forgot.html
Normal file
25
templates/auth/forgot.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Password reset - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-md-6 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h1 class="h4 mb-2"><i class="fa-solid fa-key me-2"></i>Password reset</h1>
|
||||||
|
<div class="text-muted small mb-3">Enter your email. If the account exists, we'll send a reset link.</div>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
{{ form.email(class_="form-control", placeholder="email@example.com") }}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Send link</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-muted small mt-3">
|
||||||
|
<a href="{{ url_for('auth.login') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back to login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
29
templates/auth/login.html
Normal file
29
templates/auth/login.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-md-6 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h1 class="h4 mb-3"><i class="fa-solid fa-right-to-bracket me-2"></i>Login</h1>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
{{ form.email(class_="form-control", placeholder="email@example.com") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
{{ form.password(class_="form-control", placeholder="••••••••") }}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-muted small mt-3">
|
||||||
|
No account? <a href="{{ url_for('auth.register') }}">Register</a><br>
|
||||||
|
Forgot password? <a href="{{ url_for('auth.forgot') }}">Reset</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
28
templates/auth/register.html
Normal file
28
templates/auth/register.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Register - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h1 class="h4 mb-3"><i class="fa-solid fa-user-plus me-2"></i>Register</h1>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
{{ form.email(class_="form-control", placeholder="email@example.com") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
{{ form.password(class_="form-control", placeholder="Min. 8 characters") }}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Create account</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-muted small mt-3">
|
||||||
|
Already have an account? <a href="{{ url_for('auth.login') }}">Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
26
templates/auth/reset.html
Normal file
26
templates/auth/reset.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Set new password - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-md-6 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h1 class="h4 mb-2"><i class="fa-solid fa-lock me-2"></i>Set new password</h1>
|
||||||
|
<div class="text-muted small mb-3">This link is valid for a limited time.</div>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">New password</label>
|
||||||
|
{{ form.password(class_="form-control", placeholder="••••••••") }}
|
||||||
|
<div class="form-text">Min. 8 characters.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary w-100" type="submit">Change password</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-muted small mt-3">
|
||||||
|
<a href="{{ url_for('auth.login') }}"><i class="fa-solid fa-arrow-left me-1"></i>Back to login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
101
templates/base.html
Normal file
101
templates/base.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}MikroMon{% endblock %}</title>
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<button class="btn btn-primary border-0 me-2 d-lg-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidePanel" aria-label="Menu">
|
||||||
|
<i class="fa-solid fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a class="navbar-brand fw-semibold" href="{{ url_for('dashboards.index') }}">
|
||||||
|
<i class="fa-solid fa-chart-line me-2"></i>MikroMon
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navMain">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{{ url_for('dashboards.index') }}"><i class="fa-solid fa-table-cells-large me-1"></i>Dashboards</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{{ url_for('devices.index') }}"><i class="fa-solid fa-microchip me-1"></i>Devices</a></li>
|
||||||
|
{% if current_user.is_admin() %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-shield-halved me-1"></i>Admin</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<li class="nav-item"><span class="navbar-text small me-3"><i class="fa-regular fa-user me-1"></i>{{ current_user.email }}</span></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.logout') }}"><i class="fa-solid fa-right-from-bracket me-1"></i>Logout</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{{ url_for('auth.login') }}"><i class="fa-solid fa-right-to-bracket me-1"></i>Login</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidePanel" aria-labelledby="sidePanelLabel">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title" id="sidePanelLabel"><i class="fa-solid fa-gauge-high me-2"></i>MikroMon</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for('dashboards.index') }}"><i class="fa-solid fa-table-cells-large me-2"></i>Dashboards</a>
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for('devices.index') }}"><i class="fa-solid fa-microchip me-2"></i>Devices</a>
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for('api.docs') }}"><i class="fa-solid fa-code me-2"></i>API</a>
|
||||||
|
{% if current_user.is_admin() %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.index') }}"><i class="fa-solid fa-shield-halved me-2"></i>Admin</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="small text-muted">
|
||||||
|
Signed in as: <span class="fw-semibold">{{ current_user.email }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main class="py-4">
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
{% set cls = 'info' %}
|
||||||
|
{% if category in ['danger','warning','success','info'] %}{% set cls = category %}{% endif %}
|
||||||
|
<div class="alert alert-{{ cls }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
templates/dashboards/index.html
Normal file
29
templates/dashboards/index.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboards - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Dashboards</h1>
|
||||||
|
<div class="text-muted">Your monitoring dashboards.</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('dashboards.new') }}"><i class="fa-solid fa-plus me-1"></i>New dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for d in dashboards %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold">{{ d.name }}</div>
|
||||||
|
<div class="text-muted small">{{ d.description or '' }}</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12"><div class="alert alert-info mb-0">No dashboards yet. Create the first one.</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
28
templates/dashboards/new.html
Normal file
28
templates/dashboards/new.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New dashboard - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h1 class="h4 mb-3"><i class="fa-solid fa-plus me-2"></i>New dashboard</h1>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
{{ form.name(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
{{ form.description(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-primary" type="submit">Create</button>
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('dashboards.index') }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
69
templates/dashboards/public_view.html
Normal file
69
templates/dashboards/public_view.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ dashboard.name }} - Public - MikroMon{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script>
|
||||||
|
window.MIKROMON = {
|
||||||
|
dashboardId: {{ dashboard.id }},
|
||||||
|
publicToken: "{{ token }}"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.navbar { display:none }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h1 class="h3 mb-0">{{ dashboard.name }}</h1>
|
||||||
|
|
||||||
|
{% if dashboard.description %}
|
||||||
|
<div class="text-muted">{{ dashboard.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-muted small mt-2">
|
||||||
|
<i class="fa-solid fa-link me-1"></i>
|
||||||
|
Public view (read-only)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for w in widgets %}
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<div class="fw-semibold">{{ w.title }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
{% if w.widget_type == 'table' %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" data-table-widget="{{ w.id }}">
|
||||||
|
<thead class="table-light"></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<canvas id="chart-{{ w.id }}"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-2" id="meta-{{ w.id }}">
|
||||||
|
Waiting for data...
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
No widgets available.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
65
templates/dashboards/share.html
Normal file
65
templates/dashboards/share.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Sharing - {{ dashboard.name }} - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h4 mb-0">Sharing</h1>
|
||||||
|
<div class="text-muted">Dashboard: <span class="fw-semibold">{{ dashboard.name }}</span></div>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('dashboards.view', dashboard_id=dashboard.id) }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<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.hidden_tag() }}
|
||||||
|
<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-3">{{ form.permission(class_="form-select") }}</div>
|
||||||
|
<div class="col-12 col-md-2"><button class="btn btn-primary w-100" type="submit">OK</button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-people-group me-2"></i>Current shares</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Email</th><th>Permission</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in shares %}
|
||||||
|
<tr><td>{{ s.user.email }}</td><td><span class="badge text-bg-secondary">{{ s.permission }}</span></td></tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="2" class="text-muted">None.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-link me-2"></i>Public link</div>
|
||||||
|
{% if public %}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-secondary small">No active public link.</div>
|
||||||
|
{% 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>
|
||||||
|
{% endblock %}
|
||||||
48
templates/dashboards/view.html
Normal file
48
templates/dashboards/view.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ dashboard.name }} - MikroMon{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<script>window.MIKROMON={dashboardId: {{ dashboard.id }}, publicToken: null};</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ dashboard.name }}</h1>
|
||||||
|
{% if dashboard.description %}<div class="text-muted">{{ dashboard.description }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('dashboards.share', dashboard_id=dashboard.id) }}"><i class="fa-solid fa-share-nodes me-1"></i>Share</a>
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('dashboards.widget_new', dashboard_id=dashboard.id) }}"><i class="fa-solid fa-plus me-1"></i>Add widget</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for w in widgets %}
|
||||||
|
<div class="col-12 col-lg-{{ w.col_span or 6 }}">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white d-flex align-items-center justify-content-between">
|
||||||
|
<div class="fw-semibold">{{ w.title }}</div>
|
||||||
|
<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 class="card-body">
|
||||||
|
{% if w.widget_type == 'table' %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0" data-table-widget="{{ w.id }}">
|
||||||
|
<thead class="table-light"></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="chart-wrap" style="height: {{ w.height_px or 260 }}px;"><canvas id="chart-{{ w.id }}"></canvas></div>
|
||||||
|
<div class="text-muted small mt-2" id="meta-{{ w.id }}">Waiting for data…</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12"><div class="alert alert-info mb-0">No widgets yet. Add the first one.</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
156
templates/dashboards/widget_new.html
Normal file
156
templates/dashboards/widget_new.html
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Add widget - {{ dashboard.name }} - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h4 mb-0">Add widget</h1>
|
||||||
|
<div class="text-muted">Dashboard: <span class="fw-semibold">{{ dashboard.name }}</span></div>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('dashboards.view', dashboard_id=dashboard.id) }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<form method="post" novalidate id="widgetForm">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Title</label>
|
||||||
|
{{ form.title(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Preset</label>
|
||||||
|
{{ form.preset_key(class_="form-select", id="presetSelect") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Device</label>
|
||||||
|
<select class="form-select" name="device_id" id="deviceSelect" required>
|
||||||
|
<option value="">— select —</option>
|
||||||
|
{% for d in devices %}
|
||||||
|
<option value="{{ d.id }}">{{ d.name }} ({{ d.host }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 d-none" id="itemWrap">
|
||||||
|
<label class="form-label" id="itemLabel">Item</label>
|
||||||
|
<select class="form-select" id="itemSelect">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">E.g. interface / queue (depends on preset).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label">Refresh (seconds)</label>
|
||||||
|
{{ form.refresh_seconds(class_="form-control", type="number", min="1") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advJson">
|
||||||
|
<i class="fa-solid fa-code me-1"></i>Advanced (JSON)
|
||||||
|
</button>
|
||||||
|
<div class="collapse mt-2" id="advJson">
|
||||||
|
{{ form.query_json(class_="form-control font-monospace", rows="6", placeholder='{"connector":"rest","endpoint":"/...","params":{}}') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-plus me-1"></i>Add</button>
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('dashboards.view', dashboard_id=dashboard.id) }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-wand-magic-sparkles me-2"></i>Wizard mode</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
If the preset requires selecting an item (e.g. interface), the list will load automatically after choosing a device.
|
||||||
|
If you fill the JSON manually, the JSON will be used.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const presets = {{ presets|tojson }};
|
||||||
|
const presetSelect = document.getElementById('presetSelect');
|
||||||
|
const deviceSelect = document.getElementById('deviceSelect');
|
||||||
|
const itemWrap = document.getElementById('itemWrap');
|
||||||
|
const itemSelect = document.getElementById('itemSelect');
|
||||||
|
const itemLabel = document.getElementById('itemLabel');
|
||||||
|
|
||||||
|
function presetNeedsItem(p){
|
||||||
|
if(!p) return false;
|
||||||
|
const params = p.params || {};
|
||||||
|
return Object.prototype.hasOwnProperty.call(params, 'name');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems(){
|
||||||
|
const presetKey = presetSelect.value;
|
||||||
|
const deviceId = deviceSelect.value;
|
||||||
|
const p = presets[presetKey];
|
||||||
|
if(!deviceId || !presetNeedsItem(p)){
|
||||||
|
itemWrap.classList.add('d-none');
|
||||||
|
itemSelect.innerHTML = '<option value="">— select —</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemWrap.classList.remove('d-none');
|
||||||
|
itemLabel.textContent = String(presetKey).includes('queue') ? 'Queue' : 'Interface / item';
|
||||||
|
itemSelect.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
|
||||||
|
try{
|
||||||
|
const url = new URL("{{ url_for('api.preset_items') }}", window.location.origin);
|
||||||
|
url.searchParams.set('device_id', deviceId);
|
||||||
|
url.searchParams.set('preset_key', presetKey);
|
||||||
|
const r = await fetch(url.toString(), {credentials:'same-origin'});
|
||||||
|
const j = await r.json();
|
||||||
|
const items = (j && j.data && j.data.items) ? j.data.items : [];
|
||||||
|
itemSelect.innerHTML = '<option value="">— select —</option>' + items.map(x => `<option value="${x}">${x}</option>`).join('');
|
||||||
|
}catch(e){
|
||||||
|
itemSelect.innerHTML = '<option value="">Load error</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
presetSelect?.addEventListener('change', loadItems);
|
||||||
|
deviceSelect?.addEventListener('change', loadItems);
|
||||||
|
|
||||||
|
document.getElementById('widgetForm')?.addEventListener('submit', ()=>{
|
||||||
|
const adv = document.getElementById('{{ form.query_json.id }}');
|
||||||
|
if(!adv) return;
|
||||||
|
if(adv.value && adv.value.trim()) return;
|
||||||
|
|
||||||
|
const presetKey = presetSelect.value;
|
||||||
|
const p = presets[presetKey];
|
||||||
|
if(!p) return;
|
||||||
|
|
||||||
|
const q = {
|
||||||
|
connector: p.connector || 'rest',
|
||||||
|
endpoint: p.endpoint,
|
||||||
|
params: Object.assign({}, (p.params||{})),
|
||||||
|
extract: p.extract || {},
|
||||||
|
preset_key: presetKey
|
||||||
|
};
|
||||||
|
if(presetNeedsItem(p) && itemSelect.value){
|
||||||
|
q.params.name = itemSelect.value;
|
||||||
|
}
|
||||||
|
adv.value = JSON.stringify(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadItems();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
87
templates/devices/edit.html
Normal file
87
templates/devices/edit.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Edit device - {{ device.name }} - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Edit device</h1>
|
||||||
|
<div class="text-muted">{{ device.name }} ({{ device.host }})</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('devices.view', device_id=device.id) }}"><i class="fa-solid fa-arrow-left me-1"></i>Back</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
{{ form.name(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
{{ form.host(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">REST port</label>
|
||||||
|
{{ form.rest_port(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">REST base path</label>
|
||||||
|
{{ form.rest_base_path(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
{{ form.username(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
{{ form.password(class_="form-control", placeholder="Leave blank to keep unchanged") }}
|
||||||
|
<div class="form-text">Leave blank to keep the current password.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.allow_insecure_tls(class_="form-check-input") }}
|
||||||
|
<label class="form-check-label">Allow insecure TLS (self-signed)</label>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-floppy-disk me-1"></i>Save changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-circle-info me-2"></i>Notes</div>
|
||||||
|
<ul class="small mb-0">
|
||||||
|
<li>Changing credentials updates the encrypted secret stored in the database.</li>
|
||||||
|
<li>If REST fails, verify host/port/path and TLS setting.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
32
templates/devices/index.html
Normal file
32
templates/devices/index.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Devices - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Devices</h1>
|
||||||
|
<div class="text-muted">Routers / hosts to monitor.</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('devices.new') }}"><i class="fa-solid fa-plus me-1"></i>Add device</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for d in devices %}
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold">{{ d.name }}</div>
|
||||||
|
<div class="text-muted small">{{ d.host }}</div>
|
||||||
|
{% if d.last_error %}
|
||||||
|
<div class="mt-2 alert alert-warning py-2 px-3 mb-0 small"><i class="fa-solid fa-triangle-exclamation me-1"></i>{{ d.last_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<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('devices.view', device_id=d.id) }}"><i class="fa-solid fa-eye me-1"></i>Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col-12"><div class="alert alert-info mb-0">No devices.</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
87
templates/devices/new.html
Normal file
87
templates/devices/new.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}New device - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">Add device</h1>
|
||||||
|
<div class="text-muted">Configure REST/SSH access.</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
{{ form.name(class_="form-control", placeholder="e.g. MikroTik RB4011") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
{{ form.host(class_="form-control", placeholder="192.168.1.1 or router.example.com") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">REST port</label>
|
||||||
|
{{ form.rest_port(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">REST base path</label>
|
||||||
|
{{ form.rest_base_path(class_="form-control", placeholder="/rest") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
{{ form.username(class_="form-control") }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
{{ form.password(class_="form-control", placeholder="••••••••") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ form.allow_insecure_tls(class_="form-check-input") }}
|
||||||
|
<label class="form-check-label">Allow insecure TLS (self-signed)</label>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-plus me-1"></i>Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-circle-info me-2"></i>Tips</div>
|
||||||
|
<ul class="small mb-0">
|
||||||
|
<li>REST uses the MikroTik API (<code>/rest</code>).</li>
|
||||||
|
<li>If you use a self-signed cert, enable insecure TLS.</li>
|
||||||
|
<li>SSH is optional (e.g. for commands/reads).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
78
templates/devices/view.html
Normal file
78
templates/devices/view.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ device.name }} - Device - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-0">{{ device.name }}</h1>
|
||||||
|
<div class="text-muted">{{ device.host }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<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-primary" href="{{ url_for('devices.edit', device_id=device.id) }}"><i class="fa-solid fa-pen-to-square me-1"></i>Edit</a>
|
||||||
|
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}" onsubmit="return confirm('Delete this device?');">
|
||||||
|
<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</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-primary" id="btnTest"><i class="fa-solid fa-plug-circle-check me-1"></i>Test REST</button>
|
||||||
|
<button class="btn btn-outline-primary" id="btnDiscover"><i class="fa-solid fa-magnifying-glass me-1"></i>Discover</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-server me-2"></i>Configuration</div>
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-5 text-muted">REST</dt><dd class="col-7">{{ device.host }}:{{ device.rest_port }}{{ device.rest_base_path }}</dd>
|
||||||
|
<dt class="col-5 text-muted">TLS</dt><dd class="col-7">{{ 'insecure' if device.allow_insecure_tls else 'strict' }}</dd>
|
||||||
|
<dt class="col-5 text-muted">SSH</dt><dd class="col-7">{{ 'enabled' if device.ssh_enabled else 'disabled' }}{% if device.ssh_enabled %} ({{ device.ssh_port }}){% endif %}</dd>
|
||||||
|
<dt class="col-5 text-muted">Last error</dt><dd class="col-7">{{ device.last_error or '-' }}</dd>
|
||||||
|
<dt class="col-5 text-muted">Created</dt><dd class="col-7">{{ device.created_at }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2"><i class="fa-solid fa-terminal me-2"></i>Result</div>
|
||||||
|
<pre class="bg-light border rounded p-3 mb-0" style="min-height: 240px" id="out">{}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const out = document.getElementById('out');
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
|
||||||
|
function show(obj){ out.textContent = JSON.stringify(obj, null, 2); }
|
||||||
|
async function postJson(url){
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type':'application/json', 'X-CSRFToken': csrf },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
let data = null;
|
||||||
|
try { data = await res.json(); } catch(e) { data = { ok:false, error:'Invalid JSON response' }; }
|
||||||
|
if(!res.ok) return { ok:false, ...data };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
document.getElementById('btnTest')?.addEventListener('click', async ()=>{
|
||||||
|
show({loading:true});
|
||||||
|
const data = await postJson("{{ url_for('devices.test', device_id=device.id) }}");
|
||||||
|
show(data);
|
||||||
|
});
|
||||||
|
document.getElementById('btnDiscover')?.addEventListener('click', async ()=>{
|
||||||
|
show({loading:true});
|
||||||
|
const res = await fetch("{{ url_for('devices.discover', device_id=device.id) }}");
|
||||||
|
const data = await res.json();
|
||||||
|
show(data);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
10
templates/emails/reset_password.html
Normal file
10
templates/emails/reset_password.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:Arial,Helvetica,sans-serif;line-height:1.4">
|
||||||
|
<h2 style="margin:0 0 12px 0">MikroMon — password reset</h2>
|
||||||
|
<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 style="color:#666;font-size:12px">This link expires in {{ ttl_minutes }} minutes. If this wasn't you, ignore this email.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
templates/emails/smtp_test.html
Normal file
8
templates/emails/smtp_test.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:Arial,Helvetica,sans-serif;line-height:1.4">
|
||||||
|
<h2 style="margin:0 0 12px 0">MikroMon — SMTP test</h2>
|
||||||
|
<p>If you can read this message, SMTP is working correctly.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
templates/error.html
Normal file
29
templates/error.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ code }} - {{ title }} - MikroMon{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="display-6 text-muted mb-0">{{ code }}</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="h4 mb-1">{{ title }}</h1>
|
||||||
|
<div class="text-muted">{{ description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-3">
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('dashboards.index') }}"><i class="fa-solid fa-house me-1"></i>Dashboards</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('auth.login') }}"><i class="fa-solid fa-right-to-bracket me-1"></i>Logowanie</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-outline-secondary" href="{{ url_for('api.docs') }}"><i class="fa-solid fa-code me-1"></i>API</a>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt-3">Request ID: {{ request_id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
19
tests/conftest.py
Normal file
19
tests/conftest.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from mikromon import create_app, db
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def app(tmp_path):
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite:///{tmp_path/'test.db'}"
|
||||||
|
os.environ["DEV_INPROCESS_POLLER"] = "0"
|
||||||
|
os.environ["SECRET_KEY"] = "test-secret"
|
||||||
|
# Fernet key
|
||||||
|
os.environ["CRED_ENC_KEY"] = "WmQxY2pKQ0FqV0lJdVFSWFBzYlJKUTZkdmJpZGFjY0k=" # dummy base64, will fail if used; tests avoid decrypt
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
yield app
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
27
tests/test_acl_api.py
Normal file
27
tests/test_acl_api.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import json
|
||||||
|
from mikromon import db
|
||||||
|
from mikromon.models import User, Role, RoleName, Dashboard
|
||||||
|
from mikromon.security.passwords import hash_password
|
||||||
|
|
||||||
|
def _login(client, email, password):
|
||||||
|
return client.post("/auth/login", data={"email":email,"password":password}, follow_redirects=False)
|
||||||
|
|
||||||
|
def test_api_me_requires_login(client, app):
|
||||||
|
r = client.get("/api/v1/me")
|
||||||
|
assert r.status_code in (302, 401)
|
||||||
|
|
||||||
|
def test_dashboard_acl(client, app):
|
||||||
|
with app.app_context():
|
||||||
|
user_role = Role.query.filter_by(name=RoleName.USER.value).first()
|
||||||
|
if not user_role:
|
||||||
|
user_role = Role(name=RoleName.USER.value)
|
||||||
|
db.session.add(user_role); db.session.commit()
|
||||||
|
u1 = User(email="a@example.com", password_hash=hash_password("Password123!"), role_id=user_role.id)
|
||||||
|
u2 = User(email="b@example.com", password_hash=hash_password("Password123!"), role_id=user_role.id)
|
||||||
|
db.session.add_all([u1,u2]); db.session.commit()
|
||||||
|
d = Dashboard(owner_id=u1.id, name="D1", description="")
|
||||||
|
db.session.add(d); db.session.commit()
|
||||||
|
did = d.id
|
||||||
|
_login(client, "b@example.com", "Password123!")
|
||||||
|
r = client.get(f"/api/v1/dashboards/{did}")
|
||||||
|
assert r.status_code == 403
|
||||||
11
tests/test_auth.py
Normal file
11
tests/test_auth.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
def test_login_page(client):
|
||||||
|
r = client.get("/auth/login")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_register_and_login(client):
|
||||||
|
# register
|
||||||
|
r = client.post("/auth/register", data={"email":"u1@example.com","password":"Password123!"}, follow_redirects=True)
|
||||||
|
assert r.status_code in (200, 302)
|
||||||
|
# login
|
||||||
|
r = client.post("/auth/login", data={"email":"u1@example.com","password":"Password123!"}, follow_redirects=False)
|
||||||
|
assert r.status_code in (302,)
|
||||||
Reference in New Issue
Block a user