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 @@
+
+
+
+
+
+ Prijavi se sa sistemskim korisnikom.Idriz Admin
+
Pregled sistemskih i korisničkih servisa mašine idriz.
+ +| Servis | +Stanje | +Autostart | +Uptime | +PID | +Memorija | +
|---|---|---|---|---|---|
|
+ {{ 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