Feature: отдельная страница data.html, общие css и upload-core.js

Made-with: Cursor
This commit is contained in:
cursor-agent
2026-04-06 09:33:33 +00:00
parent 6f84a178d4
commit f742f52f83
6 changed files with 1338 additions and 1057 deletions

View File

@@ -1,5 +1,15 @@
# История изменений
## 2026-04-06 18:00 UTC Многостраничный UI: загрузка на data.html
**Проблема:** Источник данных и кнопка загрузки должны быть на отдельной странице; на ней же — сводка по числу объектов в БД и по последней загрузке.
**Решение:** Вынесены стили в `css/dashboard.css`; добавлены `data.html` (загрузка, блоки статистики, `upload-core.js`) и навигация в шапке между `index.html` и `data.html`. С главной убраны PapaParse/XLSX и логика выбора файла.
**Проверка:** Открытие `/data.html`, загрузка `sample-data.csv`, проверка счётчиков и переход на мониторинг.
---
## 2026-04-06 17:00 UTC Проверка docker compose api и Nginx для /api/
**Проблема:** Запросы к `https://sd.gen7x.ru/api/health` отдавали 404: регекс статики `~* \.(json|…)$` перехватывал URI раньше префикса `/api/`.

View File

@@ -4,7 +4,16 @@
**Публичный URL (прод):** https://sd.gen7x.ru/ — статика и API через Nginx (`/opt/gen7x/nginx/conf.d/sd.gen7x.ru.conf`).
Дашборд инцидентов ServiceDesk: KPI, диаграммы, таблица, разбивка по сотрудникам. Загрузка файла (CSV, JSON, Excel) **сохраняется в PostgreSQL**: ключ записи — целое число из поля **«Название»**; при совпадении ключей строка **обновляется**, иначе **добавляется**. При открытии страницы данные **подгружаются из базы** (если API доступен).
Многостраничный интерфейс:
| Страница | Назначение |
|----------|------------|
| [`index.html`](index.html) | Мониторинг: KPI, фильтры, вкладки (графики, таблица, сотрудники). Данные из БД при открытии. |
| [`data.html`](data.html) | Источник данных: загрузка CSV/JSON/Excel, счётчики «объектов в базе», последней загрузки, пропущенных строк. |
Общие стили: [`css/dashboard.css`](css/dashboard.css). Разбор файла и API: [`js/upload-core.js`](js/upload-core.js).
Загрузка файла **сохраняется в PostgreSQL** по ключу **«Название»** (обновление / вставка). На главной данные **подгружаются из базы** при открытии (если API доступен).
## База данных и API
@@ -32,7 +41,7 @@ docker compose up -d api
### Локально (только UI)
Откройте [`index.html`](index.html). Без API сохранение в БД недоступно (будет сообщение об ошибке после разбора файла). Пример данных: [`sample-data.csv`](sample-data.csv).
Откройте [`index.html`](index.html) (мониторинг) или [`data.html`](data.html) (загрузка). Без API сохранение в БД на странице данных недоступно. Пример файла: [`sample-data.csv`](sample-data.csv).
### Docker (статика + опционально API)

1006
css/dashboard.css Normal file

File diff suppressed because it is too large Load Diff

