diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..a19d483 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,40 @@ +# Idriz Admin + +Jednostavan LAN web panel za mašinu `idriz`. Namjerno minimalan: +**Flask + waitress + python-pam**, jedan fajl (`app.py`), bez baze podataka. + +## Šta radi +- Prijava preko **PAM-a** (Linux korisnik `idriz`, ista lozinka kao sistemska). +- Stranica **Status servisa** — pregled sistemskih i korisničkih servisa + (idriz, CUPS, VNC, PipeWire...) sa stanjem, autostartom, uptime-om, PID-om i memorijom. +- Vertikalni meni sa lijeve strane (spremno za dodavanje novih stranica). + +## Pristup +- URL: `http://192.168.1.10:8080/` (samo sa LAN-a). +- Korisnik: `idriz`, lozinka: sistemska lozinka tog korisnika. + +## Pokretanje +Radi kao **korisnički systemd servis** (mora biti user servis zbog pristupa +`systemctl --user` servisima kao što je `idriz.service`): + +``` +systemctl --user status idriz-admin +systemctl --user restart idriz-admin +journalctl --user -u idriz-admin -f +``` + +Enabled je za boot; `Restart=always` + `StartLimitIntervalSec=0` (nikad ne odustaje). + +## Struktura +- `app.py` — cijela aplikacija (konfiguracija servisa je lista `SERVICES` na vrhu). +- `templates/base.html` — layout + vertikalni meni + stil. +- `templates/login.html`, `templates/status.html`. +- PAM servis: `/etc/pam.d/idriz-admin`. + +## Dodavanje novog servisa u praćenje +Dopiši red u listu `SERVICES` u `app.py` (`unit`, `scope`, `label`, `desc`) i +restartuj: `systemctl --user restart idriz-admin`. + +## Dodavanje nove stranice u meni +1. Nova ruta u `app.py` sa `@login_required`. +2. Novi `` u `templates/base.html` (u `.nav`), sa `active` klasom preko `active_page`. diff --git a/admin/app.py b/admin/app.py new file mode 100644 index 0000000..d2adb55 --- /dev/null +++ b/admin/app.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +"""Idriz admin — jednostavan LAN web panel pod PAM autentikacijom. + +Namjerno minimalno: Flask + waitress + python-pam, jedan fajl, bez baze. +Prijava ide preko PAM-a (Linux korisnik `idriz`). +""" +import os +import secrets +import subprocess +from datetime import datetime +from functools import wraps + +import pam as pam_module +from flask import ( + Flask, render_template, request, redirect, url_for, session, flash +) + +# --- Konfiguracija ----------------------------------------------------------- + +# Samo ovaj Linux korisnik smije da se prijavi. +ALLOWED_USER = os.environ.get("IDRIZ_ADMIN_USER", "idriz") +PAM_SERVICE = os.environ.get("IDRIZ_ADMIN_PAM_SERVICE", "idriz-admin") +LISTEN_HOST = os.environ.get("IDRIZ_ADMIN_HOST", "0.0.0.0") +LISTEN_PORT = int(os.environ.get("IDRIZ_ADMIN_PORT", "8080")) + +# Servisi koje panel prati. scope: "system" ili "user". +SERVICES = [ + {"unit": "idriz.service", "scope": "user", "label": "Idriz asistent", "desc": "Glasovni pomoćnik za djecu"}, + {"unit": "cups.service", "scope": "system", "label": "CUPS", "desc": "Štampanje / print server"}, + {"unit": "cups-browsed.service", "scope": "system", "label": "CUPS Browsed", "desc": "Dijeljenje štampača"}, + {"unit": "avahi-daemon.service", "scope": "system", "label": "Avahi (mDNS)", "desc": "Otkrivanje štampača na mreži"}, + {"unit": "tigervncserver@:2.service","scope": "system","label": "VNC", "desc": "Daljinski desktop :2 / 5902"}, + {"unit": "gdm3.service", "scope": "system", "label": "GDM", "desc": "Grafički login + autologin"}, + {"unit": "pipewire.service", "scope": "user", "label": "PipeWire", "desc": "Audio server"}, + {"unit": "wireplumber.service", "scope": "user", "label": "WirePlumber", "desc": "Audio session manager"}, +] + +# --- Aplikacija -------------------------------------------------------------- + +app = Flask(__name__) +# Tajni ključ za sesije; nov pri svakom startu (odjavi sve pri restartu — ok za nas). +app.secret_key = secrets.token_hex(32) +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + PERMANENT_SESSION_LIFETIME=8 * 3600, +) + + +def login_required(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not session.get("user"): + return redirect(url_for("login", next=request.path)) + return f(*args, **kwargs) + return wrapper + + +def _systemctl(scope, *args): + cmd = ["systemctl"] + if scope == "user": + cmd.append("--user") + cmd.extend(args) + try: + out = subprocess.run( + cmd, capture_output=True, text=True, timeout=8 + ) + return out.stdout.strip() + except Exception as e: # noqa: BLE001 + return f"error: {e}" + + +def get_service_status(svc): + """Vrati dict sa stanjem servisa (bez roota — samo čita).""" + scope, unit = svc["scope"], svc["unit"] + props = _systemctl( + scope, "show", unit, + "-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState", + "-p", "ActiveEnterTimestampMonotonic", "-p", "ActiveEnterTimestamp", + "-p", "MainPID", "-p", "MemoryCurrent", + ) + data = {} + for line in props.splitlines(): + if "=" in line: + k, v = line.split("=", 1) + data[k] = v + + active = data.get("ActiveState", "unknown") + sub = data.get("SubState", "") + enabled = data.get("UnitFileState", "unknown") + + # uptime iz ActiveEnterTimestamp (realtime, npr. "Pet 2026-09-04 12:00:00 CEST") + rt = _systemctl(scope, "show", unit, "--value", "-p", "ActiveEnterTimestamp") + uptime = "—" + if rt and rt not in ("", "n/a"): + try: + started = datetime.strptime(rt.rsplit(" ", 1)[0], "%a %Y-%m-%d %H:%M:%S") + delta = datetime.now() - started + total = int(delta.total_seconds()) + if total >= 0: + d, rem = divmod(total, 86400) + h, rem = divmod(rem, 3600) + m, _ = divmod(rem, 60) + parts = [] + if d: + parts.append(f"{d}d") + if h: + parts.append(f"{h}h") + if m or not parts: + parts.append(f"{m}min") + uptime = " ".join(parts) + except (ValueError, IndexError): + uptime = "—" + + mem = data.get("MemoryCurrent", "") + mem_h = "—" + if mem.isdigit(): + n = int(mem) + if n > 0: + for unit_s in ("B", "KB", "MB", "GB"): + if n < 1024: + mem_h = f"{n:.0f} {unit_s}" + break + n /= 1024 + else: + mem_h = f"{n:.1f} TB" + + if active == "active": + state = "ok" + elif active in ("activating", "reloading", "deactivating"): + state = "warn" + else: + state = "bad" + + return { + **svc, + "active": active, + "sub": sub, + "enabled": enabled, + "uptime": uptime, + "pid": data.get("MainPID", "0"), + "mem": mem_h, + "state": state, + } + + +# --- Rute -------------------------------------------------------------------- + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if session.get("user"): + return redirect(url_for("status")) + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + if username != ALLOWED_USER: + flash("Pogrešno korisničko ime ili lozinka.") + else: + p = pam_module.pam() + if p.authenticate(username, password, service=PAM_SERVICE): + session["user"] = username + session.permanent = True + nxt = request.args.get("next") or url_for("status") + if not nxt.startswith("/"): + nxt = url_for("status") + return redirect(nxt) + flash("Pogrešno korisničko ime ili lozinka.") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +@app.route("/") +@login_required +def index(): + return redirect(url_for("status")) + + +@app.route("/status") +@login_required +def status(): + services = [get_service_status(s) for s in SERVICES] + ok = sum(1 for s in services if s["state"] == "ok") + return render_template( + "status.html", + services=services, + ok=ok, + total=len(services), + now=datetime.now().strftime("%d.%m.%Y %H:%M:%S"), + active_page="status", + ) + + +if __name__ == "__main__": + from waitress import serve + print(f"Idriz admin na http://{LISTEN_HOST}:{LISTEN_PORT}") + serve(app, host=LISTEN_HOST, port=LISTEN_PORT, threads=8) diff --git a/admin/requirements.txt b/admin/requirements.txt new file mode 100644 index 0000000..56ebd28 --- /dev/null +++ b/admin/requirements.txt @@ -0,0 +1,10 @@ +blinker==1.9.0 +click==8.5.0 +Flask==3.1.3 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +python-pam==2.0.2 +six==1.17.0 +waitress==3.0.2 +Werkzeug==3.1.8 diff --git a/admin/templates/base.html b/admin/templates/base.html new file mode 100644 index 0000000..0a27a15 --- /dev/null +++ b/admin/templates/base.html @@ -0,0 +1,114 @@ + + + + + + {% block title %}Idriz Admin{% endblock %} + + + +
+ +
+ {% with msgs = get_flashed_messages() %} + {% for m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ + diff --git a/admin/templates/login.html b/admin/templates/login.html new file mode 100644 index 0000000..36ba4dd --- /dev/null +++ b/admin/templates/login.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Prijava — Idriz Admin{% endblock %} +{% block extra_css %} + .layout { display: block; } + .sidebar { display: none; } + .main { max-width: none; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; } + .main .flash { width: 340px; } + .login-box { + background: var(--panel); border: 1px solid var(--line); border-radius: 12px; + padding: 32px; width: 340px; box-shadow: 0 6px 24px rgba(0,0,0,.06); + } + .login-box h1 { font-size: 20px; margin: 0 0 4px; } + .login-box .sub { margin-bottom: 22px; } + label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; } + input { + width: 100%; padding: 10px 12px; font-size: 15px; margin-bottom: 16px; + border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink); + } + input:focus { outline: none; border-color: var(--accent); } + button { + width: 100%; padding: 11px; font-size: 15px; font-weight: 600; color: #fff; + background: var(--accent); border: none; border-radius: 8px; cursor: pointer; + } + button:hover { filter: brightness(1.05); } +{% endblock %} +{% block content %} +
+

Idriz Admin

+

Prijavi se sa sistemskim korisnikom.

+
+ + + + + +
+
+{% endblock %} diff --git a/admin/templates/status.html b/admin/templates/status.html new file mode 100644 index 0000000..65c1425 --- /dev/null +++ b/admin/templates/status.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}Status servisa — Idriz Admin{% endblock %} +{% block extra_css %} + .summary { display: flex; gap: 14px; margin-bottom: 20px; } + .card { + background: var(--panel); border: 1px solid var(--line); border-radius: 10px; + padding: 14px 18px; min-width: 130px; + } + .card .num { font-size: 26px; font-weight: 700; } + .card .lbl { color: var(--muted); font-size: 13px; } + table { width: 100%; border-collapse: collapse; background: var(--panel); + border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } + th, td { text-align: left; padding: 12px 16px; font-size: 14px; } + th { background: #fafbfc; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--line); } + tr:not(:last-child) td { border-bottom: 1px solid var(--line); } + .svc-name { font-weight: 600; } + .svc-desc { color: var(--muted); font-size: 12.5px; } + .svc-unit { color: var(--muted); font-size: 12px; font-family: ui-monospace, monospace; } + .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12.5px; font-weight: 600; } + .badge.ok { background: #e6f5ec; color: var(--ok); } + .badge.warn { background: #fdf1dd; color: var(--warn); } + .badge.bad { background: #fdecec; color: var(--bad); } + .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 7px; vertical-align: middle; } + .dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); } + .enabled-yes { color: var(--ok); } .enabled-no { color: var(--muted); } + .foot { color: var(--muted); font-size: 13px; margin-top: 16px; } + .refresh { float: right; font-size: 13px; text-decoration: none; color: var(--accent); } +{% endblock %} +{% block content %} + ↻ Osvježi +

Status servisa

+

Pregled sistemskih i korisničkih servisa mašine idriz.

+ +
+
+
{{ ok }}/{{ total }}
+
servisa aktivno
+
+
+ + + + + + + + + + + + + + {% for s in services %} + + + + + + + + + {% endfor %} + +
ServisStanjeAutostartUptimePIDMemorija
+
{{ s.label }}
+
{{ s.desc }}
+
{{ s.unit }}{% if s.scope == 'user' %} (user){% endif %}
+
+ {{ s.active }}{% if s.sub and s.sub != s.active %} / {{ s.sub }}{% endif %} + + {% if s.enabled == 'enabled' %}✓ enabled + {% elif s.enabled == 'static' %}static + {% else %}{{ s.enabled }}{% endif %} + {{ s.uptime }}{% if s.pid and s.pid != '0' %}{{ s.pid }}{% else %}—{% endif %}{{ s.mem }}
+ +

Zadnje osvježeno: {{ now }}

+{% endblock %} diff --git a/systemd/idriz-admin.service b/systemd/idriz-admin.service new file mode 100644 index 0000000..a9042b6 --- /dev/null +++ b/systemd/idriz-admin.service @@ -0,0 +1,18 @@ +[Unit] +Description=Idriz Admin - LAN web panel (status servisa) +# Nikad ne odustaj od restartovanja +StartLimitIntervalSec=0 +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=%h/src/idriz/admin +Environment=IDRIZ_ADMIN_HOST=0.0.0.0 +Environment=IDRIZ_ADMIN_PORT=8080 +ExecStart=%h/src/idriz/admin/.venv/bin/python app.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/systemd/idriz.service b/systemd/idriz.service index b73ed46..e321e39 100644 --- a/systemd/idriz.service +++ b/systemd/idriz.service @@ -1,5 +1,7 @@ [Unit] Description=Idriz - glasovni pomoćnik za djecu +# Nikad ne odustaj od restartovanja (npr. ako je mreza pala pri bootu) +StartLimitIntervalSec=0 After=pipewire.service wireplumber.service network-online.target Wants=network-online.target