Добавлены полные исходники saikyo-av-web

This commit is contained in:
Saikyo OS Team 2026-01-21 21:33:55 +03:00
parent 5820108829
commit a547db4840
33 changed files with 2204 additions and 0 deletions

View File

@ -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 = `
<div class="row header">
<div>Summary</div>
<div>Severity</div>
<div>Updated</div>
<div></div>
</div>
`;
const body = r
.map((it) => {
const cls = badgeClass(it.severity);
return `
<div class="row clickable" data-id="${it.id}">
<div>${escapeHtml(it.summary || "(no summary)")}</div>
<div><span class="badge ${cls}">${escapeHtml(it.severity || "unknown")}</span></div>
<div>${escapeHtml(fmtTime(it.created_utc || it.mtime))}</div>
<div style="text-align:right;"><span class="pill">open</span></div>
</div>
`;
})
.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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 = `<div class="muted" style="padding:12px;">No suggested fixes</div>`;
} else {
fixesEl.innerHTML = fixes
.map((f) => {
return `
<div class="fix">
<div class="fix-title">${escapeHtml(f.title || f.id || "Fix")}</div>
<div class="fix-desc">${escapeHtml(f.description || "")}</div>
<div class="fix-actions">
<button class="btn" data-fix="${escapeHtml(f.id || "")}">Apply</button>
<span class="pill">consent</span>
</div>
</div>
`;
})
.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();

View File

@ -0,0 +1,182 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Saikyo Antivirus</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<div class="logo">S</div>
<div>
<div class="brand-title">Saikyo Antivirus</div>
<div class="brand-sub">Local Protection Center</div>
</div>
</div>
<nav class="nav">
<a class="nav-item active" href="#" data-view="dashboard">Dashboard</a>
<a class="nav-item" href="#" data-view="reports">Reports</a>
<a class="nav-item" href="#" data-view="settings">Settings</a>
</nav>
<div class="sidebar-footer">
<div class="pill" id="health-pill">Checking…</div>
<div class="small">127.0.0.1 only</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="topbar-left">
<h1 id="page-title">Dashboard</h1>
<div class="muted" id="page-sub">Обзор состояния и последние отчёты</div>
</div>
<div class="topbar-right">
<button class="btn" id="refresh-btn">Refresh</button>
</div>
</header>
<section class="content">
<div class="view" id="view-dashboard">
<div class="grid">
<div class="card">
<div class="card-title">Status</div>
<div class="card-value" id="status-value"></div>
<div class="card-hint" id="status-hint">waiting for health check</div>
</div>
<div class="card">
<div class="card-title">Reports</div>
<div class="card-value" id="reports-count"></div>
<div class="card-hint">available locally</div>
</div>
<div class="card">
<div class="card-title">Mode</div>
<div class="card-value">Consent</div>
<div class="card-hint">fixes require confirmation</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">Latest reports</div>
<div class="muted">Click a report to open details</div>
</div>
</div>
<div class="table" id="latest-table"></div>
</div>
</div>
<div class="view hidden" id="view-reports">
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">Reports</div>
<div class="muted">Локальные отчёты Saikyo Antivirus</div>
</div>
<div class="panel-actions">
<input class="input" id="filter" placeholder="Filter by text…" />
</div>
</div>
<div class="table" id="reports-table"></div>
</div>
<div class="panel hidden" id="report-details">
<div class="panel-head">
<div>
<div class="panel-title" id="details-title">Report</div>
<div class="muted" id="details-sub"></div>
</div>
<div class="panel-actions">
<button class="btn-secondary" id="close-details">Close</button>
</div>
</div>
<div class="details-grid">
<div class="details-card">
<div class="details-label">Summary</div>
<div class="details-value" id="details-summary"></div>
</div>
<div class="details-card">
<div class="details-label">Severity</div>
<div class="details-badge" id="details-severity">unknown</div>
</div>
<div class="details-card">
<div class="details-label">Created</div>
<div class="details-value" id="details-created"></div>
</div>
</div>
<div class="split">
<div class="panel small-panel">
<div class="panel-head">
<div>
<div class="panel-title">Suggested fixes</div>
<div class="muted">Apply only after confirmation</div>
</div>
</div>
<div id="fixes"></div>
</div>
<div class="panel small-panel">
<div class="panel-head">
<div>
<div class="panel-title">Raw report (JSON)</div>
<div class="muted">Read-only view</div>
</div>
</div>
<pre class="code" id="raw"></pre>
</div>
</div>
</div>
</div>
<div class="view hidden" id="view-settings">
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">Settings</div>
<div class="muted">Local UI (no telemetry by default)</div>
</div>
</div>
<div class="settings">
<div class="setting">
<div>
<div class="setting-title">Reports directory</div>
<div class="muted">/var/lib/saikyo-av/reports</div>
</div>
<div class="pill">read</div>
</div>
<div class="setting">
<div>
<div class="setting-title">Network binding</div>
<div class="muted">127.0.0.1:8765</div>
</div>
<div class="pill">local only</div>
</div>
<div class="setting">
<div>
<div class="setting-title">Fix policy</div>
<div class="muted">Always require explicit operator confirmation</div>
</div>
<div class="pill">consent</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<div class="toast" id="toast"></div>
<script src="/app.js"></script>
</body>
</html>

View File

@ -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}
}