210
data.html Normal file
View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Источник данных — ServiceDesk Monitor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<link rel="stylesheet" href="css/dashboard.css" />
</head>
<body>
<div class="page">
<header class="topbar">
<div class="topbar-brand">
<div class="brand-hex" aria-hidden="true"></div>
<div>
<div class="brand-text-main">ServiceDesk Monitor</div>
<div class="brand-text-sub">OMC · Ситуационный центр</div>
</div>
</div>
<nav class="topbar-nav" aria-label="Разделы">
<a href="index.html" class="nav-link">Мониторинг</a>
<a href="data.html" class="nav-link active">Источник данных</a>
</nav>
<div class="topbar-seg">
<span class="seg-label">Статус</span>
<span class="seg-value"><span class="status-pip pip-green" aria-hidden="true"></span> АКТИВЕН</span>
</div>
<div class="topbar-seg">
<span class="seg-label">В базе</span>
<span class="seg-value" id="statusbar-count"></span>
</div>
<div class="topbar-seg">
<span class="seg-label">Дата системы</span>
<span class="seg-value" id="current-date-display"></span>
</div>
</header>
<div class="section-head">
<span class="section-head-label">Подсистема</span>
<span class="section-head-title">Источник данных</span>
<span class="section-head-rule"></span>
<span class="section-head-info">CSV · JSON · Excel → PostgreSQL, ключ «Название»</span>
</div>
<main class="main">
<div class="data-stats-row">
<div class="data-stat-card">
<div class="ds-val" id="db-count"></div>
<div class="ds-lbl">Объектов в базе</div>
<div class="ds-sub" id="db-count-sub">GET /api/incidents</div>
</div>
<div class="data-stat-card">
<div class="ds-val" id="last-applied"></div>
<div class="ds-lbl">Сохранено при последней загрузке</div>
<div class="ds-sub" id="last-upload-sub"></div>
</div>
<div class="data-stat-card">
<div class="ds-val ds-warn" id="last-skipped"></div>
<div class="ds-lbl">Пропущено (нет «Название»)</div>
<div class="ds-sub" id="last-total">Строк в файле: —</div>
</div>
</div>
<div class="upload-panel">
<span class="lbl">Загрузка файла</span>
<button type="button" class="btn-upload" id="btn-upload">↑ Выбрать файл</button>
<span class="upload-hint" id="upload-hint">CSV, JSON или Excel — те же колонки, что в экспорте ServiceDesk</span>
<input type="file" id="file-input" accept=".csv,.json,.xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel" hidden />
</div>
<div id="success" class="msg msg-ok"></div>
<div id="loading" class="msg msg-load"></div>
<div id="error" class="msg msg-err"></div>
<div class="data-info-panel">
<strong>Справка</strong>
После разбора файл отправляется в API; для каждой строки ключ — целое число из поля «Название»: существующая запись обновляется, новая добавляется.
Откройте <a href="index.html" style="color:var(--sky);font-weight:600;">мониторинг</a> для графиков и таблицы.
</div>
</main>
</div>
<script src="js/upload-core.js"></script>
<script>
(function () {
var el = function (id) { return document.getElementById(id); };
function pad(n) { return n < 10 ? "0" + n : String(n); }
function formatSystemDate() {
var d = new Date();
return pad(d.getDate()) + "." + pad(d.getMonth() + 1) + "." + d.getFullYear() + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
}
function refreshDbCount() {
if (!window.OmcUpload) return;
OmcUpload.fetchIncidents()
.then(function (j) {
if (!j || !j.rows) {
el("db-count").textContent = "—";
el("statusbar-count").textContent = "—";
return;
}
var n = j.rows.length;
el("db-count").textContent = n;
el("statusbar-count").textContent = String(n);
})
.catch(function () {
el("db-count").textContent = "—";
el("statusbar-count").textContent = "—";
});
}
function readLastUpload() {
try {
var s = sessionStorage.getItem("omc_last_upload");
if (!s) return null;
return JSON.parse(s);
} catch (e) {
return null;
}
}
function showLastUpload() {
var x = readLastUpload();
if (!x) {
el("last-applied").textContent = "—";
el("last-skipped").textContent = "—";
el("last-total").textContent = "Строк в файле: —";
el("last-upload-sub").textContent = "";
return;
}
el("last-applied").textContent = x.applied != null ? x.applied : "—";
el("last-skipped").textContent = x.skipped != null ? x.skipped : "0";
el("last-total").textContent = "Строк в файле: " + (x.total != null ? x.total : "—");
if (x.at) {
var d = new Date(x.at);
el("last-upload-sub").textContent =
"Время: " + pad(d.getDate()) + "." + pad(d.getMonth() + 1) + "." + d.getFullYear() + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
}
}
function handleFile(file) {
if (!file) return;
el("upload-hint").textContent = file.name;
el("success").classList.remove("show");
el("error").classList.remove("show");
el("loading").classList.add("show");
el("loading").textContent = "Разбор файла…";
OmcUpload.parseFileToRows(file)
.then(function (rows) {
if (!rows.length) throw new Error("Нет строк данных");
el("loading").textContent = "Сохранение в базу данных…";
return OmcUpload.syncRowsToServer(rows).then(function (j) {
return { j: j, rows: rows };
});
})
.then(function (pack) {
var j = pack.j;
var rows = pack.rows;
sessionStorage.setItem(
"omc_last_upload",
JSON.stringify({
applied: j.applied,
skipped: j.skipped,
total: rows.length,
at: new Date().toISOString()
})
);
refreshDbCount();
showLastUpload();
el("loading").classList.remove("show");
el("error").classList.remove("show");
el("success").classList.add("show");
el("success").textContent =
"✓ В базе сохранено: " + j.applied + " из " + rows.length + " строк" +
(j.skipped ? " (пропущено без «Название»: " + j.skipped + ")" : "");
})
.catch(function (err) {
el("loading").classList.remove("show");
el("success").classList.remove("show");
el("error").classList.add("show");
el("error").textContent = "✗ " + (err && err.message ? err.message : String(err));
});
}
el("btn-upload").addEventListener("click", function () {
el("file-input").click();
});
el("file-input").addEventListener("change", function () {
var f = el("file-input").files[0];
if (f) handleFile(f);
});
function tickClock() {
el("current-date-display").textContent = formatSystemDate();
}
tickClock();
setInterval(tickClock, 30000);
refreshDbCount();
showLastUpload();
})();
</script>
</body>
</html>

