Добавлены полные исходники 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