View File

@ -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())

View File

@ -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 <support@saikyo-os.ru> Mon, 20 Jan 2026 15:00:00 +0000

View File

@ -0,0 +1,4 @@
./bin/saikyo-av-web
./assets/web
./systemd/saikyo-av-web.service
./desktop/saikyo-av-web.desktop

View File

@ -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 <support@saikyo-os.ru> Mon, 20 Jan 2026 15:00:00 +0000

View File

@ -0,0 +1,13 @@
Source: saikyo-av-web
Section: admin
Priority: optional
Maintainer: SAIKYO OS <support@saikyo-os.ru>
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.

View File

@ -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.

View File

@ -0,0 +1 @@
saikyo-av-web

View File

@ -0,0 +1,2 @@
saikyo-av-web_0.1.0_all.deb admin optional
saikyo-av-web_0.1.0_amd64.buildinfo admin optional

View File

@ -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/

14
saikyo-av-web/debian/rules Executable file
View File

@ -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

View File

@ -0,0 +1 @@
dh_fixperms

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
misc:Depends=
misc:Pre-Depends=

View File

@ -0,0 +1,10 @@
Package: saikyo-av-web
Version: 0.1.0
Architecture: all
Maintainer: SAIKYO OS <support@saikyo-os.ru>
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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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;

View File

@ -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.

View File