1062
index.html

File diff suppressed because it is too large Load Diff

94
js/upload-core.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* Разбор файла (CSV / JSON / Excel) и синхронизация с API.
* Зависимости: глобальные Papa, XLSX (CDN на странице).
*/
(function (global) {
function parseExcelToRows(buffer) {
if (typeof XLSX === "undefined") {
throw new Error("Библиотека Excel не загружена (XLSX). Проверьте сеть и CDN.");
}
const wb = XLSX.read(buffer, { type: "array" });
if (!wb.SheetNames || !wb.SheetNames.length) {
throw new Error("В книге Excel нет листов");
}
const ws = wb.Sheets[wb.SheetNames[0]];
return XLSX.utils.sheet_to_json(ws, {
defval: "",
raw: false,
blankrows: false
});
}
function parseFileToRows(file) {
return new Promise(function (resolve, reject) {
const lower = file.name.toLowerCase();
const isExcel = lower.endsWith(".xlsx") || lower.endsWith(".xls");
if (isExcel) {
const reader = new FileReader();
reader.onload = function (ev) {
try {
resolve(parseExcelToRows(new Uint8Array(ev.target.result)));
} catch (e) {
reject(e);
}
};
reader.onerror = function () {
reject(new Error("Не удалось прочитать файл"));
};
reader.readAsArrayBuffer(file);
return;
}
const reader = new FileReader();
reader.onload = function (ev) {
try {
const text = String(ev.target.result || "");
let rows;
if (lower.endsWith(".json")) {
const data = JSON.parse(text);
if (!Array.isArray(data)) throw new Error("JSON должен быть массивом объектов");
rows = data;
} else {
const parsed = Papa.parse(text, { header: true, skipEmptyLines: true });
if (parsed.errors && parsed.errors.length) console.warn(parsed.errors);
rows = parsed.data || [];
}
resolve(rows);
} catch (e) {
reject(e);
}
};
reader.onerror = function () {
reject(new Error("Не удалось прочитать файл"));
};
reader.readAsText(file, "UTF-8");
});
}
function syncRowsToServer(rows) {
return fetch("/api/incidents/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rows: rows })
}).then(function (r) {
if (!r.ok) {
return r.text().then(function (t) {
throw new Error(t || "HTTP " + r.status);
});
}
return r.json();
});
}
function fetchIncidents() {
return fetch("/api/incidents").then(function (r) {
if (!r.ok) return null;
return r.json();
});
}
global.OmcUpload = {
parseFileToRows: parseFileToRows,
syncRowsToServer: syncRowsToServer,
fetchIncidents: fetchIncidents
};
})(typeof window !== "undefined" ? window : this);