diff --git a/.env.example b/.env.example
index 62d961e..8f060ef 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,7 @@
-# Порт HTTP для контейнера nginx (статическая раздача index.html)
+# Порт HTTP для локального контейнера nginx (опционально)
PORT=8080
+
+# API + PostgreSQL: строка подключения к БД omc_sd_monitor (создать вручную в Postgres).
+# Хост `postgres` — имя контейнера в Docker-сети gen7x_network. Спецсимволы в пароле — URL-encode.
+# Пример: postgresql://postgres:ПАРОЛЬ@postgres:5432/omc_sd_monitor
+DATABASE_URL=
diff --git a/HISTORY.md b/HISTORY.md
index 6ff4dac..b81f121 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,17 @@
# История изменений
+## 2026-04-06 16:00 UTC – Сохранение загрузки в PostgreSQL (ключ «Название»)
+
+**Проблема:** Нужно хранить выгрузки в БД: по ключу «Название» обновлять запись или вставлять новую.
+
+**Решение:** Добавлен API на Node.js (Express + `pg`), таблица `incidents (number_key PK, data JSONB)`. После разбора файла фронт вызывает `POST /api/incidents/sync`; при открытии — `GET /api/incidents`. Nginx на `sd.gen7x.ru` проксирует `/api/` на `127.0.0.1:3910`. Контейнер `omc-sd-api` в сети `docker_gen7x_network`, БД `omc_sd_monitor` в существующем Postgres.
+
+**Изменения:** каталог `server/`, `docker-compose.yml` (сервис `api`), `.env.example`, `index.html`, `README.md`.
+
+**Проверка:** `docker compose up -d api`, `GET /api/health`, загрузка `sample-data.csv` на https://sd.gen7x.ru/.
+
+---
+
## 2026-04-06 14:00 UTC – Загрузка из Excel
**Проблема:** Нужна загрузка выгрузки в формате Excel при той же структуре колонок, что у CSV/JSON.
diff --git a/README.md b/README.md
index 72bf3e6..62579cb 100644
--- a/README.md
+++ b/README.md
@@ -2,25 +2,47 @@
**Репозиторий:** https://git.gen7x.ru/cursor-agent/omc-servicedesk-monitor
-**Публичный URL (прод):** https://sd.gen7x.ru/ — раздача через 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 (.xlsx / .xls) с той же таблицей колонок.
+Дашборд инцидентов ServiceDesk: KPI, диаграммы, таблица, разбивка по сотрудникам. Загрузка файла (CSV, JSON, Excel) **сохраняется в PostgreSQL**: ключ записи — целое число из поля **«Название»**; при совпадении ключей строка **обновляется**, иначе **добавляется**. При открытии страницы данные **подгружаются из базы** (если API доступен).
-## Запуск
+## База данных и API
-### Локально
+- БД: PostgreSQL, база `omc_sd_monitor`, таблица `incidents` (`number_key` PK, `data` JSONB с полями как в экспорте).
+- API (Node.js, Express): `GET /api/incidents`, `POST /api/incidents/sync`, `GET /api/health`.
+- Контейнер `omc-sd-api` в Docker-сети `docker_gen7x_network`, порт на хосте `127.0.0.1:3910`; Nginx проксирует `https://sd.gen7x.ru/api/` → этот порт.
-Откройте в браузере файл [`index.html`](index.html) (двойной клик или `file://`). Для быстрой проверки можно загрузить [`sample-data.csv`](sample-data.csv).
+Создание базы (один раз, от суперпользователя Postgres):
-### Docker
+```sql
+CREATE DATABASE omc_sd_monitor;
+```
+
+В `.env` задайте `DATABASE_URL` (см. `.env.example`). Спецсимволы в пароле — в URL-кодировании (например `/` → `%2F`).
+
+Запуск только API на сервере:
```bash
cd /opt/omc-servicedesk-monitor
-cp .env.example .env # при необходимости измените PORT
-docker compose up -d
+# .env с DATABASE_URL
+docker compose up -d api
```
-Страница: `http://localhost:${PORT}` (по умолчанию 8080).
+## Запуск
+
+### Локально (только UI)
+
+Откройте [`index.html`](index.html). Без API сохранение в БД недоступно (будет сообщение об ошибке после разбора файла). Пример данных: [`sample-data.csv`](sample-data.csv).
+
+### Docker (статика + опционально API)
+
+```bash
+cd /opt/omc-servicedesk-monitor
+cp .env.example .env # задать DATABASE_URL для API
+docker compose up -d # web на PORT, api на 3910
+```
+
+Страница статики: `http://localhost:${PORT}` (по умолчанию 8080).
## Формат данных
@@ -30,7 +52,7 @@ docker compose up -d
| Колонка | Описание |
|---------|----------|
-| Название | Номер заявки (число) |
+| Название | Номер заявки (число), ключ в БД |
| Статус | Код статуса (см. бейджи в интерфейсе) |
| Ответственный (команда) | Команда |
| Ответственный (сотрудник) | Исполнитель |
@@ -42,28 +64,22 @@ docker compose up -d
| Кем решен (сотрудник) | Непустое значение трактуется как закрытый инцидент |
| Уникальный идентификатор | UUID для ссылки в ServiceDesk |
-### JSON
+### JSON / Excel
-Массив объектов с теми же ключами полей, что и в CSV.
-
-### Excel
-
-Первый лист книги; первая строка — заголовки, совпадающие с колонками CSV (те же русские названия). Поддерживаются `.xlsx` и устаревший `.xls`. Разбор в браузере через SheetJS (`xlsx`, CDN).
+Те же поля (русские названия колонок). Excel: первый лист, первая строка — заголовки.
## Недельная динамика
-График «хронология по неделям» и таблица под ним заполняются константой `WEEK_STATS` внутри `index.html` и **не** зависят от загружаемого файла. Обновляйте массив вручную при необходимости.
+График по неделям и таблица под ним задаются константой `WEEK_STATS` в `index.html`, не из БД.
## Стек
-- Chart.js (CDN)
-- PapaParse 5.4.1 (CDN)
-- SheetJS xlsx 0.18.5 (CDN) — Excel
-- Шрифты: Barlow Condensed, IBM Plex Sans, IBM Plex Mono (Google Fonts)
+- Фронт: Chart.js, PapaParse, SheetJS xlsx, шрифты Google Fonts.
+- Бэк: Node 20, `express`, `pg` (каталог `server/`).
## Логи и бэкапы
-Статический файл; логи приложения отсутствуют. Резервное копирование — копия каталога проекта или репозитория Git.
+Логи API: `docker logs omc-sd-api`. Данные инцидентов — в PostgreSQL (общий бэкап кластера). Статика — файлы в каталоге проекта.
## История изменений
diff --git a/docker-compose.yml b/docker-compose.yml
index c992ddc..bd76ef0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,3 +5,21 @@ services:
- "${PORT:-8080}:80"
volumes:
- ./:/usr/share/nginx/html:ro
+
+ api:
+ build: ./server
+ container_name: omc-sd-api
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ PORT: "3910"
+ ports:
+ - "127.0.0.1:3910:3910"
+ networks:
+ - gen7x_network
+
+networks:
+ gen7x_network:
+ external: true
+ name: docker_gen7x_network
diff --git a/index.html b/index.html
index 58352f8..0ffc6fd 100644
--- a/index.html
+++ b/index.html
@@ -965,7 +965,7 @@
Подсистема
Мониторинг инцидентов
- Формат загрузки: CSV · JSON · Excel
+ Файл: CSV · JSON · Excel · сохранение в PostgreSQL по ключу «Название»
@@ -2022,6 +2022,36 @@
applyFilters();
}
+ 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 tryLoadFromDb() {
+ fetch("/api/incidents")
+ .then(function (r) {
+ if (!r.ok) return null;
+ return r.json();
+ })
+ .then(function (j) {
+ if (!j || !j.rows || !j.rows.length) return;
+ processData(j.rows);
+ el("success").classList.add("show");
+ el("success").textContent = "✓ Загружено из базы: " + j.rows.length + " записей";
+ })
+ .catch(function () { /* API недоступен — работа только с файлом */ });
+ }
+
function parseExcelToRows(buffer) {
if (typeof XLSX === "undefined") {
throw new Error("Библиотека Excel не загружена (XLSX). Проверьте сеть и CDN.");
@@ -2052,10 +2082,26 @@
function finish(rows) {
if (!rows.length) throw new Error("Нет строк данных");
- processData(rows);
- el("loading").classList.remove("show");
- el("success").classList.add("show");
- el("success").textContent = "✓ Загружено записей: " + rows.length;
+ el("loading").textContent = "Сохранение в базу данных…";
+ syncRowsToServer(rows)
+ .then(function (j) {
+ processData(rows);
+ el("loading").classList.remove("show");
+ el("error").classList.remove("show");
+ el("success").classList.add("show");
+ var msg = "✓ В базе сохранено: " + j.applied + " из " + rows.length;
+ if (j.skipped) msg += " (без ключа «Название»: " + j.skipped + ")";
+ el("success").textContent = msg;
+ })
+ .catch(function (err) {
+ processData(rows);
+ 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));
+ });
}
function onFail(err) {
@@ -2141,6 +2187,7 @@
controlDate = startOfDay(new Date());
setControlInput();
setupTabs();
+ tryLoadFromDb();
})();