@ -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 = `
<div class="row header">
<div>Summary</div>
<div>Severity</div>
<div>Updated</div>
<div></div>
</div>
`;
const body = r
.map((it) => {
const cls = badgeClass(it.severity);
return `
<div class="row clickable" data-id="${it.id}">
<div>${escapeHtml(it.summary || "(no summary)")}</div>
<div><span class="badge ${cls}">${escapeHtml(it.severity || "unknown")}</span></div>
<div>${escapeHtml(fmtTime(it.created_utc || it.mtime))}</div>
<div style="text-align:right;"><span class="pill">open</span></div>
</div>
`;
})
.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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 = `<div class="muted" style="padding:12px;">No suggested fixes</div>`;
} else {
fixesEl.innerHTML = fixes
.map((f) => {
return `
<div class="fix">
<div class="fix-title">${escapeHtml(f.title || f.id || "Fix")}</div>
<div class="fix-desc">${escapeHtml(f.description || "")}</div>
<div class="fix-actions">
<button class="btn" data-fix="${escapeHtml(f.id || "")}">Apply</button>
<span class="pill">consent</span>
</div>
</div>
`;
})
.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();

View File

@ -0,0 +1,182 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Saikyo Antivirus</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<div class="logo">S</div>
<div>
<div class="brand-title">Saikyo Antivirus</div>
<div class="brand-sub">Local Protection Center</div>
</div>
</div>
<nav class="nav">
<a class="nav-item active" href="#" data-view="dashboard">Dashboard</a>
<a class="nav-item" href="#" data-view="reports">Reports</a>
<a class="nav-item" href="#" data-view="settings">Settings</a>
</nav>
<div class="sidebar-footer">
<div class="pill" id="health-pill">Checking…</div>
<div class="small">127.0.0.1 only</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="topbar-left">
<h1 id="page-title">Dashboard</h1>
<div class="muted" id="page-sub">Обзор состояния и последние отчёты</div>
</div>
<div class="topbar-right">
<button class="btn" id="refresh-btn">Refresh</button>
</div>
</header>
<section class="content">
<div class="view" id="view-dashboard">
<div class="grid">
<div class="card">
<div class="card-title">Status</div>
<div class="card-value" id="status-value"></div>
<div class="card-hint" id="status-hint">waiting for health check</div>
</div>
<div class="card">
<div class="card-title">Reports</div>
<div class="card-value" id="reports-count"></div>
<div class="card-hint">available locally</div>
</div>
<div class="card">
<div class="card-title">Mode</div>
<div class="card-value">Consent</div>
<div class="card-hint">fixes require confirmation</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">Latest reports</div>
<div class="muted">Click a report to open details</div>
</div>
</div>
<div class="table" id="latest-table"></div>
</div>
</div>
<div class="view hidden" id="view-reports">
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">Reports</div>
<div class="muted">Локальные отчёты Saikyo Antivirus</div>
</div>
<div class="panel-actions">
<input class="input" id="filter" placeholder="Filter by text…" />
</div>
</div>
<div class="table" id="reports-table"></div>
</div>
<div class="panel hidden" id="report-details">
<div class="panel-head">
<div>
<div class="panel-title" id="details-title">Report</div>
<div class="muted" id="details-sub"></div>
</div>
<div class="panel-actions">
<button class="btn-secondary" id="close-details">Close</button>
</div>
</div>
<div class="details-grid">
<div class="details-card">
<div class="details-label">Summary</div>
<div class="details-value" id="details-summary"></div>
</div>
<div class="details-card">
<div class="details-label">Severity</div>
<div class="details-badge" id="details-severity">unknown</div>
</div>
<div class="details-card">
<div class="details-label">Created</div>
<div class="details-value" id="details-created"></div>
</div>
</div>
<div class="split">
<div class="panel small-panel">
<div class="panel-head">
<div>
<div class="panel-title">Suggested fixes</div>
<div class="muted">Apply only after confirmation</div>
</div>
</div>
<div id="fixes"></div>
</div>
<div class="panel small-panel">
<div class="panel-head">
<div>
<div class="panel-title">Raw report (JSON)</div>
<div class="muted">Read-only view</div>
</div>
</div>
<pre class="code" id="raw"></pre>
</div>
</div>
</div>
</div>
<div class="view hidden" id="view-settings">
<div class="panel">
<div class="panel-head">
<div>
<div class="panel-title">Settings</div>
<div class="muted">Local UI (no telemetry by default)</div>
</div>
</div>
<div class="settings">
<div class="setting">
<div>
<div class="setting-title">Reports directory</div>
<div class="muted">/var/lib/saikyo-av/reports</div>
</div>
<div class="pill">read</div>
</div>
<div class="setting">
<div>
<div class="setting-title">Network binding</div>
<div class="muted">127.0.0.1:8765</div>
</div>
<div class="pill">local only</div>
</div>
<div class="setting">
<div>
<div class="setting-title">Fix policy</div>
<div class="muted">Always require explicit operator confirmation</div>
</div>
<div class="pill">consent</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<div class="toast" id="toast"></div>
<script src="/app.js"></script>
</body>
</html>

View File

@ -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}
}

View File

@ -0,0 +1 @@
3.0 (native)

View File

@ -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;

View File

@ -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