Feature: отдельная страница data.html, общие css и upload-core.js
Made-with: Cursor
This commit is contained in:
10
HISTORY.md
10
HISTORY.md
@@ -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/
|
## 2026-04-06 17:00 UTC – Проверка docker compose api и Nginx для /api/
|
||||||
|
|
||||||
**Проблема:** Запросы к `https://sd.gen7x.ru/api/health` отдавали 404: регекс статики `~* \.(json|…)$` перехватывал URI раньше префикса `/api/`.
|
**Проблема:** Запросы к `https://sd.gen7x.ru/api/health` отдавали 404: регекс статики `~* \.(json|…)$` перехватывал URI раньше префикса `/api/`.
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -4,7 +4,16 @@
|
|||||||
|
|
||||||
**Публичный URL (прод):** https://sd.gen7x.ru/ — статика и API через Nginx (`/opt/gen7x/nginx/conf.d/sd.gen7x.ru.conf`).
|
**Публичный 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
|
## База данных и API
|
||||||
|
|
||||||
@@ -32,7 +41,7 @@ docker compose up -d api
|
|||||||
|
|
||||||
### Локально (только UI)
|
### Локально (только 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)
|
### Docker (статика + опционально API)
|
||||||
|
|
||||||
|
|||||||
1006
css/dashboard.css
Normal file
1006
css/dashboard.css
Normal file
File diff suppressed because it is too large
Load Diff
210
data.html
Normal file
210
data.html
Normal 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
1062
index.html
File diff suppressed because it is too large
Load Diff
94
js/upload-core.js
Normal file
94
js/upload-core.js
Normal 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);
|
||||||
Reference in New Issue
Block a user