Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
042407f03e | ||
|
|
b2cf71fcc7 | ||
|
|
fda7f353ad |
No files matched your search
@@ -57,16 +57,31 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
docker rm -f "$NAME" mm-pg 2>/dev/null || true
|
docker rm -f "$NAME" 2>/dev/null || true
|
||||||
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
# Docker-backed Gitea runners execute this step in a separate container.
|
||||||
|
# Join its network; a published host loopback port is not reachable here.
|
||||||
|
NETWORKS=$(docker inspect --format '{{range $name, $network := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$(cat /etc/hostname)" 2>/dev/null || true)
|
||||||
|
if [ -n "$NETWORKS" ]; then
|
||||||
|
test "$(printf '%s\n' "$NETWORKS" | wc -l)" -eq 1
|
||||||
|
NETWORK_ARGS=(--network "$NETWORKS" --network-alias "$NAME")
|
||||||
|
PG_HOST="$NAME"
|
||||||
|
PG_PORT=5432
|
||||||
|
else
|
||||||
|
# Host-based runners can use a Docker-assigned ephemeral host port.
|
||||||
|
NETWORK_ARGS=(-p 127.0.0.1::5432)
|
||||||
|
PG_HOST=127.0.0.1
|
||||||
|
fi
|
||||||
docker run -d --name "$NAME" --rm \
|
docker run -d --name "$NAME" --rm \
|
||||||
-e POSTGRES_USER=mmapp \
|
-e POSTGRES_USER=mmapp \
|
||||||
-e POSTGRES_PASSWORD=mmapp \
|
-e POSTGRES_PASSWORD=mmapp \
|
||||||
-e POSTGRES_DB=mmapp \
|
-e POSTGRES_DB=mmapp \
|
||||||
-p "127.0.0.1:${HOST_PORT}:5432" \
|
"${NETWORK_ARGS[@]}" \
|
||||||
postgres:18-alpine
|
postgres:18-alpine
|
||||||
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
||||||
echo "DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}"
|
if [ "$PG_HOST" = 127.0.0.1 ]; then
|
||||||
|
PG_PORT=$(docker port "$NAME" 5432/tcp | awk -F: '{print $NF}')
|
||||||
|
fi
|
||||||
|
echo "DATABASE_URL=postgres://mmapp:mmapp@${PG_HOST}:${PG_PORT}/mmapp" >> "${GITHUB_ENV}"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
||||||
exit 0
|
exit 0
|
||||||
@@ -79,15 +94,27 @@ jobs:
|
|||||||
|
|
||||||
- name: Install and test backend
|
- name: Install and test backend
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUIRE_TEST_POSTGRES: "1"
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
npm ci
|
npm ci
|
||||||
|
node --input-type=module <<'NODE'
|
||||||
|
import pg from 'pg';
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 8000 });
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
console.log('PostgreSQL reachable from the test process');
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
NODE
|
||||||
npm run test --prefix backend
|
npm run test --prefix backend
|
||||||
|
|
||||||
- name: Stop PostgreSQL
|
- name: Stop PostgreSQL
|
||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true
|
run: docker rm -f "${PG_CONTAINER:-}" 2>/dev/null || true
|
||||||
|
|
||||||
backend-image:
|
backend-image:
|
||||||
needs:
|
needs:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Retention runs after each scheduler receipt; avoid scanning the complete history.
|
||||||
|
-- On a busy existing installation, pre-create this same index CONCURRENTLY
|
||||||
|
-- outside a transaction before deploying (deploy/PRODUCTION-RETENTION-INDEX.md).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_finished_at ON scheduler_runs (finished_at);
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.test.ts && tsx src/services/traffic-membership.test.ts && tsx src/services/traffic-aggregate-peak.test.ts && tsx src/services/traffic-users-cache.test.ts && tsx src/services/traffic-collection-lock.test.ts && tsx src/services/traffic-concurrency-sql.test.ts",
|
||||||
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
|
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
|
||||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
|
||||||
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
|
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/db/scheduler-retention-index.test.ts && tsx src/services/config-revisions.test.ts",
|
||||||
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.test.ts",
|
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.test.ts",
|
||||||
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
|
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
|
||||||
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
|
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.test.ts",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import pg from "pg"
|
||||||
|
import { env } from "../config.js"
|
||||||
|
|
||||||
|
// No bootstrap/migrations against public tables: this test needs only a connection.
|
||||||
|
const pool = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 8000, statement_timeout: 10000 })
|
||||||
|
try {
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN")
|
||||||
|
// Temporary shadow table: public scheduler history is never changed.
|
||||||
|
await client.query("CREATE TEMP TABLE scheduler_runs(id integer PRIMARY KEY, finished_at timestamptz NOT NULL) ON COMMIT DROP")
|
||||||
|
await client.query(`INSERT INTO scheduler_runs SELECT i, now() - CASE WHEN i=1 THEN interval '60 days' ELSE i * interval '1 second' END FROM generate_series(1,20000) AS i`)
|
||||||
|
await client.query("ANALYZE scheduler_runs")
|
||||||
|
const cutoff = new Date(Date.now() - 30 * 86400_000).toISOString()
|
||||||
|
const plan = async () => JSON.stringify((await client.query("EXPLAIN (FORMAT JSON) SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows)
|
||||||
|
assert.match(await plan(), /Seq Scan/)
|
||||||
|
const migration = readFileSync(new URL("../../drizzle/0013_scheduler_retention_index.sql", import.meta.url), "utf8")
|
||||||
|
await client.query(migration)
|
||||||
|
await client.query(migration)
|
||||||
|
assert.match(await plan(), /Index Scan|Bitmap Index Scan/)
|
||||||
|
assert.deepEqual((await client.query("SELECT id FROM scheduler_runs WHERE finished_at < $1", [cutoff])).rows, [{ id: 1 }])
|
||||||
|
const index = (await client.query("SELECT indisvalid FROM pg_index WHERE indrelid = 'pg_temp.scheduler_runs'::regclass AND indexrelid::regclass::text LIKE '%idx_scheduler_runs_finished_at'")).rows
|
||||||
|
assert.deepEqual(index, [{ indisvalid: true }])
|
||||||
|
console.log("Scheduler retention: actual migration/idempotence/valid index/selective plan/result preservation passed on 20,000 temporary rows")
|
||||||
|
} finally {
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
@@ -649,6 +649,7 @@ export const schedulerRuns = pgTable("scheduler_runs", {
|
|||||||
resultJson: jsonb("result_json"),
|
resultJson: jsonb("result_json"),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt),
|
index("idx_scheduler_runs_job_time").on(t.jobKey, t.startedAt),
|
||||||
|
index("idx_scheduler_runs_finished_at").on(t.finishedAt),
|
||||||
])
|
])
|
||||||
|
|
||||||
export const events = pgTable("events", {
|
export const events = pgTable("events", {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function withPgOrSkip(): Promise<boolean> {
|
|||||||
await initDatabase()
|
await initDatabase()
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (process.env.REQUIRE_TEST_POSTGRES === "1") throw err
|
||||||
console.warn(
|
console.warn(
|
||||||
"skip: PostgreSQL недоступен",
|
"skip: PostgreSQL недоступен",
|
||||||
err instanceof Error ? err.message : err,
|
err instanceof Error ? err.message : err,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Индекс истории планировщика — 2026-10-15
|
||||||
|
|
||||||
|
Диагностика production: scheduler_runs около1.06M строк/853МБ, retention DELETE после каждого задания, индекса finished_at нет. Наблюдалось ожидание IO/AioIoCompletion; EXPLAIN эквивалентного SELECT показал полный скан. Это подтверждает лишнюю нагрузку БД, но не доказывает причину каждого тайм-аута RouterOS.
|
||||||
|
|
||||||
|
Миграция0013 создаёт индекс для новых установок и последующего старта backend. Для работающей установки ниже приведён отдельный online-вариант. **На production выполнен 2026-10-15, запуск в14:55:45 UTC, после разрешения владельца только на индекс и проверку нагрузки.** Создание индекса изменяет БД и временно добавляет нагрузку на CPU/диск.
|
||||||
|
|
||||||
|
Подключение psql к текущему mmapp-postgres, без вывода пароля:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it mmapp-postgres sh -c 'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Сначала проверить, что имя не занято другим или невалидным индексом:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT indexrelid::regclass, indisvalid, indisready, pg_get_indexdef(indexrelid)
|
||||||
|
FROM pg_index WHERE indexrelid = to_regclass('public.idx_scheduler_runs_finished_at');
|
||||||
|
```
|
||||||
|
|
||||||
|
Если индекс отсутствует, выполнить **вне BEGIN/транзакции**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SET lock_timeout = '5s';
|
||||||
|
SET statement_timeout = '10min';
|
||||||
|
CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at
|
||||||
|
ON public.scheduler_runs (finished_at);
|
||||||
|
RESET statement_timeout;
|
||||||
|
RESET lock_timeout;
|
||||||
|
```
|
||||||
|
|
||||||
|
При прерывании concurrent build не повторять вслепую с IF NOT EXISTS: сначала проверить indisvalid/indisready. Если остался невалидный индекс, отдельно согласовать его удаление и повторное построение.
|
||||||
|
|
||||||
|
После успеха повторить проверку определения и валидности индекса, снять EXPLAIN условия retention и сравнить CPU/IO в нескольких окнах. При очень большой доле устаревших строк PostgreSQL может обоснованно выбрать Seq Scan; не отключать его принудительно. Срок хранения30 дней и код удаления не меняются. Индекс не удаляет записи и не требует перезапуска контейнеров.
|
||||||
|
|
||||||
|
Удаление индекса, перезапуски и изменение ресурсов не входят в эту процедуру. Исправление карты — отдельный frontend-патч и само этим SQL не разворачивается.
|
||||||
|
|
||||||
|
## Результат применения (2026-10-15)
|
||||||
|
|
||||||
|
CREATE INDEX CONCURRENTLY завершён успешно; indisvalid/indisready=true. Повторная проверка в15:01:30 UTC:23МБ,697 использований индекса. EXPLAIN эквивалентного SELECT retention: Bitmap Heap Scan → Bitmap Index Scan idx_scheduler_runs_finished_at вместо Parallel Seq Scan. EXPLAIN ANALYZE DELETE не запускался.
|
||||||
|
|
||||||
|
| Измерение | До индекса | После,14:56 UTC | После,15:00 UTC |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| CPU контейнера PostgreSQL, docker stats | 66,51% | 0,16% | 0,13% |
|
||||||
|
| CPU idle сервера, секундные vmstat | 0% | 74–94% | 94–97% |
|
||||||
|
| CPU iowait сервера, секундные vmstat | 29–34% | 0–5% | 0–1% |
|
||||||
|
| IO pressure some avg10 | 63,67% | 1,70% | 0,48% |
|
||||||
|
|
||||||
|
Первая строка vmstat (среднее с загрузки ОС) исключена из сравнения. Замеры показывают устойчивое улучшение в пределах нескольких минут обычной работы, а не нагрузочную приёмку или гарантию отсутствия всех тайм-аутов. PostgreSQL/контейнеры не перезапускались; ручное удаление данных, изменение retention и параметров приложения не выполнялись. Действующий штатный retention продолжал работать.
|
||||||
|
|
||||||
|
Автоматическая проверка фактического SQL миграции выполнена на отдельном тестовом Debian в контейнере mmapp-test-backend:20тыс. временных строк, повторный запуск, валидность индекса, смена плана и сохранение результата выборки. Тест не запускает bootstrap и не изменяет public-таблицы. Backend build и scoped ESLint прошли. [Структурированный протокол](../docs/ux-ui/acceptance/prod266-retention.json).
|
||||||
+19
-1
@@ -1,6 +1,6 @@
|
|||||||
# UX/UI: журнал реализации
|
# UX/UI: журнал реализации
|
||||||
|
|
||||||
Актуальное состояние 2026-10-15: 264 — лабораторная приёмка основного UX/UI-плана завершена в документированных границах стенда. Приняты1396 основных UI и221 отдельных context-сценарий;120 live default states30 экранов. Runtime backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Публикация main/2.0.0 отдельно разрешена владельцем; [описание релиза](releases/2.0.0.md).
|
Актуальное состояние 2026-10-15: Production266 — с отдельного разрешения создан online-индекс retention; нагрузка БД существенно снизилась в двух повторных замерах. Карта исправлена локально (265), frontend-патч ещё не развёрнут. Проверка трафика и причин остальных тайм-аутов продолжается отдельно. Лабораторная приёмка264 завершена в документированных границах стенда:1396 основных UI и221 context-сценарий;120 live default states30 экранов. Runtime стенда backend258/frontend261. Непроверенные внешние группы сохранены в итоговом акте. Production v2.0.0 подтверждён через UI; [описание релиза](releases/2.0.0.md).
|
||||||
|
|
||||||
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
|
||||||
|
|
||||||
@@ -38,6 +38,24 @@
|
|||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. |
|
||||||
|
|
||||||
|
## Production267 — подключение PostgreSQL в CI (2026-10-15)
|
||||||
|
|
||||||
|
После push main backend-test2466/8410 завершился ECONNREFUSED: PostgreSQL публиковался на loopback Docker-хоста, но тестовый процесс работал в отдельном контейнере Gitea runner. Прежние SQL-тесты пропускали подключение; новый обязательный retention-тест выявил проблему. PostgreSQL теперь присоединяется к сети контейнера задания и доступен по уникальному DNS alias; для host runner сохранён вариант с назначенным Docker портом. Удаление общего mm-pg исключено, cleanup затрагивает только контейнер текущего запуска. Перед тестами выполняется SELECT1 из процесса задания; REQUIRE_TEST_POSTGRES=1 запрещает скрытый skip при недоступности/ошибке bootstrap.
|
||||||
|
|
||||||
|
Workflow YAML/scoped ESLint/diff check прошли. Проверены оба режима helper на недоступном localhost-порту: локально false/skip, в обязательном режиме исходная ошибка подключения выходит наружу. Полная CI-проверка сети и SQL-профилей должна подтвердиться следующим запуском; успешный тест retention на реальном тестовом PostgreSQL описан в266. Production этим блоком напрямую не изменялся.
|
||||||
|
|
||||||
|
## Production266 — online-индекс retention и проверка нагрузки (2026-10-15)
|
||||||
|
|
||||||
|
После прямого разрешения владельца только на индекс и проверку нагрузки в14:55:45 UTC запущен CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at ON public.scheduler_runs(finished_at). Завершён успешно, indisvalid/indisready=true. В15:01:30 UTC индекс23МБ,697 использований; план эквивалентного SELECT сменился с Parallel Seq Scan на Bitmap Index Scan. PostgreSQL CPU66,51% →0,16% →0,13%; секундные vmstat iowait29–34% →0–5% →0–1%, CPU idle0% →74–94% →94–97%. Наблюдения ограничены несколькими минутами обычной нагрузки и не доказывают устранение каждого тайм-аута RouterOS.
|
||||||
|
|
||||||
|
Добавлены миграция0013 и индекс в Drizzle schema, автоматический SQL-тест в test:pg, [процедура](../deploy/PRODUCTION-RETENTION-INDEX.md) и [протокол](ux-ui/acceptance/prod266-retention.json). Фактический тест выполнен на отдельном тестовом Debian:20тыс. временных строк, повторный запуск миграции, валидный индекс, смена плана, сохранение результата; fixture откатан. Bootstrap/public-таблицы тест не меняет. Backend build/scoped ESLint passed. Production-контейнеры не перезапускались; ручного удаления данных и изменения срока retention не было. Приложение не развёртывалось, push отсутствует. Следующее действие: завершить независимую проверку расчётов трафика и подготовить обновление REST history; применение frontend-патчей к production требует отдельного разрешения.
|
||||||
|
|
||||||
|
## Production265 — исчезновение узлов карты и первичная диагностика (2026-10-15)
|
||||||
|
|
||||||
|
Через выданный read-only аккаунт подтверждён production v2.0.0. Карта отклоняет все4 устройства: API polledAt имеет штатный PostgreSQL timestamptz string (+00), frontend требует ISO. В локальной ветке исправлена только нормализация явного offset; отсутствующая зона и невозможная дата по-прежнему отклоняются. Сохранённый приватный ответ production теперь проходит:4 servers/25 GRE/0 probes. Регрессия сначала воспроизведена новым unit test; после исправления6 unit и13 map E2E прошли, TypeScript/scoped ESLint passed. Production не обновлялся; push этого исправления не выполнялся.
|
||||||
|
|
||||||
|
SSH только чтением:1 vCPU/1973MiB/no swap, PostgreSQL нагружает CPU/IO. Дважды наблюдался retention DELETE по scheduler_runs.finished_at: около1.06M live rows,853MB, нет индекса по этому столбцу. Исходник выполняет cleanup после каждого задания; EXPLAIN эквивалентного SELECT показывает Parallel Seq Scan. TCP443 из backend до4 устройств сейчас3–50ms; TLS/REST этим не приняты. Причинность всех10s тайм-аутов не установлена; очередь shared maxSockets2 входит в deadline. Трафик: live SSE актуален, REST history/list не обновляются автоматически; вычисления требуют отдельной проверки. [Анонимизированный протокол](ux-ui/acceptance/prod265-diagnostics.json). Приватные сырые ответы и учётные данные в Git не включаются. Следующее действие — подготовить и проверить индекс retention, продолжить трафик; применение DDL и развёртывание production требуют отдельного разрешения владельца, предоставившего доступ для чтения.
|
||||||
|
|
||||||
## Подготовка версии 2.0.0 (2026-10-15)
|
## Подготовка версии 2.0.0 (2026-10-15)
|
||||||
|
|
||||||
По прямому поручению владельца публикация main/2.0 заменяет прежний режим только локальных коммитов. Добавлен major bump для breaking subject со scope/без scope и двух стандартных footer; CI запускает10 integration cases перед вычислением версии. Package.json и теги вручную не меняются. В выпуск включены накопленные изменения ReUI skills/portable MCP config и сохранённые снимки; локальные PID/cache и приватные fixtures не публикуются. Проверена новая история на известные приватные Portal credentials и шаблоны внешних токенов: совпадений0. [Состав и ограничения релиза](releases/2.0.0.md). Отправка main запускает существующую публикацию образов/релиза и настроенный deploy webhook. Фактический результат сборок определяется Gitea Actions. Первый запуск2464 отклонён валидатором Gitea28.1 до jobs: job-level secrets в if больше не принимается. После подтверждения причины read-only ViewPost по официальному handler условие перенесено в step if по job env; side effects webhook не расширены.
|
По прямому поручению владельца публикация main/2.0 заменяет прежний режим только локальных коммитов. Добавлен major bump для breaking subject со scope/без scope и двух стандартных footer; CI запускает10 integration cases перед вычислением версии. Package.json и теги вручную не меняются. В выпуск включены накопленные изменения ReUI skills/portable MCP config и сохранённые снимки; локальные PID/cache и приватные fixtures не публикуются. Проверена новая история на известные приватные Portal credentials и шаблоны внешних токенов: совпадений0. [Состав и ограничения релиза](releases/2.0.0.md). Отправка main запускает существующую публикацию образов/релиза и настроенный deploy webhook. Фактический результат сборок определяется Gitea Actions. Первый запуск2464 отклонён валидатором Gitea28.1 до jobs: job-level secrets в if больше не принимается. После подтверждения причины read-only ViewPost по официальному handler условие перенесено в step if по job env; side effects webhook не расширены.
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-15",
|
||||||
|
"scope": "Production diagnosis using a read-only application account and read-only SSH/system/PostgreSQL inspection. No production deployment or configuration changes.",
|
||||||
|
"productionVersion": "2.0.0",
|
||||||
|
"map": {
|
||||||
|
"cause": "PostgreSQL polledAt text with explicit offset is rejected by the ISO-only map validator; the whole topology snapshot is discarded.",
|
||||||
|
"before": {
|
||||||
|
"serverValidationErrors": 4,
|
||||||
|
"field": "polledAt",
|
||||||
|
"error": "invalid_format"
|
||||||
|
},
|
||||||
|
"localFix": {
|
||||||
|
"preservesExplicitTimeZone": true,
|
||||||
|
"rejectsMissingZoneAndInvalidCalendarDate": true,
|
||||||
|
"exactPrivateResponseAccepted": {
|
||||||
|
"servers": 4,
|
||||||
|
"tunnels": 25,
|
||||||
|
"probes": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"unitPassed": 6,
|
||||||
|
"e2ePassed": 13,
|
||||||
|
"typescript": "passed",
|
||||||
|
"scopedEslint": "passed"
|
||||||
|
},
|
||||||
|
"deployed": false
|
||||||
|
},
|
||||||
|
"traffic": {
|
||||||
|
"confirmed": "REST history/list/detail load once per scope/retry; live SSE keeps updating separately. History can become stale while live rate is current.",
|
||||||
|
"calculationNotYetFullyAccepted": true,
|
||||||
|
"rawResponsesPrivate": true
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"host": {
|
||||||
|
"vcpu": 1,
|
||||||
|
"memoryMiB": 1973,
|
||||||
|
"swapMiB": 0,
|
||||||
|
"loadAverage": [
|
||||||
|
4.62,
|
||||||
|
4.87,
|
||||||
|
5.09
|
||||||
|
],
|
||||||
|
"rootUsedPercent": 46
|
||||||
|
},
|
||||||
|
"sample": {
|
||||||
|
"postgresCpuPercent": 66.51,
|
||||||
|
"postgresMemory": "642.6MiB",
|
||||||
|
"backendCpuPercent": 3.26,
|
||||||
|
"cpuIdlePercent": 0,
|
||||||
|
"ioWaitPercent": [
|
||||||
|
34,
|
||||||
|
29
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"schedulerRuns": {
|
||||||
|
"estimatedLiveRows": 1064090,
|
||||||
|
"tableSize": "853 MB",
|
||||||
|
"indexes": "primary key and (job_key, started_at), no finished_at index",
|
||||||
|
"observedActiveQuery": "DELETE FROM scheduler_runs WHERE finished_at < $1",
|
||||||
|
"wait": "IO/AioIoCompletion",
|
||||||
|
"retention": "30 days after every recorded scheduler job",
|
||||||
|
"explainEquivalentSelect": "Gather / Parallel Seq Scan"
|
||||||
|
},
|
||||||
|
"tcpConnectMsByAnonymousServerId": {
|
||||||
|
"1": 36,
|
||||||
|
"2": 3,
|
||||||
|
"3": 50,
|
||||||
|
"4": 6
|
||||||
|
},
|
||||||
|
"limits": [
|
||||||
|
"TCP connect success does not prove TLS/REST authentication or rule out previous failures.",
|
||||||
|
"The native HTTP client timeout also includes time queued in maxSockets=2; actual queue delay has not been measured.",
|
||||||
|
"Database temp-byte and deadlock counters are cumulative with an unknown reset epoch; not attributed to this release.",
|
||||||
|
"No proof that database pressure alone caused every REST timeout."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-15",
|
||||||
|
"authorization": "Only the retention index and load verification in production",
|
||||||
|
"productionAction": {
|
||||||
|
"startedAt": "2026-10-15T14:55:45Z",
|
||||||
|
"statement": "CREATE INDEX CONCURRENTLY idx_scheduler_runs_finished_at ON public.scheduler_runs (finished_at)",
|
||||||
|
"result": "success",
|
||||||
|
"valid": true,
|
||||||
|
"ready": true,
|
||||||
|
"verifiedAt": "2026-10-15T15:01:30Z",
|
||||||
|
"size": "23 MB",
|
||||||
|
"indexScans": 697,
|
||||||
|
"planBefore": "Parallel Seq Scan",
|
||||||
|
"planAfter": "Bitmap Heap Scan / Bitmap Index Scan idx_scheduler_runs_finished_at",
|
||||||
|
"restarts": false,
|
||||||
|
"manualDataDeletion": false,
|
||||||
|
"retentionPeriodChanged": false,
|
||||||
|
"applicationDeployed": false
|
||||||
|
},
|
||||||
|
"observations": [
|
||||||
|
{ "period": "before", "postgresCpuPercent": 66.51, "hostIdlePercent": [0, 0], "hostIoWaitPercent": [29, 34], "ioPressureSomeAvg10Percent": 63.67 },
|
||||||
|
{ "period": "2026-10-15T14:56Z", "postgresCpuPercent": 0.16, "hostIdlePercent": [74, 94], "hostIoWaitPercent": [0, 5], "ioPressureSomeAvg10Percent": 1.70 },
|
||||||
|
{ "period": "2026-10-15T15:00Z", "postgresCpuPercent": 0.13, "hostIdlePercent": [94, 97], "hostIoWaitPercent": [0, 1], "ioPressureSomeAvg10Percent": 0.48 }
|
||||||
|
],
|
||||||
|
"checks": {
|
||||||
|
"actualMigrationTest": "passed on separate test Debian; bundled from committed TypeScript source",
|
||||||
|
"temporaryRows": 20000,
|
||||||
|
"migrationIdempotence": "passed",
|
||||||
|
"selectivePlanAndResultPreservation": "passed",
|
||||||
|
"temporaryFixtureRolledBack": true,
|
||||||
|
"testBootstrapExecuted": false,
|
||||||
|
"backendBuild": "passed",
|
||||||
|
"scopedEslint": "passed"
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Several-minute observations under ordinary workload, not a capacity benchmark",
|
||||||
|
"Not proof that every RouterOS timeout or interface stall has been resolved",
|
||||||
|
"Map fix remains local; traffic history refresh and numerical validation remain separate work"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,6 +2,18 @@ import {test} from "node:test"
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import {validateNetworkMapSnapshot} from "./network-map-response"
|
import {validateNetworkMapSnapshot} from "./network-map-response"
|
||||||
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0}
|
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0}
|
||||||
|
test("network map retains jump and exit nodes with PostgreSQL timestamps",()=>{
|
||||||
|
const timestamps=["2026-10-15 13:46:40.855+00","2026-10-15 20:46:40.855+07","2026-10-15T13:46:40.855Z",null]
|
||||||
|
const rows=timestamps.map((polledAt,index)=>({...server,id:index+1,type:index===0?"exit-node":"jump-host",polledAt}))
|
||||||
|
const snapshot=validateNetworkMapSnapshot(rows,{tunnels:[],failures:[]},{probes:[]})
|
||||||
|
assert.equal(snapshot.servers.length,4)
|
||||||
|
assert.equal(snapshot.servers[0].type,"exit-node")
|
||||||
|
assert.equal(Date.parse(snapshot.servers[0].polledAt!),Date.parse(snapshot.servers[1].polledAt!))
|
||||||
|
assert.equal(snapshot.servers[3].polledAt,null)
|
||||||
|
for(const polledAt of ["2026-10-15 13:46:40.855","2026-02-30 13:46:40+00","not-a-date",42]) {
|
||||||
|
assert.throws(()=>validateNetworkMapSnapshot([{...server,polledAt}],{tunnels:[],failures:[]},{probes:[]}))
|
||||||
|
}
|
||||||
|
})
|
||||||
test("network map retains configured GRE IPsec without key material",()=>{
|
test("network map retains configured GRE IPsec without key material",()=>{
|
||||||
const tunnel={id:"*1",serverId:"1",name:"own210",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1/30",remoteInnerIp:"10.0.0.2/30",poolId:"",ipsec:{secret:""},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
|
const tunnel={id:"*1",serverId:"1",name:"own210",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1/30",remoteInnerIp:"10.0.0.2/30",poolId:"",ipsec:{secret:""},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
|
||||||
const result=validateNetworkMapSnapshot([server],{tunnels:[tunnel],failures:[]},{probes:[]})
|
const result=validateNetworkMapSnapshot([server],{tunnels:[tunnel],failures:[]},{probes:[]})
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import {z} from "zod"
|
|||||||
import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response"
|
import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response"
|
||||||
import {filtersGreResponseSchema} from "./filters-response"
|
import {filtersGreResponseSchema} from "./filters-response"
|
||||||
import {greResponseSchema} from "./gre-response"
|
import {greResponseSchema} from "./gre-response"
|
||||||
|
// Drizzle's string-mode timestamptz is returned as PostgreSQL text (+00),
|
||||||
|
// while test/other API adapters may already return ISO. Preserve the explicit
|
||||||
|
// offset and validate the resulting calendar date; never infer a missing zone.
|
||||||
|
const mapObservedTime=z.preprocess(value=>typeof value==="string"
|
||||||
|
?value.replace(/^(\d{4}-\d{2}-\d{2}) /,"$1T").replace(/([+-]\d{2})$/,"$1:00")
|
||||||
|
:value,z.string().datetime({offset:true}))
|
||||||
export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({
|
export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({
|
||||||
status:z.enum(["online","offline","degraded"]).nullable(),
|
status:z.enum(["online","offline","degraded"]).nullable(),
|
||||||
asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(),
|
asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(),
|
||||||
sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(),
|
sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(),
|
||||||
cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(),
|
cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(),
|
||||||
polledAt:z.string().datetime({offset:true}).nullable().optional(),
|
polledAt:mapObservedTime.nullable().optional(),
|
||||||
})).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory))
|
})).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory))
|
||||||
export type NetworkMapServerRow=z.infer<typeof networkMapServersSchema>[number]
|
export type NetworkMapServerRow=z.infer<typeof networkMapServersSchema>[number]
|
||||||
const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId)))
|
const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId)))
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import {test,expect} from "@playwright/test"
|
import {test,expect} from "@playwright/test"
|
||||||
const paths={servers:"/api/servers",gre:"/api/filters/gre-tunnels",probes:"/api/uptime/speed-probes"} as const
|
const paths={servers:"/api/servers",gre:"/api/filters/gre-tunnels",probes:"/api/uptime/speed-probes"} as const
|
||||||
const body=(path:string,name="Роутер")=>path===paths.servers?[{id:1,name,host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:0}]:path===paths.gre?{tunnels:[],failures:[]}:path===paths.probes?{probes:[]}:path==="/api/auth/config"?{required:false}:{}
|
const body=(path:string,name="Роутер")=>path===paths.servers?[{id:1,name,host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:0}]:path===paths.gre?{tunnels:[],failures:[]}:path===paths.probes?{probes:[]}:path==="/api/auth/config"?{required:false}:{}
|
||||||
|
test("Карта: PostgreSQL даты не скрывают JumpHost и Exit Node",async({page})=>{
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
||||||
|
const rows=[{id:1,name:"Узел JH",host:"192.0.2.1",type:"jump-host",country:"RU",enabled:true,status:"online",polledAt:"2026-10-15 13:46:40.855+00"},{id:2,name:"Узел EN",host:"192.0.2.2",type:"exit-node",country:"SE",enabled:true,status:"online",polledAt:"2026-10-15 20:46:40.855+07"}]
|
||||||
|
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p===paths.servers?rows:body(p)})})
|
||||||
|
await page.goto("/network-map")
|
||||||
|
const map=page.getByRole("group",{name:"Интерактивная карта сети"})
|
||||||
|
await expect(map.getByText("Узел JH",{exact:true})).toBeVisible()
|
||||||
|
await expect(map.getByText("Узел EN",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled()
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toHaveCount(0)
|
||||||
|
})
|
||||||
for(const part of ["servers","gre","probes"] as const)for(const malformed of [false,true])test("Карта: первоначальный отказ и повтор "+part+"/"+malformed,async({page})=>{
|
for(const part of ["servers","gre","probes"] as const)for(const malformed of [false,true])test("Карта: первоначальный отказ и повтор "+part+"/"+malformed,async({page})=>{
|
||||||
let failing=true
|
let failing=true
|
||||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
||||||
|
|||||||
Reference in new issue
Block a user