feat(detmir): deploy read-only operator portal
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f6f8;
|
||||
--panel: #ffffff;
|
||||
--ink: #172026;
|
||||
--muted: #5d6872;
|
||||
--line: #d8dee5;
|
||||
--ok: #167347;
|
||||
--ok-bg: #e5f4ec;
|
||||
--warn: #9a6500;
|
||||
--warn-bg: #fff2cc;
|
||||
--fail: #b42318;
|
||||
--fail-bg: #fde7e5;
|
||||
--unknown: #59636e;
|
||||
--unknown-bg: #e9edf1;
|
||||
--link: #075985;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.is-active {
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.content-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px;
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card h3,
|
||||
.section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-pill,
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-ok { background: var(--ok-bg); color: var(--ok); }
|
||||
.status-warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.status-fail { background: var(--fail-bg); color: var(--fail); }
|
||||
.status-unknown { background: var(--unknown-bg); color: var(--unknown); }
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 1fr) minmax(160px, 2fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell { padding: 14px; }
|
||||
.topbar { flex-direction: column; }
|
||||
h1 { font-size: 24px; }
|
||||
.row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
const state = { tab: "operator", links: null };
|
||||
|
||||
function apiBase() {
|
||||
const path = window.location.pathname;
|
||||
return path.startsWith("/portal") ? "/portal/api" : "/api";
|
||||
}
|
||||
|
||||
async function loadJson(path) {
|
||||
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
const s = String(status || "UNKNOWN").toLowerCase();
|
||||
if (s === "ok" || s === "true") return "status-ok";
|
||||
if (s === "warn" || s === "warning") return "status-warn";
|
||||
if (s === "fail" || s === "false") return "status-fail";
|
||||
return "status-unknown";
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
const global = document.getElementById("globalStatus");
|
||||
global.className = `status-pill ${statusClass(summary.severity)}`;
|
||||
global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
|
||||
const blocks = Object.entries(summary.blocks || {});
|
||||
document.getElementById("summary").innerHTML = blocks.map(([name, block]) => `
|
||||
<article class="card">
|
||||
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
|
||||
<h3>${escapeHtml(label(name))}</h3>
|
||||
<p class="muted">${escapeHtml(block.text)}</p>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function label(name) {
|
||||
return {
|
||||
collection: "Сбор данных",
|
||||
grafana: "Grafana",
|
||||
dlp: "DLP",
|
||||
worktime: "Работа сегодня",
|
||||
one_c: "1С"
|
||||
}[name] || name;
|
||||
}
|
||||
|
||||
function renderLinks(links) {
|
||||
return `<div class="links">
|
||||
<a class="button" href="${links.detmir_activitywatch}">DetMir ActivityWatch</a>
|
||||
<a class="button" href="${links.grafana_dashboards}">Grafana</a>
|
||||
<a class="button" href="${links.aw_ui}">AW UI</a>
|
||||
<a class="button" href="${links.worktime_report}">Worktime</a>
|
||||
<a class="button" href="${links.file1c_brief}">1C brief</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSourceList(data) {
|
||||
const sources = [
|
||||
["DetMir", data.detmir_status],
|
||||
["Проверки", data.detmir_check],
|
||||
["Systemd", data.failed_units],
|
||||
["Grafana data", data.grafana_data]
|
||||
];
|
||||
return `<div class="list">${sources.map(([name, source]) => `
|
||||
<div class="row">
|
||||
<strong>${escapeHtml(name)}</strong>
|
||||
<span class="muted">${escapeHtml(source?.summary || source?.error || "нет данных")}</span>
|
||||
<span class="badge ${statusClass(source?.status || source?.ok)}">${escapeHtml(source?.status || (source?.ok ? "OK" : "FAIL"))}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderOperator(data) {
|
||||
return `
|
||||
<h2 class="section-title">Оператор</h2>
|
||||
<div class="grid-2">
|
||||
<section class="card">
|
||||
<h3>Контур</h3>
|
||||
${renderSourceList(data)}
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>Быстрые переходы</h3>
|
||||
${renderLinks(data.links)}
|
||||
</section>
|
||||
</div>
|
||||
<h3 class="section-title">Проблемы</h3>
|
||||
${renderIncidentsList(data.incidents)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderManager(data) {
|
||||
return `
|
||||
<h2 class="section-title">Руководитель</h2>
|
||||
<div class="grid-2">
|
||||
<section class="card">
|
||||
<h3>Работа сегодня</h3>
|
||||
<p class="muted">Сотрудников: ${data.users_count}; активных часов: ${Number(data.total_active_hours || 0).toFixed(1)}</p>
|
||||
<p class="muted">${escapeHtml(data.status?.text || "")}</p>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>Приложения</h3>
|
||||
<div class="list">${(data.applications || []).slice(0, 8).map(app => `
|
||||
<div class="row">
|
||||
<strong>${escapeHtml(app.application)}</strong>
|
||||
<span class="muted">${escapeHtml(app.proved_work_human || "")}</span>
|
||||
<span class="badge status-ok">${escapeHtml(app.evidence_events || 0)}</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
</section>
|
||||
</div>
|
||||
<h3 class="section-title">Сотрудники</h3>
|
||||
<div class="list">${(data.users || []).map(user => `
|
||||
<div class="row">
|
||||
<strong>${escapeHtml(user.user)}</strong>
|
||||
<span class="muted">Активно: ${escapeHtml(user.active_hhmm || "00:00")} · последнее: ${escapeHtml(user.last_activity || "-")}</span>
|
||||
<span class="badge status-ok">${escapeHtml(user.sessions_count || 0)} сесс.</span>
|
||||
</div>
|
||||
`).join("")}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderOwner(data) {
|
||||
const cards = Object.entries(data.cards || {});
|
||||
return `
|
||||
<h2 class="section-title">Владелец</h2>
|
||||
<div class="summary-grid">${cards.map(([name, block]) => `
|
||||
<article class="card">
|
||||
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
|
||||
<h3>${escapeHtml(label(name))}</h3>
|
||||
<p class="muted">${escapeHtml(block.text)}</p>
|
||||
</article>
|
||||
`).join("")}</div>
|
||||
<section class="card">
|
||||
<h3>Что сделать</h3>
|
||||
<div class="list">${(data.recommendations || []).map(item => `<div class="row"><strong>Рекомендация</strong><span class="muted">${escapeHtml(item)}</span><span></span></div>`).join("")}</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>Переходы</h3>
|
||||
${renderLinks(data.links)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderIncidentsList(items) {
|
||||
if (!items || items.length === 0) return `<p class="muted">Активных проблем нет.</p>`;
|
||||
return `<div class="list">${items.map(item => `
|
||||
<div class="row">
|
||||
<strong>${escapeHtml(item.source)}</strong>
|
||||
<span class="muted">${escapeHtml(item.summary)}</span>
|
||||
<span class="badge ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
`).join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderIncidents(data) {
|
||||
return `
|
||||
<h2 class="section-title">Инциденты</h2>
|
||||
${renderIncidentsList(data)}
|
||||
`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const summary = await loadJson("/summary");
|
||||
renderSummary(summary);
|
||||
const content = document.getElementById("content");
|
||||
const data = await loadJson(`/${state.tab}`);
|
||||
if (state.tab === "operator") content.innerHTML = renderOperator(data);
|
||||
if (state.tab === "manager") content.innerHTML = renderManager(data);
|
||||
if (state.tab === "owner") content.innerHTML = renderOwner(data);
|
||||
if (state.tab === "incidents") content.innerHTML = renderIncidents(data);
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
state.tab = tab;
|
||||
document.querySelectorAll(".tab").forEach(btn => {
|
||||
btn.classList.toggle("is-active", btn.dataset.tab === tab);
|
||||
});
|
||||
document.getElementById("content").innerHTML = `<p class="muted">Загрузка...</p>`;
|
||||
refresh().catch(showError);
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
document.getElementById("content").innerHTML = `<pre>${escapeHtml(error.stack || error.message || error)}</pre>`;
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab").forEach(btn => {
|
||||
btn.addEventListener("click", () => setTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
refresh().catch(showError);
|
||||
setInterval(() => refresh().catch(showError), 60000);
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DetMir Portal</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">DetMir Portal</p>
|
||||
<h1>Контур компании</h1>
|
||||
</div>
|
||||
<div id="globalStatus" class="status-pill status-unknown">Загрузка</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="Разделы портала">
|
||||
<button class="tab is-active" data-tab="operator">Оператор</button>
|
||||
<button class="tab" data-tab="manager">Руководитель</button>
|
||||
<button class="tab" data-tab="owner">Владелец</button>
|
||||
<button class="tab" data-tab="incidents">Инциденты</button>
|
||||
</nav>
|
||||
|
||||
<section id="summary" class="summary-grid"></section>
|
||||
<section id="content" class="content-panel" aria-live="polite"></section>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user