Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdeb97d841 | ||
|
|
36c5305db7 | ||
|
|
5f2b4e2d40 | ||
|
|
cff26813b9 | ||
|
|
39c8ec4a02 | ||
|
|
c6c859a495 | ||
|
|
bf5cfff12c | ||
|
|
30a8ec4420 |
@@ -53,30 +53,39 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run -d --name mm-pg \
|
||||
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||
docker rm -f "$NAME" mm-pg 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 run -d --name "$NAME" --rm \
|
||||
-e POSTGRES_USER=mmapp \
|
||||
-e POSTGRES_PASSWORD=mmapp \
|
||||
-e POSTGRES_DB=mmapp \
|
||||
-p 5432:5432 \
|
||||
-p "127.0.0.1:${HOST_PORT}:5432" \
|
||||
postgres:18-alpine
|
||||
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
||||
echo "DATABASE_URL=postgres://mmapp:[email protected]:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}"
|
||||
for i in $(seq 1 40); do
|
||||
if docker exec mm-pg pg_isready -U mmapp -d mmapp; then
|
||||
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL не поднялся"
|
||||
docker logs "$NAME" || true
|
||||
exit 1
|
||||
|
||||
- name: Install and test backend
|
||||
shell: bash
|
||||
env:
|
||||
DATABASE_URL: postgres://mmapp:[email protected]:5432/mmapp
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run test --prefix backend
|
||||
|
||||
- name: Stop PostgreSQL
|
||||
if: always()
|
||||
shell: bash
|
||||
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true
|
||||
|
||||
backend-image:
|
||||
needs:
|
||||
- prepare-release
|
||||
|
||||
@@ -128,7 +128,7 @@ sequenceDiagram
|
||||
| Зависимость | Реализация |
|
||||
|-------------|------------|
|
||||
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
|
||||
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При первом старте, если PG пустой, backend сам импортирует данные и ставит маркер. Повторный старт не копирует заново. |
|
||||
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
|
||||
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
+309
-583
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
@@ -243,7 +244,7 @@ function CertPartReference() {
|
||||
return (
|
||||
<OpsPanel
|
||||
title="RouterOS 7 · /certificate — справка CLI"
|
||||
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
|
||||
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
|
||||
contentClassName="px-5 py-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||
@@ -715,6 +716,8 @@ export default function CertificatesPage() {
|
||||
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||
|
||||
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
||||
|
||||
{liveReady && (
|
||||
<CertPartAcmeSettings
|
||||
acmeDirectoryUrl={acmeDirectoryUrl}
|
||||
|
||||
+260
-851
File diff suppressed because it is too large
Load Diff
@@ -1127,7 +1127,11 @@ export default function DataCollectionPage() {
|
||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
disabled={!draftCertRenewEnabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
|
||||
@@ -952,10 +952,6 @@ export default function TrafficPage() {
|
||||
setFlowAnalytics(null)
|
||||
return
|
||||
}
|
||||
if (range === "5m") {
|
||||
setFlowAnalytics(null)
|
||||
return
|
||||
}
|
||||
if (range === "30d") {
|
||||
const month = new Date().toISOString().slice(0, 7)
|
||||
void getFlowMonthly(backendUrl, {
|
||||
@@ -1128,7 +1124,7 @@ export default function TrafficPage() {
|
||||
const ingestLine = flowIngestLine(flowStats)
|
||||
const collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived)
|
||||
const flowError = liveError
|
||||
|| (flowLiveError && !(collectorAlive && /live HTTP 500/.test(flowLiveError)) ? flowLiveError : null)
|
||||
|| flowLiveError
|
||||
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||||
|
||||
const flowKpiItems = [
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
|
||||
-- recreate with smaller time-series rows. SQLite is re-imported after this.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename <> 'schema_migrations'
|
||||
LOOP
|
||||
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 443,
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
type TEXT NOT NULL DEFAULT 'home-router'
|
||||
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||
site TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
asn TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_public_key TEXT NOT NULL DEFAULT '',
|
||||
host_private_key TEXT NOT NULL DEFAULT '',
|
||||
hub_server_id BIGINT,
|
||||
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||
top_n INTEGER NOT NULL DEFAULT 200,
|
||||
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||
last_datagram_at TIMESTAMPTZ,
|
||||
last_exporter_ip TEXT,
|
||||
last_error TEXT,
|
||||
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
bot_token TEXT NOT NULL DEFAULT '',
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
message_thread_id INTEGER,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||
last_collected_at TIMESTAMPTZ,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
last_source_finished_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_migration (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
sqlite_imported_at TIMESTAMPTZ,
|
||||
sqlite_path TEXT,
|
||||
sqlite_sha256 TEXT,
|
||||
report_json JSONB COMPRESSION lz4
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
community TEXT NOT NULL,
|
||||
community_name TEXT,
|
||||
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
dst_address TEXT NOT NULL,
|
||||
gateway TEXT NOT NULL,
|
||||
distance INTEGER NOT NULL DEFAULT 1,
|
||||
scope INTEGER,
|
||||
target_scope INTEGER,
|
||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||
check_gateway TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
polled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||
latency_ms DOUBLE PRECISION,
|
||||
ros_version TEXT,
|
||||
board_name TEXT,
|
||||
uptime TEXT,
|
||||
cpu_load INTEGER,
|
||||
free_memory BIGINT,
|
||||
total_memory BIGINT,
|
||||
identity_name TEXT,
|
||||
raw_interfaces JSONB COMPRESSION lz4,
|
||||
raw_ip_addresses JSONB COMPRESSION lz4,
|
||||
PRIMARY KEY (id, polled_at)
|
||||
) PARTITION BY RANGE (polled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||
flags SMALLINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
ok BOOLEAN NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
error TEXT,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
src INET NOT NULL,
|
||||
dst INET NOT NULL,
|
||||
proto SMALLINT NOT NULL DEFAULT 0,
|
||||
src_port INTEGER NOT NULL DEFAULT 0,
|
||||
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
in_iface TEXT NOT NULL DEFAULT '',
|
||||
out_iface TEXT NOT NULL DEFAULT '',
|
||||
next_hop INET,
|
||||
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||
conversations INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
bucket_at TIMESTAMPTZ NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||
) PARTITION BY RANGE (bucket_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
packets BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, day, dim, key)
|
||||
) PARTITION BY RANGE (day);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||
prefix TEXT PRIMARY KEY,
|
||||
asn INTEGER NOT NULL DEFAULT 0,
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
lat DOUBLE PRECISION,
|
||||
lng DOUBLE PRECISION,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||
asn INTEGER PRIMARY KEY,
|
||||
holder TEXT NOT NULL DEFAULT '',
|
||||
fetched_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
rtt_ms INTEGER,
|
||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||
PRIMARY KEY (probe_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (server_id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||
id TEXT PRIMARY KEY,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_tx_avg_mbps DOUBLE PRECISION,
|
||||
last_rx_avg_mbps DOUBLE PRECISION,
|
||||
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||
last_error TEXT,
|
||||
last_ping_rtt_ms INTEGER,
|
||||
last_ping_loss_pct INTEGER,
|
||||
last_ping_at TIMESTAMPTZ,
|
||||
last_ping_error TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
probe_id TEXT,
|
||||
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
src_interface TEXT NOT NULL DEFAULT '',
|
||||
dst_interface TEXT NOT NULL DEFAULT '',
|
||||
src_address TEXT,
|
||||
dst_address TEXT,
|
||||
src_interface_address TEXT,
|
||||
dst_interface_address TEXT,
|
||||
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||
tx_avg_mbps DOUBLE PRECISION,
|
||||
rx_avg_mbps DOUBLE PRECISION,
|
||||
ping_rtt_ms INTEGER,
|
||||
ping_loss_pct INTEGER,
|
||||
ping_error TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names JSONB COMPRESSION lz4 NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown_override TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
condition TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||
confirm_stability_sec INTEGER,
|
||||
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||
recovery_stability_sec INTEGER,
|
||||
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
target TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||
condition_line TEXT NOT NULL,
|
||||
sort_index INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||
last_payload_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
payload_hash TEXT NOT NULL,
|
||||
since_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
rule_id TEXT,
|
||||
group_id TEXT,
|
||||
rule_name TEXT NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||
message TEXT NOT NULL,
|
||||
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
fired_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_key TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
finished_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||
error TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
result_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||
event_type TEXT NOT NULL,
|
||||
source_module TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
payload_json JSONB COMPRESSION lz4
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
last_seen TIMESTAMPTZ,
|
||||
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
interface_name TEXT NOT NULL,
|
||||
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||
peer_name TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (server_id, interface_name, peer_public_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||
sampled_at TIMESTAMPTZ NOT NULL,
|
||||
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||
PRIMARY KEY (id, sampled_at)
|
||||
) PARTITION BY RANGE (sampled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||
|
||||
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- S3-compatible storage for RouterOS backups
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_storage_settings (
|
||||
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
|
||||
s3_endpoint TEXT NOT NULL DEFAULT '',
|
||||
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
|
||||
s3_bucket TEXT NOT NULL DEFAULT '',
|
||||
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
|
||||
s3_access_key_id TEXT NOT NULL DEFAULT '',
|
||||
s3_secret_access_key TEXT NOT NULL DEFAULT '',
|
||||
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_test_at TIMESTAMPTZ,
|
||||
last_test_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
INSERT INTO backup_storage_settings (id)
|
||||
VALUES (1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_key TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
|
||||
|
||||
ALTER TABLE backup_entries
|
||||
ADD COLUMN IF NOT EXISTS upload_error TEXT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
|
||||
) THEN
|
||||
ALTER TABLE backup_entries
|
||||
ADD CONSTRAINT backup_entries_storage_check
|
||||
CHECK (storage IN ('local', 's3', 'both'));
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -17,10 +17,12 @@
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.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-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",
|
||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
|
||||
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/pg-schema.test.ts",
|
||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg"
|
||||
"test:pg": "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",
|
||||
"test:backups": "tsx src/services/s3-backup-client.test.ts",
|
||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pool } from "./index.js"
|
||||
import { applySqlMigrations } from "./migrate.js"
|
||||
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
|
||||
import { importSqliteToPostgres, shouldImportSqlite } from "./sqlite-import.js"
|
||||
import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.js"
|
||||
import { env } from "../config.js"
|
||||
|
||||
const ETL_LOCK = 8723101
|
||||
@@ -19,6 +19,15 @@ export async function initDatabase(): Promise<void> {
|
||||
console.log(
|
||||
`SQLite → PostgreSQL: готово за ${report.durationMs}ms, таблиц ${Object.keys(report.tables).length}`,
|
||||
)
|
||||
} else {
|
||||
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
|
||||
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||
)
|
||||
if (!marker.rows[0]?.sqlite_imported_at && !sqliteFileLooksPresent(env.DATABASE_PATH)) {
|
||||
console.warn(
|
||||
`SQLite → PostgreSQL: файл ${env.DATABASE_PATH} не найден, база после wipe остаётся пустой (defaults settings)`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
|
||||
+23
-12
@@ -1,9 +1,9 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Pool } from "pg"
|
||||
|
||||
const MIGRATION_ID = "0000_postgresql"
|
||||
const FIRST_MIGRATION = "0000_postgresql.sql"
|
||||
|
||||
function migrationsDir(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -14,7 +14,7 @@ function migrationsDir(): string {
|
||||
]
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
readFileSync(join(dir, `${MIGRATION_ID}.sql`), "utf8")
|
||||
readFileSync(join(dir, FIRST_MIGRATION), "utf8")
|
||||
return dir
|
||||
} catch {
|
||||
/* try next */
|
||||
@@ -23,6 +23,10 @@ function migrationsDir(): string {
|
||||
throw new Error("Не найден backend/drizzle/0000_postgresql.sql")
|
||||
}
|
||||
|
||||
function migrationId(file: string): string {
|
||||
return file.replace(/\.sql$/i, "")
|
||||
}
|
||||
|
||||
export async function applySqlMigrations(pool: Pool): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
@@ -30,14 +34,21 @@ export async function applySqlMigrations(pool: Pool): Promise<void> {
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`)
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
`SELECT id FROM schema_migrations WHERE id = $1`,
|
||||
[MIGRATION_ID],
|
||||
const dir = migrationsDir()
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => /^\d{4}_.+\.sql$/i.test(f))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
const applied = new Set(
|
||||
(await pool.query<{ id: string }>(`SELECT id FROM schema_migrations`)).rows.map((r) => r.id),
|
||||
)
|
||||
if (rows.length > 0) return
|
||||
const sql = readFileSync(join(migrationsDir(), `${MIGRATION_ID}.sql`), "utf8")
|
||||
await pool.query(sql)
|
||||
await pool.query(`INSERT INTO schema_migrations (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, [
|
||||
MIGRATION_ID,
|
||||
])
|
||||
for (const file of files) {
|
||||
const id = migrationId(file)
|
||||
if (applied.has(id)) continue
|
||||
const sql = readFileSync(join(dir, file), "utf8")
|
||||
await pool.query(sql)
|
||||
await pool.query(
|
||||
`INSERT INTO schema_migrations (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||
[id],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,37 @@ export const PARTITION_SPECS: PartitionSpec[] = [
|
||||
{ parent: "internet_path_snapshots", kind: "week", keepDays: 21 },
|
||||
]
|
||||
|
||||
const WEEK_SLACK_DAYS = 7
|
||||
|
||||
async function resolveKeepDays(pool: Pool): Promise<Map<string, number>> {
|
||||
const map = new Map(PARTITION_SPECS.map((s) => [s.parent, s.keepDays]))
|
||||
try {
|
||||
const traffic = await pool.query<{ retention_days: number }>(
|
||||
`SELECT retention_days FROM traffic_settings WHERE id = 1`,
|
||||
)
|
||||
const td = Number(traffic.rows[0]?.retention_days)
|
||||
if (Number.isFinite(td) && td > 0) map.set("traffic_samples", td + WEEK_SLACK_DAYS)
|
||||
|
||||
const uptime = await pool.query<{ retention_days: number }>(
|
||||
`SELECT retention_days FROM uptime_settings WHERE id = 1`,
|
||||
)
|
||||
const ud = Number(uptime.rows[0]?.retention_days)
|
||||
if (Number.isFinite(ud) && ud > 0) {
|
||||
map.set("uptime_probe_samples", ud + WEEK_SLACK_DAYS)
|
||||
map.set("uptime_resource_samples", ud + WEEK_SLACK_DAYS)
|
||||
}
|
||||
|
||||
const path = await pool.query<{ retention_days: number }>(
|
||||
`SELECT retention_days FROM internet_path_settings WHERE id = 1`,
|
||||
)
|
||||
const pd = Number(path.rows[0]?.retention_days)
|
||||
if (Number.isFinite(pd) && pd > 0) map.set("internet_path_snapshots", pd + WEEK_SLACK_DAYS)
|
||||
} catch {
|
||||
/* settings may be absent mid-migration */
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function utcDate(d: Date): Date {
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
||||
}
|
||||
@@ -82,10 +113,29 @@ export async function ensurePartitionFor(
|
||||
return name
|
||||
}
|
||||
|
||||
export async function ensurePartitionsBetween(
|
||||
pool: Pool,
|
||||
parent: string,
|
||||
kind: PartitionKind,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<void> {
|
||||
const start = from.getTime() <= to.getTime() ? from : to
|
||||
const end = from.getTime() <= to.getTime() ? to : from
|
||||
for (let t = new Date(start.getTime()); t <= end; ) {
|
||||
await ensurePartitionFor(pool, parent, kind, t)
|
||||
if (kind === "day") t = addUtcDays(t, 1)
|
||||
else if (kind === "week") t = addUtcDays(t, 7)
|
||||
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePartitionsAround(pool: Pool, around = new Date()): Promise<void> {
|
||||
const keepDays = await resolveKeepDays(pool)
|
||||
for (const spec of PARTITION_SPECS) {
|
||||
const keep = keepDays.get(spec.parent) ?? spec.keepDays
|
||||
const daysAhead = spec.kind === "month" ? 40 : spec.kind === "week" ? 21 : 8
|
||||
const start = addUtcDays(around, -spec.keepDays)
|
||||
const start = addUtcDays(around, -keep)
|
||||
const end = addUtcDays(around, daysAhead)
|
||||
for (let t = new Date(start.getTime()); t < end; ) {
|
||||
await ensurePartitionFor(pool, spec.parent, spec.kind, t)
|
||||
@@ -97,8 +147,10 @@ export async function ensurePartitionsAround(pool: Pool, around = new Date()): P
|
||||
}
|
||||
|
||||
export async function dropExpiredPartitions(pool: Pool, around = new Date()): Promise<void> {
|
||||
const keepDays = await resolveKeepDays(pool)
|
||||
for (const spec of PARTITION_SPECS) {
|
||||
const cutoff = addUtcDays(around, -spec.keepDays)
|
||||
const keep = keepDays.get(spec.parent) ?? spec.keepDays
|
||||
const cutoff = addUtcDays(around, -keep)
|
||||
const { rows } = await pool.query<{ relname: string }>(
|
||||
`SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { withPgOrSkip } from "../test/pg.js"
|
||||
import { dbQuery, pool } from "./index.js"
|
||||
import { applySqlMigrations } from "./migrate.js"
|
||||
import { ensurePartitionFor } from "./partitions.js"
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
@@ -53,4 +54,120 @@ if (!(await withPgOrSkip())) {
|
||||
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
|
||||
}
|
||||
|
||||
{
|
||||
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232", publicKey: "x" }]
|
||||
await dbQuery(
|
||||
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-json-arr', 'pg-json-arr', $1::jsonb, now())`,
|
||||
[JSON.stringify(peers)],
|
||||
)
|
||||
const { rows } = await dbQuery<{ payload_json: unknown }>(
|
||||
`SELECT payload_json FROM alert_outbox WHERE id = 'pg-json-arr'`,
|
||||
)
|
||||
assert.equal(Array.isArray(rows[0]?.payload_json), true)
|
||||
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-arr'`)
|
||||
|
||||
let arrayAsPgArrayFailed = false
|
||||
try {
|
||||
await dbQuery(
|
||||
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||
VALUES ('pg-json-bad', 'pg-json-bad', $1, now())`,
|
||||
[peers],
|
||||
)
|
||||
} catch (err) {
|
||||
arrayAsPgArrayFailed = err instanceof Error && /json|22P02/i.test(err.message)
|
||||
}
|
||||
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-bad'`).catch(() => undefined)
|
||||
assert.equal(arrayAsPgArrayFailed, true, "JS array must not be bound as jsonb without stringify")
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ attname: string }>(`
|
||||
SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
||||
WHERE i.indrelid = 'traffic_samples'::regclass AND i.indisprimary
|
||||
ORDER BY k.ord
|
||||
`)
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.attname),
|
||||
["server_id", "sampled_at", "interface_name", "peer_public_key"],
|
||||
"traffic_samples PK без id",
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ column_name: string }>(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'traffic_samples'
|
||||
`)
|
||||
const cols = new Set(rows.map((r) => r.column_name))
|
||||
assert.equal(cols.has("id"), false)
|
||||
assert.equal(cols.has("running"), false)
|
||||
assert.equal(cols.has("disabled"), false)
|
||||
assert.equal(cols.has("flags"), true)
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
|
||||
SELECT column_name, udt_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'flow_buckets'
|
||||
AND column_name IN ('src', 'dst', 'next_hop', 'proto')
|
||||
`)
|
||||
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
|
||||
assert.equal(by.src, "inet")
|
||||
assert.equal(by.dst, "inet")
|
||||
assert.equal(by.next_hop, "inet")
|
||||
assert.equal(by.proto, "int2")
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ indexdef: string }>(`
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = 'traffic_samples'
|
||||
`)
|
||||
const defs = rows.map((r) => r.indexdef.toLowerCase())
|
||||
assert.equal(defs.some((d) => d.includes("using brin")), false, "нет BRIN на traffic_samples")
|
||||
assert.equal(
|
||||
defs.filter((d) => d.includes("idx_traffic_samples_server_iface_time")).length,
|
||||
1,
|
||||
"один btree (server_id, interface_name, sampled_at)",
|
||||
)
|
||||
assert.equal(defs.some((d) => d.includes("idx_traffic_samples_server_time")), false)
|
||||
}
|
||||
|
||||
{
|
||||
const { rows } = await dbQuery<{ column_name: string }>(`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'uptime_resource_samples'
|
||||
`)
|
||||
const cols = new Set(rows.map((r) => r.column_name))
|
||||
assert.equal(cols.has("board_name"), false)
|
||||
assert.equal(cols.has("ros_version"), false)
|
||||
}
|
||||
|
||||
{
|
||||
const mig = await dbQuery<{ id: string }>(
|
||||
`SELECT id FROM schema_migrations WHERE id = '0002_compact_schema'`,
|
||||
)
|
||||
assert.equal(mig.rows.length, 1, "0002 применена")
|
||||
|
||||
await dbQuery(`INSERT INTO servers (name, host) VALUES ('pg-wipe-idempotent', '127.0.0.1')`)
|
||||
await applySqlMigrations(pool)
|
||||
const still = await dbQuery<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n FROM servers WHERE name = 'pg-wipe-idempotent'`,
|
||||
)
|
||||
assert.equal(still.rows[0]?.n, "1", "повторный applySqlMigrations не wipe")
|
||||
await dbQuery(`DELETE FROM servers WHERE name = 'pg-wipe-idempotent'`)
|
||||
}
|
||||
|
||||
{
|
||||
const marker = await dbQuery<{ sqlite_imported_at: string | null }>(
|
||||
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||
)
|
||||
assert.ok(marker.rows[0], "data_migration singleton после wipe")
|
||||
}
|
||||
|
||||
console.log("pg-schema.test.ts: ok")
|
||||
|
||||
+31
-20
@@ -5,10 +5,12 @@ import {
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
inet,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
@@ -135,15 +137,13 @@ export const serversApiPingSettings = pgTable("servers_api_ping_settings", {
|
||||
})
|
||||
|
||||
export const serversRestPingSamples = pgTable("servers_rest_ping_samples", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
ok: boolean("ok").notNull(),
|
||||
latencyMs: integer("latency_ms"),
|
||||
error: text("error"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_servers_rest_ping_samples_server_id").on(t.serverId, t.sampledAt),
|
||||
primaryKey({ columns: [t.serverId, t.sampledAt] }),
|
||||
])
|
||||
|
||||
export const trafficFlowSettings = pgTable("traffic_flow_settings", {
|
||||
@@ -211,16 +211,16 @@ export const flowDailyDims = pgTable("flow_daily_dims", {
|
||||
export const flowBuckets = pgTable("flow_buckets", {
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
bucketAt: ts("bucket_at").notNull(),
|
||||
src: text("src").notNull(),
|
||||
dst: text("dst").notNull(),
|
||||
proto: integer("proto").notNull().default(0),
|
||||
src: inet("src").notNull(),
|
||||
dst: inet("dst").notNull(),
|
||||
proto: smallint("proto").notNull().default(0),
|
||||
srcPort: integer("src_port").notNull().default(0),
|
||||
dstPort: integer("dst_port").notNull().default(0),
|
||||
bytes: bigint("bytes", { mode: "number" }).notNull().default(0),
|
||||
packets: bigint("packets", { mode: "number" }).notNull().default(0),
|
||||
inIface: text("in_iface").notNull().default(""),
|
||||
outIface: text("out_iface").notNull().default(""),
|
||||
nextHop: text("next_hop").notNull().default(""),
|
||||
nextHop: inet("next_hop"),
|
||||
flowStartMs: bigint("flow_start_ms", { mode: "number" }).notNull().default(0),
|
||||
flowEndMs: bigint("flow_end_ms", { mode: "number" }).notNull().default(0),
|
||||
}, (t) => [
|
||||
@@ -249,7 +249,6 @@ export const flowAsnMeta = pgTable("flow_asn_meta", {
|
||||
})
|
||||
|
||||
export const trafficSamples = pgTable("traffic_samples", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
interfaceName: text("interface_name").notNull(),
|
||||
peerPublicKey: text("peer_public_key").notNull().default(""),
|
||||
@@ -258,11 +257,9 @@ export const trafficSamples = pgTable("traffic_samples", {
|
||||
txBytes: bigint("tx_bytes", { mode: "number" }).notNull().default(0),
|
||||
rxBps: bigint("rx_bps", { mode: "number" }).notNull().default(0),
|
||||
txBps: bigint("tx_bps", { mode: "number" }).notNull().default(0),
|
||||
running: boolean("running").notNull().default(false),
|
||||
disabled: boolean("disabled").notNull().default(false),
|
||||
flags: smallint("flags").notNull().default(0),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_traffic_samples_server_time").on(t.serverId, t.sampledAt),
|
||||
primaryKey({ columns: [t.serverId, t.sampledAt, t.interfaceName, t.peerPublicKey] }),
|
||||
index("idx_traffic_samples_server_iface_time").on(t.serverId, t.interfaceName, t.sampledAt),
|
||||
])
|
||||
|
||||
@@ -302,19 +299,16 @@ export const uptimeProbes = pgTable("uptime_probes", {
|
||||
])
|
||||
|
||||
export const uptimeProbeSamples = pgTable("uptime_probe_samples", {
|
||||
id: idIdentity(),
|
||||
probeId: text("probe_id").notNull().references(() => uptimeProbes.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
rttMs: integer("rtt_ms"),
|
||||
lossPct: integer("loss_pct").notNull().default(0),
|
||||
status: text("status", { enum: ["up", "warn", "down"] }).notNull().default("down"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_uptime_probe_samples_probe_time").on(t.probeId, t.sampledAt),
|
||||
primaryKey({ columns: [t.probeId, t.sampledAt] }),
|
||||
])
|
||||
|
||||
export const uptimeResourceSamples = pgTable("uptime_resource_samples", {
|
||||
id: idIdentity(),
|
||||
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
|
||||
sampledAt: ts("sampled_at").notNull(),
|
||||
status: text("status", { enum: ["online", "offline"] }).notNull().default("offline"),
|
||||
@@ -324,11 +318,8 @@ export const uptimeResourceSamples = pgTable("uptime_resource_samples", {
|
||||
freeHddSpace: bigint("free_hdd_space", { mode: "number" }).notNull().default(0),
|
||||
totalHddSpace: bigint("total_hdd_space", { mode: "number" }).notNull().default(0),
|
||||
uptimeSeconds: bigint("uptime_seconds", { mode: "number" }).notNull().default(0),
|
||||
boardName: text("board_name").notNull().default(""),
|
||||
rosVersion: text("ros_version").notNull().default(""),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.id, t.sampledAt] }),
|
||||
index("idx_uptime_resource_samples_server_time").on(t.serverId, t.sampledAt),
|
||||
primaryKey({ columns: [t.serverId, t.sampledAt] }),
|
||||
])
|
||||
|
||||
export const uptimeSpeedProbes = pgTable("uptime_speed_probes", {
|
||||
@@ -429,6 +420,22 @@ export const backupScheduleSettings = pgTable("backup_schedule_settings", {
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const backupStorageSettings = pgTable("backup_storage_settings", {
|
||||
id: idSingleton(),
|
||||
provider: text("provider", { enum: ["local", "s3"] }).notNull().default("local"),
|
||||
s3Endpoint: text("s3_endpoint").notNull().default(""),
|
||||
s3Region: text("s3_region").notNull().default("us-east-1"),
|
||||
s3Bucket: text("s3_bucket").notNull().default(""),
|
||||
s3Prefix: text("s3_prefix").notNull().default("mikrotik"),
|
||||
s3AccessKeyId: text("s3_access_key_id").notNull().default(""),
|
||||
s3SecretAccessKey: text("s3_secret_access_key").notNull().default(""),
|
||||
s3ForcePathStyle: boolean("s3_force_path_style").notNull().default(true),
|
||||
keepLocalCopy: boolean("keep_local_copy").notNull().default(true),
|
||||
lastTestAt: ts("last_test_at"),
|
||||
lastTestError: text("last_test_error"),
|
||||
updatedAt: ts("updated_at").notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const backupEntries = pgTable("backup_entries", {
|
||||
id: text("id").primaryKey(),
|
||||
serverId: bigint("server_id", { mode: "number" })
|
||||
@@ -438,6 +445,10 @@ export const backupEntries = pgTable("backup_entries", {
|
||||
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||
kind: text("kind", { enum: ["manual", "auto"] }).notNull().default("manual"),
|
||||
notes: text("notes"),
|
||||
storage: text("storage", { enum: ["local", "s3", "both"] }).notNull().default("local"),
|
||||
s3Key: text("s3_key"),
|
||||
s3Etag: text("s3_etag"),
|
||||
uploadError: text("upload_error"),
|
||||
createdAt: ts("created_at").notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("idx_backup_entries_filename").on(t.filename),
|
||||
|
||||
@@ -3,7 +3,8 @@ import { existsSync, readFileSync } from "node:fs"
|
||||
import Database from "better-sqlite3"
|
||||
import type { Pool } from "pg"
|
||||
import { env } from "../config.js"
|
||||
import { ensurePartitionFor, specForParent } from "./partitions.js"
|
||||
import { ensurePartitionsBetween, specForParent } from "./partitions.js"
|
||||
import { encodeTrafficFlags } from "./traffic-flags.js"
|
||||
|
||||
export interface ImportReport {
|
||||
sqlitePath: string
|
||||
@@ -15,7 +16,9 @@ export interface ImportReport {
|
||||
|
||||
const SNAPSHOT_RETENTION_DAYS = 14
|
||||
|
||||
type ColKind = "ts" | "date" | "bool" | "json" | "json-null" | "bigint-id" | "int" | "text" | "num"
|
||||
type ColKind = "ts" | "date" | "bool" | "json" | "json-null" | "bigint-id" | "int" | "text" | "num" | "flags" | "inet"
|
||||
|
||||
const INSERT_CHUNK = 1000
|
||||
|
||||
interface TableCopy {
|
||||
table: string
|
||||
@@ -78,6 +81,12 @@ const TABLES: TableCopy[] = [
|
||||
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
|
||||
["last_error", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "backup_storage_settings", upsert: true, columns: [
|
||||
["id", "int"], ["provider", "text"], ["s3_endpoint", "text"], ["s3_region", "text"],
|
||||
["s3_bucket", "text"], ["s3_prefix", "text"], ["s3_access_key_id", "text"],
|
||||
["s3_secret_access_key", "text"], ["s3_force_path_style", "bool"], ["keep_local_copy", "bool"],
|
||||
["last_test_at", "ts"], ["last_test_error", "text"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "internet_path_settings", upsert: true, columns: [
|
||||
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||
@@ -103,18 +112,18 @@ const TABLES: TableCopy[] = [
|
||||
["free_memory", "int"], ["total_memory", "int"], ["identity_name", "text"],
|
||||
["raw_interfaces", "json-null"], ["raw_ip_addresses", "json-null"],
|
||||
]},
|
||||
{ table: "traffic_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["interface_name", "text"], ["peer_public_key", "text"],
|
||||
{ table: "traffic_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["server_id", "int"], ["interface_name", "text"], ["peer_public_key", "text"],
|
||||
["sampled_at", "ts"], ["rx_bytes", "int"], ["tx_bytes", "int"], ["rx_bps", "int"], ["tx_bps", "int"],
|
||||
["running", "bool"], ["disabled", "bool"],
|
||||
["flags", "flags"],
|
||||
]},
|
||||
{ table: "servers_rest_ping_samples", identity: true, timeCol: "sampled_at", retentionDays: 30, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sampled_at", "ts"], ["ok", "bool"], ["latency_ms", "int"], ["error", "text"],
|
||||
{ table: "servers_rest_ping_samples", timeCol: "sampled_at", retentionDays: 30, columns: [
|
||||
["server_id", "int"], ["sampled_at", "ts"], ["ok", "bool"], ["latency_ms", "int"], ["error", "text"],
|
||||
]},
|
||||
{ table: "flow_buckets", timeCol: "bucket_at", retentionDays: 2, columns: [
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["src", "text"], ["dst", "text"], ["proto", "int"],
|
||||
["server_id", "int"], ["bucket_at", "ts"], ["src", "inet"], ["dst", "inet"], ["proto", "int"],
|
||||
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
|
||||
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "text"],
|
||||
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"],
|
||||
["flow_start_ms", "int"], ["flow_end_ms", "int"],
|
||||
]},
|
||||
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
|
||||
@@ -139,13 +148,13 @@ const TABLES: TableCopy[] = [
|
||||
["target", "text"], ["probe_filter", "text"], ["enabled", "bool"], ["interval_sec", "int"],
|
||||
["show_on_dashboard", "bool"], ["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||
]},
|
||||
{ table: "uptime_probe_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["probe_id", "text"], ["sampled_at", "ts"], ["rtt_ms", "int"], ["loss_pct", "int"], ["status", "text"],
|
||||
{ table: "uptime_probe_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["probe_id", "text"], ["sampled_at", "ts"], ["rtt_ms", "int"], ["loss_pct", "int"], ["status", "text"],
|
||||
]},
|
||||
{ table: "uptime_resource_samples", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["id", "int"], ["server_id", "int"], ["sampled_at", "ts"], ["status", "text"], ["cpu_load", "int"],
|
||||
{ table: "uptime_resource_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||
["server_id", "int"], ["sampled_at", "ts"], ["status", "text"], ["cpu_load", "int"],
|
||||
["free_memory", "int"], ["total_memory", "int"], ["free_hdd_space", "int"], ["total_hdd_space", "int"],
|
||||
["uptime_seconds", "int"], ["board_name", "text"], ["ros_version", "text"],
|
||||
["uptime_seconds", "int"],
|
||||
]},
|
||||
{ table: "uptime_speed_probes", columns: [
|
||||
["id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"], ["src_interface", "text"],
|
||||
@@ -269,7 +278,20 @@ function parseJson(value: unknown, fallback: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function coerce(kind: ColKind, value: unknown, strict: boolean, rejects: string[], ctx: string): unknown {
|
||||
function parseInet(value: unknown): string | null {
|
||||
const s = String(value ?? "").trim()
|
||||
if (!s) return null
|
||||
return s
|
||||
}
|
||||
|
||||
function coerce(
|
||||
kind: ColKind,
|
||||
value: unknown,
|
||||
strict: boolean,
|
||||
rejects: string[],
|
||||
ctx: string,
|
||||
row?: Record<string, unknown>,
|
||||
): unknown {
|
||||
switch (kind) {
|
||||
case "ts":
|
||||
return parseTs(value, strict, rejects, ctx)
|
||||
@@ -278,9 +300,11 @@ function coerce(kind: ColKind, value: unknown, strict: boolean, rejects: string[
|
||||
case "bool":
|
||||
return parseBool(value)
|
||||
case "json":
|
||||
return parseJson(value, [])
|
||||
// Always JSON text. JS arrays must not go to node-pg as values — it encodes
|
||||
// them as PG arrays (`{...}`), which jsonb rejects (22P02).
|
||||
return JSON.stringify(parseJson(value, []))
|
||||
case "json-null":
|
||||
return value == null || value === "" ? null : parseJson(value, null)
|
||||
return value == null || value === "" ? null : JSON.stringify(parseJson(value, null))
|
||||
case "bigint-id": {
|
||||
const t = String(value ?? "").trim()
|
||||
if (!t) return null
|
||||
@@ -301,6 +325,10 @@ function coerce(kind: ColKind, value: unknown, strict: boolean, rejects: string[
|
||||
return Number(value)
|
||||
case "text":
|
||||
return value == null ? "" : String(value)
|
||||
case "flags":
|
||||
return encodeTrafficFlags(parseBool(row?.running), parseBool(row?.disabled))
|
||||
case "inet":
|
||||
return parseInet(value)
|
||||
default:
|
||||
return value == null ? null : String(value)
|
||||
}
|
||||
@@ -324,6 +352,35 @@ async function setval(pool: Pool, table: string): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
function placeholderFor(kind: ColKind, index: number): string {
|
||||
if (kind === "json" || kind === "json-null") return `$${index}::jsonb`
|
||||
if (kind === "inet") return `$${index}::inet`
|
||||
return `$${index}`
|
||||
}
|
||||
|
||||
async function precreatePartitions(
|
||||
sqlite: Database.Database,
|
||||
pool: Pool,
|
||||
spec: TableCopy,
|
||||
where: string,
|
||||
): Promise<void> {
|
||||
const part = specForParent(spec.table)
|
||||
if (!part || !spec.timeCol) return
|
||||
const bounds = sqlite.prepare(
|
||||
`SELECT MIN(${spec.timeCol}) AS a, MAX(${spec.timeCol}) AS b FROM ${spec.table}${where}`,
|
||||
).get() as { a?: unknown; b?: unknown }
|
||||
if (bounds?.a == null || bounds?.b == null) return
|
||||
const isDate = spec.columns.find((c) => c[0] === spec.timeCol)?.[1] === "date"
|
||||
const fromIso = isDate
|
||||
? `${String(bounds.a).slice(0, 10)}T00:00:00Z`
|
||||
: parseTs(bounds.a, false, [], spec.table)
|
||||
const toIso = isDate
|
||||
? `${String(bounds.b).slice(0, 10)}T00:00:00Z`
|
||||
: parseTs(bounds.b, false, [], spec.table)
|
||||
if (!fromIso || !toIso) return
|
||||
await ensurePartitionsBetween(pool, spec.table, part.kind, new Date(fromIso), new Date(toIso))
|
||||
}
|
||||
|
||||
async function copyTable(
|
||||
sqlite: Database.Database,
|
||||
pool: Pool,
|
||||
@@ -339,30 +396,32 @@ async function copyTable(
|
||||
where = ` WHERE ${spec.timeCol} >= '${cutoff.replace("T", " ").slice(0, 19)}' OR ${spec.timeCol} >= '${cutoff}'`
|
||||
}
|
||||
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
|
||||
const part = specForParent(spec.table)
|
||||
await precreatePartitions(sqlite, pool, spec, where)
|
||||
const cols = spec.columns.map(([c]) => c)
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ")
|
||||
const conflictSql = spec.upsert
|
||||
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
|
||||
: `ON CONFLICT DO NOTHING`
|
||||
const insertSql = `INSERT INTO ${spec.table} (${cols.join(", ")}) VALUES (${placeholders}) ${conflictSql}`
|
||||
let copied = 0
|
||||
let skipped = 0
|
||||
const stmt = sqlite.prepare(`SELECT * FROM ${spec.table}${where}`)
|
||||
const batch: unknown[][] = []
|
||||
const flush = async () => {
|
||||
if (batch.length === 0) return
|
||||
const n = spec.columns.length
|
||||
const valuesSql = batch.map((_, i) =>
|
||||
`(${spec.columns.map(([, kind], j) => placeholderFor(kind, i * n + j + 1)).join(", ")})`,
|
||||
).join(", ")
|
||||
const insertSql = `INSERT INTO ${spec.table} (${cols.join(", ")}) VALUES ${valuesSql} ${conflictSql}`
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
for (const values of batch) {
|
||||
await client.query(insertSql, values)
|
||||
copied += 1
|
||||
}
|
||||
await client.query(insertSql, batch.flat())
|
||||
copied += batch.length
|
||||
await client.query("COMMIT")
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK")
|
||||
throw err
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`${spec.table}: ${detail}`)
|
||||
} finally {
|
||||
client.release()
|
||||
batch.length = 0
|
||||
@@ -370,22 +429,15 @@ async function copyTable(
|
||||
}
|
||||
for (const row of stmt.iterate() as Iterable<Record<string, unknown>>) {
|
||||
try {
|
||||
if (part && spec.timeCol) {
|
||||
const raw = row[spec.timeCol]
|
||||
const ts = spec.columns.find((c) => c[0] === spec.timeCol)?.[1] === "date"
|
||||
? `${String(raw).slice(0, 10)}T00:00:00Z`
|
||||
: parseTs(raw, false, opts.rejects, spec.table)
|
||||
if (ts) await ensurePartitionFor(pool, spec.table, part.kind, new Date(ts))
|
||||
}
|
||||
const values = spec.columns.map(([col, kind]) =>
|
||||
coerce(kind, row[col], opts.strict, opts.rejects, `${spec.table}.${col}`),
|
||||
coerce(kind, row[col], opts.strict, opts.rejects, `${spec.table}.${col}`, row),
|
||||
)
|
||||
if (spec.table === "certificate_issue_jobs" && values[4] == null) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
batch.push(values)
|
||||
if (batch.length >= 200) await flush()
|
||||
if (batch.length >= INSERT_CHUNK) await flush()
|
||||
} catch (err) {
|
||||
skipped += 1
|
||||
const msg = `${spec.table}: ${err instanceof Error ? err.message : String(err)}`
|
||||
@@ -421,7 +473,11 @@ export async function shouldImportSqlite(pool: Pool, sqlitePath: string): Promis
|
||||
)
|
||||
if (marker.rows[0]?.sqlite_imported_at) return false
|
||||
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
|
||||
if (Number(servers.rows[0]?.c ?? 0) > 0) return false
|
||||
if (Number(servers.rows[0]?.c ?? 0) > 0) {
|
||||
console.warn(
|
||||
"SQLite → PostgreSQL: повтор недописанного импорта (маркера нет, servers уже не пустые)",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -462,6 +518,7 @@ export async function importSqliteToPostgres(
|
||||
return report
|
||||
}
|
||||
for (const spec of TABLES) {
|
||||
console.log(`SQLite → PostgreSQL: таблица ${spec.table}`)
|
||||
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
|
||||
}
|
||||
sqlite.close()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
/** node-pg encodes a JS array as a PostgreSQL array (`{...}`), not JSON (`[...]`). */
|
||||
{
|
||||
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232" }]
|
||||
const asJson = JSON.stringify(peers)
|
||||
assert.equal(asJson.startsWith("["), true)
|
||||
assert.equal(asJson.includes("msk-gw02.rtnt.top:13232"), true)
|
||||
}
|
||||
|
||||
console.log("sqlite-json.test.ts: ok")
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
decodeTrafficSampleFlags,
|
||||
encodeTrafficFlags,
|
||||
trafficFlagDisabled,
|
||||
trafficFlagRunning,
|
||||
} from "./traffic-flags.js"
|
||||
|
||||
assert.equal(encodeTrafficFlags(true, false), 1)
|
||||
assert.equal(encodeTrafficFlags(false, true), 2)
|
||||
assert.equal(encodeTrafficFlags(true, true), 3)
|
||||
assert.equal(encodeTrafficFlags(false, false), 0)
|
||||
assert.equal(trafficFlagRunning(1), true)
|
||||
assert.equal(trafficFlagDisabled(1), false)
|
||||
assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false })
|
||||
assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false })
|
||||
assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false })
|
||||
|
||||
console.log("traffic-flags.test.ts: ok")
|
||||
@@ -0,0 +1,24 @@
|
||||
export const TRAFFIC_FLAG_RUNNING = 1
|
||||
export const TRAFFIC_FLAG_DISABLED = 2
|
||||
|
||||
export function encodeTrafficFlags(running: boolean, disabled: boolean): number {
|
||||
return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0)
|
||||
}
|
||||
|
||||
export function trafficFlagRunning(flags: number | null | undefined): boolean {
|
||||
return ((Number(flags) || 0) & TRAFFIC_FLAG_RUNNING) !== 0
|
||||
}
|
||||
|
||||
export function trafficFlagDisabled(flags: number | null | undefined): boolean {
|
||||
return ((Number(flags) || 0) & TRAFFIC_FLAG_DISABLED) !== 0
|
||||
}
|
||||
|
||||
export function decodeTrafficSampleFlags(flags: number | null | undefined): {
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
} {
|
||||
return {
|
||||
running: trafficFlagRunning(flags),
|
||||
disabled: trafficFlagDisabled(flags),
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from "@mmapp/contracts/users"
|
||||
import { db } from "../../../db/index.js"
|
||||
import { parseJsonArray } from "../../../db/json.js"
|
||||
import { decodeTrafficSampleFlags } from "../../../db/traffic-flags.js"
|
||||
import { servers, trafficSamples } from "../../../db/schema.js"
|
||||
import {
|
||||
createBindingRow,
|
||||
@@ -270,8 +271,7 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
peerPublicKey: trafficSamples.peerPublicKey,
|
||||
running: trafficSamples.running,
|
||||
disabled: trafficSamples.disabled,
|
||||
flags: trafficSamples.flags,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId)))
|
||||
@@ -281,11 +281,12 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
|
||||
for (const r of rows) {
|
||||
if (seen.has(r.interfaceName)) continue
|
||||
seen.add(r.interfaceName)
|
||||
const decoded = decodeTrafficSampleFlags(r.flags)
|
||||
ifaces.push({
|
||||
name: r.interfaceName,
|
||||
type: mapRosInterfaceType("", r.interfaceName),
|
||||
running: Boolean(r.running),
|
||||
disabled: Boolean(r.disabled),
|
||||
running: decoded.running,
|
||||
disabled: decoded.disabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { z } from "zod"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
|
||||
import {
|
||||
putBackupScheduleSettingsSchema,
|
||||
putBackupStorageSettingsSchema,
|
||||
} from "@mmapp/contracts/backups"
|
||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import {
|
||||
deleteBackupRecord,
|
||||
getBackupById,
|
||||
getBackupsDir,
|
||||
getBackupScheduleSettings,
|
||||
getBackupStorageSettings,
|
||||
listBackups,
|
||||
readBackupContent,
|
||||
restoreBackupToDevice,
|
||||
runBackupForServer,
|
||||
syncBackupsFromS3,
|
||||
testBackupStorageConnection,
|
||||
updateBackupScheduleSettings,
|
||||
updateBackupStorageSettings,
|
||||
type BackupMeta,
|
||||
} from "../services/backup-service.js"
|
||||
|
||||
@@ -97,6 +103,39 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.get("/backups/storage", async (_req, reply) => {
|
||||
return reply.send(await getBackupStorageSettings())
|
||||
})
|
||||
|
||||
app.put("/backups/storage", async (req, reply) => {
|
||||
const parsed = putBackupStorageSettingsSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
return reply.send(await updateBackupStorageSettings(parsed.data))
|
||||
})
|
||||
|
||||
app.post("/backups/storage/test", async (_req, reply) => {
|
||||
try {
|
||||
return reply.send(await testBackupStorageConnection())
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: err instanceof Error ? err.message : "Ошибка проверки S3",
|
||||
settings: await getBackupStorageSettings(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/backups/storage/sync", async (_req, reply) => {
|
||||
try {
|
||||
return reply.send(await syncBackupsFromS3())
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: err instanceof Error ? err.message : "Ошибка синхронизации S3",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
|
||||
const inputIds = req.body.serverIds.map((x) => String(x))
|
||||
const notes = req.body.notes?.trim() || undefined
|
||||
@@ -169,12 +208,34 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
const hit = await getBackupById(req.params.id)
|
||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
const filePath = path.join(getBackupsDir(), hit.filename)
|
||||
const content = await readFile(filePath, "utf8").catch(() => null)
|
||||
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
||||
reply.header("Content-Type", "text/plain; charset=utf-8")
|
||||
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
|
||||
return reply.send(content)
|
||||
try {
|
||||
const content = await readBackupContent(hit)
|
||||
reply.header("Content-Type", "text/plain; charset=utf-8")
|
||||
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
|
||||
return reply.send(content)
|
||||
} catch {
|
||||
return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/backups/:id/restore", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
try {
|
||||
const result = await restoreBackupToDevice(req.params.id)
|
||||
await appendEvent({
|
||||
level: "warning",
|
||||
eventType: "backups.restore",
|
||||
sourceModule: "backups",
|
||||
title: "Восстановление бэкапа",
|
||||
message: `${result.filename} → ${result.serverName}`,
|
||||
entityType: "backup",
|
||||
entityId: req.params.id,
|
||||
})
|
||||
return reply.send(result)
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: err instanceof Error ? err.message : "Не удалось восстановить бэкап",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
|
||||
@@ -217,7 +217,7 @@ async function getLatestSnapshotLatencyMs(serverId: number): Promise<number> {
|
||||
return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100
|
||||
}
|
||||
|
||||
async function latestTrafficByInterface(serverId: number): Promise<Map<string, { id: number; serverId: number; disabled: boolean; sampledAt: string; interfaceName: string; peerPublicKey: string; rxBytes: number; txBytes: number; rxBps: number; txBps: number; running: boolean; }>> {
|
||||
async function latestTrafficByInterface(serverId: number): Promise<Map<string, TrafficSampleRow>> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trafficSamples)
|
||||
|
||||
@@ -275,7 +275,7 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
const payload = safeBuildLiveFlowSample(liveQuery)
|
||||
const payload = await safeBuildLiveFlowSample(liveQuery)
|
||||
writeSse(reply.raw, payload.event, payload.data)
|
||||
await sleep(LIVE_TICK_MS, abort.signal)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
||||
import { servers, serverSnapshots, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||
import { refreshScheduler, getSchedulerStatus } from "../services/scheduler.js"
|
||||
@@ -502,7 +502,14 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
const resources = await Promise.all(allServers.map(async (s) => {
|
||||
const rows = await readResourceSamplesSince(sinceIso, s.id)
|
||||
const [rows, snap] = await Promise.all([
|
||||
readResourceSamplesSince(sinceIso, s.id),
|
||||
db.select({ boardName: serverSnapshots.boardName })
|
||||
.from(serverSnapshots)
|
||||
.where(eq(serverSnapshots.serverId, s.id))
|
||||
.orderBy(desc(serverSnapshots.polledAt))
|
||||
.limit(1),
|
||||
])
|
||||
const { row: pick, hasData } = pickResourceDisplayRow(rows, resourceFallbackMaxGapMs)
|
||||
const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40)
|
||||
return {
|
||||
@@ -515,7 +522,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
hddUsed: Math.max(0, ((pick?.totalHddSpace ?? 0) - (pick?.freeHddSpace ?? 0)) / (1024 * 1024)),
|
||||
hddTotal: Math.max(0, (pick?.totalHddSpace ?? 0) / (1024 * 1024)),
|
||||
uptimeSeconds: pick?.uptimeSeconds ?? 0,
|
||||
boardName: pick?.boardName || "RouterBOARD",
|
||||
boardName: snap[0]?.boardName || "RouterBOARD",
|
||||
temp: undefined as number | undefined,
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, rm, stat, writeFile } from "node:fs/promises"
|
||||
import { mkdir, rm, stat, writeFile, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
|
||||
import type {
|
||||
BackupScheduleSettingsDto,
|
||||
BackupStorageSettingsDto,
|
||||
PutBackupStorageSettings,
|
||||
} from "@mmapp/contracts/backups"
|
||||
import { db } from "../db/index.js"
|
||||
import { parseJsonArray } from "../db/json.js"
|
||||
import { backupEntries, backupScheduleSettings } from "../db/schema.js"
|
||||
import { backupEntries, backupScheduleSettings, backupStorageSettings } from "../db/schema.js"
|
||||
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import {
|
||||
buildS3ObjectKey,
|
||||
createS3ClientFromConfig,
|
||||
parseS3ObjectKey,
|
||||
sanitizeServerName,
|
||||
s3DeleteObject,
|
||||
s3GetObject,
|
||||
s3ListObjects,
|
||||
s3PutObject,
|
||||
s3TestConnection,
|
||||
type S3BackupConfig,
|
||||
} from "./s3-backup-client.js"
|
||||
|
||||
const SETTINGS_ID = 1
|
||||
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
||||
@@ -22,9 +38,14 @@ export type BackupMeta = {
|
||||
createdAt: string
|
||||
kind: "manual" | "auto"
|
||||
notes?: string
|
||||
storage: "local" | "s3" | "both"
|
||||
s3Key?: string | null
|
||||
uploadError?: string | null
|
||||
}
|
||||
|
||||
function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
|
||||
type BackupRow = typeof backupEntries.$inferSelect
|
||||
|
||||
function rowToMeta(row: BackupRow): BackupMeta {
|
||||
return {
|
||||
id: row.id,
|
||||
serverId: row.serverId,
|
||||
@@ -34,6 +55,9 @@ function rowToMeta(row: typeof backupEntries.$inferSelect): BackupMeta {
|
||||
createdAt: row.createdAt,
|
||||
kind: row.kind,
|
||||
notes: row.notes ?? undefined,
|
||||
storage: row.storage ?? "local",
|
||||
s3Key: row.s3Key,
|
||||
uploadError: row.uploadError,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,16 +84,36 @@ export async function insertBackup(meta: BackupMeta): Promise<void> {
|
||||
sizeBytes: meta.sizeBytes,
|
||||
kind: meta.kind,
|
||||
notes: meta.notes ?? null,
|
||||
storage: meta.storage,
|
||||
s3Key: meta.s3Key ?? null,
|
||||
s3Etag: null,
|
||||
uploadError: meta.uploadError ?? null,
|
||||
createdAt: meta.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
async function getBackupRow(id: string): Promise<BackupRow | undefined> {
|
||||
return (await db.select().from(backupEntries).where(eq(backupEntries.id, id)).limit(1))[0]
|
||||
}
|
||||
|
||||
export async function deleteBackupRecord(id: string): Promise<BackupMeta | null> {
|
||||
const hit = await getBackupById(id)
|
||||
if (!hit) return null
|
||||
const row = await getBackupRow(id)
|
||||
if (!row) return null
|
||||
const meta = rowToMeta(row)
|
||||
if (row.s3Key) {
|
||||
try {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
if (cfg) {
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
await s3DeleteObject(client, cfg.bucket, row.s3Key)
|
||||
}
|
||||
} catch {
|
||||
/* объект мог уже отсутствовать */
|
||||
}
|
||||
}
|
||||
await db.delete(backupEntries).where(eq(backupEntries.id, id))
|
||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||
return hit
|
||||
await rm(path.join(BACKUPS_DIR, row.filename), { force: true })
|
||||
return meta
|
||||
}
|
||||
|
||||
function fmtTs(d = new Date()): string {
|
||||
@@ -167,6 +211,127 @@ export async function touchBackupScheduleRunMeta(patch: {
|
||||
}).where(eq(backupScheduleSettings.id, SETTINGS_ID))
|
||||
}
|
||||
|
||||
function defaultStorageRow() {
|
||||
return {
|
||||
id: SETTINGS_ID,
|
||||
provider: "local" as const,
|
||||
s3Endpoint: "",
|
||||
s3Region: "us-east-1",
|
||||
s3Bucket: "",
|
||||
s3Prefix: "mikrotik",
|
||||
s3AccessKeyId: "",
|
||||
s3SecretAccessKey: "",
|
||||
s3ForcePathStyle: true,
|
||||
keepLocalCopy: true,
|
||||
lastTestAt: null as string | null,
|
||||
lastTestError: null as string | null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
async function getBackupStorageSettingsRow() {
|
||||
return (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
|
||||
?? defaultStorageRow()
|
||||
}
|
||||
|
||||
function toStorageDto(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): BackupStorageSettingsDto {
|
||||
return {
|
||||
provider: row.provider,
|
||||
s3Endpoint: row.s3Endpoint,
|
||||
s3Region: row.s3Region,
|
||||
s3Bucket: row.s3Bucket,
|
||||
s3Prefix: row.s3Prefix,
|
||||
s3AccessKeyId: row.s3AccessKeyId,
|
||||
secretConfigured: Boolean(row.s3SecretAccessKey),
|
||||
s3ForcePathStyle: row.s3ForcePathStyle,
|
||||
keepLocalCopy: row.keepLocalCopy,
|
||||
lastTestAt: row.lastTestAt ?? null,
|
||||
lastTestError: row.lastTestError ?? null,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBackupStorageSettings(): Promise<BackupStorageSettingsDto> {
|
||||
return toStorageDto(await getBackupStorageSettingsRow())
|
||||
}
|
||||
|
||||
export async function updateBackupStorageSettings(
|
||||
patch: PutBackupStorageSettings,
|
||||
): Promise<BackupStorageSettingsDto> {
|
||||
const prev = await getBackupStorageSettingsRow()
|
||||
const now = new Date().toISOString()
|
||||
const secret = patch.s3SecretAccessKey
|
||||
const next = {
|
||||
provider: patch.provider ?? prev.provider,
|
||||
s3Endpoint: patch.s3Endpoint ?? prev.s3Endpoint,
|
||||
s3Region: patch.s3Region ?? prev.s3Region,
|
||||
s3Bucket: patch.s3Bucket ?? prev.s3Bucket,
|
||||
s3Prefix: patch.s3Prefix ?? prev.s3Prefix,
|
||||
s3AccessKeyId: patch.s3AccessKeyId ?? prev.s3AccessKeyId,
|
||||
s3SecretAccessKey: secret && secret.length > 0 ? secret : prev.s3SecretAccessKey,
|
||||
s3ForcePathStyle: patch.s3ForcePathStyle ?? prev.s3ForcePathStyle,
|
||||
keepLocalCopy: patch.keepLocalCopy ?? prev.keepLocalCopy,
|
||||
updatedAt: now,
|
||||
}
|
||||
if ((await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]) {
|
||||
await db.update(backupStorageSettings).set(next).where(eq(backupStorageSettings.id, SETTINGS_ID))
|
||||
} else {
|
||||
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...next })
|
||||
}
|
||||
return await getBackupStorageSettings()
|
||||
}
|
||||
|
||||
function rowToS3Config(row: Awaited<ReturnType<typeof getBackupStorageSettingsRow>>): S3BackupConfig | null {
|
||||
if (row.provider !== "s3") return null
|
||||
if (!row.s3Bucket.trim() || !row.s3AccessKeyId.trim() || !row.s3SecretAccessKey) return null
|
||||
return {
|
||||
endpoint: row.s3Endpoint,
|
||||
region: row.s3Region,
|
||||
bucket: row.s3Bucket.trim(),
|
||||
prefix: row.s3Prefix,
|
||||
accessKeyId: row.s3AccessKeyId,
|
||||
secretAccessKey: row.s3SecretAccessKey,
|
||||
forcePathStyle: row.s3ForcePathStyle,
|
||||
}
|
||||
}
|
||||
|
||||
async function getS3ConfigIfEnabled(): Promise<S3BackupConfig | null> {
|
||||
return rowToS3Config(await getBackupStorageSettingsRow())
|
||||
}
|
||||
|
||||
export async function testBackupStorageConnection(): Promise<BackupStorageSettingsDto> {
|
||||
const row = await getBackupStorageSettingsRow()
|
||||
const cfg = rowToS3Config(row)
|
||||
const now = new Date().toISOString()
|
||||
if (!cfg) {
|
||||
const error = row.provider === "s3"
|
||||
? "Заполните bucket, ключ доступа и секрет"
|
||||
: "S3 не выбран"
|
||||
await persistStorageTest(now, error)
|
||||
throw new Error(error)
|
||||
}
|
||||
try {
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
await s3TestConnection(client, cfg.bucket)
|
||||
await persistStorageTest(now, null)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
await persistStorageTest(now, message)
|
||||
throw new Error(message)
|
||||
}
|
||||
return await getBackupStorageSettings()
|
||||
}
|
||||
|
||||
async function persistStorageTest(at: string, error: string | null) {
|
||||
const exists = (await db.select().from(backupStorageSettings).where(eq(backupStorageSettings.id, SETTINGS_ID)).limit(1))[0]
|
||||
const patch = { lastTestAt: at, lastTestError: error, updatedAt: at }
|
||||
if (exists) {
|
||||
await db.update(backupStorageSettings).set(patch).where(eq(backupStorageSettings.id, SETTINGS_ID))
|
||||
} else {
|
||||
await db.insert(backupStorageSettings).values({ id: SETTINGS_ID, ...patch })
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveBackupServerIds(settings: BackupScheduleSettingsDto): Promise<string[]> {
|
||||
const enabled = new Set((await listServersRead()).map((s) => String(s.id)))
|
||||
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
|
||||
@@ -214,6 +379,23 @@ export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, last
|
||||
return true
|
||||
}
|
||||
|
||||
async function uploadBackupToS3(params: {
|
||||
serverName: string
|
||||
filename: string
|
||||
body: string
|
||||
}): Promise<{ key: string; etag?: string } | { error: string }> {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
if (!cfg) return { error: "S3 не настроен" }
|
||||
try {
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
const key = buildS3ObjectKey(cfg.prefix, sanitizeServerName(params.serverName), params.filename)
|
||||
const put = await s3PutObject(client, cfg.bucket, key, params.body)
|
||||
return { key, etag: put.etag }
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBackupForServer(
|
||||
id: string,
|
||||
kind: BackupMeta["kind"],
|
||||
@@ -230,12 +412,33 @@ export async function runBackupForServer(
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const script = await client.exportConfigScript()
|
||||
const ts = fmtTs()
|
||||
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
const safeServer = sanitizeServerName(row.name)
|
||||
const filename = `${safeServer}_${ts}.rsc`
|
||||
const filePath = path.join(BACKUPS_DIR, filename)
|
||||
await ensureBackupStorage()
|
||||
await writeFile(filePath, script, "utf8")
|
||||
const st = await stat(filePath)
|
||||
const storageRow = await getBackupStorageSettingsRow()
|
||||
let storage: BackupMeta["storage"] = "local"
|
||||
let s3Key: string | null = null
|
||||
let s3Etag: string | null = null
|
||||
let uploadError: string | null = null
|
||||
|
||||
if (storageRow.provider === "s3") {
|
||||
const uploaded = await uploadBackupToS3({ serverName: row.name, filename, body: script })
|
||||
if ("key" in uploaded) {
|
||||
s3Key = uploaded.key
|
||||
s3Etag = uploaded.etag ?? null
|
||||
storage = storageRow.keepLocalCopy ? "both" : "s3"
|
||||
if (!storageRow.keepLocalCopy) {
|
||||
await rm(filePath, { force: true })
|
||||
}
|
||||
} else {
|
||||
uploadError = uploaded.error
|
||||
storage = "local"
|
||||
}
|
||||
}
|
||||
|
||||
const meta: BackupMeta = {
|
||||
id: randomUUID(),
|
||||
serverId: row.id,
|
||||
@@ -245,8 +448,24 @@ export async function runBackupForServer(
|
||||
createdAt: new Date().toISOString(),
|
||||
kind,
|
||||
notes,
|
||||
storage,
|
||||
s3Key,
|
||||
uploadError,
|
||||
}
|
||||
await insertBackup(meta)
|
||||
await db.insert(backupEntries).values({
|
||||
id: meta.id,
|
||||
serverId: meta.serverId,
|
||||
serverName: meta.serverName,
|
||||
filename: meta.filename,
|
||||
sizeBytes: meta.sizeBytes,
|
||||
kind: meta.kind,
|
||||
notes: meta.notes ?? null,
|
||||
storage: meta.storage,
|
||||
s3Key: meta.s3Key ?? null,
|
||||
s3Etag,
|
||||
uploadError: meta.uploadError ?? null,
|
||||
createdAt: meta.createdAt,
|
||||
})
|
||||
return meta
|
||||
}
|
||||
|
||||
@@ -257,12 +476,82 @@ export async function pruneBackupsForServer(serverId: string, keepCount: number)
|
||||
if (rows.length <= keepCount) return 0
|
||||
const toDelete = rows.slice(keepCount)
|
||||
for (const hit of toDelete) {
|
||||
await db.delete(backupEntries).where(eq(backupEntries.id, hit.id))
|
||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||
await deleteBackupRecord(hit.id)
|
||||
}
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
export async function readBackupContent(meta: BackupMeta): Promise<Buffer> {
|
||||
if ((meta.storage === "s3" || meta.storage === "both") && meta.s3Key) {
|
||||
try {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
if (cfg) {
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
return await s3GetObject(client, cfg.bucket, meta.s3Key)
|
||||
}
|
||||
} catch {
|
||||
/* fallback: локальная копия, если есть */
|
||||
}
|
||||
}
|
||||
return await readFile(path.join(BACKUPS_DIR, meta.filename))
|
||||
}
|
||||
|
||||
export async function restoreBackupToDevice(id: string): Promise<{ filename: string; serverName: string }> {
|
||||
const meta = await getBackupById(id)
|
||||
if (!meta) throw new Error("Бэкап не найден")
|
||||
if (!meta.serverId) throw new Error("Сервер бэкапа удалён — восстановить нельзя")
|
||||
const row = await getServerRowById(meta.serverId)
|
||||
if (!row) throw new Error("Сервер не найден")
|
||||
const content = await readBackupContent(meta)
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const uploaded = await client.uploadTextFile(meta.filename, content.toString("utf8"), 60_000)
|
||||
await client.importUploadedFile(uploaded)
|
||||
return { filename: meta.filename, serverName: row.name }
|
||||
}
|
||||
|
||||
export async function syncBackupsFromS3(): Promise<{ imported: number; skipped: number }> {
|
||||
const cfg = await getS3ConfigIfEnabled()
|
||||
if (!cfg) throw new Error("S3 не настроен")
|
||||
const client = createS3ClientFromConfig(cfg)
|
||||
const objects = await s3ListObjects(client, cfg.bucket, cfg.prefix)
|
||||
const existing = new Set(
|
||||
(await db.select({ filename: backupEntries.filename, s3Key: backupEntries.s3Key }).from(backupEntries))
|
||||
.flatMap((row) => [row.filename, row.s3Key].filter((v): v is string => Boolean(v))),
|
||||
)
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
for (const obj of objects) {
|
||||
if (!obj.key.toLowerCase().endsWith(".rsc")) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
const parsed = parseS3ObjectKey(obj.key)
|
||||
if (existing.has(obj.key) || existing.has(parsed.filename)) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
const createdAt = obj.lastModified ?? new Date().toISOString()
|
||||
await db.insert(backupEntries).values({
|
||||
id: randomUUID(),
|
||||
serverId: null,
|
||||
serverName: parsed.serverName,
|
||||
filename: parsed.filename,
|
||||
sizeBytes: obj.size,
|
||||
kind: "auto",
|
||||
notes: "Импорт из S3",
|
||||
storage: "s3",
|
||||
s3Key: obj.key,
|
||||
s3Etag: null,
|
||||
uploadError: null,
|
||||
createdAt,
|
||||
})
|
||||
existing.add(obj.key)
|
||||
existing.add(parsed.filename)
|
||||
imported += 1
|
||||
}
|
||||
return { imported, skipped }
|
||||
}
|
||||
|
||||
export function getBackupsDir(): string {
|
||||
return BACKUPS_DIR
|
||||
}
|
||||
|
||||
@@ -113,6 +113,15 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
|
||||
continue
|
||||
}
|
||||
|
||||
const stillOn = await getCertificateRenewSettings()
|
||||
if (!stillOn.enabled) {
|
||||
item.action = "skipped"
|
||||
item.message = "Автообновление выключено"
|
||||
snapshot.skippedTargets += 1
|
||||
snapshot.targets?.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const jobId = randomUUID()
|
||||
await createIssueJobRecord({
|
||||
id: jobId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, lt } from "drizzle-orm"
|
||||
import { and, asc, desc, eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
filterRules,
|
||||
@@ -45,11 +45,6 @@ async function getSettingsRow() {
|
||||
return (await db.select().from(internetPathSettings).where(eq(internetPathSettings.id, 1)).limit(1))[0]
|
||||
}
|
||||
|
||||
async function cleanupSnapshots(retentionDays: number) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
await db.delete(internetPathSnapshots).where(lt(internetPathSnapshots.sampledAt, cutoff))
|
||||
}
|
||||
|
||||
async function buildRulesets() {
|
||||
const enabled = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||
const rules = await db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder))
|
||||
@@ -273,7 +268,6 @@ export async function collectInternetPathSnapshotOnce(): Promise<InternetPathRun
|
||||
sampledAt,
|
||||
payloadJson: payload,
|
||||
})
|
||||
await cleanupSnapshots(Math.max(1, settings.retentionDays))
|
||||
await db.update(internetPathSettings).set({
|
||||
lastCollectedAt: sampledAt,
|
||||
lastDurationMs: Date.now() - started,
|
||||
|
||||
@@ -586,6 +586,14 @@ export class MikrotikClient {
|
||||
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
|
||||
}
|
||||
|
||||
async importUploadedFile(fileName: string): Promise<unknown> {
|
||||
try {
|
||||
return await this.post("/import", { "file-name": fileName }, 120_000)
|
||||
} catch {
|
||||
return await this.post("/execute", { script: `/import file-name="${fileName}"` }, 120_000)
|
||||
}
|
||||
}
|
||||
|
||||
async importCertificate(params: {
|
||||
fileName: string
|
||||
name: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq, lt } from "drizzle-orm"
|
||||
import { db, pool } from "../db/index.js"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db, dbQuery, pool } from "../db/index.js"
|
||||
import { dropExpiredPartitions, ensurePartitionsAround } from "../db/partitions.js"
|
||||
import { servers, serverSnapshots } from "../db/schema.js"
|
||||
import type { SnapshotInsert } from "../db/schema.js"
|
||||
@@ -82,11 +82,18 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
||||
.values(partialSnap as SnapshotInsert)
|
||||
.returning()
|
||||
|
||||
const cutoffIso = new Date(Date.now() - 14 * 24 * 3600_000).toISOString()
|
||||
await db.delete(serverSnapshots).where(lt(serverSnapshots.polledAt, cutoffIso))
|
||||
if (inserted) {
|
||||
await dbQuery(
|
||||
`UPDATE server_snapshots
|
||||
SET raw_interfaces = NULL, raw_ip_addresses = NULL
|
||||
WHERE server_id = $1 AND polled_at < $2
|
||||
AND (raw_interfaces IS NOT NULL OR raw_ip_addresses IS NOT NULL)`,
|
||||
[serverId, inserted.polledAt],
|
||||
)
|
||||
}
|
||||
void dropExpiredPartitions(pool).then(() => ensurePartitionsAround(pool))
|
||||
|
||||
return toSnapshotRead(inserted)
|
||||
return toSnapshotRead(inserted!)
|
||||
}
|
||||
|
||||
// ── helper ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
type S3Client,
|
||||
} from "@aws-sdk/client-s3"
|
||||
import {
|
||||
buildS3ObjectKey,
|
||||
normalizeS3Prefix,
|
||||
parseS3ObjectKey,
|
||||
sanitizeServerName,
|
||||
s3DeleteObject,
|
||||
s3GetObject,
|
||||
s3ListObjects,
|
||||
s3PutObject,
|
||||
s3TestConnection,
|
||||
} from "./s3-backup-client.js"
|
||||
|
||||
assert.equal(normalizeS3Prefix("/mikrotik/backups/"), "mikrotik/backups")
|
||||
assert.equal(sanitizeServerName("MSK CHR 01"), "MSK_CHR_01")
|
||||
assert.equal(
|
||||
buildS3ObjectKey("mikrotik", "msk-chr01", "chr_2026-09-08_03-00-00.rsc"),
|
||||
"mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc",
|
||||
)
|
||||
assert.deepEqual(
|
||||
parseS3ObjectKey("mikrotik/msk-chr01/chr_2026-09-08_03-00-00.rsc"),
|
||||
{ filename: "chr_2026-09-08_03-00-00.rsc", serverName: "msk-chr01" },
|
||||
)
|
||||
|
||||
const store = new Map<string, Buffer>()
|
||||
let lastCommand = ""
|
||||
|
||||
const fake = {
|
||||
send: async (command: { input?: Record<string, unknown> }) => {
|
||||
const name = command.constructor.name
|
||||
lastCommand = name
|
||||
const input = command.input ?? {}
|
||||
if (command instanceof HeadBucketCommand || name === "HeadBucketCommand") {
|
||||
if (input.Bucket !== "backups") throw new Error("no bucket")
|
||||
return {}
|
||||
}
|
||||
if (command instanceof PutObjectCommand || name === "PutObjectCommand") {
|
||||
const key = String(input.Key)
|
||||
const body = input.Body
|
||||
store.set(key, Buffer.isBuffer(body) ? body : Buffer.from(String(body)))
|
||||
return { ETag: '"etag-1"' }
|
||||
}
|
||||
if (command instanceof GetObjectCommand || name === "GetObjectCommand") {
|
||||
const key = String(input.Key)
|
||||
const body = store.get(key)
|
||||
if (!body) throw new Error("not found")
|
||||
return { Body: { transformToByteArray: async () => new Uint8Array(body) } }
|
||||
}
|
||||
if (command instanceof DeleteObjectCommand || name === "DeleteObjectCommand") {
|
||||
store.delete(String(input.Key))
|
||||
return {}
|
||||
}
|
||||
if (command instanceof ListObjectsV2Command || name === "ListObjectsV2Command") {
|
||||
const prefix = String(input.Prefix ?? "")
|
||||
const contents = [...store.entries()]
|
||||
.filter(([key]) => !prefix || key.startsWith(prefix))
|
||||
.map(([key, buf]) => ({ Key: key, Size: buf.length, LastModified: new Date("2026-09-08T00:00:00Z") }))
|
||||
return { Contents: contents, IsTruncated: false }
|
||||
}
|
||||
throw new Error(`unexpected command ${name}`)
|
||||
},
|
||||
} as unknown as S3Client
|
||||
|
||||
await s3TestConnection(fake, "backups")
|
||||
assert.equal(lastCommand === "HeadBucketCommand" || lastCommand.includes("Head"), true)
|
||||
|
||||
const put = await s3PutObject(fake, "backups", "mikrotik/a/file.rsc", "hello")
|
||||
assert.equal(put.etag, '"etag-1"')
|
||||
|
||||
const got = await s3GetObject(fake, "backups", "mikrotik/a/file.rsc")
|
||||
assert.equal(got.toString("utf8"), "hello")
|
||||
|
||||
const listed = await s3ListObjects(fake, "backups", "mikrotik")
|
||||
assert.equal(listed.length, 1)
|
||||
assert.equal(listed[0]?.key, "mikrotik/a/file.rsc")
|
||||
|
||||
await s3DeleteObject(fake, "backups", "mikrotik/a/file.rsc")
|
||||
const after = await s3ListObjects(fake, "backups", "mikrotik")
|
||||
assert.equal(after.length, 0)
|
||||
|
||||
console.log("s3-backup-client.test.ts: ok")
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
type S3ClientConfig,
|
||||
} from "@aws-sdk/client-s3"
|
||||
|
||||
export type S3BackupConfig = {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
prefix: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
forcePathStyle: boolean
|
||||
}
|
||||
|
||||
export type S3ListedObject = {
|
||||
key: string
|
||||
size: number
|
||||
lastModified?: string
|
||||
}
|
||||
|
||||
export function normalizeS3Prefix(prefix: string): string {
|
||||
return prefix.trim().replace(/^\/+|\/+$/g, "")
|
||||
}
|
||||
|
||||
export function sanitizeServerName(name: string): string {
|
||||
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "")
|
||||
return safe || "server"
|
||||
}
|
||||
|
||||
export function buildS3ObjectKey(prefix: string, serverSafe: string, filename: string): string {
|
||||
const parts = [normalizeS3Prefix(prefix), sanitizeServerName(serverSafe), filename]
|
||||
.filter((part) => part.length > 0)
|
||||
return parts.join("/")
|
||||
}
|
||||
|
||||
export function parseS3ObjectKey(key: string): { filename: string; serverName: string } {
|
||||
const parts = key.split("/").filter(Boolean)
|
||||
const filename = parts.pop() ?? key
|
||||
const folder = parts.pop() ?? ""
|
||||
const base = filename.replace(/\.(rsc|backup)$/i, "")
|
||||
const fromFilename = base.replace(/_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/, "")
|
||||
return { filename, serverName: folder || fromFilename || filename }
|
||||
}
|
||||
|
||||
export function createS3ClientFromConfig(cfg: S3BackupConfig): S3Client {
|
||||
const options: S3ClientConfig = {
|
||||
region: cfg.region.trim() || "us-east-1",
|
||||
credentials: {
|
||||
accessKeyId: cfg.accessKeyId,
|
||||
secretAccessKey: cfg.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: cfg.forcePathStyle,
|
||||
}
|
||||
const endpoint = cfg.endpoint.trim()
|
||||
if (endpoint) options.endpoint = endpoint
|
||||
return new S3Client(options)
|
||||
}
|
||||
|
||||
export async function s3TestConnection(client: S3Client, bucket: string): Promise<void> {
|
||||
try {
|
||||
await client.send(new HeadBucketCommand({ Bucket: bucket }))
|
||||
} catch {
|
||||
await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
export async function s3PutObject(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
body: Buffer | string,
|
||||
): Promise<{ etag?: string }> {
|
||||
const out = await client.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
}))
|
||||
return { etag: out.ETag }
|
||||
}
|
||||
|
||||
export async function s3GetObject(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
key: string,
|
||||
): Promise<Buffer> {
|
||||
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
|
||||
const bytes = await out.Body?.transformToByteArray()
|
||||
if (!bytes) throw new Error("Пустой объект S3")
|
||||
return Buffer.from(bytes)
|
||||
}
|
||||
|
||||
export async function s3DeleteObject(client: S3Client, bucket: string, key: string): Promise<void> {
|
||||
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }))
|
||||
}
|
||||
|
||||
export async function s3ListObjects(
|
||||
client: S3Client,
|
||||
bucket: string,
|
||||
prefix: string,
|
||||
): Promise<S3ListedObject[]> {
|
||||
const items: S3ListedObject[] = []
|
||||
let token: string | undefined
|
||||
const normalized = normalizeS3Prefix(prefix)
|
||||
do {
|
||||
const out = await client.send(new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: normalized ? `${normalized}/` : undefined,
|
||||
ContinuationToken: token,
|
||||
}))
|
||||
for (const obj of out.Contents ?? []) {
|
||||
if (!obj.Key) continue
|
||||
items.push({
|
||||
key: obj.Key,
|
||||
size: obj.Size ?? 0,
|
||||
lastModified: obj.LastModified?.toISOString(),
|
||||
})
|
||||
}
|
||||
token = out.IsTruncated ? out.NextContinuationToken : undefined
|
||||
} while (token)
|
||||
return items
|
||||
}
|
||||
@@ -162,8 +162,6 @@ export async function collectServersRestPingOnce(): Promise<ServersRestPingRunSn
|
||||
}
|
||||
})
|
||||
|
||||
await dbQuery(`DELETE FROM servers_rest_ping_samples WHERE sampled_at < now() - interval '30 days'`)
|
||||
|
||||
await db.update(serversApiPingSettings)
|
||||
.set({
|
||||
lastCollectedAt: sampledAt,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
||||
import { decodeTrafficSampleFlags, encodeTrafficFlags } from "../db/traffic-flags.js"
|
||||
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
@@ -79,12 +80,6 @@ async function getSettingsRow() {
|
||||
return (await db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1))[0]
|
||||
}
|
||||
|
||||
async function cleanupOldSamples(retentionDays: number) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
await db.delete(trafficSamples)
|
||||
.where(lt(trafficSamples.sampledAt, cutoff))
|
||||
}
|
||||
|
||||
async function readPreviousWave(serverId: number): Promise<Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>> {
|
||||
const last = (await db
|
||||
.select({ sampledAt: trafficSamples.sampledAt })
|
||||
@@ -164,8 +159,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
running,
|
||||
disabled,
|
||||
flags: encodeTrafficFlags(running, disabled),
|
||||
}
|
||||
})
|
||||
try {
|
||||
@@ -194,8 +188,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
running,
|
||||
disabled,
|
||||
flags: encodeTrafficFlags(running, disabled),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
@@ -224,7 +217,6 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
await cleanupOldSamples(Math.max(1, settings.retentionDays))
|
||||
await db.update(trafficSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - startedAt,
|
||||
@@ -286,11 +278,12 @@ export async function updateTrafficSettings(patch: {
|
||||
}
|
||||
|
||||
export async function readServerSamplesInRange(serverId: number, sinceIso: string) {
|
||||
return await db.select()
|
||||
const rows = await db.select()
|
||||
.from(trafficSamples)
|
||||
.where(and(
|
||||
eq(trafficSamples.serverId, serverId),
|
||||
gte(trafficSamples.sampledAt, sinceIso),
|
||||
))
|
||||
.orderBy(asc(trafficSamples.sampledAt))
|
||||
return rows.map((r) => ({ ...r, ...decodeTrafficSampleFlags(r.flags) }))
|
||||
}
|
||||
|
||||
@@ -16,6 +16,29 @@ import {
|
||||
seedRipeCacheForTests,
|
||||
} from "./traffic-flow-ripe.js"
|
||||
|
||||
{
|
||||
const liveErr = await formatLiveSseFromBuilder(() => {
|
||||
throw new Error("SQLITE_BUSY")
|
||||
})
|
||||
assert.equal(liveErr.event, "error")
|
||||
assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY")
|
||||
const liveOk = await formatLiveSseFromBuilder(() => ({ ok: true }))
|
||||
assert.equal(liveOk.event, "sample")
|
||||
const liveAsync = await formatLiveSseFromBuilder(async () => ({
|
||||
uniqueSrc: 3,
|
||||
destinations: [{ id: "8.8.8.8", label: "8.8.8.8", bytes: 1, packets: 1, bps: 1, percent: 100 }],
|
||||
}))
|
||||
assert.equal(liveAsync.event, "sample")
|
||||
assert.notEqual(JSON.stringify(liveAsync.data), "{}")
|
||||
assert.equal((liveAsync.data as { uniqueSrc: number }).uniqueSrc, 3)
|
||||
assert.ok(Array.isArray((liveAsync.data as { destinations: unknown[] }).destinations))
|
||||
const liveReject = await formatLiveSseFromBuilder(async () => {
|
||||
throw new Error("pg down")
|
||||
})
|
||||
assert.equal(liveReject.event, "error")
|
||||
assert.equal((liveReject.data as { error: string }).error, "pg down")
|
||||
}
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("traffic-flow-analytics.test.ts: skip")
|
||||
process.exit(0)
|
||||
@@ -248,13 +271,6 @@ try {
|
||||
assert.equal(degraded.degraded, true)
|
||||
assert.equal(degraded.conversationsList.length, 0)
|
||||
assert.ok((degraded.bytes ?? 0) >= 12_000)
|
||||
const liveErr = formatLiveSseFromBuilder(() => {
|
||||
throw new Error("SQLITE_BUSY")
|
||||
})
|
||||
assert.equal(liveErr.event, "error")
|
||||
assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY")
|
||||
const liveOk = formatLiveSseFromBuilder(() => ({ ok: true }))
|
||||
assert.equal(liveOk.event, "sample")
|
||||
const exporters = await listFlowExporters(5)
|
||||
const clients = await listFlowClients(5)
|
||||
assert.ok(Array.isArray(exporters.exporters))
|
||||
|
||||
@@ -599,9 +599,12 @@ export async function listFlowClients(minutes: number): Promise<{ clients: { id:
|
||||
return { clients }
|
||||
}
|
||||
|
||||
export function formatLiveSseFromBuilder(build: () => unknown): { event: "sample" | "error"; data: unknown } {
|
||||
export async function formatLiveSseFromBuilder(
|
||||
build: () => unknown | Promise<unknown>,
|
||||
): Promise<{ event: "sample" | "error"; data: unknown }> {
|
||||
try {
|
||||
return { event: "sample", data: build() }
|
||||
const data = await build()
|
||||
return { event: "sample", data }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return { event: "error", data: { error: message } }
|
||||
@@ -613,10 +616,10 @@ export function isFlowAnalyticsDegraded(): boolean {
|
||||
return health.pendingSize >= LIVE_DEGRADED_PENDING
|
||||
}
|
||||
|
||||
export function safeBuildLiveFlowSample(q: Omit<FlowAnalyticsQuery, "minutes" | "skipHeavy">): {
|
||||
export async function safeBuildLiveFlowSample(q: Omit<FlowAnalyticsQuery, "minutes" | "skipHeavy">): Promise<{
|
||||
event: "sample" | "error"
|
||||
data: unknown
|
||||
} {
|
||||
}> {
|
||||
return formatLiveSseFromBuilder(async () => {
|
||||
const skipHeavy = isFlowAnalyticsDegraded()
|
||||
return await buildFlowAnalytics({ ...q, minutes: LIVE_ANALYTICS_MINUTES, skipHeavy })
|
||||
|
||||
@@ -60,7 +60,7 @@ function stopListener(): void {
|
||||
clearInterval(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
void flushPending().catch((e) => {
|
||||
void flushPending({ force: true }).catch((e) => {
|
||||
setEngineError(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
if (socket) {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
|
||||
export const TICK_MS = 2_000
|
||||
export const PERSIST_MS = 10_000
|
||||
export const STATS_PERSIST_MS = 15_000
|
||||
export const RING_LEN = 60
|
||||
export const MAX_PENDING = 50_000
|
||||
export const DAILY_ASN_TOP = 500
|
||||
@@ -48,6 +50,66 @@ export interface PendingFlowRow {
|
||||
flowEndMs: number
|
||||
}
|
||||
|
||||
function inetOrNull(value: string | null | undefined): string | null {
|
||||
const s = String(value ?? "").trim()
|
||||
return s.length > 0 ? s : null
|
||||
}
|
||||
|
||||
export function isValidFlowInet(value: string): boolean {
|
||||
const s = value.trim()
|
||||
if (!s) return false
|
||||
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s)
|
||||
if (v4) {
|
||||
return v4.slice(1).every((octet) => {
|
||||
const n = Number(octet)
|
||||
return Number.isInteger(n) && n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
if (!s.includes(":")) return false
|
||||
if (!/^[0-9a-fA-F:]+$/.test(s)) return false
|
||||
const parts = s.split(":")
|
||||
if (parts.length < 3 || parts.length > 8) return false
|
||||
return parts.every((p) => p.length <= 4)
|
||||
}
|
||||
|
||||
function clampProto(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0
|
||||
return Math.max(0, Math.min(255, Math.trunc(n)))
|
||||
}
|
||||
|
||||
function sanitizeFlowRow(r: PendingFlowRow): PendingFlowRow | null {
|
||||
const src = (r.src || "").trim() || "0.0.0.0"
|
||||
const dst = (r.dst || "").trim() || "0.0.0.0"
|
||||
if (!isValidFlowInet(src) || !isValidFlowInet(dst)) return null
|
||||
const next = inetOrNull(r.nextHop)
|
||||
return {
|
||||
...r,
|
||||
src,
|
||||
dst,
|
||||
nextHop: next && isValidFlowInet(next) ? next : "",
|
||||
proto: clampProto(r.proto),
|
||||
}
|
||||
}
|
||||
|
||||
function flowUpsertParams(r: PendingFlowRow) {
|
||||
return {
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface,
|
||||
nextHop: inetOrNull(r.nextHop),
|
||||
flowStartMs: r.flowStartMs,
|
||||
flowEndMs: r.flowEndMs,
|
||||
}
|
||||
}
|
||||
|
||||
export interface EngineStats {
|
||||
packetsReceived: number
|
||||
lastExporterIp: string | null
|
||||
@@ -105,6 +167,9 @@ let rowsStored = 0
|
||||
let lastFlushUsedTransaction = false
|
||||
let lastPruneAt = 0
|
||||
let lastPassiveCheckpointAt = 0
|
||||
let lastPersistAt = 0
|
||||
let lastFlushedMinute = ""
|
||||
let lastStatsPersistAt = 0
|
||||
let dataEpoch = 0
|
||||
let lastPersistedStats: {
|
||||
packetsReceived: number
|
||||
@@ -449,7 +514,7 @@ export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; ou
|
||||
}
|
||||
}
|
||||
|
||||
async function persistListenerStats(): Promise<boolean> {
|
||||
async function persistListenerStats(force = false): Promise<boolean> {
|
||||
if (
|
||||
lastPersistedStats
|
||||
&& lastPersistedStats.packetsReceived === packetsReceived
|
||||
@@ -459,6 +524,12 @@ async function persistListenerStats(): Promise<boolean> {
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const errorChanged = lastPersistedStats?.lastError !== lastError
|
||||
const exporterChanged = lastPersistedStats?.lastExporterIp !== lastExporterIp
|
||||
const now = Date.now()
|
||||
if (!force && !errorChanged && !exporterChanged && now - lastStatsPersistAt < STATS_PERSIST_MS) {
|
||||
return false
|
||||
}
|
||||
await dbQuery(`
|
||||
UPDATE traffic_flow_settings
|
||||
SET packets_received = @packetsReceived,
|
||||
@@ -480,6 +551,7 @@ async function persistListenerStats(): Promise<boolean> {
|
||||
lastExporterIp,
|
||||
lastError,
|
||||
}
|
||||
lastStatsPersistAt = now
|
||||
invalidateTrafficFlowSettingsCache()
|
||||
return true
|
||||
}
|
||||
@@ -644,20 +716,70 @@ function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
|
||||
return out
|
||||
}
|
||||
|
||||
export async function flushPending(): Promise<void> {
|
||||
pruneRecent()
|
||||
rollFlowRings()
|
||||
await persistListenerStats()
|
||||
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
|
||||
await pruneStored()
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
const rows = topNPending([...pending.values()].map(toPendingRow))
|
||||
pending.clear()
|
||||
for (const row of rows) mergeInto(recent, row)
|
||||
function persistDue(force: boolean, hasWork: boolean): boolean {
|
||||
if (force) return true
|
||||
if (!hasWork) return false
|
||||
if (minuteBucketIso() !== lastFlushedMinute) return true
|
||||
return Date.now() - lastPersistAt >= PERSIST_MS
|
||||
}
|
||||
|
||||
const upsertSql = `
|
||||
async function upsertFlowBucketsBatch(rows: PendingFlowRow[]): Promise<void> {
|
||||
if (rows.length === 0) return
|
||||
const days = new Set(rows.map((r) => r.bucketAt))
|
||||
for (const bucketAt of days) await ensureParentPartition("flow_buckets", bucketAt)
|
||||
await pool.query({
|
||||
text: `
|
||||
INSERT INTO flow_buckets (
|
||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
||||
)
|
||||
SELECT *
|
||||
FROM UNNEST(
|
||||
$1::bigint[],
|
||||
$2::timestamptz[],
|
||||
$3::inet[],
|
||||
$4::inet[],
|
||||
$5::smallint[],
|
||||
$6::int[],
|
||||
$7::int[],
|
||||
$8::bigint[],
|
||||
$9::bigint[],
|
||||
$10::text[],
|
||||
$11::text[],
|
||||
$12::inet[],
|
||||
$13::bigint[],
|
||||
$14::bigint[]
|
||||
) AS t(server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms)
|
||||
ON CONFLICT (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||
DO UPDATE SET
|
||||
bytes = flow_buckets.bytes + excluded.bytes,
|
||||
packets = flow_buckets.packets + excluded.packets,
|
||||
out_iface = CASE WHEN excluded.out_iface != '' THEN excluded.out_iface ELSE flow_buckets.out_iface END,
|
||||
next_hop = COALESCE(excluded.next_hop, flow_buckets.next_hop),
|
||||
flow_start_ms = CASE
|
||||
WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms)
|
||||
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
|
||||
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms)
|
||||
`,
|
||||
values: [
|
||||
rows.map((r) => r.serverId),
|
||||
rows.map((r) => r.bucketAt),
|
||||
rows.map((r) => r.src),
|
||||
rows.map((r) => r.dst),
|
||||
rows.map((r) => r.proto),
|
||||
rows.map((r) => r.srcPort),
|
||||
rows.map((r) => r.dstPort),
|
||||
rows.map((r) => r.bytes),
|
||||
rows.map((r) => r.packets),
|
||||
rows.map((r) => r.inIface),
|
||||
rows.map((r) => r.outIface),
|
||||
rows.map((r) => inetOrNull(r.nextHop)),
|
||||
rows.map((r) => r.flowStartMs),
|
||||
rows.map((r) => r.flowEndMs),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const FLOW_UPSERT_SQL = `
|
||||
INSERT INTO flow_buckets (
|
||||
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface, out_iface, next_hop, flow_start_ms, flow_end_ms
|
||||
) VALUES (
|
||||
@@ -668,61 +790,93 @@ export async function flushPending(): Promise<void> {
|
||||
bytes = flow_buckets.bytes + excluded.bytes,
|
||||
packets = flow_buckets.packets + excluded.packets,
|
||||
out_iface = CASE WHEN excluded.out_iface != '' THEN excluded.out_iface ELSE flow_buckets.out_iface END,
|
||||
next_hop = CASE WHEN excluded.next_hop != '' THEN excluded.next_hop ELSE flow_buckets.next_hop END,
|
||||
next_hop = COALESCE(excluded.next_hop, flow_buckets.next_hop),
|
||||
flow_start_ms = CASE
|
||||
WHEN excluded.flow_start_ms > 0 AND (flow_buckets.flow_start_ms = 0 OR excluded.flow_start_ms < flow_buckets.flow_start_ms)
|
||||
THEN excluded.flow_start_ms ELSE flow_buckets.flow_start_ms END,
|
||||
flow_end_ms = GREATEST(flow_buckets.flow_end_ms, excluded.flow_end_ms)
|
||||
`
|
||||
lastFlushUsedTransaction = false
|
||||
|
||||
async function upsertFlowBuckets(rows: PendingFlowRow[]): Promise<number> {
|
||||
if (rows.length === 0) return 0
|
||||
try {
|
||||
for (const r of rows) {
|
||||
await ensureParentPartition("flow_buckets", r.bucketAt)
|
||||
await dbQuery(upsertSql, {
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface,
|
||||
nextHop: r.nextHop,
|
||||
flowStartMs: r.flowStartMs,
|
||||
flowEndMs: r.flowEndMs,
|
||||
})
|
||||
}
|
||||
lastFlushUsedTransaction = true
|
||||
rowsStored += rows.length
|
||||
bumpDataEpoch()
|
||||
} catch {
|
||||
await upsertFlowBucketsBatch(rows)
|
||||
return rows.length
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setEngineError(`flow_buckets: ${message}`)
|
||||
let stored = 0
|
||||
for (const r of rows) {
|
||||
try {
|
||||
await ensureParentPartition("flow_buckets", r.bucketAt)
|
||||
await dbQuery(upsertSql, {
|
||||
serverId: r.serverId,
|
||||
bucketAt: r.bucketAt,
|
||||
src: r.src,
|
||||
dst: r.dst,
|
||||
proto: r.proto,
|
||||
srcPort: r.srcPort,
|
||||
dstPort: r.dstPort,
|
||||
bytes: r.bytes,
|
||||
packets: r.packets,
|
||||
inIface: r.inIface,
|
||||
outIface: r.outIface,
|
||||
nextHop: r.nextHop,
|
||||
flowStartMs: r.flowStartMs,
|
||||
flowEndMs: r.flowEndMs,
|
||||
})
|
||||
rowsStored += 1
|
||||
} catch {
|
||||
/* ignore single-row failures */
|
||||
await dbQuery(FLOW_UPSERT_SQL, flowUpsertParams(r))
|
||||
stored += 1
|
||||
} catch (rowErr) {
|
||||
if (stored === 0 && lastError.startsWith("flow_buckets:")) {
|
||||
const rowMsg = rowErr instanceof Error ? rowErr.message : String(rowErr)
|
||||
setEngineError(`flow_buckets: ${rowMsg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushPending(opts?: { force?: boolean }): Promise<void> {
|
||||
pruneRecent()
|
||||
rollFlowRings()
|
||||
const force = Boolean(opts?.force)
|
||||
const hasWork = pending.size > 0 || minuteRollup.size > 0 || minuteDims.size > 0
|
||||
const due = persistDue(force, hasWork)
|
||||
try {
|
||||
await persistListenerStats(force)
|
||||
} catch {
|
||||
/* settings row may be absent in unit tests */
|
||||
}
|
||||
|
||||
if (!hasWork) {
|
||||
if (force) {
|
||||
try {
|
||||
await pruneStored()
|
||||
} catch {
|
||||
/* prune best-effort */
|
||||
}
|
||||
}
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
if (!due) {
|
||||
lastFlushUsedTransaction = false
|
||||
return
|
||||
}
|
||||
|
||||
const sanitized: PendingFlowRow[] = []
|
||||
let skippedInet = 0
|
||||
for (const row of topNPending([...pending.values()].map(toPendingRow))) {
|
||||
const clean = sanitizeFlowRow(row)
|
||||
if (!clean) {
|
||||
skippedInet += 1
|
||||
continue
|
||||
}
|
||||
sanitized.push(clean)
|
||||
}
|
||||
pending.clear()
|
||||
for (const row of sanitized) mergeInto(recent, row)
|
||||
if (skippedInet > 0) {
|
||||
setEngineError(`flow_buckets: пропуск ${skippedInet} строк с невалидным IP`)
|
||||
}
|
||||
|
||||
lastFlushUsedTransaction = false
|
||||
lastPersistAt = Date.now()
|
||||
lastFlushedMinute = minuteBucketIso()
|
||||
try {
|
||||
const stored = await upsertFlowBuckets(sanitized)
|
||||
lastFlushUsedTransaction = stored === sanitized.length
|
||||
rowsStored += stored
|
||||
if (stored > 0) bumpDataEpoch()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setEngineError(`flow_buckets: ${message}`)
|
||||
}
|
||||
try {
|
||||
await upsertMinuteAndDaily()
|
||||
@@ -730,7 +884,11 @@ export async function flushPending(): Promise<void> {
|
||||
} catch {
|
||||
/* rollup best-effort */
|
||||
}
|
||||
await pruneStored()
|
||||
try {
|
||||
await pruneStored()
|
||||
} catch {
|
||||
/* prune best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
export function lastFlushUsedTransactionForTests(): boolean {
|
||||
@@ -738,7 +896,7 @@ export function lastFlushUsedTransactionForTests(): boolean {
|
||||
}
|
||||
|
||||
export async function flushPendingForTests(): Promise<void> {
|
||||
await flushPending()
|
||||
await flushPending({ force: true })
|
||||
}
|
||||
|
||||
export function onEngineTick(): void {
|
||||
@@ -766,6 +924,9 @@ export function resetEngineForTests(): void {
|
||||
lastFlushUsedTransaction = false
|
||||
lastPruneAt = 0
|
||||
lastPassiveCheckpointAt = Date.now()
|
||||
lastPersistAt = 0
|
||||
lastFlushedMinute = ""
|
||||
lastStatsPersistAt = 0
|
||||
lastPersistedStats = null
|
||||
bumpDataEpoch()
|
||||
pendingCap = MAX_PENDING
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
setWantListenForTests,
|
||||
simulateWorkerExitForTests,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { configureEngine, droppedForTests, pendingSizeForTests } from "./traffic-flow-engine.js"
|
||||
import { configureEngine, droppedForTests, getEngineStats, isValidFlowInet, pendingSizeForTests } from "./traffic-flow-engine.js"
|
||||
import { dbQuery } from "../db/index.js"
|
||||
import { withPgOrSkip } from "../test/pg.js"
|
||||
|
||||
@@ -69,6 +69,27 @@ assert.equal(droppedForTests(), 3)
|
||||
assert.equal(peekPendingFlows().length, 3)
|
||||
setPendingCapForTests(null)
|
||||
|
||||
assert.equal(isValidFlowInet("10.0.0.1"), true)
|
||||
assert.equal(isValidFlowInet("8.8.8.8"), true)
|
||||
assert.equal(isValidFlowInet("0:0:0:0:0:0:0:1"), true)
|
||||
assert.equal(isValidFlowInet("not-an-ip"), false)
|
||||
assert.equal(isValidFlowInet("999.1.1.1"), false)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
ingestParsedFlowsForServerForTests(1, [{
|
||||
src: "not-an-ip",
|
||||
dst: "8.8.8.8",
|
||||
proto: 6,
|
||||
srcPort: 1,
|
||||
dstPort: 443,
|
||||
bytes: 10,
|
||||
packets: 1,
|
||||
inIface: "2",
|
||||
outIface: "",
|
||||
}])
|
||||
await flushPendingForTests()
|
||||
assert.match(getEngineStats().lastError, /невалидн/)
|
||||
|
||||
resetFlowRingsForTests()
|
||||
configureEngine({ topN: 20 })
|
||||
const talkers = Array.from({ length: 25 }, (_, i) => ({
|
||||
|
||||
@@ -267,7 +267,7 @@ export async function startTrafficFlowListener() {
|
||||
export function stopTrafficFlowListener() {
|
||||
wantListen = false
|
||||
stopWorkerProcess()
|
||||
void flushPending().catch(() => { /* ignore */ })
|
||||
void flushPending({ force: true }).catch(() => { /* ignore */ })
|
||||
state = { bound: false, address: null }
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ export async function ingestParsedFlowsForTests(exporterIp: string, flows: Parse
|
||||
if (serverId == null) return
|
||||
queueParsedFlows(serverId, flows)
|
||||
rollFlowRings()
|
||||
await flushPending()
|
||||
await flushPending({ force: true })
|
||||
}
|
||||
|
||||
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlowInput[]) {
|
||||
@@ -444,7 +444,7 @@ export function lastFlushUsedTransactionForTests(): boolean {
|
||||
}
|
||||
|
||||
export async function flushPendingForTests(): Promise<void> {
|
||||
await flushPending()
|
||||
await flushPending({ force: true })
|
||||
}
|
||||
|
||||
async function tableCount(name: string): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, gte, inArray, lt, max, or } from "drizzle-orm"
|
||||
import { and, asc, eq, gte, inArray, max, or } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
servers,
|
||||
@@ -63,12 +63,6 @@ export async function getSettings() {
|
||||
return (await db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1))[0]
|
||||
}
|
||||
|
||||
async function cleanup(retentionDays: number) {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString()
|
||||
await db.delete(uptimeProbeSamples).where(lt(uptimeProbeSamples.sampledAt, cutoff))
|
||||
await db.delete(uptimeResourceSamples).where(lt(uptimeResourceSamples.sampledAt, cutoff))
|
||||
}
|
||||
|
||||
export function parsePing(results: Array<{ time?: string; status?: string; sent?: string; received?: string; "packet-loss"?: string; "avg-rtt"?: string }>) {
|
||||
const sum = [...results].reverse().find((r) =>
|
||||
r.sent != null || r.received != null || r["packet-loss"] != null || r["avg-rtt"] != null,
|
||||
@@ -166,8 +160,6 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
|
||||
freeHddSpace: freeHdd,
|
||||
totalHddSpace: totalHdd,
|
||||
uptimeSeconds,
|
||||
boardName: String(resource["board-name"] ?? ""),
|
||||
rosVersion: String(resource["version"] ?? ""),
|
||||
})
|
||||
const memUsedMb = totalMem > 0 ? Math.round((totalMem - freeMem) / (1024 * 1024)) : 0
|
||||
const memTotalMb = totalMem > 0 ? Math.round(totalMem / (1024 * 1024)) : 0
|
||||
@@ -198,8 +190,6 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
|
||||
freeHddSpace: 0,
|
||||
totalHddSpace: 0,
|
||||
uptimeSeconds: 0,
|
||||
boardName: "",
|
||||
rosVersion: "",
|
||||
})
|
||||
snapshot.servers.push({
|
||||
serverId: s.id,
|
||||
@@ -211,7 +201,6 @@ export async function collectResourceSamplesOnce(): Promise<ResourcesRunSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
await cleanup(Math.max(1, settings.retentionDays))
|
||||
await db.update(uptimeSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - started,
|
||||
@@ -324,7 +313,6 @@ export async function collectPingProbesOnce(): Promise<PingRunSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
await cleanup(Math.max(1, settings.retentionDays))
|
||||
await db.update(uptimeSettings).set({
|
||||
lastCollectedAt: now,
|
||||
lastDurationMs: Date.now() - started,
|
||||
@@ -399,7 +387,6 @@ export async function collectPingForProbeIds(probeIds: string[]): Promise<{ poll
|
||||
polled += 1
|
||||
}
|
||||
|
||||
await cleanup(Math.max(1, settings.retentionDays))
|
||||
return { polled }
|
||||
} finally {
|
||||
collectingPing = false
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { AlertCircleIcon, LoaderCircleIcon, TriangleAlertIcon } from "lucide-react"
|
||||
|
||||
export function BackupDeleteDialog({
|
||||
backup,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
backup: Backup | null
|
||||
busy: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить бэкап?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет удалён
|
||||
{backup?.storage === "s3" || backup?.storage === "both" ? " локально и из S3" : " с диска"}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" disabled={busy} onClick={onConfirm}>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackupRestoreDialog({
|
||||
backup,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
backup: Backup | null
|
||||
busy: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={Boolean(backup)} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-warning/10 text-warning">
|
||||
<TriangleAlertIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Восстановить конфигурацию?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="flex flex-col gap-3">
|
||||
<span>
|
||||
Файл <span className="font-mono text-foreground">{backup?.filename}</span> будет загружен
|
||||
на <span className="text-foreground">{backup?.server}</span> и импортирован.
|
||||
</span>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Это изменит рабочую конфигурацию роутера</AlertTitle>
|
||||
<AlertDescription>
|
||||
Сессия может оборваться. Убедитесь, что выбран именно этот сервер и этот снимок.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction disabled={busy} onClick={onConfirm}>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Восстановить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import type { Server } from "@/lib/data"
|
||||
import { FormField } from "@/components/form-kit"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function BackupCreateSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
step,
|
||||
onStepChange,
|
||||
servers,
|
||||
selected,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
notes,
|
||||
onNotesChange,
|
||||
destinationLabel,
|
||||
busy,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
step: number
|
||||
onStepChange: (step: number) => void
|
||||
servers: Server[]
|
||||
selected: Set<string>
|
||||
onToggle: (id: string) => void
|
||||
onSelectAll: () => void
|
||||
onClear: () => void
|
||||
notes: string
|
||||
onNotesChange: (value: string) => void
|
||||
destinationLabel: string
|
||||
busy: boolean
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) onStepChange(1) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Новый бэкап</SheetTitle>
|
||||
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<Stepper value={step} onValueChange={onStepChange} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||
<StepperNav className="mb-5">
|
||||
<StepperItem step={1}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>1</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Серверы</StepperTitle>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={2}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>2</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Заметка</StepperTitle>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={3}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>3</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Подтверждение</StepperTitle>
|
||||
</StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
<StepperPanel className="flex-1 overflow-y-auto">
|
||||
<StepperContent value={1} className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-sm font-medium">Выберите серверы</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
|
||||
Все
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{servers.map((s) => {
|
||||
const checked = selected.has(s.id)
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => onToggle(s.id)}
|
||||
aria-label={`Выбрать ${s.name}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{s.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
{s.status === "offline" && (
|
||||
<span className="text-xs text-muted-foreground">недоступен</span>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</StepperContent>
|
||||
<StepperContent value={2} className="flex flex-col gap-4">
|
||||
<FormField label="Заметка">
|
||||
<Input
|
||||
placeholder="Например: перед обновлением BGP"
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</StepperContent>
|
||||
<StepperContent value={3} className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
Будет создан бэкап для <strong className="text-foreground">{selected.size}</strong> серверов.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Куда сохранится: <strong className="text-foreground">{destinationLabel}</strong>
|
||||
</p>
|
||||
{notes ? <p className="text-muted-foreground">Заметка: {notes}</p> : null}
|
||||
</StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
{step > 1 && (
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={() => onStepChange(step - 1)}>
|
||||
Назад
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={step === 1 && selected.size === 0}
|
||||
onClick={() => onStepChange(step + 1)}
|
||||
>
|
||||
Далее
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={selected.size === 0 || busy}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Снять бэкап ({selected.size})
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import type { Filter } from "@/components/reui/filters"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { applyReuiFilters } from "@/lib/data-filters/apply-reui-filters"
|
||||
import {
|
||||
BACKUP_FILTER_ACCESSORS,
|
||||
BACKUP_FILTER_FIELDS,
|
||||
} from "@/lib/data-filters/backup-filter-fields"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { BackupsDataGrid } from "@/components/data-grids/backups-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
|
||||
type KindFilter = "all" | "auto" | "manual"
|
||||
|
||||
export function BackupsHistory({
|
||||
backups,
|
||||
kindFilter,
|
||||
onKindFilterChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onDownload,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: {
|
||||
backups: Backup[]
|
||||
kindFilter: KindFilter
|
||||
onKindFilterChange: (value: KindFilter) => void
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
filters: Filter[]
|
||||
onFiltersChange: (filters: Filter[]) => void
|
||||
onDownload: (id: string, filename: string) => void
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (backup: Backup) => void
|
||||
onCreate: () => void
|
||||
}) {
|
||||
const autoCount = backups.filter((b) => b.kind === "auto").length
|
||||
const manualCount = backups.filter((b) => b.kind === "manual").length
|
||||
const byKind = kindFilter === "all" ? backups : backups.filter((b) => b.kind === kindFilter)
|
||||
const q = search.trim().toLowerCase()
|
||||
const searched = q
|
||||
? byKind.filter((b) =>
|
||||
[b.filename, b.server, b.notes].some((v) => v.toLowerCase().includes(q)),
|
||||
)
|
||||
: byKind
|
||||
const filtered = applyReuiFilters(searched, filters, BACKUP_FILTER_ACCESSORS)
|
||||
const isEmptyAll = backups.length === 0
|
||||
|
||||
return (
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: kindFilter,
|
||||
onChange: onKindFilterChange,
|
||||
options: [
|
||||
{ value: "all", label: "Все", count: backups.length },
|
||||
{ value: "auto", label: "Авто", count: autoCount },
|
||||
{ value: "manual", label: "Вручную", count: manualCount },
|
||||
],
|
||||
}}
|
||||
filters={filters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
filterFields={BACKUP_FILTER_FIELDS}
|
||||
search={search}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Поиск по файлу, серверу, заметке…"
|
||||
countLabel={`${filtered.length} бэкапов`}
|
||||
/>
|
||||
<BackupsDataGrid
|
||||
backups={filtered}
|
||||
onDownload={onDownload}
|
||||
onRestore={onRestore}
|
||||
onDelete={onDelete}
|
||||
emptyTitle={isEmptyAll ? "Нет бэкапов" : "Ничего не найдено"}
|
||||
emptyDescription={
|
||||
isEmptyAll
|
||||
? "Создайте первый бэкап вручную или настройте расписание"
|
||||
: "Измените фильтры или поисковый запрос"
|
||||
}
|
||||
emptyAction={
|
||||
isEmptyAll ? (
|
||||
<Button type="button" size="sm" onClick={onCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
Новый бэкап
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</DataPageCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client"
|
||||
|
||||
import type { Server } from "@/lib/data"
|
||||
import type { BackupStorageSettingsDto } from "@mmapp/contracts/backups"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LoaderCircleIcon } from "lucide-react"
|
||||
|
||||
export const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
|
||||
export type BackupFreq = "daily" | "weekly" | "monthly"
|
||||
export type StorageProvider = "local" | "s3"
|
||||
|
||||
export type BackupScheduleForm = {
|
||||
enabled: boolean
|
||||
frequency: BackupFreq
|
||||
hour: number
|
||||
minute: number
|
||||
weekDay: number
|
||||
monthDay: number
|
||||
keepCount: number
|
||||
format: "rsc" | "backup"
|
||||
}
|
||||
|
||||
export type BackupStorageForm = {
|
||||
provider: StorageProvider
|
||||
s3Endpoint: string
|
||||
s3Region: string
|
||||
s3Bucket: string
|
||||
s3Prefix: string
|
||||
s3AccessKeyId: string
|
||||
s3SecretAccessKey: string
|
||||
s3ForcePathStyle: boolean
|
||||
keepLocalCopy: boolean
|
||||
showPassword: boolean
|
||||
}
|
||||
|
||||
export const defaultSchedule: BackupScheduleForm = {
|
||||
enabled: true,
|
||||
frequency: "daily",
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
weekDay: 0,
|
||||
monthDay: 1,
|
||||
keepCount: 7,
|
||||
format: "rsc",
|
||||
}
|
||||
|
||||
export const defaultStorageForm: BackupStorageForm = {
|
||||
provider: "local",
|
||||
s3Endpoint: "",
|
||||
s3Region: "us-east-1",
|
||||
s3Bucket: "",
|
||||
s3Prefix: "mikrotik",
|
||||
s3AccessKeyId: "",
|
||||
s3SecretAccessKey: "",
|
||||
s3ForcePathStyle: true,
|
||||
keepLocalCopy: true,
|
||||
showPassword: false,
|
||||
}
|
||||
|
||||
function storageStatus(saved: BackupStorageSettingsDto | null, form: BackupStorageForm) {
|
||||
if (form.provider === "local") {
|
||||
return { label: "Локально", variant: "secondary" as const }
|
||||
}
|
||||
if (saved?.lastTestError) {
|
||||
return { label: "Ошибка", variant: "destructive-light" as const }
|
||||
}
|
||||
if (saved?.lastTestAt && !saved.lastTestError) {
|
||||
return { label: "Connected", variant: "success-light" as const }
|
||||
}
|
||||
if (saved?.secretConfigured && saved.s3Bucket) {
|
||||
return { label: "Не проверено", variant: "warning-light" as const }
|
||||
}
|
||||
return { label: "Не настроено", variant: "secondary" as const }
|
||||
}
|
||||
|
||||
export function BackupsSettings({
|
||||
schedule,
|
||||
onScheduleChange,
|
||||
storage,
|
||||
onStorageChange,
|
||||
savedStorage,
|
||||
servers,
|
||||
selectedServers,
|
||||
onToggleServer,
|
||||
onSelectAll,
|
||||
onClearServers,
|
||||
onSave,
|
||||
onTest,
|
||||
onSync,
|
||||
saveBusy,
|
||||
testBusy,
|
||||
syncBusy,
|
||||
}: {
|
||||
schedule: BackupScheduleForm
|
||||
onScheduleChange: <K extends keyof BackupScheduleForm>(k: K, v: BackupScheduleForm[K]) => void
|
||||
storage: BackupStorageForm
|
||||
onStorageChange: <K extends keyof BackupStorageForm>(k: K, v: BackupStorageForm[K]) => void
|
||||
savedStorage: BackupStorageSettingsDto | null
|
||||
servers: Server[]
|
||||
selectedServers: Set<string>
|
||||
onToggleServer: (id: string) => void
|
||||
onSelectAll: () => void
|
||||
onClearServers: () => void
|
||||
onSave: () => void
|
||||
onTest: () => void
|
||||
onSync: () => void
|
||||
saveBusy: boolean
|
||||
testBusy: boolean
|
||||
syncBusy: boolean
|
||||
}) {
|
||||
const status = storageStatus(savedStorage, storage)
|
||||
const secretPlaceholder = savedStorage?.secretConfigured ? "•••••••• (сохранён)" : "••••••••"
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<OpsPanel title="Расписание" description="Автоматический съём конфигурации" contentClassName="px-5 py-5 flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Автоматический бэкап</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
|
||||
</div>
|
||||
<FormToggle checked={schedule.enabled} onChange={(v) => onScheduleChange("enabled", v)} />
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-col gap-4", !schedule.enabled && "opacity-40 pointer-events-none")}>
|
||||
<FormField label="Частота">
|
||||
<SegmentedControl
|
||||
value={schedule.frequency}
|
||||
onChange={(v) => onScheduleChange("frequency", v)}
|
||||
options={[
|
||||
{ value: "daily", label: "Ежедневно" },
|
||||
{ value: "weekly", label: "Еженедельно" },
|
||||
{ value: "monthly", label: "Ежемесячно" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{schedule.frequency === "weekly" && (
|
||||
<FormField label="День недели">
|
||||
<div className="flex gap-1">
|
||||
{WEEK_DAYS.map((d, i) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => onScheduleChange("weekDay", i)}
|
||||
className={cn(
|
||||
"w-9 h-9 rounded text-sm font-medium border transition-colors",
|
||||
schedule.weekDay === i
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{schedule.frequency === "monthly" && (
|
||||
<FormField label="День месяца" hint="1–28">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={28}
|
||||
className="font-mono w-24"
|
||||
value={schedule.monthDay}
|
||||
onChange={(e) => onScheduleChange("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField label="Время запуска">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
className="font-mono w-20 text-center"
|
||||
value={String(schedule.hour).padStart(2, "0")}
|
||||
onChange={(e) => onScheduleChange("hour", Math.min(23, Math.max(0, Number(e.target.value))))}
|
||||
/>
|
||||
<span className="text-muted-foreground font-mono text-lg">:</span>
|
||||
<div className="flex gap-1">
|
||||
{[0, 15, 30, 45].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onScheduleChange("minute", m)}
|
||||
className={cn(
|
||||
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
|
||||
schedule.minute === m
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{String(m).padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Хранить бэкапов" hint="На каждый сервер">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
className="font-mono w-24"
|
||||
value={schedule.keepCount}
|
||||
onChange={(e) => onScheduleChange("keepCount", Math.max(1, Number(e.target.value)))}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Формат файла</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Снимается текстовый экспорт RouterOS</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" size="sm">.rsc</Badge>
|
||||
<Badge variant="warning-light" size="sm">.backup скоро</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
|
||||
<OpsPanel
|
||||
title="Хранилище"
|
||||
description="Локальный диск приложения или S3-compatible бакет"
|
||||
headerRight={
|
||||
<Badge variant={status.variant} size="sm" radius="full">
|
||||
{status.label}
|
||||
</Badge>
|
||||
}
|
||||
contentClassName="px-5 py-5 flex flex-col gap-5"
|
||||
>
|
||||
<FormField label="Тип хранилища">
|
||||
<SegmentedControl
|
||||
value={storage.provider}
|
||||
onChange={(v) => onStorageChange("provider", v)}
|
||||
options={[
|
||||
{ value: "local", label: "Локально" },
|
||||
{ value: "s3", label: "S3" },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{storage.provider === "local" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Файлы пишутся в каталог приложения <span className="font-mono text-foreground">storage/backups</span>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField label="Endpoint" hint="Пусто для AWS. Для R2/MinIO/Selectel — полный URL">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="https://s3.amazonaws.com"
|
||||
value={storage.s3Endpoint}
|
||||
onChange={(e) => onStorageChange("s3Endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="Region">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="us-east-1"
|
||||
value={storage.s3Region}
|
||||
onChange={(e) => onStorageChange("s3Region", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Bucket" required>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="mikrotik-backups"
|
||||
value={storage.s3Bucket}
|
||||
onChange={(e) => onStorageChange("s3Bucket", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Prefix">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="mikrotik"
|
||||
value={storage.s3Prefix}
|
||||
onChange={(e) => onStorageChange("s3Prefix", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="Access key" required>
|
||||
<Input
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
value={storage.s3AccessKeyId}
|
||||
onChange={(e) => onStorageChange("s3AccessKeyId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Secret key">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={storage.showPassword ? "text" : "password"}
|
||||
className="font-mono pr-14"
|
||||
autoComplete="new-password"
|
||||
placeholder={secretPlaceholder}
|
||||
value={storage.s3SecretAccessKey}
|
||||
onChange={(e) => onStorageChange("s3SecretAccessKey", e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStorageChange("showPassword", !storage.showPassword)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{storage.showPassword ? "скрыть" : "показ"}
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Path-style</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Нужен для MinIO и части совместимых API</p>
|
||||
</div>
|
||||
<FormToggle checked={storage.s3ForcePathStyle} onChange={(v) => onStorageChange("s3ForcePathStyle", v)} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Оставлять локальную копию</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">После успешной загрузки в S3</p>
|
||||
</div>
|
||||
<FormToggle checked={storage.keepLocalCopy} onChange={(v) => onStorageChange("keepLocalCopy", v)} />
|
||||
</div>
|
||||
{savedStorage?.lastTestError ? (
|
||||
<p className="text-xs text-destructive">{savedStorage.lastTestError}</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onTest} disabled={testBusy}>
|
||||
{testBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
|
||||
Проверить
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onSync} disabled={syncBusy}>
|
||||
{syncBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : null}
|
||||
Синхронизировать из бакета
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OpsPanel>
|
||||
|
||||
<OpsPanel
|
||||
className="lg:col-span-2"
|
||||
title="Серверы для бэкапа"
|
||||
headerRight={
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button type="button" onClick={onSelectAll} className="text-xs text-primary hover:underline">
|
||||
Выбрать все
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearServers}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
contentClassName="px-5 py-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||
{servers.map((s) => {
|
||||
const checked = selectedServers.has(s.id)
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||
checked
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border hover:border-border/80 hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => onToggleServer(s.id)}
|
||||
aria-label={`Выбрать ${s.name}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">{s.site}</span>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выбрано {selectedServers.size} из {servers.length} серверов
|
||||
</p>
|
||||
</OpsPanel>
|
||||
|
||||
<div className="lg:col-span-2 flex items-center gap-3">
|
||||
<Button type="button" onClick={onSave} className="gap-2" disabled={saveBusy}>
|
||||
{saveBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
getCertificateRenewSettings,
|
||||
putCertificateRenewSettings,
|
||||
} from "@/shared/api/certificates"
|
||||
import { toast } from "sonner"
|
||||
|
||||
/**
|
||||
* Автообновление сертификатов через MM (ACME DNS-01).
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function CertificateRenewSettingsPanel({
|
||||
backendUrl,
|
||||
liveReady,
|
||||
}: {
|
||||
backendUrl: string
|
||||
liveReady: boolean
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [intervalDraft, setIntervalDraft] = useState("21600")
|
||||
const [daysDraft, setDaysDraft] = useState("30")
|
||||
const [lastCollectedAt, setLastCollectedAt] = useState<string | null>(null)
|
||||
const [lastError, setLastError] = useState<string | null>(null)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [toggleBusy, setToggleBusy] = useState(false)
|
||||
const [saveBusy, setSaveBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!liveReady) return
|
||||
try {
|
||||
const s = await getCertificateRenewSettings(backendUrl)
|
||||
setEnabled(s.enabled)
|
||||
setIntervalDraft(String(s.intervalSec))
|
||||
setDaysDraft(String(s.renewBeforeDays))
|
||||
setLastCollectedAt(s.lastCollectedAt ?? null)
|
||||
setLastError(s.lastError ?? null)
|
||||
setLoaded(true)
|
||||
} catch (e) {
|
||||
setLoaded(true)
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось загрузить настройки автообновления")
|
||||
}
|
||||
}, [backendUrl, liveReady])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load()
|
||||
})
|
||||
}, [load])
|
||||
|
||||
async function handleEnabledChange(next: boolean) {
|
||||
if (!liveReady || toggleBusy) return
|
||||
const prev = enabled
|
||||
setEnabled(next)
|
||||
setToggleBusy(true)
|
||||
try {
|
||||
const saved = await putCertificateRenewSettings(backendUrl, { enabled: next })
|
||||
setEnabled(saved.enabled)
|
||||
toast.success(next ? "Автообновление через MikrotikManager включено" : "Автообновление через MikrotikManager выключено")
|
||||
} catch (e) {
|
||||
setEnabled(prev)
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||
} finally {
|
||||
setToggleBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSchedule() {
|
||||
if (!liveReady || saveBusy) return
|
||||
const intervalSec = Math.max(300, Number.parseInt(intervalDraft, 10) || 21600)
|
||||
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(daysDraft, 10) || 30))
|
||||
setSaveBusy(true)
|
||||
try {
|
||||
const saved = await putCertificateRenewSettings(backendUrl, { intervalSec, renewBeforeDays })
|
||||
setIntervalDraft(String(saved.intervalSec))
|
||||
setDaysDraft(String(saved.renewBeforeDays))
|
||||
toast.success("Расписание автообновления сохранено")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось сохранить расписание")
|
||||
} finally {
|
||||
setSaveBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const interactionsOff = !liveReady || toggleBusy || (liveReady && !loaded)
|
||||
|
||||
return (
|
||||
<OpsPanel
|
||||
title="Автообновление через MikrotikManager"
|
||||
description="Фоновый выпуск Let's Encrypt (Cloudflare DNS-01) для сертификатов, выпущенных из этой панели. Ручной выпуск не зависит от переключателя."
|
||||
headerRight={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={!liveReady ? "warning-light" : enabled ? "success-light" : "secondary"}
|
||||
>
|
||||
{!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"}
|
||||
</Badge>
|
||||
}
|
||||
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Обновлять сертификаты из MM</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
Если ACME уже крутит RouterOS — выключите, чтобы не было двойного перевыпуска.
|
||||
</p>
|
||||
</div>
|
||||
<FormToggle checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
|
||||
</div>
|
||||
|
||||
{!liveReady ? (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Нет подключения к API</AlertTitle>
|
||||
<AlertDescription>
|
||||
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : enabled ? (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
|
||||
<AlertDescription>
|
||||
MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let's Encrypt на
|
||||
устройстве для тех же имён лучше не включать одновременно.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Обновление отдано RouterOS</AlertTitle>
|
||||
<AlertDescription>
|
||||
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
|
||||
остаются доступны.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className={cn("flex flex-col gap-4", !enabled && "pointer-events-none opacity-40")}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={intervalDraft}
|
||||
onChange={(e) => setIntervalDraft(e.target.value)}
|
||||
disabled={!liveReady || saveBusy}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={daysDraft}
|
||||
onChange={(e) => setDaysDraft(e.target.value)}
|
||||
disabled={!liveReady || saveBusy}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={!liveReady || saveBusy || !enabled} onClick={() => void handleSaveSchedule()}>
|
||||
Сохранить расписание
|
||||
</Button>
|
||||
<Link href="/data-collection" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 text-xs")}>
|
||||
Журнал планировщика →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastCollectedAt || lastError ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Последний прогон:{" "}
|
||||
{lastCollectedAt ? new Date(lastCollectedAt).toLocaleString("ru-RU") : "ещё не было"}
|
||||
{lastError ? ` · ошибка: ${lastError}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { EventItem } from "@mmapp/contracts/events"
|
||||
|
||||
function formatEventAge(iso: string): string {
|
||||
const ts = Date.parse(iso)
|
||||
if (!Number.isFinite(ts)) return "—"
|
||||
const diffMs = Math.max(0, Date.now() - ts)
|
||||
const minutes = Math.floor(diffMs / 60_000)
|
||||
if (minutes < 1) return "сейчас"
|
||||
if (minutes < 60) return `${minutes}м`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}ч`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}д`
|
||||
}
|
||||
|
||||
const LEVEL_DOT: Record<EventItem["level"], string> = {
|
||||
critical: "border-destructive bg-destructive/20 group-data-completed/timeline-item:border-destructive",
|
||||
warning: "border-warning bg-warning/20 group-data-completed/timeline-item:border-warning",
|
||||
info: "border-info bg-info/20 group-data-completed/timeline-item:border-info",
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact activity timeline.
|
||||
* Preview: https://reui.io/preview/base/timeline-3
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
export function DashboardEventsTimeline({
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
events: EventItem[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}) {
|
||||
if (loading && events.length === 0) {
|
||||
return <p className="text-muted-foreground px-5 py-6 text-sm">Загрузка событий…</p>
|
||||
}
|
||||
if (error && events.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>События недоступны</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground px-5 py-6 text-sm">
|
||||
Событий пока нет.{" "}
|
||||
<Link href="/alerts" className="underline underline-offset-2">
|
||||
Оповещения
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline defaultValue={events.length} className="px-5 py-4">
|
||||
{events.map((event, index) => (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator />
|
||||
<TimelineDate>{formatEventAge(event.createdAt)}</TimelineDate>
|
||||
<TimelineTitle className="text-[13px] leading-tight">{event.title}</TimelineTitle>
|
||||
<TimelineIndicator className={cn(LEVEL_DOT[event.level])} />
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="text-xs leading-snug">{event.message}</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import { Maximize2Icon, ZoomInIcon, ZoomOutIcon } from "lucide-react"
|
||||
@@ -167,7 +165,7 @@ function ServerNode({
|
||||
)
|
||||
}
|
||||
|
||||
export function InternetPathMapCard({ model }: { model: InternetPathViewModel | null }) {
|
||||
export function InternetPathMapCanvas({ model }: { model: InternetPathViewModel | null }) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -226,22 +224,7 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
||||
}
|
||||
|
||||
return (
|
||||
<OpsPanel
|
||||
title="Internet path map"
|
||||
description="Основной и текущий путь трафика HomeRouter → Internet"
|
||||
headerRight={
|
||||
<StatusBadge
|
||||
status={
|
||||
model?.pathState === "healthy"
|
||||
? "online"
|
||||
: model?.pathState === "failover"
|
||||
? "degraded"
|
||||
: "offline"
|
||||
}
|
||||
/>
|
||||
}
|
||||
contentClassName="px-5 pb-4"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{!model && (
|
||||
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
||||
Недостаточно данных для построения маршрута
|
||||
@@ -412,6 +395,6 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OpsPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import { InternetPathMapCanvas } from "@/components/dashboard/internet-path-map"
|
||||
import { InternetPathSummary } from "@/components/dashboard/internet-path-summary"
|
||||
|
||||
const PATH_MAP_OPEN_LS = "mm:dashboard-path-map-open"
|
||||
|
||||
function readMapOpen(): boolean {
|
||||
if (typeof window === "undefined") return true
|
||||
try {
|
||||
const raw = localStorage.getItem(PATH_MAP_OPEN_LS)
|
||||
if (raw === "0") return false
|
||||
if (raw === "1") return true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function InternetPathPanel({
|
||||
model,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
model: InternetPathViewModel | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setOpen(readMapOpen())
|
||||
setHydrated(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
setOpen(next)
|
||||
try {
|
||||
localStorage.setItem(PATH_MAP_OPEN_LS, next ? "1" : "0")
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OpsPanel
|
||||
title="Internet path"
|
||||
description="Home → WAN → JH → Exit"
|
||||
headerRight={
|
||||
<Link href="/network-map" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
||||
Карта сети →
|
||||
</Link>
|
||||
}
|
||||
contentClassName="flex flex-col gap-3 px-5 pb-4"
|
||||
>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Не удалось загрузить путь</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{loading && !model ? (
|
||||
<div className="h-20 animate-pulse rounded-md bg-muted/40" />
|
||||
) : (
|
||||
<InternetPathSummary model={model} />
|
||||
)}
|
||||
|
||||
<Collapsible open={hydrated ? open : true} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-7 w-fit gap-1.5 text-xs")}
|
||||
>
|
||||
<ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
|
||||
{open ? "Скрыть карту" : "Показать карту"}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<InternetPathMapCanvas model={model} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Flag } from "@/components/flag"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
import type { ServerStatus } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowRightIcon, HomeIcon, RadioIcon, ServerIcon, GlobeIcon } from "lucide-react"
|
||||
|
||||
function pathStateToServerStatus(state: InternetPathViewModel["pathState"]): ServerStatus {
|
||||
if (state === "healthy") return "online"
|
||||
if (state === "failover" || state === "degraded") return "degraded"
|
||||
return "offline"
|
||||
}
|
||||
|
||||
function HopChip({
|
||||
label,
|
||||
name,
|
||||
country,
|
||||
icon,
|
||||
iconClassName,
|
||||
}: {
|
||||
label: string
|
||||
name: string
|
||||
country?: string
|
||||
icon: ReactNode
|
||||
iconClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md border border-border/60 px-2 py-1.5">
|
||||
<IconTile variant="elevated" size="sm" className={cn("shrink-0", iconClassName)} aria-hidden="true">
|
||||
{icon}
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">{label}</p>
|
||||
<p className="flex items-center gap-1 truncate text-sm font-medium">
|
||||
{country ? <Flag code={country} className="shrink-0" /> : null}
|
||||
<span className="truncate">{name}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact live path strip (Frame-friendly). Canvas lives separately.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function InternetPathSummary({ model }: { model: InternetPathViewModel | null }) {
|
||||
if (!model) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Недостаточно данных для пути. Добавьте home-router и проверьте{" "}
|
||||
<Link href="/network-map" className="underline underline-offset-2">
|
||||
карту сети
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const hop = model.currentHop ?? model.primaryHop
|
||||
const wanName = hop?.wan.name ?? model.activeWanUplink?.name ?? "WAN"
|
||||
const wanIsp = hop?.wan.isp ?? model.activeWanUplink?.isp ?? "—"
|
||||
const ping = hop?.wanJhMetrics.pingMs
|
||||
const dl = hop?.wanJhMetrics.dlMbps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={pathStateToServerStatus(model.pathState)} />
|
||||
{model.pathState === "failover" ? (
|
||||
<Badge variant="warning-light" size="sm">failover</Badge>
|
||||
) : null}
|
||||
{ping != null ? (
|
||||
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||
{ping} мс
|
||||
</Badge>
|
||||
) : null}
|
||||
{dl != null ? (
|
||||
<Badge variant="outline" size="sm" className="tabular-nums">
|
||||
{Math.round(dl)} ↓ Мбит/с
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 @3xl:flex-row @3xl:items-center">
|
||||
<HopChip
|
||||
label="Home"
|
||||
name={model.homeRouter.name}
|
||||
country={model.homeRouter.country}
|
||||
icon={<HomeIcon />}
|
||||
iconClassName="text-success"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="WAN"
|
||||
name={`${wanName} · ${wanIsp}`}
|
||||
icon={<RadioIcon />}
|
||||
iconClassName="text-info"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="JH"
|
||||
name={hop?.jumpHost.name ?? model.fallbackJumpHost?.name ?? "—"}
|
||||
country={hop?.jumpHost.country ?? model.fallbackJumpHost?.country}
|
||||
icon={<ServerIcon />}
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<ArrowRightIcon className="text-muted-foreground hidden size-4 shrink-0 @3xl:block" aria-hidden="true" />
|
||||
<HopChip
|
||||
label="Exit"
|
||||
name={hop?.exitNode.name ?? model.fallbackExitNode?.name ?? "—"}
|
||||
country={hop?.exitNode.country ?? model.fallbackExitNode?.country}
|
||||
icon={<GlobeIcon />}
|
||||
iconClassName="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{model.currentPath?.reason ?? model.primaryPath?.reason ?? "Текущий путь не определён"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { Backup } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
@@ -18,13 +19,28 @@ import {
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { DownloadIcon, HardDriveIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
|
||||
import {
|
||||
CloudIcon,
|
||||
DownloadIcon,
|
||||
HardDriveIcon,
|
||||
RefreshCwIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
interface BackupsDataGridProps {
|
||||
backups: Backup[]
|
||||
onDownload: (id: string, filename: string) => void
|
||||
onRestore: (backup: Backup) => void
|
||||
onDelete: (id: string) => void
|
||||
onDelete: (backup: Backup) => void
|
||||
emptyAction?: ReactNode
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
function storageBadge(storage: Backup["storage"]) {
|
||||
if (storage === "s3") return { label: "S3", variant: "info-light" as const }
|
||||
if (storage === "both") return { label: "Локально + S3", variant: "success-light" as const }
|
||||
return { label: "Локально", variant: "secondary" as const }
|
||||
}
|
||||
|
||||
function BackupsDataGrid({
|
||||
@@ -32,6 +48,9 @@ function BackupsDataGrid({
|
||||
onDownload,
|
||||
onRestore,
|
||||
onDelete,
|
||||
emptyAction,
|
||||
emptyTitle = "Нет бэкапов",
|
||||
emptyDescription = "Создайте первый бэкап вручную или настройте расписание",
|
||||
}: BackupsDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<Backup>[]>(
|
||||
() => [
|
||||
@@ -39,9 +58,27 @@ function BackupsDataGrid({
|
||||
id: "filename",
|
||||
accessorKey: "filename",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Файл" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs font-medium">{row.original.filename}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const b = row.original
|
||||
const inS3 = b.storage === "s3" || b.storage === "both"
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={inS3 ? "size-10.5 shrink-0 text-info" : "size-10.5 shrink-0 text-muted-foreground"}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{inS3 ? <CloudIcon /> : <HardDriveIcon />}
|
||||
</IconTile>
|
||||
<div className="min-w-0">
|
||||
<span className="font-mono text-xs font-medium block truncate">{b.filename}</span>
|
||||
{b.uploadError ? (
|
||||
<span className="text-[11px] text-destructive truncate block">{b.uploadError}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Файл",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
@@ -78,23 +115,35 @@ function BackupsDataGrid({
|
||||
id: "kind",
|
||||
accessorKey: "kind",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Тип" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.kind === "manual" ? "info-light" : "secondary"}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{row.original.kind === "auto" ? "авто" : "вручную"}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "storage",
|
||||
accessorKey: "storage",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Хранилище" />,
|
||||
cell: ({ row }) => {
|
||||
const kind = row.original.kind
|
||||
const badge = storageBadge(row.original.storage)
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded border font-medium",
|
||||
kind === "manual"
|
||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
: "bg-muted text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{kind === "auto" ? "авто" : "вручную"}
|
||||
</span>
|
||||
<Badge variant={badge.variant} size="sm" radius="full">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Тип",
|
||||
headerTitle: "Хранилище",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
@@ -135,29 +184,35 @@ function BackupsDataGrid({
|
||||
return (
|
||||
<div className="flex items-center gap-1 justify-end opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title="Скачать"
|
||||
aria-label={`Скачать ${b.filename}`}
|
||||
onClick={() => onDownload(b.id, b.filename)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title="Восстановить"
|
||||
aria-label={`Восстановить ${b.filename}`}
|
||||
onClick={() => onRestore(b)}
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive hover:text-destructive"
|
||||
title="Удалить"
|
||||
onClick={() => onDelete(b.id)}
|
||||
aria-label={`Удалить ${b.filename}`}
|
||||
onClick={() => onDelete(b)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
@@ -186,9 +241,10 @@ function BackupsDataGrid({
|
||||
if (backups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<HardDriveIcon className="size-4" />}
|
||||
title="Нет бэкапов"
|
||||
description="Создайте первый бэкап вручную или настройте расписание"
|
||||
icon={<HardDriveIcon className="size-5" />}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={emptyAction}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { ListFilterIcon, SearchIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Filters,
|
||||
type Filter,
|
||||
@@ -77,6 +78,12 @@ function DataPageToolbar<T extends string = string>({
|
||||
fields={filterFields}
|
||||
onChange={onFiltersChange}
|
||||
size="sm"
|
||||
trigger={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{onSearchChange != null && (
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Sibling Frame columns for dashboard attention queue.
|
||||
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export interface AttentionQueueColumn {
|
||||
id: string
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
iconClassName?: string
|
||||
count: number
|
||||
countVariant?: "destructive" | "warning" | "secondary" | "destructive-light" | "warning-light"
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
emptyAction?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface AttentionQueueProps {
|
||||
columns: AttentionQueueColumn[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||
|
||||
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"@container grid min-w-0 items-start gap-2 @3xl:grid-cols-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const Icon = column.icon
|
||||
const isEmpty = column.count === 0
|
||||
return (
|
||||
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="sm"
|
||||
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon />
|
||||
</IconTile>
|
||||
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
|
||||
{column.count > 0 ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={column.countVariant ?? "secondary"}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{column.count}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="min-w-0">
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-24 flex-col items-start justify-center gap-1 py-3">
|
||||
<p className="text-sm font-medium">{column.emptyTitle}</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{column.emptyDescription}
|
||||
</p>
|
||||
{column.emptyAction ? <div className="pt-1">{column.emptyAction}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
column.children
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,3 +3,7 @@ export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet
|
||||
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
||||
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
||||
export { kpiCols } from "./kpi-cols"
|
||||
export { QuickActionGrid } from "./quick-action-grid"
|
||||
export type { QuickActionItem } from "./quick-action-grid"
|
||||
export { AttentionQueue } from "./attention-queue"
|
||||
export type { AttentionQueueColumn } from "./attention-queue"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import type { KeyboardEvent, ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { kpiCols } from "./kpi-cols"
|
||||
|
||||
type QuickActionBase = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
badge?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type QuickActionItem = QuickActionBase &
|
||||
(
|
||||
| { href: string; onClick?: never }
|
||||
| { onClick: () => void; href?: never }
|
||||
)
|
||||
|
||||
interface QuickActionGridProps {
|
||||
actions: QuickActionItem[]
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||
|
||||
function resolveBadge(action: QuickActionItem): string {
|
||||
if (action.badge) return action.badge
|
||||
return action.onClick ? "Выполнить" : "Перейти"
|
||||
}
|
||||
|
||||
function handleActionKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn("size-10.5", action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||
<Badge variant="outline" size="sm" className="shrink-0">
|
||||
{resolveBadge(action)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{action.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function panelClassName(disabled?: boolean) {
|
||||
return cn(
|
||||
"relative isolate flex h-full flex-col transition-colors",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "hover:bg-muted/40 focus-within:ring-ring cursor-pointer focus-within:ring-2",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function QuickActionGrid({
|
||||
actions,
|
||||
title = "Быстрые действия",
|
||||
description,
|
||||
className,
|
||||
}: QuickActionGridProps) {
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn("@container w-full", className)}>
|
||||
{(title || description) && (
|
||||
<FrameHeader>
|
||||
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</FrameHeader>
|
||||
)}
|
||||
<div className={cn("grid gap-2", kpiCols(actions.length))}>
|
||||
{actions.map((action) => {
|
||||
const label = `${action.title}: ${action.description}`
|
||||
|
||||
if ("href" in action && action.href) {
|
||||
return (
|
||||
<FramePanel key={action.id} className={panelClassName(action.disabled)}>
|
||||
{action.disabled ? (
|
||||
<div aria-disabled aria-label={label}>
|
||||
<QuickActionBody action={action} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<QuickActionBody action={action} />
|
||||
<Link
|
||||
href={action.href}
|
||||
className="absolute inset-0 z-20 focus-visible:outline-none"
|
||||
aria-label={label}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
const onClick = action.onClick
|
||||
const onActivate = () => {
|
||||
if (action.disabled || !onClick) return
|
||||
onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel
|
||||
key={action.id}
|
||||
className={panelClassName(action.disabled)}
|
||||
role="button"
|
||||
tabIndex={action.disabled ? -1 : 0}
|
||||
aria-disabled={action.disabled || undefined}
|
||||
aria-label={label}
|
||||
onClick={onActivate}
|
||||
onKeyDown={(e) => handleActionKeyDown(onActivate, e)}
|
||||
>
|
||||
<QuickActionBody action={action} />
|
||||
</FramePanel>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -70,63 +70,72 @@ export function TrafficRxTxChart({
|
||||
rx,
|
||||
tx,
|
||||
range = "1h",
|
||||
embedded = false,
|
||||
}: {
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
range?: string
|
||||
/** Skip outer Frame when already inside OpsPanel / Frame. */
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
||||
const data = toChartData(rx, tx, rangeMinutes)
|
||||
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
||||
|
||||
const chart = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="4 8"
|
||||
vertical={false}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickMargin={10}
|
||||
interval={tickEvery - 1}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v: number) => fmtRate(Number(v))}
|
||||
tickMargin={8}
|
||||
width={72}
|
||||
/>
|
||||
<ChartTooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
dataKey="rx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-rx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="tx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-tx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<div className="mb-1 flex items-center justify-center gap-6">
|
||||
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
||||
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) return chart
|
||||
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="4 8"
|
||||
vertical={false}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickMargin={10}
|
||||
interval={tickEvery - 1}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v: number) => fmtRate(Number(v))}
|
||||
tickMargin={8}
|
||||
width={72}
|
||||
/>
|
||||
<ChartTooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
dataKey="rx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-rx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="tx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-tx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<div className="mb-1 flex items-center justify-center gap-6">
|
||||
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
||||
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
<FramePanel className="flex flex-col gap-6">{chart}</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,9 +144,24 @@ services:
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=on
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
- mmapp-pgdata:/var/lib/postgresql/data
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
networks:
|
||||
- mmapp
|
||||
healthcheck:
|
||||
|
||||
@@ -30,9 +30,24 @@ services:
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=on
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
- mmapp-pgdata:/var/lib/postgresql/data
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
|
||||
@@ -46,9 +46,24 @@ services:
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=on
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
- mmapp-pgdata:/var/lib/postgresql/data
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
networks:
|
||||
- mmapp
|
||||
healthcheck:
|
||||
|
||||
@@ -81,9 +81,24 @@ services:
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=on
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
- mmapp-pgdata:/var/lib/postgresql/data
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
networks:
|
||||
- mmapp
|
||||
healthcheck:
|
||||
|
||||
@@ -26,9 +26,24 @@ services:
|
||||
- -c
|
||||
- maintenance_work_mem=128MB
|
||||
- -c
|
||||
- wal_compression=on
|
||||
- wal_compression=lz4
|
||||
- -c
|
||||
- default_toast_compression=lz4
|
||||
- -c
|
||||
- io_method=worker
|
||||
- -c
|
||||
- io_workers=3
|
||||
- -c
|
||||
- effective_io_concurrency=200
|
||||
- -c
|
||||
- max_wal_size=2GB
|
||||
- -c
|
||||
- checkpoint_timeout=15min
|
||||
- -c
|
||||
- checkpoint_completion_target=0.9
|
||||
volumes:
|
||||
- mmapp-pgdata:/var/lib/postgresql/data
|
||||
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||
- mmapp-pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||
interval: 5s
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# MikrotikManager — PostgreSQL 18 runtime (overrides via `postgres -c`).
|
||||
# Не подменять целиком config_file образа: эти GUC передаются как -c в compose.
|
||||
|
||||
timezone = UTC
|
||||
listen_addresses = '*'
|
||||
max_connections = 100
|
||||
shared_buffers = 256MB
|
||||
work_mem = 16MB
|
||||
maintenance_work_mem = 128MB
|
||||
|
||||
# PG18 AIO: worker (io_uring в Docker/Alpine часто недоступен)
|
||||
io_method = worker
|
||||
io_workers = 3
|
||||
effective_io_concurrency = 200
|
||||
|
||||
# TOAST: lz4 (не zstd — для колонок только pglz/lz4)
|
||||
default_toast_compression = lz4
|
||||
wal_compression = lz4
|
||||
|
||||
# Реже checkpoint / меньше full-page writes (NetFlow upsert)
|
||||
max_wal_size = 2GB
|
||||
checkpoint_timeout = 15min
|
||||
checkpoint_completion_target = 0.9
|
||||
@@ -97,14 +97,16 @@ if ! docker inspect mmapp-postgres >/dev/null 2>&1; then
|
||||
-e POSTGRES_INITDB_ARGS="--encoding=UTF8 --locale=C.UTF-8 --data-checksums" \
|
||||
-e TZ=UTC \
|
||||
-e PGTZ=UTC \
|
||||
-v mmapp-pgdata:/var/lib/postgresql/data \
|
||||
-v mmapp-pgdata:/var/lib/postgresql \
|
||||
--health-cmd="pg_isready -U mmapp -d mmapp" \
|
||||
--health-interval=5s \
|
||||
--health-timeout=5s \
|
||||
--health-retries=10 \
|
||||
postgres:18-alpine \
|
||||
postgres -c timezone=UTC -c listen_addresses=* -c max_connections=100 \
|
||||
-c shared_buffers=256MB -c work_mem=16MB -c maintenance_work_mem=128MB -c wal_compression=on
|
||||
-c shared_buffers=256MB -c work_mem=16MB -c maintenance_work_mem=128MB \
|
||||
-c wal_compression=lz4 -c default_toast_compression=lz4 \
|
||||
-c io_method=worker -c io_workers=3 -c effective_io_concurrency=200
|
||||
fi
|
||||
until docker exec mmapp-postgres pg_isready -U mmapp -d mmapp >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
dashLatency,
|
||||
greTunnels as mockGreTunnels,
|
||||
pingProbes,
|
||||
servers as mockServers,
|
||||
traffic as mockTraffic,
|
||||
vxlanTunnels as mockVxlan,
|
||||
type GreTunnel,
|
||||
type PingProbe,
|
||||
type Server,
|
||||
type ServerStatus,
|
||||
type ServerType,
|
||||
} from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { listEvents } from "@/shared/api/events"
|
||||
import { listWireGuard } from "@/shared/api/wireguard"
|
||||
import type { EventItem } from "@mmapp/contracts/events"
|
||||
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
|
||||
import {
|
||||
buildDashboardInternetPath,
|
||||
type HomeWanRuntime,
|
||||
resolveDefaultRouteLookup,
|
||||
type InternetPathViewModel,
|
||||
} from "@/lib/dashboard-internet-path"
|
||||
import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-optimizer-data"
|
||||
|
||||
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
|
||||
const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed"
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return requestJson<T>(backendUrl, path, init)
|
||||
}
|
||||
}
|
||||
|
||||
function readMockDashboardStarIds(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set()
|
||||
try {
|
||||
const raw = localStorage.getItem(MOCK_DASH_STARS_LS)
|
||||
const arr = raw ? (JSON.parse(raw) as unknown) : []
|
||||
return new Set(Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
interface BackendServerRow {
|
||||
id: number
|
||||
name: string
|
||||
host: string
|
||||
site: string
|
||||
country: string
|
||||
asn: string
|
||||
type: ServerType
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
latency: number | null
|
||||
os: string | null
|
||||
model: string | null
|
||||
sessions?: number
|
||||
wanUplinks?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
isp: string
|
||||
iface: string
|
||||
ip: string
|
||||
maxDl: number
|
||||
maxUl: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface ApiGreTunnelRow {
|
||||
id: string
|
||||
name: string
|
||||
serverId: string
|
||||
localAddress: string
|
||||
remoteAddress: string
|
||||
localInnerIp: string
|
||||
remoteInnerIp: string
|
||||
poolId: string
|
||||
ipsec: null
|
||||
mtu: number
|
||||
keepaliveInterval: number
|
||||
keepaliveRetries: number
|
||||
dscp: "inherit" | number
|
||||
clampTcpMss: boolean
|
||||
allowFastPath: boolean
|
||||
comment: string
|
||||
enabled: boolean
|
||||
status: "up" | "down" | "degraded"
|
||||
}
|
||||
|
||||
interface InternetPathSnapshotPayload {
|
||||
sampledAt: string
|
||||
servers: BackendServerRow[]
|
||||
greTunnels: ApiGreTunnelRow[]
|
||||
filtersRulesets: FiltersRulesetRow[]
|
||||
speedProbes: RouteOptimizerSpeedProbe[]
|
||||
routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null } | null>
|
||||
wanRuntimeByHomeId: Record<string, HomeWanRuntime | null>
|
||||
}
|
||||
|
||||
interface TrafficServerRow {
|
||||
id: string
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
export type OverlayKind = "gre" | "wg" | "vxlan"
|
||||
|
||||
export interface OverlayItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: OverlayKind
|
||||
href: string
|
||||
status: "up" | "down" | "degraded"
|
||||
}
|
||||
|
||||
export interface AttentionRow {
|
||||
id: string
|
||||
title: string
|
||||
hint: string
|
||||
href: string
|
||||
tone: "destructive" | "warning"
|
||||
}
|
||||
|
||||
export type LatencyBlock =
|
||||
| { kind: "loading" }
|
||||
| { kind: "empty"; message: string }
|
||||
| { kind: "mock"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
|
||||
| { kind: "live"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
|
||||
|
||||
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
serverId: String(t.serverId),
|
||||
localAddress: t.localAddress,
|
||||
remoteAddress: t.remoteAddress,
|
||||
localInnerIp: t.localInnerIp,
|
||||
remoteInnerIp: t.remoteInnerIp,
|
||||
poolId: t.poolId || "live",
|
||||
ipsec: null,
|
||||
mtu: t.mtu,
|
||||
keepaliveInterval: t.keepaliveInterval,
|
||||
keepaliveRetries: t.keepaliveRetries,
|
||||
dscp: t.dscp,
|
||||
clampTcpMss: t.clampTcpMss,
|
||||
allowFastPath: t.allowFastPath,
|
||||
comment: t.comment,
|
||||
enabled: t.enabled,
|
||||
status: t.status,
|
||||
}
|
||||
}
|
||||
|
||||
function mapBackendToServer(s: BackendServerRow): Server {
|
||||
const wanUplinks = Array.isArray(s.wanUplinks)
|
||||
? s.wanUplinks
|
||||
.filter((w) => typeof w === "object" && w != null)
|
||||
.map((w, idx) => ({
|
||||
id: String(w.id || `wan-${s.id}-${idx + 1}`),
|
||||
name: String(w.name || `WAN${idx + 1}`),
|
||||
isp: String(w.isp || "—"),
|
||||
iface: String(w.iface || ""),
|
||||
ip: String(w.ip || ""),
|
||||
maxDl: Math.max(1, Math.round(Number(w.maxDl) || 100)),
|
||||
maxUl: Math.max(1, Math.round(Number(w.maxUl) || 100)),
|
||||
}))
|
||||
: []
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
host: s.host,
|
||||
model: s.model ?? "—",
|
||||
os: s.os ?? "—",
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as ServerStatus,
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: s.sessions ?? 0,
|
||||
wanUplinks,
|
||||
}
|
||||
}
|
||||
|
||||
function sumSeries(rows: TrafficServerRow[], key: "rxSeries" | "txSeries"): number[] {
|
||||
const len = Math.max(60, ...rows.map((r) => r[key].length), 0)
|
||||
const out = Array.from({ length: len }, () => 0)
|
||||
for (const row of rows) {
|
||||
const series = row[key]
|
||||
for (let i = 0; i < len; i++) {
|
||||
out[i] += series[i] ?? 0
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function mockOverlayItems(): OverlayItem[] {
|
||||
const gre: OverlayItem[] = mockGreTunnels.map((t) => ({
|
||||
id: `gre-${t.id}`,
|
||||
name: t.name,
|
||||
kind: "gre",
|
||||
href: "/gre",
|
||||
status: t.status,
|
||||
}))
|
||||
const wg: OverlayItem[] = mockServers.flatMap((s) =>
|
||||
(s.wireGuardIfaces ?? []).map((iface) => ({
|
||||
id: `wg-${iface.id}`,
|
||||
name: iface.name,
|
||||
kind: "wg" as const,
|
||||
href: "/wireguard",
|
||||
status: iface.status,
|
||||
})),
|
||||
)
|
||||
const vx: OverlayItem[] = mockVxlan.map((t) => ({
|
||||
id: `vx-${t.id}`,
|
||||
name: t.name,
|
||||
kind: "vxlan",
|
||||
href: "/vxlan",
|
||||
status: t.status,
|
||||
}))
|
||||
return [...gre, ...wg, ...vx]
|
||||
}
|
||||
|
||||
export function useDashboardLive() {
|
||||
const pathname = usePathname()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = prefsHydrated && mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||
const [liveServers, setLiveServers] = useState<Server[] | null>(null)
|
||||
const [overlayItems, setOverlayItems] = useState<OverlayItem[] | null>(null)
|
||||
const [bgp, setBgp] = useState<{ prefixSum: number; establishedCount: number } | null>(null)
|
||||
const [trafficSeries, setTrafficSeries] = useState<{
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
rxNow: number
|
||||
txNow: number
|
||||
} | null>(null)
|
||||
const [recentEvents, setRecentEvents] = useState<EventItem[]>([])
|
||||
const [eventsError, setEventsError] = useState<string | null>(null)
|
||||
const [eventsLoading, setEventsLoading] = useState(false)
|
||||
const [internetPath, setInternetPath] = useState<InternetPathViewModel | null>(null)
|
||||
const [internetPathLoading, setInternetPathLoading] = useState(false)
|
||||
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mockDashEpoch, setMockDashEpoch] = useState(0)
|
||||
|
||||
const fetchSnapshot = useCallback(async (silent: boolean) => {
|
||||
if (!isLive) return
|
||||
if (!silent) {
|
||||
setLoading(true)
|
||||
setInternetPathLoading(true)
|
||||
}
|
||||
try {
|
||||
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes] = await Promise.allSettled([
|
||||
apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"),
|
||||
apiFetch<BackendServerRow[]>("/api/servers"),
|
||||
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
||||
apiFetch<Array<{ state?: string; prefixesRx?: number }>>("/api/bgp/sessions"),
|
||||
apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"),
|
||||
apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"),
|
||||
listWireGuard(backendUrl),
|
||||
apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"),
|
||||
])
|
||||
|
||||
const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected"
|
||||
if (hardFail) {
|
||||
const reason = overviewRes.reason
|
||||
setError(reason instanceof Error ? reason.message : "Не удалось загрузить дашборд")
|
||||
} else {
|
||||
setError(null)
|
||||
}
|
||||
setInternetPathError(null)
|
||||
|
||||
if (overviewRes.status === "fulfilled") {
|
||||
setLiveProbes(overviewRes.value.probes)
|
||||
} else {
|
||||
setLiveProbes([])
|
||||
}
|
||||
|
||||
let serversMapped: Server[] = []
|
||||
if (serversRes.status === "fulfilled") {
|
||||
serversMapped = serversRes.value.map(mapBackendToServer)
|
||||
setLiveServers(serversMapped)
|
||||
} else {
|
||||
setLiveServers([])
|
||||
}
|
||||
|
||||
if (br.status === "fulfilled") {
|
||||
let prefixSum = 0
|
||||
let establishedCount = 0
|
||||
for (const s of br.value) {
|
||||
const st = String(s.state ?? "")
|
||||
if (/established/i.test(st)) {
|
||||
establishedCount += 1
|
||||
prefixSum += Number(s.prefixesRx ?? 0)
|
||||
}
|
||||
}
|
||||
setBgp({ prefixSum, establishedCount })
|
||||
} else {
|
||||
setBgp(null)
|
||||
}
|
||||
|
||||
const greItems: OverlayItem[] =
|
||||
greRes.status === "fulfilled"
|
||||
? (greRes.value.tunnels ?? []).map((t) => ({
|
||||
id: `gre-${t.id}`,
|
||||
name: t.name,
|
||||
kind: "gre" as const,
|
||||
href: "/gre",
|
||||
status: t.status,
|
||||
}))
|
||||
: []
|
||||
const wgItems: OverlayItem[] =
|
||||
wgRes.status === "fulfilled"
|
||||
? wgRes.value.interfaces.map((iface) => ({
|
||||
id: `wg-${iface.id}`,
|
||||
name: iface.name,
|
||||
kind: "wg" as const,
|
||||
href: "/wireguard",
|
||||
status: iface.status,
|
||||
}))
|
||||
: []
|
||||
setOverlayItems([...greItems, ...wgItems])
|
||||
|
||||
if (trafficRes.status === "fulfilled") {
|
||||
const rows = trafficRes.value.servers ?? []
|
||||
setTrafficSeries({
|
||||
rx: sumSeries(rows, "rxSeries"),
|
||||
tx: sumSeries(rows, "txSeries"),
|
||||
rxNow: rows.reduce((n, r) => n + (r.rxNow ?? 0), 0),
|
||||
txNow: rows.reduce((n, r) => n + (r.txNow ?? 0), 0),
|
||||
})
|
||||
} else {
|
||||
setTrafficSeries(null)
|
||||
}
|
||||
|
||||
const greMapped =
|
||||
greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : []
|
||||
|
||||
if (serversMapped.length > 0) {
|
||||
const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null
|
||||
if (snap) {
|
||||
setInternetPath(
|
||||
buildDashboardInternetPath({
|
||||
servers: snap.servers.map(mapBackendToServer),
|
||||
greTunnels: (snap.greTunnels ?? []).map(apiGreToGreTunnel),
|
||||
probes: snap.speedProbes ?? [],
|
||||
filtersRulesets: snap.filtersRulesets ?? [],
|
||||
routeLookupByServerId: snap.routeLookupByServerId ?? {},
|
||||
wanRuntimeByHomeId: snap.wanRuntimeByHomeId ?? {},
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
const filterRulesets: FiltersRulesetRow[] =
|
||||
fr.status === "fulfilled" ? ((fr.value.rulesets as FiltersRulesetRow[]) ?? []) : []
|
||||
const homes = serversMapped.filter((s) => s.type === "home-router")
|
||||
const lookups = await Promise.all(
|
||||
homes.map(async (h) => ({
|
||||
id: h.id,
|
||||
lookup: await resolveDefaultRouteLookup(apiFetch, h.id),
|
||||
})),
|
||||
)
|
||||
const wanRuntimeRows = await Promise.all(
|
||||
homes.map(async (h) => {
|
||||
try {
|
||||
const rt = await apiFetch<HomeWanRuntime>(`/api/servers/${h.id}/wan-runtime`)
|
||||
return { id: h.id, runtime: rt }
|
||||
} catch {
|
||||
return { id: h.id, runtime: null }
|
||||
}
|
||||
}),
|
||||
)
|
||||
const speedRes = await apiFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(
|
||||
() => ({ probes: [] }),
|
||||
)
|
||||
const lookupById = Object.fromEntries(lookups.map((x) => [x.id, x.lookup]))
|
||||
const wanRuntimeById = Object.fromEntries(wanRuntimeRows.map((x) => [x.id, x.runtime]))
|
||||
setInternetPath(
|
||||
buildDashboardInternetPath({
|
||||
servers: serversMapped,
|
||||
greTunnels: greMapped,
|
||||
probes: speedRes.probes ?? [],
|
||||
filtersRulesets: filterRulesets,
|
||||
routeLookupByServerId: lookupById,
|
||||
wanRuntimeByHomeId: wanRuntimeById,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setInternetPath(null)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Не удалось загрузить дашборд"
|
||||
setInternetPathError(msg)
|
||||
setInternetPath(null)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false)
|
||||
setInternetPathLoading(false)
|
||||
}
|
||||
}
|
||||
}, [apiFetch, backendUrl, isLive])
|
||||
|
||||
const fetchRecentEvents = useCallback(async (silent: boolean) => {
|
||||
if (!isLive) {
|
||||
setRecentEvents([])
|
||||
setEventsError(null)
|
||||
return
|
||||
}
|
||||
if (!silent) setEventsLoading(true)
|
||||
try {
|
||||
const rows = await listEvents(backendUrl, { limit: 6 })
|
||||
setRecentEvents(rows)
|
||||
setEventsError(null)
|
||||
} catch (err) {
|
||||
setRecentEvents([])
|
||||
setEventsError(err instanceof Error ? err.message : "Не удалось загрузить события")
|
||||
} finally {
|
||||
if (!silent) setEventsLoading(false)
|
||||
}
|
||||
}, [backendUrl, isLive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
queueMicrotask(() => {
|
||||
setLiveProbes(null)
|
||||
setLiveServers(null)
|
||||
setOverlayItems(null)
|
||||
setBgp(null)
|
||||
setTrafficSeries(null)
|
||||
setError(null)
|
||||
setInternetPath(null)
|
||||
setInternetPathError(null)
|
||||
})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
void fetchSnapshot(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isLive, fetchSnapshot])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void fetchRecentEvents(false)
|
||||
})
|
||||
}, [fetchRecentEvents])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
queueMicrotask(() => {
|
||||
void fetchRecentEvents(true)
|
||||
})
|
||||
}, 20_000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchRecentEvents])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
const id = setInterval(() => {
|
||||
queueMicrotask(() => {
|
||||
void fetchSnapshot(true)
|
||||
})
|
||||
}, 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [isLive, fetchSnapshot])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
queueMicrotask(() => {
|
||||
void fetchSnapshot(true)
|
||||
})
|
||||
}, [pathname, isLive, fetchSnapshot])
|
||||
|
||||
useEffect(() => {
|
||||
const bumpMock = () => setMockDashEpoch((x) => x + 1)
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === MOCK_DASH_STARS_LS) bumpMock()
|
||||
}
|
||||
const onVis = () => {
|
||||
if (document.visibilityState === "visible") bumpMock()
|
||||
}
|
||||
const onUptimeChanged = () => {
|
||||
bumpMock()
|
||||
if (isLive) void fetchSnapshot(true)
|
||||
}
|
||||
window.addEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
|
||||
window.addEventListener("storage", onStorage)
|
||||
document.addEventListener("visibilitychange", onVis)
|
||||
return () => {
|
||||
window.removeEventListener(UPTIME_PROBES_CHANGED, onUptimeChanged)
|
||||
window.removeEventListener("storage", onStorage)
|
||||
document.removeEventListener("visibilitychange", onVis)
|
||||
}
|
||||
}, [isLive, fetchSnapshot])
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setMockDashEpoch((x) => x + 1))
|
||||
}, [pathname])
|
||||
|
||||
const servers = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockServers
|
||||
return liveServers ?? []
|
||||
}, [prefsHydrated, isLive, liveServers])
|
||||
|
||||
const mockActiveProbes = useMemo(() => {
|
||||
void mockDashEpoch
|
||||
const stars = readMockDashboardStarIds()
|
||||
return pingProbes.filter((p) => p.enabled && stars.has(p.id))
|
||||
}, [mockDashEpoch])
|
||||
|
||||
const activeProbes = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockActiveProbes
|
||||
if (liveProbes === null) return []
|
||||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||
}, [prefsHydrated, isLive, liveProbes, mockActiveProbes])
|
||||
|
||||
const enabledProbes = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return pingProbes.filter((p) => p.enabled)
|
||||
return liveProbes?.filter((p) => p.enabled) ?? []
|
||||
}, [prefsHydrated, isLive, liveProbes])
|
||||
|
||||
const overlay = useMemo(() => {
|
||||
const items = !prefsHydrated ? [] : isLive ? (overlayItems ?? []) : mockOverlayItems()
|
||||
const up = items.filter((i) => i.status === "up").length
|
||||
const down = items.filter((i) => i.status !== "up").length
|
||||
return { items, total: items.length, up, down }
|
||||
}, [prefsHydrated, isLive, overlayItems])
|
||||
|
||||
const traffic = useMemo(() => {
|
||||
if (!prefsHydrated) return null
|
||||
if (!isLive) {
|
||||
return {
|
||||
rx: mockTraffic.rx,
|
||||
tx: mockTraffic.tx,
|
||||
rxNow: mockTraffic.rx[mockTraffic.rx.length - 1] ?? 0,
|
||||
txNow: mockTraffic.tx[mockTraffic.tx.length - 1] ?? 0,
|
||||
demo: true,
|
||||
}
|
||||
}
|
||||
if (!trafficSeries) return null
|
||||
return { ...trafficSeries, demo: false }
|
||||
}, [prefsHydrated, isLive, trafficSeries])
|
||||
|
||||
const latency: LatencyBlock = useMemo(() => {
|
||||
if (!prefsHydrated) return { kind: "loading" }
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock",
|
||||
series: dashLatency,
|
||||
subtitle: "Последние 60 минут · демо",
|
||||
}
|
||||
}
|
||||
if (liveProbes === null && loading) return { kind: "loading" }
|
||||
if (!liveProbes?.length) {
|
||||
return {
|
||||
kind: "empty",
|
||||
message: "Нет данных проб. Откройте мониторинг и проверьте сборщик uptime.",
|
||||
}
|
||||
}
|
||||
const { series, labels } = buildLatencySeriesByProbeSource(liveProbes, liveServers ?? [], {
|
||||
maxServers: 8,
|
||||
points: 60,
|
||||
})
|
||||
if (Object.keys(series).length === 0) {
|
||||
return {
|
||||
kind: "empty",
|
||||
message: "Нет включённых проб с историей RTT. Включите пробы на странице «Мониторинг».",
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "live",
|
||||
series,
|
||||
labels,
|
||||
subtitle: "Средний RTT · 1 ч · до 8 узлов",
|
||||
}
|
||||
}, [prefsHydrated, isLive, liveProbes, loading, liveServers])
|
||||
|
||||
const attentionServers: AttentionRow[] = useMemo(() => {
|
||||
return servers
|
||||
.filter((s) => s.enabled && s.status !== "online")
|
||||
.slice(0, 5)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.name,
|
||||
hint: s.status === "degraded" ? "degraded" : "offline",
|
||||
href: "/servers",
|
||||
tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const),
|
||||
}))
|
||||
}, [servers])
|
||||
|
||||
const attentionOverlay: AttentionRow[] = useMemo(() => {
|
||||
return overlay.items
|
||||
.filter((i) => i.status !== "up")
|
||||
.slice(0, 5)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
title: i.name,
|
||||
hint: i.status === "degraded" ? "degraded" : "down",
|
||||
href: i.href,
|
||||
tone: i.status === "degraded" ? ("warning" as const) : ("destructive" as const),
|
||||
}))
|
||||
}, [overlay.items])
|
||||
|
||||
const attentionProbes: AttentionRow[] = useMemo(() => {
|
||||
return enabledProbes
|
||||
.filter((p) => p.status === "down" || p.status === "warn")
|
||||
.slice(0, 5)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.name,
|
||||
hint: p.status === "warn" ? "warn" : "down",
|
||||
href: "/uptime",
|
||||
tone: p.status === "warn" ? ("warning" as const) : ("destructive" as const),
|
||||
}))
|
||||
}, [enabledProbes])
|
||||
|
||||
const onlineCount = servers.filter((s) => s.status === "online").length
|
||||
const probeDown = enabledProbes.filter((p) => p.status === "down").length
|
||||
const probeWarn = enabledProbes.filter((p) => p.status === "warn").length
|
||||
const dataPending = isLive && !error && (liveServers === null || liveProbes === null)
|
||||
const kpiLoading = !prefsHydrated || dataPending
|
||||
|
||||
const probesSubtitle = !prefsHydrated
|
||||
? "Загрузка…"
|
||||
: !isLive
|
||||
? mockActiveProbes.length > 0
|
||||
? `${mockActiveProbes.length} на дашборде · демо`
|
||||
: "Нет проб на дашборде · отметьте ★ в мониторинге"
|
||||
: error && liveProbes === null
|
||||
? error
|
||||
: activeProbes.length > 0
|
||||
? `${activeProbes.length} на дашборде · 1 ч`
|
||||
: "Нет проб на дашборде · отметьте ★ в мониторинге"
|
||||
|
||||
return {
|
||||
prefsHydrated,
|
||||
isLive,
|
||||
loading: kpiLoading,
|
||||
error,
|
||||
retry: () => {
|
||||
void fetchSnapshot(false)
|
||||
void fetchRecentEvents(false)
|
||||
},
|
||||
servers,
|
||||
activeProbes,
|
||||
overlay,
|
||||
bgp: isLive ? bgp : { prefixSum: 8432, establishedCount: 3 },
|
||||
traffic,
|
||||
onlineCount,
|
||||
totalServers: servers.length,
|
||||
probeDown,
|
||||
probeWarn,
|
||||
latency,
|
||||
recentEvents,
|
||||
eventsLoading,
|
||||
eventsError,
|
||||
internetPath,
|
||||
internetPathLoading: isLive && internetPathLoading && !internetPath,
|
||||
internetPathError,
|
||||
attentionServers,
|
||||
attentionOverlay,
|
||||
attentionProbes,
|
||||
probesSubtitle,
|
||||
probesLoading: isLive && loading && liveProbes === null,
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -15,6 +15,12 @@ function parseSseBlock(block: string): { event: string; data: string } {
|
||||
return { event, data: dataLines.join("\n") }
|
||||
}
|
||||
|
||||
export function isValidFlowLiveSample(value: unknown): value is FlowAnalyticsDto {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const v = value as Partial<FlowAnalyticsDto>
|
||||
return typeof v.uniqueSrc === "number" && Array.isArray(v.destinations)
|
||||
}
|
||||
|
||||
export function useFlowLive(opts: {
|
||||
enabled: boolean
|
||||
backendUrl: string
|
||||
@@ -75,7 +81,11 @@ export function useFlowLive(opts: {
|
||||
if (!raw.trim() || raw.trim().startsWith(":")) continue
|
||||
const ev = parseSseBlock(raw)
|
||||
if (ev.event === "sample" && ev.data) {
|
||||
const parsed = JSON.parse(ev.data) as FlowAnalyticsDto
|
||||
const parsed = JSON.parse(ev.data) as unknown
|
||||
if (!isValidFlowLiveSample(parsed)) {
|
||||
setError("live sample пустой")
|
||||
continue
|
||||
}
|
||||
setSample(parsed)
|
||||
setError(parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||||
} else if (ev.event === "error" && ev.data) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FilterFieldConfig } from "@/components/reui/filters"
|
||||
import type { Backup } from "@/lib/data"
|
||||
|
||||
export const BACKUP_FILTER_FIELDS: FilterFieldConfig[] = [
|
||||
{
|
||||
key: "server",
|
||||
label: "Сервер",
|
||||
type: "text",
|
||||
placeholder: "Имя сервера",
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
label: "Тип",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "auto", label: "авто" },
|
||||
{ value: "manual", label: "вручную" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
label: "Хранилище",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "local", label: "Локально" },
|
||||
{ value: "s3", label: "S3" },
|
||||
{ value: "both", label: "Локально + S3" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const BACKUP_FILTER_ACCESSORS = {
|
||||
server: (b: Backup) => b.server,
|
||||
kind: (b: Backup) => b.kind,
|
||||
storage: (b: Backup) => b.storage,
|
||||
}
|
||||
+8
-5
@@ -189,11 +189,14 @@ export interface FirewallAddressListEntry {
|
||||
export interface Backup {
|
||||
id: string
|
||||
server: string
|
||||
serverId?: string | null
|
||||
filename: string
|
||||
size: string
|
||||
created: string
|
||||
kind: "auto" | "manual"
|
||||
notes: string
|
||||
storage: "local" | "s3" | "both"
|
||||
uploadError?: string
|
||||
}
|
||||
|
||||
// ─── VXLAN ───────────────────────────────────────────────────────────────────
|
||||
@@ -467,11 +470,11 @@ export const firewallRules: FirewallRule[] = [
|
||||
]
|
||||
|
||||
export const backups: Backup[] = [
|
||||
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением" },
|
||||
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
|
||||
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "" },
|
||||
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP" },
|
||||
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "" },
|
||||
{ id: "b1", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-26_03-00.rsc", size: "124 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "снапшот перед обновлением", storage: "local" },
|
||||
{ id: "b2", server: "mt-msk-core-01", filename: "mt-msk-core-01_2026-04-25_03-00.rsc", size: "124 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
|
||||
{ id: "b3", server: "mt-spb-edge-01", filename: "mt-spb-edge-01_2026-04-26_03-00.rsc", size: "88 КБ", created: "Сегодня, 03:00", kind: "auto", notes: "", storage: "s3" },
|
||||
{ id: "b4", server: "mt-fra-edge-01", filename: "mt-fra-edge-01_2026-04-26_manual.rsc",size: "92 КБ", created: "Сегодня, 14:18", kind: "manual", notes: "перед изменением BGP", storage: "both" },
|
||||
{ id: "b5", server: "mt-ams-edge-01", filename: "mt-ams-edge-01_2026-04-25_03-00.rsc", size: "64 КБ", created: "Вчера, 03:00", kind: "auto", notes: "", storage: "local" },
|
||||
]
|
||||
|
||||
export const pingProbes: PingProbe[] = [
|
||||
|
||||
@@ -40,7 +40,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
||||
gre_bgp:
|
||||
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
|
||||
certificates_renew:
|
||||
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
|
||||
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
|
||||
backups:
|
||||
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
||||
alert_engine:
|
||||
|
||||
Generated
+411
@@ -50,6 +50,7 @@
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
@@ -110,6 +111,314 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1000.29",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz",
|
||||
"integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1127.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1127.0.tgz",
|
||||
"integrity": "sha512-0ZSAgmEda33xPqVPt+bx2KzrXV1cUCKRRVPGliLu+V7DzHPa04CqPSi1maGEM4O0LDP0iI6HRSW5ULloNwayNw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1000.29",
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.82",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.75",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/fetch-http-handler": "^5.7.2",
|
||||
"@smithy/node-http-handler": "^4.11.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.977.9",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz",
|
||||
"integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@aws-sdk/xml-builder": "^3.972.40",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/signature-v4": "^5.6.12",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.70",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz",
|
||||
"integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz",
|
||||
"integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/fetch-http-handler": "^5.7.2",
|
||||
"@smithy/node-http-handler": "^4.11.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.973.15",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz",
|
||||
"integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.70",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.77",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.70",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.14",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
|
||||
"@aws-sdk/nested-clients": "^3.997.44",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/credential-provider-imds": "^4.4.16",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.77",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz",
|
||||
"integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/nested-clients": "^3.997.44",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.82",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz",
|
||||
"integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.70",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.15",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.70",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.14",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/credential-provider-imds": "^4.4.16",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.70",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz",
|
||||
"integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.973.14",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz",
|
||||
"integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/nested-clients": "^3.997.44",
|
||||
"@aws-sdk/token-providers": "3.1116.0",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.76",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz",
|
||||
"integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/nested-clients": "^3.997.44",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.75",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz",
|
||||
"integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.44",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz",
|
||||
"integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/fetch-http-handler": "^5.7.2",
|
||||
"@smithy/node-http-handler": "^4.11.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.46",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
|
||||
"integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/signature-v4": "^5.6.12",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1116.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz",
|
||||
"integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.977.9",
|
||||
"@aws-sdk/nested-clients": "^3.997.44",
|
||||
"@aws-sdk/types": "^3.974.5",
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.974.5",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz",
|
||||
"integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.40",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
|
||||
"integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -3759,6 +4068,87 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.33.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz",
|
||||
"integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
|
||||
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.2",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
|
||||
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
|
||||
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
|
||||
"integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz",
|
||||
"integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -5565,6 +5955,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
@@ -14401,6 +14797,21 @@
|
||||
"dependencies": {
|
||||
"zod": "^4.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from "zod"
|
||||
|
||||
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
|
||||
export const backupFormatSchema = z.enum(["rsc", "backup"])
|
||||
export const backupStorageProviderSchema = z.enum(["local", "s3"])
|
||||
export const backupObjectStorageSchema = z.enum(["local", "s3", "both"])
|
||||
|
||||
export const backupScheduleSettingsDtoSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -32,3 +34,48 @@ export const putBackupScheduleSettingsSchema = z.object({
|
||||
})
|
||||
|
||||
export type BackupScheduleSettingsDto = z.infer<typeof backupScheduleSettingsDtoSchema>
|
||||
|
||||
export const backupStorageSettingsDtoSchema = z.object({
|
||||
provider: backupStorageProviderSchema,
|
||||
s3Endpoint: z.string(),
|
||||
s3Region: z.string(),
|
||||
s3Bucket: z.string(),
|
||||
s3Prefix: z.string(),
|
||||
s3AccessKeyId: z.string(),
|
||||
secretConfigured: z.boolean(),
|
||||
s3ForcePathStyle: z.boolean(),
|
||||
keepLocalCopy: z.boolean(),
|
||||
lastTestAt: z.string().nullable().optional(),
|
||||
lastTestError: z.string().nullable().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export const putBackupStorageSettingsSchema = z.object({
|
||||
provider: backupStorageProviderSchema.optional(),
|
||||
s3Endpoint: z.string().optional(),
|
||||
s3Region: z.string().optional(),
|
||||
s3Bucket: z.string().optional(),
|
||||
s3Prefix: z.string().optional(),
|
||||
s3AccessKeyId: z.string().optional(),
|
||||
s3SecretAccessKey: z.string().optional(),
|
||||
s3ForcePathStyle: z.boolean().optional(),
|
||||
keepLocalCopy: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const backupItemDtoSchema = z.object({
|
||||
id: z.string(),
|
||||
serverId: z.number().nullable(),
|
||||
serverName: z.string(),
|
||||
filename: z.string(),
|
||||
sizeBytes: z.number(),
|
||||
createdAt: z.string(),
|
||||
kind: z.enum(["manual", "auto"]),
|
||||
notes: z.string().optional(),
|
||||
storage: backupObjectStorageSchema,
|
||||
s3Key: z.string().nullable().optional(),
|
||||
uploadError: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type BackupStorageSettingsDto = z.infer<typeof backupStorageSettingsDtoSchema>
|
||||
export type PutBackupStorageSettings = z.infer<typeof putBackupStorageSettingsSchema>
|
||||
export type BackupItemDto = z.infer<typeof backupItemDtoSchema>
|
||||
|
||||
+41
-2
@@ -1,15 +1,22 @@
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
|
||||
import type {
|
||||
BackupScheduleSettingsDto,
|
||||
BackupStorageSettingsDto,
|
||||
PutBackupStorageSettings,
|
||||
} from "@mmapp/contracts/backups"
|
||||
|
||||
export type BackupItem = {
|
||||
id: string
|
||||
serverId: string
|
||||
serverId: number | string | null
|
||||
serverName: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
kind: "manual" | "auto"
|
||||
notes?: string
|
||||
storage?: "local" | "s3" | "both"
|
||||
s3Key?: string | null
|
||||
uploadError?: string | null
|
||||
}
|
||||
|
||||
type CreateBackupResponse = {
|
||||
@@ -68,6 +75,12 @@ export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
|
||||
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function restoreBackup(baseUrl: string, id: string): Promise<{ filename: string; serverName: string }> {
|
||||
return requestJson<{ filename: string; serverName: string }>(baseUrl, `/api/backups/${id}/restore`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
|
||||
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
|
||||
}
|
||||
@@ -81,3 +94,29 @@ export async function putBackupScheduleSettings(
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBackupStorageSettings(baseUrl: string): Promise<BackupStorageSettingsDto> {
|
||||
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage")
|
||||
}
|
||||
|
||||
export async function putBackupStorageSettings(
|
||||
baseUrl: string,
|
||||
payload: PutBackupStorageSettings,
|
||||
): Promise<BackupStorageSettingsDto> {
|
||||
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function testBackupStorage(baseUrl: string): Promise<BackupStorageSettingsDto> {
|
||||
return requestJson<BackupStorageSettingsDto>(baseUrl, "/api/backups/storage/test", { method: "POST" })
|
||||
}
|
||||
|
||||
export async function syncBackupsFromStorage(
|
||||
baseUrl: string,
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
return requestJson<{ imported: number; skipped: number }>(baseUrl, "/api/backups/storage/sync", {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user