diff --git a/saikyo-av-web/assets/web/app.js b/saikyo-av-web/assets/web/app.js
new file mode 100644
index 0000000..52c39c1
--- /dev/null
+++ b/saikyo-av-web/assets/web/app.js
@@ -0,0 +1,234 @@
+const $ = (sel) => document.querySelector(sel);
+const $$ = (sel) => Array.from(document.querySelectorAll(sel));
+
+function badgeClass(sev) {
+ const s = (sev || "").toLowerCase();
+ if (["ok", "info", "low"].includes(s)) return "good";
+ if (["medium", "warn", "warning"].includes(s)) return "warn";
+ if (["high", "critical", "bad"].includes(s)) return "bad";
+ return "";
+}
+
+function fmtTime(ts) {
+ if (!ts) return "—";
+ try {
+ if (typeof ts === "number") return new Date(ts * 1000).toLocaleString();
+ return new Date(ts).toLocaleString();
+ } catch {
+ return String(ts);
+ }
+}
+
+function toast(msg) {
+ const t = $("#toast");
+ t.textContent = msg;
+ t.classList.add("show");
+ setTimeout(() => t.classList.remove("show"), 3500);
+}
+
+async function api(path, opts) {
+ const res = await fetch(path, opts);
+ const ct = res.headers.get("content-type") || "";
+ const data = ct.includes("application/json") ? await res.json() : await res.text();
+ return { ok: res.ok, status: res.status, data };
+}
+
+let state = {
+ view: "dashboard",
+ reports: [],
+ selected: null,
+};
+
+function setView(v) {
+ state.view = v;
+ $$(".nav-item").forEach((a) => a.classList.toggle("active", a.dataset.view === v));
+ $("#view-dashboard").classList.toggle("hidden", v !== "dashboard");
+ $("#view-reports").classList.toggle("hidden", v !== "reports");
+ $("#view-settings").classList.toggle("hidden", v !== "settings");
+
+ if (v === "dashboard") {
+ $("#page-title").textContent = "Dashboard";
+ $("#page-sub").textContent = "Обзор состояния и последние отчёты";
+ }
+ if (v === "reports") {
+ $("#page-title").textContent = "Reports";
+ $("#page-sub").textContent = "Просмотр отчётов и применение действий (по согласию)";
+ }
+ if (v === "settings") {
+ $("#page-title").textContent = "Settings";
+ $("#page-sub").textContent = "Локальные параметры интерфейса";
+ }
+}
+
+function renderTable(el, rows, { limit = null } = {}) {
+ const r = limit ? rows.slice(0, limit) : rows;
+ const header = `
+
+ `;
+
+ const body = r
+ .map((it) => {
+ const cls = badgeClass(it.severity);
+ return `
+
+
${escapeHtml(it.summary || "(no summary)")}
+
${escapeHtml(it.severity || "unknown")}
+
${escapeHtml(fmtTime(it.created_utc || it.mtime))}
+
open
+
+ `;
+ })
+ .join("");
+
+ el.innerHTML = header + body;
+ el.querySelectorAll(".row.clickable").forEach((row) => {
+ row.addEventListener("click", () => openReport(row.dataset.id));
+ });
+}
+
+function escapeHtml(s) {
+ return String(s)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+async function refresh() {
+ const h = await api("/api/health");
+ if (h.ok) {
+ $("#health-pill").textContent = "UI: OK";
+ $("#status-value").textContent = "Protected";
+ $("#status-hint").textContent = "local UI service running";
+ } else {
+ $("#health-pill").textContent = "UI: error";
+ $("#status-value").textContent = "Degraded";
+ $("#status-hint").textContent = "health check failed";
+ }
+
+ const r = await api("/api/reports");
+ if (r.ok) {
+ state.reports = r.data.reports || [];
+ $("#reports-count").textContent = String(state.reports.length);
+ renderTable($("#latest-table"), state.reports, { limit: 6 });
+ renderTable($("#reports-table"), state.reports);
+ } else {
+ toast("Failed to load reports");
+ }
+}
+
+async function openReport(reportId) {
+ const p = await api(`/api/reports/${encodeURIComponent(reportId)}`);
+ if (!p.ok) {
+ toast("Report not found");
+ return;
+ }
+ const report = p.data.report;
+ state.selected = { id: reportId, report };
+
+ $("#report-details").classList.remove("hidden");
+ $("#details-title").textContent = `Report: ${reportId}`;
+ $("#details-sub").textContent = "Read-only details";
+ $("#details-summary").textContent = report.summary || "—";
+
+ const sev = report.severity || "unknown";
+ const cls = badgeClass(sev);
+ const badge = $("#details-severity");
+ badge.textContent = sev;
+ badge.className = `badge ${cls}`;
+
+ $("#details-created").textContent = fmtTime(report.created_utc || "—");
+ $("#raw").textContent = JSON.stringify(report, null, 2);
+
+ const fixesEl = $("#fixes");
+ fixesEl.classList.add("fixes");
+ const fixes = report.suggested_fixes || [];
+ if (!fixes.length) {
+ fixesEl.innerHTML = `No suggested fixes
`;
+ } else {
+ fixesEl.innerHTML = fixes
+ .map((f) => {
+ return `
+
+
${escapeHtml(f.title || f.id || "Fix")}
+
${escapeHtml(f.description || "")}
+
+
+ consent
+
+
+ `;
+ })
+ .join("");
+
+ fixesEl.querySelectorAll("button[data-fix]").forEach((b) => {
+ b.addEventListener("click", () => applyFix(b.dataset.fix));
+ });
+ }
+}
+
+async function applyFix(fixId) {
+ if (!state.selected) return;
+ const reportId = state.selected.id;
+
+ const confirm1 = window.confirm(
+ `Apply fix '${fixId}' for report '${reportId}'?\n\nThis action requires explicit operator confirmation.`
+ );
+ if (!confirm1) return;
+
+ const res = await api("/api/apply-fix", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ report_id: reportId, fix_id: fixId, confirm: true }),
+ });
+
+ if (res.ok) {
+ toast("Fix applied");
+ } else {
+ if (res.status === 501) {
+ toast("Fix runner not implemented yet (MVP UI only)");
+ return;
+ }
+ if (res.status === 409) {
+ toast("Confirmation required");
+ return;
+ }
+ toast("Failed to apply fix");
+ }
+}
+
+function bindEvents() {
+ $$(".nav-item").forEach((a) => {
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ setView(a.dataset.view);
+ });
+ });
+
+ $("#refresh-btn").addEventListener("click", () => refresh());
+
+ $("#close-details").addEventListener("click", () => {
+ $("#report-details").classList.add("hidden");
+ state.selected = null;
+ });
+
+ $("#filter").addEventListener("input", () => {
+ const q = $("#filter").value.trim().toLowerCase();
+ const rows = !q
+ ? state.reports
+ : state.reports.filter((r) =>
+ String(r.summary || "").toLowerCase().includes(q) || String(r.id || "").toLowerCase().includes(q)
+ );
+ renderTable($("#reports-table"), rows);
+ });
+}
+
+bindEvents();
+setView("dashboard");
+refresh();
diff --git a/saikyo-av-web/assets/web/index.html b/saikyo-av-web/assets/web/index.html
new file mode 100644
index 0000000..192a750
--- /dev/null
+++ b/saikyo-av-web/assets/web/index.html
@@ -0,0 +1,182 @@
+
+
+
+
+
+ Saikyo Antivirus
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Status
+
—
+
waiting for health check
+
+
+
Reports
+
—
+
available locally
+
+
+
Mode
+
Consent
+
fixes require confirmation
+
+
+
+
+
+
+
Latest reports
+
Click a report to open details
+
+
+
+
+
+
+
+
+
+
+
Reports
+
Локальные отчёты Saikyo Antivirus
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Suggested fixes
+
Apply only after confirmation
+
+
+
+
+
+
+
+
+
Raw report (JSON)
+
Read-only view
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
Local UI (no telemetry by default)
+
+
+
+
+
+
+
Reports directory
+
/var/lib/saikyo-av/reports
+
+
read
+
+
+
+
+
Network binding
+
127.0.0.1:8765
+
+
local only
+
+
+
+
+
Fix policy
+
Always require explicit operator confirmation
+
+
consent
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/saikyo-av-web/assets/web/styles.css b/saikyo-av-web/assets/web/styles.css
new file mode 100644
index 0000000..d637e2f
--- /dev/null
+++ b/saikyo-av-web/assets/web/styles.css
@@ -0,0 +1,281 @@
+:root{
+ --bg:#0b1220;
+ --panel:#0f1a2e;
+ --panel2:#0c1628;
+ --text:#e7eefc;
+ --muted:#9bb0d1;
+ --line:rgba(255,255,255,.08);
+ --good:#2dd4bf;
+ --warn:#fbbf24;
+ --bad:#fb7185;
+ --accent:#60a5fa;
+ --shadow: 0 20px 60px rgba(0,0,0,.35);
+ --radius:18px;
+ --radius2:14px;
+ --font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+*{box-sizing:border-box}
+html,body{height:100%}
+body{
+ margin:0;
+ font-family:var(--font);
+ color:var(--text);
+ background: radial-gradient(1200px 800px at 15% 5%, rgba(96,165,250,.25), transparent 60%),
+ radial-gradient(900px 700px at 80% 20%, rgba(45,212,191,.18), transparent 55%),
+ radial-gradient(800px 600px at 60% 85%, rgba(251,113,133,.14), transparent 55%),
+ var(--bg);
+}
+
+a{color:inherit}
+
+.app{
+ height:100%;
+ display:grid;
+ grid-template-columns: 320px 1fr;
+}
+
+.sidebar{
+ padding:24px;
+ border-right:1px solid var(--line);
+ background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 30%),
+ rgba(15,26,46,.55);
+ backdrop-filter: blur(10px);
+}
+
+.brand{
+ display:flex;
+ gap:14px;
+ align-items:center;
+ padding:14px;
+ border:1px solid var(--line);
+ border-radius: var(--radius);
+ background: rgba(12,22,40,.55);
+ box-shadow: var(--shadow);
+}
+.logo{
+ width:44px;
+ height:44px;
+ border-radius:14px;
+ display:grid;
+ place-items:center;
+ font-weight:800;
+ background: linear-gradient(135deg, rgba(96,165,250,.8), rgba(45,212,191,.75));
+ color:#0b1220;
+}
+.brand-title{font-size:16px;font-weight:800;letter-spacing:.2px}
+.brand-sub{font-size:12px;color:var(--muted)}
+
+.nav{margin-top:18px;display:flex;flex-direction:column;gap:8px}
+.nav-item{
+ text-decoration:none;
+ padding:12px 14px;
+ border-radius: 14px;
+ border:1px solid transparent;
+ color:var(--muted);
+ background: rgba(12,22,40,.20);
+}
+.nav-item:hover{border-color:var(--line);color:var(--text)}
+.nav-item.active{color:var(--text);border-color:rgba(96,165,250,.45);background: rgba(96,165,250,.12)}
+
+.sidebar-footer{
+ position:absolute;
+ left:24px;
+ right:24px;
+ bottom:24px;
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:12px;
+}
+
+.pill{
+ padding:8px 10px;
+ font-size:12px;
+ border-radius:999px;
+ border:1px solid var(--line);
+ background: rgba(12,22,40,.55);
+ color:var(--muted);
+}
+.small{font-size:12px;color:var(--muted)}
+
+.main{padding:26px}
+.topbar{
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:18px;
+}
+.topbar h1{margin:0;font-size:26px}
+.muted{color:var(--muted);font-size:13px}
+
+.btn{
+ cursor:pointer;
+ border:1px solid rgba(96,165,250,.45);
+ background: rgba(96,165,250,.15);
+ color:var(--text);
+ padding:10px 14px;
+ border-radius: 12px;
+ font-weight:700;
+}
+.btn:hover{background: rgba(96,165,250,.22)}
+
+.btn-secondary{
+ cursor:pointer;
+ border:1px solid var(--line);
+ background: rgba(255,255,255,.04);
+ color:var(--text);
+ padding:10px 14px;
+ border-radius: 12px;
+ font-weight:700;
+}
+.btn-secondary:hover{background: rgba(255,255,255,.06)}
+
+.input{
+ border:1px solid var(--line);
+ background: rgba(12,22,40,.45);
+ color:var(--text);
+ padding:10px 12px;
+ border-radius:12px;
+ min-width:260px;
+}
+
+.content{margin-top:20px}
+.view.hidden{display:none}
+
+.grid{
+ display:grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap:14px;
+}
+
+.card{
+ border:1px solid var(--line);
+ border-radius: var(--radius);
+ background: rgba(15,26,46,.55);
+ box-shadow: var(--shadow);
+ padding:16px;
+}
+.card-title{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}
+.card-value{font-size:30px;margin-top:8px;font-weight:900}
+.card-hint{margin-top:6px;font-size:12px;color:var(--muted)}
+
+.panel{
+ margin-top:14px;
+ border:1px solid var(--line);
+ border-radius: var(--radius);
+ background: rgba(15,26,46,.55);
+ box-shadow: var(--shadow);
+ overflow:hidden;
+}
+.panel.small-panel{margin-top:0}
+.panel-head{
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ padding:14px 16px;
+ border-bottom:1px solid var(--line);
+ background: rgba(12,22,40,.35);
+}
+.panel-title{font-weight:900}
+.panel-actions{display:flex;gap:10px;align-items:center}
+
+.table{display:flex;flex-direction:column}
+.row{
+ display:grid;
+ grid-template-columns: 1.2fr .6fr .6fr auto;
+ gap:10px;
+ padding:12px 16px;
+ border-bottom:1px solid var(--line);
+ align-items:center;
+}
+.row.header{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em}
+.row:last-child{border-bottom:none}
+.row.clickable{cursor:pointer}
+.row.clickable:hover{background: rgba(255,255,255,.03)}
+
+.badge{
+ display:inline-flex;
+ align-items:center;
+ gap:8px;
+ padding:7px 10px;
+ border-radius:999px;
+ font-size:12px;
+ border:1px solid var(--line);
+ color:var(--muted);
+}
+.badge.good{border-color:rgba(45,212,191,.35);color:rgba(45,212,191,.95)}
+.badge.warn{border-color:rgba(251,191,36,.35);color:rgba(251,191,36,.95)}
+.badge.bad{border-color:rgba(251,113,133,.35);color:rgba(251,113,133,.95)}
+
+.details-grid{
+ padding:16px;
+ display:grid;
+ grid-template-columns: repeat(3, minmax(0,1fr));
+ gap:12px;
+}
+.details-card{
+ border:1px solid var(--line);
+ border-radius: var(--radius2);
+ background: rgba(12,22,40,.35);
+ padding:12px;
+}
+.details-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em}
+.details-value{margin-top:8px;font-weight:800}
+.details-badge{margin-top:8px;display:inline-flex}
+
+.split{
+ padding:16px;
+ display:grid;
+ grid-template-columns: 1fr 1fr;
+ gap:12px;
+}
+
+.code{
+ margin:0;
+ padding:14px 16px;
+ overflow:auto;
+ max-height:420px;
+ font-size:12px;
+ border-top:1px solid var(--line);
+ background: rgba(8,14,24,.55);
+}
+
+.fixes{padding:12px}
+.fix{
+ border:1px solid var(--line);
+ border-radius: var(--radius2);
+ padding:12px;
+ background: rgba(12,22,40,.35);
+ margin-bottom:10px;
+}
+.fix-title{font-weight:900}
+.fix-desc{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.35}
+.fix-actions{margin-top:10px;display:flex;gap:10px;align-items:center}
+
+.settings{padding:16px;display:flex;flex-direction:column;gap:12px}
+.setting{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius: var(--radius2);padding:12px;background: rgba(12,22,40,.35)}
+.setting-title{font-weight:900}
+
+.toast{
+ position:fixed;
+ bottom:22px;
+ right:22px;
+ padding:12px 14px;
+ border-radius: 14px;
+ border:1px solid var(--line);
+ background: rgba(15,26,46,.85);
+ box-shadow: var(--shadow);
+ color:var(--text);
+ display:none;
+ max-width:520px;
+}
+.toast.show{display:block}
+
+@media (max-width: 980px){
+ .app{grid-template-columns:1fr}
+ .sidebar{position:relative}
+ .sidebar-footer{position:relative;left:auto;right:auto;bottom:auto;margin-top:16px}
+ .grid{grid-template-columns:1fr}
+ .split{grid-template-columns:1fr}
+}
diff --git a/saikyo-av-web/bin/saikyo-av-web b/saikyo-av-web/bin/saikyo-av-web
new file mode 100644
index 0000000..acacea5
--- /dev/null
+++ b/saikyo-av-web/bin/saikyo-av-web
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+import argparse
+import datetime as _dt
+import html
+import json
+import os
+import pathlib
+import re
+import socketserver
+import sys
+import urllib.parse
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+def _now_utc_iso() -> str:
+ return _dt.datetime.now(tz=_dt.timezone.utc).isoformat(timespec="seconds")
+
+
+_SAFE_REPORT_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
+
+
+def _safe_report_id(report_id: str) -> str:
+ if not _SAFE_REPORT_ID_RE.match(report_id):
+ raise ValueError("invalid report id")
+ return report_id
+
+
+def _read_json(path: pathlib.Path) -> dict:
+ with path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def _list_reports(reports_dir: pathlib.Path) -> list[dict]:
+ items: list[dict] = []
+ if not reports_dir.exists():
+ return items
+ for p in sorted(reports_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
+ try:
+ data = _read_json(p)
+ except Exception:
+ data = {}
+ items.append(
+ {
+ "id": p.stem,
+ "file": p.name,
+ "mtime": int(p.stat().st_mtime),
+ "summary": data.get("summary") or "(no summary)",
+ "severity": data.get("severity") or "unknown",
+ "created_utc": data.get("created_utc") or None,
+ }
+ )
+ return items
+
+
+def _read_report(reports_dir: pathlib.Path, report_id: str) -> dict:
+ report_id = _safe_report_id(report_id)
+ path = reports_dir / f"{report_id}.json"
+ if not path.exists():
+ raise FileNotFoundError(report_id)
+ return _read_json(path)
+
+
+def _write_json(path: pathlib.Path, data: dict) -> None:
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.parent.mkdir(parents=True, exist_ok=True)
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.write("\n")
+ os.replace(tmp, path)
+
+
+def _append_audit(reports_dir: pathlib.Path, entry: dict) -> None:
+ audit_dir = reports_dir / "audit"
+ audit_dir.mkdir(parents=True, exist_ok=True)
+ day = _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%d")
+ path = audit_dir / f"actions-{day}.jsonl"
+ with path.open("a", encoding="utf-8") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+
+def _static_file(root: pathlib.Path, rel: str) -> pathlib.Path:
+ rel = rel.lstrip("/")
+ p = (root / rel).resolve()
+ if not str(p).startswith(str(root.resolve())):
+ raise ValueError("bad path")
+ return p
+
+
+class Handler(BaseHTTPRequestHandler):
+ server_version = "SaikyoAvWeb/0.1"
+
+ def _send_json(self, status: int, obj: dict | list) -> None:
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _send_text(self, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
+ body = text.encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _read_body_json(self) -> dict:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ if length <= 0:
+ return {}
+ raw = self.rfile.read(length)
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except Exception:
+ return {}
+
+ def do_GET(self) -> None: # noqa: N802
+ parsed = urllib.parse.urlparse(self.path)
+ path = parsed.path
+
+ if path == "/api/health":
+ self._send_json(200, {"status": "ok", "time_utc": _now_utc_iso()})
+ return
+
+ if path == "/api/reports":
+ reports = _list_reports(self.server.reports_dir)
+ self._send_json(200, {"reports": reports})
+ return
+
+ if path.startswith("/api/reports/"):
+ report_id = path.removeprefix("/api/reports/").strip("/")
+ try:
+ data = _read_report(self.server.reports_dir, report_id)
+ except FileNotFoundError:
+ self._send_json(404, {"error": "not_found"})
+ return
+ except ValueError:
+ self._send_json(400, {"error": "bad_request"})
+ return
+ self._send_json(200, {"id": report_id, "report": data})
+ return
+
+ # static
+ if path == "/":
+ path = "/index.html"
+ try:
+ p = _static_file(self.server.web_root, path)
+ except ValueError:
+ self._send_text(400, "bad request")
+ return
+ if not p.exists() or not p.is_file():
+ self._send_text(404, "not found")
+ return
+
+ ctype = "application/octet-stream"
+ if p.name.endswith(".html"):
+ ctype = "text/html; charset=utf-8"
+ elif p.name.endswith(".css"):
+ ctype = "text/css; charset=utf-8"
+ elif p.name.endswith(".js"):
+ ctype = "application/javascript; charset=utf-8"
+ elif p.name.endswith(".svg"):
+ ctype = "image/svg+xml"
+
+ try:
+ data = p.read_bytes()
+ except OSError:
+ self._send_text(500, "internal error")
+ return
+
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_POST(self) -> None: # noqa: N802
+ parsed = urllib.parse.urlparse(self.path)
+ path = parsed.path
+
+ if path == "/api/apply-fix":
+ body = self._read_body_json()
+ report_id = body.get("report_id")
+ fix_id = body.get("fix_id")
+ confirm = body.get("confirm")
+
+ if not report_id or not fix_id:
+ self._send_json(400, {"error": "missing_fields"})
+ return
+
+ # Consent gate: without explicit confirmation we do not attempt any fix.
+ if confirm is not True:
+ self._send_json(
+ 409,
+ {
+ "error": "confirmation_required",
+ "message": "Fixes are applied only with explicit operator confirmation.",
+ },
+ )
+ return
+
+ # MVP: backend fixer not implemented yet.
+ entry = {
+ "time_utc": _now_utc_iso(),
+ "action": "apply_fix_requested",
+ "report_id": str(report_id),
+ "fix_id": str(fix_id),
+ "result": "not_implemented",
+ }
+ try:
+ _append_audit(self.server.reports_dir, entry)
+ except Exception:
+ pass
+
+ self._send_json(
+ 501,
+ {
+ "error": "not_implemented",
+ "message": "Fix runner is not implemented in saikyo-av-web MVP. Use saikyo-av CLI once available.",
+ },
+ )
+ return
+
+ self._send_json(404, {"error": "not_found"})
+
+
+class Server(ThreadingHTTPServer):
+ def __init__(self, server_address, RequestHandlerClass, reports_dir: pathlib.Path, web_root: pathlib.Path):
+ super().__init__(server_address, RequestHandlerClass)
+ self.reports_dir = reports_dir
+ self.web_root = web_root
+
+
+def _ensure_sample_report(reports_dir: pathlib.Path) -> None:
+ # Create a small sample report only if the directory is empty.
+ try:
+ reports_dir.mkdir(parents=True, exist_ok=True)
+ if any(reports_dir.glob("*.json")):
+ return
+ except Exception:
+ return
+
+ sample = {
+ "created_utc": _now_utc_iso(),
+ "severity": "info",
+ "summary": "Saikyo Antivirus Web UI is installed (sample report)",
+ "details": {
+ "note": "This is a placeholder report. Real reports will appear here once saikyo-avd is installed and running.",
+ },
+ "suggested_fixes": [
+ {
+ "id": "collect_artifacts",
+ "title": "Collect diagnostics artifacts",
+ "description": "Run collect-artifacts.sh and attach the resulting archive to a support ticket.",
+ "requires_consent": True,
+ }
+ ],
+ }
+ _write_json(reports_dir / "sample.json", sample)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(prog="saikyo-av-web")
+ parser.add_argument("--bind", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8765)
+ parser.add_argument("--reports-dir", default="/var/lib/saikyo-av/reports")
+ parser.add_argument("--web-root", default="/usr/share/saikyo-av/web")
+ parser.add_argument("--no-sample", action="store_true")
+ args = parser.parse_args()
+
+ reports_dir = pathlib.Path(args.reports_dir)
+ web_root = pathlib.Path(args.web_root)
+
+ if not args.no_sample:
+ _ensure_sample_report(reports_dir)
+
+ httpd = Server((args.bind, args.port), Handler, reports_dir=reports_dir, web_root=web_root)
+ print(f"Listening on http://{args.bind}:{args.port}")
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/dh_installchangelogs.dch.trimmed b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/dh_installchangelogs.dch.trimmed
new file mode 100644
index 0000000..29f1fcc
--- /dev/null
+++ b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/dh_installchangelogs.dch.trimmed
@@ -0,0 +1,5 @@
+saikyo-av-web (0.1.0) stable; urgency=medium
+
+ * Initial release: local-only web UI for Saikyo Antivirus reports.
+
+ -- SAIKYO OS Mon, 20 Jan 2026 15:00:00 +0000
diff --git a/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_install b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_install
new file mode 100644
index 0000000..76dc076
--- /dev/null
+++ b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_install
@@ -0,0 +1,4 @@
+./bin/saikyo-av-web
+./assets/web
+./systemd/saikyo-av-web.service
+./desktop/saikyo-av-web.desktop
diff --git a/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_installdocs b/saikyo-av-web/debian/.debhelper/generated/saikyo-av-web/installed-by-dh_installdocs
new file mode 100644
index 0000000..e69de29
diff --git a/saikyo-av-web/debian/changelog b/saikyo-av-web/debian/changelog
new file mode 100644
index 0000000..29f1fcc
--- /dev/null
+++ b/saikyo-av-web/debian/changelog
@@ -0,0 +1,5 @@
+saikyo-av-web (0.1.0) stable; urgency=medium
+
+ * Initial release: local-only web UI for Saikyo Antivirus reports.
+
+ -- SAIKYO OS Mon, 20 Jan 2026 15:00:00 +0000
diff --git a/saikyo-av-web/debian/control b/saikyo-av-web/debian/control
new file mode 100644
index 0000000..78a6a06
--- /dev/null
+++ b/saikyo-av-web/debian/control
@@ -0,0 +1,13 @@
+Source: saikyo-av-web
+Section: admin
+Priority: optional
+Maintainer: SAIKYO OS
+Build-Depends: debhelper-compat (= 13)
+Standards-Version: 4.6.2
+Rules-Requires-Root: no
+
+Package: saikyo-av-web
+Architecture: all
+Depends: ${misc:Depends}, python3, xdg-utils, systemd | systemd-sysv
+Description: Saikyo Antivirus local web UI
+ Local-only web interface for Saikyo Antivirus reports and operator actions.
diff --git a/saikyo-av-web/debian/copyright b/saikyo-av-web/debian/copyright
new file mode 100644
index 0000000..4805add
--- /dev/null
+++ b/saikyo-av-web/debian/copyright
@@ -0,0 +1,26 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: saikyo-av-web
+Source: https://saikyo-os.ru/
+
+Files: *
+Copyright: 2026 SAIKYO OS
+License: MIT
+
+License: MIT
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+ .
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
diff --git a/saikyo-av-web/debian/debhelper-build-stamp b/saikyo-av-web/debian/debhelper-build-stamp
new file mode 100644
index 0000000..bf74cd3
--- /dev/null
+++ b/saikyo-av-web/debian/debhelper-build-stamp
@@ -0,0 +1 @@
+saikyo-av-web
diff --git a/saikyo-av-web/debian/files b/saikyo-av-web/debian/files
new file mode 100644
index 0000000..8d379e7
--- /dev/null
+++ b/saikyo-av-web/debian/files
@@ -0,0 +1,2 @@
+saikyo-av-web_0.1.0_all.deb admin optional
+saikyo-av-web_0.1.0_amd64.buildinfo admin optional
diff --git a/saikyo-av-web/debian/install b/saikyo-av-web/debian/install
new file mode 100644
index 0000000..bf615d2
--- /dev/null
+++ b/saikyo-av-web/debian/install
@@ -0,0 +1,4 @@
+bin/saikyo-av-web usr/sbin/
+assets/web usr/share/saikyo-av/
+systemd/saikyo-av-web.service lib/systemd/system/
+desktop/saikyo-av-web.desktop usr/share/applications/
diff --git a/saikyo-av-web/debian/rules b/saikyo-av-web/debian/rules
new file mode 100755
index 0000000..303080e
--- /dev/null
+++ b/saikyo-av-web/debian/rules
@@ -0,0 +1,14 @@
+#!/usr/bin/make -f
+
+%:
+ dh $@
+
+override_dh_auto_build:
+
+override_dh_auto_test:
+
+override_dh_fixperms:
+ dh_fixperms
+ chmod 0755 debian/saikyo-av-web/usr/sbin/saikyo-av-web
+ chmod 0755 debian/saikyo-av-web.postinst
+ chmod 0755 debian/saikyo-av-web.prerm
diff --git a/saikyo-av-web/debian/saikyo-av-web.debhelper.log b/saikyo-av-web/debian/saikyo-av-web.debhelper.log
new file mode 100644
index 0000000..93c5512
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web.debhelper.log
@@ -0,0 +1 @@
+dh_fixperms
diff --git a/saikyo-av-web/debian/saikyo-av-web.postinst b/saikyo-av-web/debian/saikyo-av-web.postinst
new file mode 100755
index 0000000..0337072
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web.postinst
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+case "$1" in
+ configure)
+ mkdir -p /var/lib/saikyo-av/reports || true
+ chmod 0755 /var/lib/saikyo-av /var/lib/saikyo-av/reports 2>/dev/null || true
+ systemctl daemon-reload >/dev/null 2>&1 || true
+ systemctl enable --now saikyo-av-web.service >/dev/null 2>&1 || true
+ ;;
+esac
+
+exit 0
diff --git a/saikyo-av-web/debian/saikyo-av-web.prerm b/saikyo-av-web/debian/saikyo-av-web.prerm
new file mode 100755
index 0000000..71addf8
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web.prerm
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+case "$1" in
+ remove|deconfigure)
+ systemctl disable --now saikyo-av-web.service >/dev/null 2>&1 || true
+ ;;
+esac
+
+exit 0
diff --git a/saikyo-av-web/debian/saikyo-av-web.substvars b/saikyo-av-web/debian/saikyo-av-web.substvars
new file mode 100644
index 0000000..978fc8b
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web.substvars
@@ -0,0 +1,2 @@
+misc:Depends=
+misc:Pre-Depends=
diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/control b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/control
new file mode 100644
index 0000000..dc0b1b1
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/control
@@ -0,0 +1,10 @@
+Package: saikyo-av-web
+Version: 0.1.0
+Architecture: all
+Maintainer: SAIKYO OS
+Installed-Size: 55
+Depends: python3, xdg-utils, systemd | systemd-sysv
+Section: admin
+Priority: optional
+Description: Saikyo Antivirus local web UI
+ Local-only web interface for Saikyo Antivirus reports and operator actions.
diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums
new file mode 100644
index 0000000..f2128b7
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums
@@ -0,0 +1,8 @@
+741fe4509e1bf5476938555cd0110891 lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service
+c66991a9362e3a17db76e241fc164d14 usr/sbin/saikyo-av-web/saikyo-av-web
+2017591af37f7ef11dac74bd7cb3ad82 usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop
+eb195e490a2fa9ab640e3eebc3873676 usr/share/doc/saikyo-av-web/changelog.gz
+ae84f8183c7c089da5d4e1bf78693e79 usr/share/doc/saikyo-av-web/copyright
+beea2a09eabd1f2131d4cf937c144548 usr/share/saikyo-av/web/web/app.js
+3e0b1b07fe39cb631cb97c8a84ebc60c usr/share/saikyo-av/web/web/index.html
+2038384467f0e3e494e097005a67d5c5 usr/share/saikyo-av/web/web/styles.css
diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst
new file mode 100755
index 0000000..0337072
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+case "$1" in
+ configure)
+ mkdir -p /var/lib/saikyo-av/reports || true
+ chmod 0755 /var/lib/saikyo-av /var/lib/saikyo-av/reports 2>/dev/null || true
+ systemctl daemon-reload >/dev/null 2>&1 || true
+ systemctl enable --now saikyo-av-web.service >/dev/null 2>&1 || true
+ ;;
+esac
+
+exit 0
diff --git a/saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm
new file mode 100755
index 0000000..71addf8
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+case "$1" in
+ remove|deconfigure)
+ systemctl disable --now saikyo-av-web.service >/dev/null 2>&1 || true
+ ;;
+esac
+
+exit 0
diff --git a/saikyo-av-web/debian/saikyo-av-web/lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service b/saikyo-av-web/debian/saikyo-av-web/lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service
new file mode 100644
index 0000000..3b3c4a7
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/lib/systemd/system/saikyo-av-web.service/saikyo-av-web.service
@@ -0,0 +1,25 @@
+[Unit]
+Description=Saikyo Antivirus Web UI (local)
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/python3 /usr/sbin/saikyo-av-web --bind 127.0.0.1 --port 8765 --reports-dir /var/lib/saikyo-av/reports
+Restart=on-failure
+RestartSec=3
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectSystem=strict
+ProtectHome=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+RestrictSUIDSGID=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+SystemCallFilter=@system-service
+ReadWritePaths=/var/lib/saikyo-av/reports
+
+[Install]
+WantedBy=multi-user.target
diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web b/saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web
new file mode 100755
index 0000000..acacea5
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+import argparse
+import datetime as _dt
+import html
+import json
+import os
+import pathlib
+import re
+import socketserver
+import sys
+import urllib.parse
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+def _now_utc_iso() -> str:
+ return _dt.datetime.now(tz=_dt.timezone.utc).isoformat(timespec="seconds")
+
+
+_SAFE_REPORT_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
+
+
+def _safe_report_id(report_id: str) -> str:
+ if not _SAFE_REPORT_ID_RE.match(report_id):
+ raise ValueError("invalid report id")
+ return report_id
+
+
+def _read_json(path: pathlib.Path) -> dict:
+ with path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def _list_reports(reports_dir: pathlib.Path) -> list[dict]:
+ items: list[dict] = []
+ if not reports_dir.exists():
+ return items
+ for p in sorted(reports_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
+ try:
+ data = _read_json(p)
+ except Exception:
+ data = {}
+ items.append(
+ {
+ "id": p.stem,
+ "file": p.name,
+ "mtime": int(p.stat().st_mtime),
+ "summary": data.get("summary") or "(no summary)",
+ "severity": data.get("severity") or "unknown",
+ "created_utc": data.get("created_utc") or None,
+ }
+ )
+ return items
+
+
+def _read_report(reports_dir: pathlib.Path, report_id: str) -> dict:
+ report_id = _safe_report_id(report_id)
+ path = reports_dir / f"{report_id}.json"
+ if not path.exists():
+ raise FileNotFoundError(report_id)
+ return _read_json(path)
+
+
+def _write_json(path: pathlib.Path, data: dict) -> None:
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.parent.mkdir(parents=True, exist_ok=True)
+ with tmp.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.write("\n")
+ os.replace(tmp, path)
+
+
+def _append_audit(reports_dir: pathlib.Path, entry: dict) -> None:
+ audit_dir = reports_dir / "audit"
+ audit_dir.mkdir(parents=True, exist_ok=True)
+ day = _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%d")
+ path = audit_dir / f"actions-{day}.jsonl"
+ with path.open("a", encoding="utf-8") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+
+def _static_file(root: pathlib.Path, rel: str) -> pathlib.Path:
+ rel = rel.lstrip("/")
+ p = (root / rel).resolve()
+ if not str(p).startswith(str(root.resolve())):
+ raise ValueError("bad path")
+ return p
+
+
+class Handler(BaseHTTPRequestHandler):
+ server_version = "SaikyoAvWeb/0.1"
+
+ def _send_json(self, status: int, obj: dict | list) -> None:
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _send_text(self, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None:
+ body = text.encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _read_body_json(self) -> dict:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ if length <= 0:
+ return {}
+ raw = self.rfile.read(length)
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except Exception:
+ return {}
+
+ def do_GET(self) -> None: # noqa: N802
+ parsed = urllib.parse.urlparse(self.path)
+ path = parsed.path
+
+ if path == "/api/health":
+ self._send_json(200, {"status": "ok", "time_utc": _now_utc_iso()})
+ return
+
+ if path == "/api/reports":
+ reports = _list_reports(self.server.reports_dir)
+ self._send_json(200, {"reports": reports})
+ return
+
+ if path.startswith("/api/reports/"):
+ report_id = path.removeprefix("/api/reports/").strip("/")
+ try:
+ data = _read_report(self.server.reports_dir, report_id)
+ except FileNotFoundError:
+ self._send_json(404, {"error": "not_found"})
+ return
+ except ValueError:
+ self._send_json(400, {"error": "bad_request"})
+ return
+ self._send_json(200, {"id": report_id, "report": data})
+ return
+
+ # static
+ if path == "/":
+ path = "/index.html"
+ try:
+ p = _static_file(self.server.web_root, path)
+ except ValueError:
+ self._send_text(400, "bad request")
+ return
+ if not p.exists() or not p.is_file():
+ self._send_text(404, "not found")
+ return
+
+ ctype = "application/octet-stream"
+ if p.name.endswith(".html"):
+ ctype = "text/html; charset=utf-8"
+ elif p.name.endswith(".css"):
+ ctype = "text/css; charset=utf-8"
+ elif p.name.endswith(".js"):
+ ctype = "application/javascript; charset=utf-8"
+ elif p.name.endswith(".svg"):
+ ctype = "image/svg+xml"
+
+ try:
+ data = p.read_bytes()
+ except OSError:
+ self._send_text(500, "internal error")
+ return
+
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def do_POST(self) -> None: # noqa: N802
+ parsed = urllib.parse.urlparse(self.path)
+ path = parsed.path
+
+ if path == "/api/apply-fix":
+ body = self._read_body_json()
+ report_id = body.get("report_id")
+ fix_id = body.get("fix_id")
+ confirm = body.get("confirm")
+
+ if not report_id or not fix_id:
+ self._send_json(400, {"error": "missing_fields"})
+ return
+
+ # Consent gate: without explicit confirmation we do not attempt any fix.
+ if confirm is not True:
+ self._send_json(
+ 409,
+ {
+ "error": "confirmation_required",
+ "message": "Fixes are applied only with explicit operator confirmation.",
+ },
+ )
+ return
+
+ # MVP: backend fixer not implemented yet.
+ entry = {
+ "time_utc": _now_utc_iso(),
+ "action": "apply_fix_requested",
+ "report_id": str(report_id),
+ "fix_id": str(fix_id),
+ "result": "not_implemented",
+ }
+ try:
+ _append_audit(self.server.reports_dir, entry)
+ except Exception:
+ pass
+
+ self._send_json(
+ 501,
+ {
+ "error": "not_implemented",
+ "message": "Fix runner is not implemented in saikyo-av-web MVP. Use saikyo-av CLI once available.",
+ },
+ )
+ return
+
+ self._send_json(404, {"error": "not_found"})
+
+
+class Server(ThreadingHTTPServer):
+ def __init__(self, server_address, RequestHandlerClass, reports_dir: pathlib.Path, web_root: pathlib.Path):
+ super().__init__(server_address, RequestHandlerClass)
+ self.reports_dir = reports_dir
+ self.web_root = web_root
+
+
+def _ensure_sample_report(reports_dir: pathlib.Path) -> None:
+ # Create a small sample report only if the directory is empty.
+ try:
+ reports_dir.mkdir(parents=True, exist_ok=True)
+ if any(reports_dir.glob("*.json")):
+ return
+ except Exception:
+ return
+
+ sample = {
+ "created_utc": _now_utc_iso(),
+ "severity": "info",
+ "summary": "Saikyo Antivirus Web UI is installed (sample report)",
+ "details": {
+ "note": "This is a placeholder report. Real reports will appear here once saikyo-avd is installed and running.",
+ },
+ "suggested_fixes": [
+ {
+ "id": "collect_artifacts",
+ "title": "Collect diagnostics artifacts",
+ "description": "Run collect-artifacts.sh and attach the resulting archive to a support ticket.",
+ "requires_consent": True,
+ }
+ ],
+ }
+ _write_json(reports_dir / "sample.json", sample)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(prog="saikyo-av-web")
+ parser.add_argument("--bind", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8765)
+ parser.add_argument("--reports-dir", default="/var/lib/saikyo-av/reports")
+ parser.add_argument("--web-root", default="/usr/share/saikyo-av/web")
+ parser.add_argument("--no-sample", action="store_true")
+ args = parser.parse_args()
+
+ reports_dir = pathlib.Path(args.reports_dir)
+ web_root = pathlib.Path(args.web_root)
+
+ if not args.no_sample:
+ _ensure_sample_report(reports_dir)
+
+ httpd = Server((args.bind, args.port), Handler, reports_dir=reports_dir, web_root=web_root)
+ print(f"Listening on http://{args.bind}:{args.port}")
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop b/saikyo-av-web/debian/saikyo-av-web/usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop
new file mode 100644
index 0000000..792b08c
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/applications/saikyo-av-web.desktop/saikyo-av-web.desktop
@@ -0,0 +1,8 @@
+[Desktop Entry]
+Type=Application
+Name=Saikyo Antivirus
+Comment=Local antivirus dashboard
+Exec=xdg-open http://127.0.0.1:8765
+Icon=security-high
+Terminal=false
+Categories=System;Security;
diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/changelog.gz b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/changelog.gz
new file mode 100644
index 0000000..c1fd877
Binary files /dev/null and b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/changelog.gz differ
diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/copyright b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/copyright
new file mode 100644
index 0000000..4805add
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/doc/saikyo-av-web/copyright
@@ -0,0 +1,26 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+Upstream-Name: saikyo-av-web
+Source: https://saikyo-os.ru/
+
+Files: *
+Copyright: 2026 SAIKYO OS
+License: MIT
+
+License: MIT
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to deal
+ in the Software without restriction, including without limitation the rights
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+ .
+ The above copyright notice and this permission notice shall be included in all
+ copies or substantial portions of the Software.
+ .
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ SOFTWARE.
diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/app.js b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/app.js
new file mode 100644
index 0000000..52c39c1
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/app.js
@@ -0,0 +1,234 @@
+const $ = (sel) => document.querySelector(sel);
+const $$ = (sel) => Array.from(document.querySelectorAll(sel));
+
+function badgeClass(sev) {
+ const s = (sev || "").toLowerCase();
+ if (["ok", "info", "low"].includes(s)) return "good";
+ if (["medium", "warn", "warning"].includes(s)) return "warn";
+ if (["high", "critical", "bad"].includes(s)) return "bad";
+ return "";
+}
+
+function fmtTime(ts) {
+ if (!ts) return "—";
+ try {
+ if (typeof ts === "number") return new Date(ts * 1000).toLocaleString();
+ return new Date(ts).toLocaleString();
+ } catch {
+ return String(ts);
+ }
+}
+
+function toast(msg) {
+ const t = $("#toast");
+ t.textContent = msg;
+ t.classList.add("show");
+ setTimeout(() => t.classList.remove("show"), 3500);
+}
+
+async function api(path, opts) {
+ const res = await fetch(path, opts);
+ const ct = res.headers.get("content-type") || "";
+ const data = ct.includes("application/json") ? await res.json() : await res.text();
+ return { ok: res.ok, status: res.status, data };
+}
+
+let state = {
+ view: "dashboard",
+ reports: [],
+ selected: null,
+};
+
+function setView(v) {
+ state.view = v;
+ $$(".nav-item").forEach((a) => a.classList.toggle("active", a.dataset.view === v));
+ $("#view-dashboard").classList.toggle("hidden", v !== "dashboard");
+ $("#view-reports").classList.toggle("hidden", v !== "reports");
+ $("#view-settings").classList.toggle("hidden", v !== "settings");
+
+ if (v === "dashboard") {
+ $("#page-title").textContent = "Dashboard";
+ $("#page-sub").textContent = "Обзор состояния и последние отчёты";
+ }
+ if (v === "reports") {
+ $("#page-title").textContent = "Reports";
+ $("#page-sub").textContent = "Просмотр отчётов и применение действий (по согласию)";
+ }
+ if (v === "settings") {
+ $("#page-title").textContent = "Settings";
+ $("#page-sub").textContent = "Локальные параметры интерфейса";
+ }
+}
+
+function renderTable(el, rows, { limit = null } = {}) {
+ const r = limit ? rows.slice(0, limit) : rows;
+ const header = `
+
+ `;
+
+ const body = r
+ .map((it) => {
+ const cls = badgeClass(it.severity);
+ return `
+
+
${escapeHtml(it.summary || "(no summary)")}
+
${escapeHtml(it.severity || "unknown")}
+
${escapeHtml(fmtTime(it.created_utc || it.mtime))}
+
open
+
+ `;
+ })
+ .join("");
+
+ el.innerHTML = header + body;
+ el.querySelectorAll(".row.clickable").forEach((row) => {
+ row.addEventListener("click", () => openReport(row.dataset.id));
+ });
+}
+
+function escapeHtml(s) {
+ return String(s)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+async function refresh() {
+ const h = await api("/api/health");
+ if (h.ok) {
+ $("#health-pill").textContent = "UI: OK";
+ $("#status-value").textContent = "Protected";
+ $("#status-hint").textContent = "local UI service running";
+ } else {
+ $("#health-pill").textContent = "UI: error";
+ $("#status-value").textContent = "Degraded";
+ $("#status-hint").textContent = "health check failed";
+ }
+
+ const r = await api("/api/reports");
+ if (r.ok) {
+ state.reports = r.data.reports || [];
+ $("#reports-count").textContent = String(state.reports.length);
+ renderTable($("#latest-table"), state.reports, { limit: 6 });
+ renderTable($("#reports-table"), state.reports);
+ } else {
+ toast("Failed to load reports");
+ }
+}
+
+async function openReport(reportId) {
+ const p = await api(`/api/reports/${encodeURIComponent(reportId)}`);
+ if (!p.ok) {
+ toast("Report not found");
+ return;
+ }
+ const report = p.data.report;
+ state.selected = { id: reportId, report };
+
+ $("#report-details").classList.remove("hidden");
+ $("#details-title").textContent = `Report: ${reportId}`;
+ $("#details-sub").textContent = "Read-only details";
+ $("#details-summary").textContent = report.summary || "—";
+
+ const sev = report.severity || "unknown";
+ const cls = badgeClass(sev);
+ const badge = $("#details-severity");
+ badge.textContent = sev;
+ badge.className = `badge ${cls}`;
+
+ $("#details-created").textContent = fmtTime(report.created_utc || "—");
+ $("#raw").textContent = JSON.stringify(report, null, 2);
+
+ const fixesEl = $("#fixes");
+ fixesEl.classList.add("fixes");
+ const fixes = report.suggested_fixes || [];
+ if (!fixes.length) {
+ fixesEl.innerHTML = `No suggested fixes
`;
+ } else {
+ fixesEl.innerHTML = fixes
+ .map((f) => {
+ return `
+
+
${escapeHtml(f.title || f.id || "Fix")}
+
${escapeHtml(f.description || "")}
+
+
+ consent
+
+
+ `;
+ })
+ .join("");
+
+ fixesEl.querySelectorAll("button[data-fix]").forEach((b) => {
+ b.addEventListener("click", () => applyFix(b.dataset.fix));
+ });
+ }
+}
+
+async function applyFix(fixId) {
+ if (!state.selected) return;
+ const reportId = state.selected.id;
+
+ const confirm1 = window.confirm(
+ `Apply fix '${fixId}' for report '${reportId}'?\n\nThis action requires explicit operator confirmation.`
+ );
+ if (!confirm1) return;
+
+ const res = await api("/api/apply-fix", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ report_id: reportId, fix_id: fixId, confirm: true }),
+ });
+
+ if (res.ok) {
+ toast("Fix applied");
+ } else {
+ if (res.status === 501) {
+ toast("Fix runner not implemented yet (MVP UI only)");
+ return;
+ }
+ if (res.status === 409) {
+ toast("Confirmation required");
+ return;
+ }
+ toast("Failed to apply fix");
+ }
+}
+
+function bindEvents() {
+ $$(".nav-item").forEach((a) => {
+ a.addEventListener("click", (e) => {
+ e.preventDefault();
+ setView(a.dataset.view);
+ });
+ });
+
+ $("#refresh-btn").addEventListener("click", () => refresh());
+
+ $("#close-details").addEventListener("click", () => {
+ $("#report-details").classList.add("hidden");
+ state.selected = null;
+ });
+
+ $("#filter").addEventListener("input", () => {
+ const q = $("#filter").value.trim().toLowerCase();
+ const rows = !q
+ ? state.reports
+ : state.reports.filter((r) =>
+ String(r.summary || "").toLowerCase().includes(q) || String(r.id || "").toLowerCase().includes(q)
+ );
+ renderTable($("#reports-table"), rows);
+ });
+}
+
+bindEvents();
+setView("dashboard");
+refresh();
diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/index.html b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/index.html
new file mode 100644
index 0000000..192a750
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/index.html
@@ -0,0 +1,182 @@
+
+
+
+
+
+ Saikyo Antivirus
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Status
+
—
+
waiting for health check
+
+
+
Reports
+
—
+
available locally
+
+
+
Mode
+
Consent
+
fixes require confirmation
+
+
+
+
+
+
+
Latest reports
+
Click a report to open details
+
+
+
+
+
+
+
+
+
+
+
Reports
+
Локальные отчёты Saikyo Antivirus
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Suggested fixes
+
Apply only after confirmation
+
+
+
+
+
+
+
+
+
Raw report (JSON)
+
Read-only view
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
Local UI (no telemetry by default)
+
+
+
+
+
+
+
Reports directory
+
/var/lib/saikyo-av/reports
+
+
read
+
+
+
+
+
Network binding
+
127.0.0.1:8765
+
+
local only
+
+
+
+
+
Fix policy
+
Always require explicit operator confirmation
+
+
consent
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/styles.css b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/styles.css
new file mode 100644
index 0000000..d637e2f
--- /dev/null
+++ b/saikyo-av-web/debian/saikyo-av-web/usr/share/saikyo-av/web/web/styles.css
@@ -0,0 +1,281 @@
+:root{
+ --bg:#0b1220;
+ --panel:#0f1a2e;
+ --panel2:#0c1628;
+ --text:#e7eefc;
+ --muted:#9bb0d1;
+ --line:rgba(255,255,255,.08);
+ --good:#2dd4bf;
+ --warn:#fbbf24;
+ --bad:#fb7185;
+ --accent:#60a5fa;
+ --shadow: 0 20px 60px rgba(0,0,0,.35);
+ --radius:18px;
+ --radius2:14px;
+ --font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+*{box-sizing:border-box}
+html,body{height:100%}
+body{
+ margin:0;
+ font-family:var(--font);
+ color:var(--text);
+ background: radial-gradient(1200px 800px at 15% 5%, rgba(96,165,250,.25), transparent 60%),
+ radial-gradient(900px 700px at 80% 20%, rgba(45,212,191,.18), transparent 55%),
+ radial-gradient(800px 600px at 60% 85%, rgba(251,113,133,.14), transparent 55%),
+ var(--bg);
+}
+
+a{color:inherit}
+
+.app{
+ height:100%;
+ display:grid;
+ grid-template-columns: 320px 1fr;
+}
+
+.sidebar{
+ padding:24px;
+ border-right:1px solid var(--line);
+ background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 30%),
+ rgba(15,26,46,.55);
+ backdrop-filter: blur(10px);
+}
+
+.brand{
+ display:flex;
+ gap:14px;
+ align-items:center;
+ padding:14px;
+ border:1px solid var(--line);
+ border-radius: var(--radius);
+ background: rgba(12,22,40,.55);
+ box-shadow: var(--shadow);
+}
+.logo{
+ width:44px;
+ height:44px;
+ border-radius:14px;
+ display:grid;
+ place-items:center;
+ font-weight:800;
+ background: linear-gradient(135deg, rgba(96,165,250,.8), rgba(45,212,191,.75));
+ color:#0b1220;
+}
+.brand-title{font-size:16px;font-weight:800;letter-spacing:.2px}
+.brand-sub{font-size:12px;color:var(--muted)}
+
+.nav{margin-top:18px;display:flex;flex-direction:column;gap:8px}
+.nav-item{
+ text-decoration:none;
+ padding:12px 14px;
+ border-radius: 14px;
+ border:1px solid transparent;
+ color:var(--muted);
+ background: rgba(12,22,40,.20);
+}
+.nav-item:hover{border-color:var(--line);color:var(--text)}
+.nav-item.active{color:var(--text);border-color:rgba(96,165,250,.45);background: rgba(96,165,250,.12)}
+
+.sidebar-footer{
+ position:absolute;
+ left:24px;
+ right:24px;
+ bottom:24px;
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:12px;
+}
+
+.pill{
+ padding:8px 10px;
+ font-size:12px;
+ border-radius:999px;
+ border:1px solid var(--line);
+ background: rgba(12,22,40,.55);
+ color:var(--muted);
+}
+.small{font-size:12px;color:var(--muted)}
+
+.main{padding:26px}
+.topbar{
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:18px;
+}
+.topbar h1{margin:0;font-size:26px}
+.muted{color:var(--muted);font-size:13px}
+
+.btn{
+ cursor:pointer;
+ border:1px solid rgba(96,165,250,.45);
+ background: rgba(96,165,250,.15);
+ color:var(--text);
+ padding:10px 14px;
+ border-radius: 12px;
+ font-weight:700;
+}
+.btn:hover{background: rgba(96,165,250,.22)}
+
+.btn-secondary{
+ cursor:pointer;
+ border:1px solid var(--line);
+ background: rgba(255,255,255,.04);
+ color:var(--text);
+ padding:10px 14px;
+ border-radius: 12px;
+ font-weight:700;
+}
+.btn-secondary:hover{background: rgba(255,255,255,.06)}
+
+.input{
+ border:1px solid var(--line);
+ background: rgba(12,22,40,.45);
+ color:var(--text);
+ padding:10px 12px;
+ border-radius:12px;
+ min-width:260px;
+}
+
+.content{margin-top:20px}
+.view.hidden{display:none}
+
+.grid{
+ display:grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap:14px;
+}
+
+.card{
+ border:1px solid var(--line);
+ border-radius: var(--radius);
+ background: rgba(15,26,46,.55);
+ box-shadow: var(--shadow);
+ padding:16px;
+}
+.card-title{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em}
+.card-value{font-size:30px;margin-top:8px;font-weight:900}
+.card-hint{margin-top:6px;font-size:12px;color:var(--muted)}
+
+.panel{
+ margin-top:14px;
+ border:1px solid var(--line);
+ border-radius: var(--radius);
+ background: rgba(15,26,46,.55);
+ box-shadow: var(--shadow);
+ overflow:hidden;
+}
+.panel.small-panel{margin-top:0}
+.panel-head{
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ padding:14px 16px;
+ border-bottom:1px solid var(--line);
+ background: rgba(12,22,40,.35);
+}
+.panel-title{font-weight:900}
+.panel-actions{display:flex;gap:10px;align-items:center}
+
+.table{display:flex;flex-direction:column}
+.row{
+ display:grid;
+ grid-template-columns: 1.2fr .6fr .6fr auto;
+ gap:10px;
+ padding:12px 16px;
+ border-bottom:1px solid var(--line);
+ align-items:center;
+}
+.row.header{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em}
+.row:last-child{border-bottom:none}
+.row.clickable{cursor:pointer}
+.row.clickable:hover{background: rgba(255,255,255,.03)}
+
+.badge{
+ display:inline-flex;
+ align-items:center;
+ gap:8px;
+ padding:7px 10px;
+ border-radius:999px;
+ font-size:12px;
+ border:1px solid var(--line);
+ color:var(--muted);
+}
+.badge.good{border-color:rgba(45,212,191,.35);color:rgba(45,212,191,.95)}
+.badge.warn{border-color:rgba(251,191,36,.35);color:rgba(251,191,36,.95)}
+.badge.bad{border-color:rgba(251,113,133,.35);color:rgba(251,113,133,.95)}
+
+.details-grid{
+ padding:16px;
+ display:grid;
+ grid-template-columns: repeat(3, minmax(0,1fr));
+ gap:12px;
+}
+.details-card{
+ border:1px solid var(--line);
+ border-radius: var(--radius2);
+ background: rgba(12,22,40,.35);
+ padding:12px;
+}
+.details-label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.10em}
+.details-value{margin-top:8px;font-weight:800}
+.details-badge{margin-top:8px;display:inline-flex}
+
+.split{
+ padding:16px;
+ display:grid;
+ grid-template-columns: 1fr 1fr;
+ gap:12px;
+}
+
+.code{
+ margin:0;
+ padding:14px 16px;
+ overflow:auto;
+ max-height:420px;
+ font-size:12px;
+ border-top:1px solid var(--line);
+ background: rgba(8,14,24,.55);
+}
+
+.fixes{padding:12px}
+.fix{
+ border:1px solid var(--line);
+ border-radius: var(--radius2);
+ padding:12px;
+ background: rgba(12,22,40,.35);
+ margin-bottom:10px;
+}
+.fix-title{font-weight:900}
+.fix-desc{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.35}
+.fix-actions{margin-top:10px;display:flex;gap:10px;align-items:center}
+
+.settings{padding:16px;display:flex;flex-direction:column;gap:12px}
+.setting{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius: var(--radius2);padding:12px;background: rgba(12,22,40,.35)}
+.setting-title{font-weight:900}
+
+.toast{
+ position:fixed;
+ bottom:22px;
+ right:22px;
+ padding:12px 14px;
+ border-radius: 14px;
+ border:1px solid var(--line);
+ background: rgba(15,26,46,.85);
+ box-shadow: var(--shadow);
+ color:var(--text);
+ display:none;
+ max-width:520px;
+}
+.toast.show{display:block}
+
+@media (max-width: 980px){
+ .app{grid-template-columns:1fr}
+ .sidebar{position:relative}
+ .sidebar-footer{position:relative;left:auto;right:auto;bottom:auto;margin-top:16px}
+ .grid{grid-template-columns:1fr}
+ .split{grid-template-columns:1fr}
+}
diff --git a/saikyo-av-web/debian/source/format b/saikyo-av-web/debian/source/format
new file mode 100644
index 0000000..89ae9db
--- /dev/null
+++ b/saikyo-av-web/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/saikyo-av-web/desktop/saikyo-av-web.desktop b/saikyo-av-web/desktop/saikyo-av-web.desktop
new file mode 100644
index 0000000..792b08c
--- /dev/null
+++ b/saikyo-av-web/desktop/saikyo-av-web.desktop
@@ -0,0 +1,8 @@
+[Desktop Entry]
+Type=Application
+Name=Saikyo Antivirus
+Comment=Local antivirus dashboard
+Exec=xdg-open http://127.0.0.1:8765
+Icon=security-high
+Terminal=false
+Categories=System;Security;
diff --git a/saikyo-av-web/systemd/saikyo-av-web.service b/saikyo-av-web/systemd/saikyo-av-web.service
new file mode 100644
index 0000000..3b3c4a7
--- /dev/null
+++ b/saikyo-av-web/systemd/saikyo-av-web.service
@@ -0,0 +1,25 @@
+[Unit]
+Description=Saikyo Antivirus Web UI (local)
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/python3 /usr/sbin/saikyo-av-web --bind 127.0.0.1 --port 8765 --reports-dir /var/lib/saikyo-av/reports
+Restart=on-failure
+RestartSec=3
+NoNewPrivileges=yes
+PrivateTmp=yes
+ProtectSystem=strict
+ProtectHome=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectControlGroups=yes
+RestrictSUIDSGID=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+SystemCallArchitectures=native
+SystemCallFilter=@system-service
+ReadWritePaths=/var/lib/saikyo-av/reports
+
+[Install]
+WantedBy=multi-user.target