Добавлены полные исходники saikyo-av-web
This commit is contained in:
parent
5820108829
commit
a547db4840
234
saikyo-av-web/assets/web/app.js
Normal file
234
saikyo-av-web/assets/web/app.js
Normal 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("&", "&")
|
||||
.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 = `<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();
|
||||
182
saikyo-av-web/assets/web/index.html
Normal file
182
saikyo-av-web/assets/web/index.html
Normal 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>
|
||||
281
saikyo-av-web/assets/web/styles.css
Normal file
281
saikyo-av-web/assets/web/styles.css
Normal 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}
|
||||
}
|
||||
288
saikyo-av-web/bin/saikyo-av-web
Normal file
288
saikyo-av-web/bin/saikyo-av-web
Normal 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())
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
./bin/saikyo-av-web
|
||||
./assets/web
|
||||
./systemd/saikyo-av-web.service
|
||||
./desktop/saikyo-av-web.desktop
|
||||
5
saikyo-av-web/debian/changelog
Normal file
5
saikyo-av-web/debian/changelog
Normal 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
|
||||
13
saikyo-av-web/debian/control
Normal file
13
saikyo-av-web/debian/control
Normal 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.
|
||||
26
saikyo-av-web/debian/copyright
Normal file
26
saikyo-av-web/debian/copyright
Normal 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.
|
||||
1
saikyo-av-web/debian/debhelper-build-stamp
Normal file
1
saikyo-av-web/debian/debhelper-build-stamp
Normal file
|
|
@ -0,0 +1 @@
|
|||
saikyo-av-web
|
||||
2
saikyo-av-web/debian/files
Normal file
2
saikyo-av-web/debian/files
Normal 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
|
||||
4
saikyo-av-web/debian/install
Normal file
4
saikyo-av-web/debian/install
Normal 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
14
saikyo-av-web/debian/rules
Executable 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
|
||||
1
saikyo-av-web/debian/saikyo-av-web.debhelper.log
Normal file
1
saikyo-av-web/debian/saikyo-av-web.debhelper.log
Normal file
|
|
@ -0,0 +1 @@
|
|||
dh_fixperms
|
||||
13
saikyo-av-web/debian/saikyo-av-web.postinst
Executable file
13
saikyo-av-web/debian/saikyo-av-web.postinst
Executable 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
|
||||
10
saikyo-av-web/debian/saikyo-av-web.prerm
Executable file
10
saikyo-av-web/debian/saikyo-av-web.prerm
Executable 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
|
||||
2
saikyo-av-web/debian/saikyo-av-web.substvars
Normal file
2
saikyo-av-web/debian/saikyo-av-web.substvars
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
misc:Depends=
|
||||
misc:Pre-Depends=
|
||||
10
saikyo-av-web/debian/saikyo-av-web/DEBIAN/control
Normal file
10
saikyo-av-web/debian/saikyo-av-web/DEBIAN/control
Normal 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.
|
||||
8
saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums
Normal file
8
saikyo-av-web/debian/saikyo-av-web/DEBIAN/md5sums
Normal 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
|
||||
13
saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst
Executable file
13
saikyo-av-web/debian/saikyo-av-web/DEBIAN/postinst
Executable 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
|
||||
10
saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm
Executable file
10
saikyo-av-web/debian/saikyo-av-web/DEBIAN/prerm
Executable 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
|
||||
|
|
@ -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
|
||||
288
saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web
Executable file
288
saikyo-av-web/debian/saikyo-av-web/usr/sbin/saikyo-av-web/saikyo-av-web
Executable 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())
|
||||
|
|
@ -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;
|
||||
Binary file not shown.
|
|
@ -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.
|
||||
|
|
@ -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("&", "&")
|
||||
.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 = `<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();
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
}
|
||||
1
saikyo-av-web/debian/source/format
Normal file
1
saikyo-av-web/debian/source/format
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.0 (native)
|
||||
8
saikyo-av-web/desktop/saikyo-av-web.desktop
Normal file
8
saikyo-av-web/desktop/saikyo-av-web.desktop
Normal 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;
|
||||
25
saikyo-av-web/systemd/saikyo-av-web.service
Normal file
25
saikyo-av-web/systemd/saikyo-av-web.service
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user