Compare commits
11
Commits
bdf3f22b12
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c58384056 | ||
|
|
74c5b66482 | ||
|
|
1ae23d848b | ||
|
|
ab14fc979e | ||
|
|
76a99f098a | ||
|
|
e0bdafd25e | ||
|
|
3c2eefe108 | ||
|
|
6777422a27 | ||
|
|
d060e3bada | ||
|
|
797c73d35c | ||
|
|
79e00a2390 |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: design-harbor-device-ecosystem
|
name: design-harbor-device-ecosystem
|
||||||
description: Design, plan, implement, review, or document Harbor Gateway integrations with current and future companion devices. Use for LAN advertisement and discovery, Connect-Gateway pairing, trusted device identity, connected-client presence, ecosystem membership in the Gateway device list, multi-Gateway selection, capability or protocol negotiation, and related API, persistence, migration, or UI contracts. Do not use for traffic-only LAN inventory or visual polish that does not change ecosystem behavior.
|
description: "Use when changing or reviewing Harbor companion-device discovery, pairing, trust, presence, routing handoff, or their API and persistence contracts. Not for traffic-only inventory or visual polish."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Design Harbor Device Ecosystem
|
# Design Harbor Device Ecosystem
|
||||||
@@ -18,7 +18,7 @@ Keep discovery, trust, application presence, routing, and LAN observation separa
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read `PRODUCT.md`, `workpack/PRODUCT_PRIORITIES.md`, `workpack/DATA_CONSISTENCY_MODEL.md`, `workpack/DESIGN_OWNER_POLICY.md`, `workpack/STATUS.md`, and the selected workpack task.
|
1. Start with the selected workpack task when one exists. Read `PRODUCT.md` and `workpack/PRODUCT_PRIORITIES.md` for product scope, `workpack/DATA_CONSISTENCY_MODEL.md` for persistence or projection changes, and `workpack/STATUS.md` for active roadmap/cutover work. Any visible UI decision requires `workpack/DESIGN_OWNER_POLICY.md`; reuse an exact owner decision already recorded for this scope.
|
||||||
2. Read [device-ecosystem-contract.md](references/device-ecosystem-contract.md) before changing discovery, pairing, identity, presence, routing handoff, or the Gateway device projection.
|
2. Read [device-ecosystem-contract.md](references/device-ecosystem-contract.md) before changing discovery, pairing, identity, presence, routing handoff, or the Gateway device projection.
|
||||||
3. Trace the current producer -> transport -> persistence -> canonical snapshot -> UI path. Confirm the owning service and every caller before editing.
|
3. Trace the current producer -> transport -> persistence -> canonical snapshot -> UI path. Confirm the owning service and every caller before editing.
|
||||||
4. Classify each proposed field and state as one of: discovery candidate, persisted trust relationship, authenticated presence lease, route state, network observation, or derived binding. Reject fields that mix categories.
|
4. Classify each proposed field and state as one of: discovery candidate, persisted trust relationship, authenticated presence lease, route state, network observation, or derived binding. Reject fields that mix categories.
|
||||||
@@ -44,7 +44,7 @@ Keep discovery, trust, application presence, routing, and LAN observation separa
|
|||||||
|
|
||||||
## Acceptance pass
|
## Acceptance pass
|
||||||
|
|
||||||
Before handing off an ecosystem change, verify:
|
Verify the affected contracts below. Protocol, authentication, shared projection, or migration changes require coverage across the impacted lifecycle; a documentation-only correction uses consistency/link checks. Do not operate live devices without the exact authorization described above:
|
||||||
|
|
||||||
- A supported client can discover more than one Gateway and survives address changes without changing Gateway identity.
|
- A supported client can discover more than one Gateway and survives address changes without changing Gateway identity.
|
||||||
- Spoofed discovery cannot create trust, change the active route, or appear as a paired ecosystem member.
|
- Spoofed discovery cannot create trust, change the active route, or appear as a paired ecosystem member.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: design-vpn-client-ui
|
name: design-vpn-client-ui
|
||||||
description: Design, implement, review, or refine the client-facing VPN interfaces in this repository using the established calm monospace visual language and smooth state-driven motion. Use for the current macOS client and future end-user gateway client screens, especially power controls, subscriptions, traffic usage, proxy copy controls, server selection, responsive layout, hover feedback, transitions, and animation polish. Do not use for the administrative gateway UI unless the user explicitly asks to apply the client visual language there.
|
description: "Use when changing or reviewing Harbor Connect and Gateway client UI. Apply the established visual and motion language; not for administrative Gateway UI unless explicitly requested."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Design VPN Client UI
|
# Design VPN Client UI
|
||||||
@@ -9,23 +9,20 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read `PRODUCT.md` and the complete client component and styles before editing.
|
1. Inspect the affected client component, state owner, relevant styles, and nearest analogue. Read `PRODUCT.md` when product behavior or mode semantics change; expand context when shared invariants require it.
|
||||||
2. Inspect supplied evidence and trace the real DOM and state change that causes the visual issue. Follow repository testing policy; do not launch manual or interactive visual testing unless the user explicitly requests it in the current prompt.
|
2. Inspect supplied evidence and trace the real DOM and state change that causes the visual issue. Follow repository testing policy; do not launch manual or interactive visual testing unless the user explicitly requests it in the current prompt.
|
||||||
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
|
3. Read [visual-language.md](references/visual-language.md) for layout, hierarchy, color, and typography work.
|
||||||
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
|
4. Read [motion-and-interaction.md](references/motion-and-interaction.md) for animation, hover, refresh, input, copy, or state-transition work.
|
||||||
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
|
5. Reuse existing React state, CSS variables, formatters, and API paths. Prefer a narrow CSS/markup change over a new abstraction or dependency.
|
||||||
6. Keep geometry stable across every state. Reserve space before animating content.
|
6. Keep geometry stable across every state. Reserve space before animating content.
|
||||||
7. Implement `prefers-reduced-motion` alongside every new animation.
|
7. Implement `prefers-reduced-motion` alongside every new animation.
|
||||||
8. Run `npm test`, `npm run build`, and `git diff --check`. Perform manual visual inspection only when explicitly requested.
|
8. Run focused automated checks for affected behavior and `npm run build` for client code changes; use the full `npm test` suite when shared behavior changes or narrower evidence is insufficient. Documentation-only work uses structural/link checks and `git diff --check`. Manual visual inspection requires an explicit request in the current prompt.
|
||||||
|
|
||||||
## Communicating a proposed change
|
## Communicating a proposed change
|
||||||
|
|
||||||
For implementation proposals and progress summaries, combine structure instead of maximizing it:
|
For a local correction, report the result and focused evidence briefly. For a change spanning
|
||||||
|
several owners or state lifecycles, use one compact table of affected modules plus the
|
||||||
- start with one compact table grouped by affected modules such as frontend, UI, backend/API, styles, tests, and versions;
|
relevant states, accessibility, and motion decisions. Omit empty sections and unrelated modules.
|
||||||
- use short lists only for cross-cutting details such as states, accessibility, motion, or changed files;
|
|
||||||
- keep unaffected modules visible in the table when that clarifies scope;
|
|
||||||
- avoid both an unstructured paragraph and a separate table for every subsection.
|
|
||||||
|
|
||||||
## Non-negotiable decisions
|
## Non-negotiable decisions
|
||||||
|
|
||||||
@@ -49,7 +46,7 @@ For implementation proposals and progress summaries, combine structure instead o
|
|||||||
|
|
||||||
## Acceptance pass
|
## Acceptance pass
|
||||||
|
|
||||||
Before handing off, verify:
|
Check the affected behaviors below using automated evidence where possible. Shared layout or motion changes require broader coverage; a local correction does not require exercising unrelated controls. Manual inspection remains limited to an explicit request in the current prompt:
|
||||||
|
|
||||||
- Power on/off is unmistakable without reading the label.
|
- Power on/off is unmistakable without reading the label.
|
||||||
- Switching on/off preserves the exact positions of title, timer, and hint.
|
- Switching on/off preserves the exact positions of title, timer, and hint.
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ INSTALL_SINGBOX=true
|
|||||||
PROXY_PORT=8080
|
PROXY_PORT=8080
|
||||||
PROXY_BIND_IP=0.0.0.0
|
PROXY_BIND_IP=0.0.0.0
|
||||||
SING_BOX_API_PORT=19090
|
SING_BOX_API_PORT=19090
|
||||||
SING_BOX_TRAFFIC_SOURCE=snapshot
|
SING_BOX_TRAFFIC_SOURCE=native
|
||||||
TPROXY_PORT=7895
|
TPROXY_PORT=7895
|
||||||
TPROXY_MARK=1
|
TPROXY_MARK=1
|
||||||
TPROXY_TABLE=100
|
TPROXY_TABLE=100
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
DEPLOY_PATH: /opt/vpn-proxy
|
DEPLOY_PATH: /opt/vpn-proxy
|
||||||
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
BASE_IMAGE: vpn-proxy-runtime-base:bookworm-slim
|
||||||
NODE_BUILD_IMAGE: mirror.gcr.io/library/node:20.19-bookworm
|
NODE_BUILD_IMAGE: mirror.gcr.io/library/node:24.21.0-bookworm
|
||||||
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
RUNTIME_BASE_SOURCE_IMAGE: mirror.gcr.io/library/debian:bookworm-slim
|
||||||
APT_MIRROR: http://mirror.yandex.ru/debian
|
APT_MIRROR: http://mirror.yandex.ru/debian
|
||||||
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
APT_SECURITY_MIRROR: http://mirror.yandex.ru/debian-security
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
echo "Restart scope: ${RESTART_SCOPE}"
|
echo "Restart scope: ${RESTART_SCOPE}"
|
||||||
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
echo "affected_components=${AFFECTED_COMPONENTS}" >> "$GITHUB_OUTPUT"
|
||||||
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
|
echo "restart_scope=${RESTART_SCOPE}" >> "$GITHUB_OUTPUT"
|
||||||
if command -v npm >/dev/null 2>&1; then
|
if command -v npm >/dev/null 2>&1 && node scripts/check-sqlite-runtime.mjs; then
|
||||||
npm ci --no-audit --no-fund
|
npm ci --no-audit --no-fund
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run check:boundaries
|
npm run check:boundaries
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
npm run build:production
|
npm run build:production
|
||||||
else
|
else
|
||||||
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null && command -v git >/dev/null && test -x /bin/bash'; then
|
if ! docker run --rm "${{ env.NODE_BUILD_IMAGE }}" sh -lc 'command -v npm >/dev/null && command -v git >/dev/null && test -x /bin/bash'; then
|
||||||
echo "Cannot validate change: host npm and the Node 20.19 build toolchain are unavailable." >&2
|
echo "Cannot validate change: the pinned Node 24.21.0 build toolchain is unavailable." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
echo "Host npm not found; validating inside ${{ env.NODE_BUILD_IMAGE }}"
|
||||||
@@ -147,6 +147,7 @@ jobs:
|
|||||||
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
APT_MIRROR="${{ env.APT_MIRROR }}" \
|
||||||
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
APT_SECURITY_MIRROR="${{ env.APT_SECURITY_MIRROR }}" \
|
||||||
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
SINGBOX_VERSION="${{ env.SINGBOX_VERSION }}" \
|
||||||
|
NODE_BUILD_IMAGE="${{ env.NODE_BUILD_IMAGE }}" \
|
||||||
./scripts/build-runtime-base.sh
|
./scripts/build-runtime-base.sh
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ data/
|
|||||||
|
|
||||||
# Local roadmap and task workspace
|
# Local roadmap and task workspace
|
||||||
/workpack/
|
/workpack/
|
||||||
|
/design-qa.md
|
||||||
|
|
||||||
# Node/Vite
|
# Node/Vite
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24.21.0
|
||||||
+9
-2
@@ -1,8 +1,10 @@
|
|||||||
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
|
||||||
ARG BASE_IMAGE=debian:bookworm-slim
|
ARG BASE_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
FROM ${NODE_BUILD_IMAGE} AS build
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs ./scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY index.html vite.config.ts tsconfig*.json ./
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
@@ -13,13 +15,14 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
|
|||||||
RUN npm run build:production
|
RUN npm run build:production
|
||||||
|
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
COPY --from=build /usr/local /usr/local
|
||||||
ARG SINGBOX_VERSION=1.14.0-rc.5
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
ARG INSTALL_RUNTIME_DEPS=true
|
ARG INSTALL_RUNTIME_DEPS=true
|
||||||
ARG INSTALL_SINGBOX=true
|
ARG INSTALL_SINGBOX=true
|
||||||
|
|
||||||
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \
|
||||||
apt-get update \
|
apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data nodejs dumb-init \
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 ieee-data dumb-init \
|
||||||
&& rm -rf /var/lib/apt/lists/*; \
|
&& rm -rf /var/lib/apt/lists/*; \
|
||||||
else \
|
else \
|
||||||
command -v dumb-init >/dev/null \
|
command -v dumb-init >/dev/null \
|
||||||
@@ -49,6 +52,10 @@ COPY --from=build /src/dist /app/dist
|
|||||||
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||||
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||||
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||||
|
COPY --from=build /src/node_modules/tldts /app/node_modules/tldts
|
||||||
|
COPY --from=build /src/node_modules/tldts-core /app/node_modules/tldts-core
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs /app/scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node /app/scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -1,8 +1,10 @@
|
|||||||
ARG NODE_BUILD_IMAGE=node:20.19-alpine
|
ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
|
||||||
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
ARG RUNTIME_IMAGE=debian:bookworm-slim
|
||||||
|
|
||||||
FROM ${NODE_BUILD_IMAGE} AS build
|
FROM ${NODE_BUILD_IMAGE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs ./scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY index.html vite.config.ts tsconfig*.json ./
|
COPY index.html vite.config.ts tsconfig*.json ./
|
||||||
@@ -13,10 +15,11 @@ COPY monitoring/grafana/harbor-gateway.json ./monitoring/grafana/harbor-gateway.
|
|||||||
RUN npm run build:production
|
RUN npm run build:production
|
||||||
|
|
||||||
FROM ${RUNTIME_IMAGE}
|
FROM ${RUNTIME_IMAGE}
|
||||||
|
COPY --from=build /usr/local /usr/local
|
||||||
ARG SINGBOX_VERSION=1.14.0-rc.5
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init nodejs tar \
|
&& apt-get install -y --no-install-recommends ca-certificates curl dumb-init tar \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
@@ -37,6 +40,10 @@ COPY --from=build /src/dist /app/dist
|
|||||||
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
COPY --from=build /src/node_modules/@bufbuild/protobuf /app/node_modules/@bufbuild/protobuf
|
||||||
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
COPY --from=build /src/node_modules/@connectrpc/connect /app/node_modules/@connectrpc/connect
|
||||||
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
COPY --from=build /src/node_modules/@connectrpc/connect-node /app/node_modules/@connectrpc/connect-node
|
||||||
|
COPY --from=build /src/node_modules/tldts /app/node_modules/tldts
|
||||||
|
COPY --from=build /src/node_modules/tldts-core /app/node_modules/tldts-core
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs /app/scripts/check-sqlite-runtime.mjs
|
||||||
|
RUN node /app/scripts/check-sqlite-runtime.mjs
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY entrypoint.client.sh /entrypoint.client.sh
|
COPY entrypoint.client.sh /entrypoint.client.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
ARG BASE_IMAGE=mirror.gcr.io/library/debian:bookworm-slim
|
||||||
|
ARG NODE_BUILD_IMAGE=node:24.21.0-bookworm
|
||||||
|
FROM ${NODE_BUILD_IMAGE} AS node-runtime
|
||||||
FROM ${BASE_IMAGE}
|
FROM ${BASE_IMAGE}
|
||||||
|
COPY --from=node-runtime /usr/local /usr/local
|
||||||
|
COPY scripts/check-sqlite-runtime.mjs /opt/harbor/check-sqlite-runtime.mjs
|
||||||
|
RUN node /opt/harbor/check-sqlite-runtime.mjs
|
||||||
ARG SINGBOX_VERSION=1.14.0-rc.5
|
ARG SINGBOX_VERSION=1.14.0-rc.5
|
||||||
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
ARG APT_MIRROR=http://mirror.yandex.ru/debian
|
||||||
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
ARG APT_SECURITY_MIRROR=http://mirror.yandex.ru/debian-security
|
||||||
@@ -32,7 +37,7 @@ RUN export http_proxy="${http_proxy:-${HTTP_PROXY:-}}" \
|
|||||||
-o Acquire::http::Timeout=20 \
|
-o Acquire::http::Timeout=20 \
|
||||||
-o Acquire::https::Timeout=20 \
|
-o Acquire::https::Timeout=20 \
|
||||||
-o Acquire::ForceIPv4=true \
|
-o Acquire::ForceIPv4=true \
|
||||||
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data nodejs npm dumb-init \
|
install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 ieee-data dumb-init \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory.
|
||||||
|
|
||||||
Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются вместе с `devices.json`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway.
|
Левая панель списка ищет по имени, hostname, IP, MAC и тегам, фильтрует новые, закреплённые, фоновые или устройства без тегов и позволяет выбрать несколько тегов по правилу «хотя бы один». Каталог тегов общий для Gateway: в нём можно создать до 32 тегов и назначить устройству до 8. Назначения сохраняются в документе устройств внутри `harbor.sqlite`, но маршруты не меняют. После удаления устройства по 30-дневному retention его назначения удаляются, сам каталог остаётся; вернувшееся позже устройство появляется без тегов. Если Mac-клиент подключён к старой версии Gateway, список продолжает работать, а управление тегами скрывается до обновления Gateway.
|
||||||
|
|
||||||
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
|
Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters.
|
||||||
|
|
||||||
@@ -271,14 +271,14 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh |
|
|||||||
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
|
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
|
||||||
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
|
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
|
||||||
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
|
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
|
||||||
| `SING_BOX_TRAFFIC_SOURCE` | `snapshot` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` |
|
| `SING_BOX_TRAFFIC_SOURCE` | `native` | Источник Gateway traffic counters: `snapshot`, `shadow` или `native` |
|
||||||
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
|
| `LOG_LEVEL` | `info` | Уровень подробности журнала |
|
||||||
|
|
||||||
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
|
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании.
|
||||||
|
|
||||||
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
После изменения `.env` пересоздайте контейнер командой `up -d` — обычного `restart` недостаточно.
|
||||||
|
|
||||||
`snapshot` сохраняет прежний опрос Clash API раз в 2 секунды. `shadow` дополнительно читает native lifecycle, но оставляет snapshot единственным источником публичных totals. `native` делает lifecycle единственным writer и не опрашивает `/connections`; Clash API остаётся только для selector/failover. Режим меняется только при пересоздании обоих Gateway-контейнеров и не переключается автоматически при ошибке.
|
Production Gateway по умолчанию использует `native`: новый счётчик видит полный жизненный цикл соединений, включая короткие соединения и последние байты перед закрытием. `shadow` оставляет основным старый счётчик и запускает новый только для сравнения. `snapshot` полностью выключает инспектор и опрашивает активные соединения раз в 2 секунды. Режим меняется только при пересоздании обоих Gateway-контейнеров и не переключается автоматически при ошибке, чтобы одни байты не были посчитаны дважды.
|
||||||
|
|
||||||
Rollback сохраняет volumes и возвращает прежний writer:
|
Rollback сохраняет volumes и возвращает прежний writer:
|
||||||
|
|
||||||
@@ -288,8 +288,18 @@ SING_BOX_TRAFFIC_SOURCE=snapshot \
|
|||||||
docker compose -f docker-compose.gateway.yml up -d --build
|
docker compose -f docker-compose.gateway.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Локальная история трафика
|
||||||
|
|
||||||
|
Harbor полностью работает без Prometheus. В существующем drawer «Трафик» режимы `Сейчас / История` разделяют текущие соединения и локальные суммы. История поддерживает `24 часа / 7 дней / 30 дней / 90 дней`, поиск, маршрут и устройство на Gateway; строки раскрываются как сервис → домен → полное имя → IP. Например, `www.yandex.ru` и `mail.yandex.com` остаются разными именами внутри группы «Яндекс». IP без наблюдённого домена не выдаётся за распознанный сайт.
|
||||||
|
|
||||||
|
`traffic.sqlite` хранит рабочие данные за 90 дней: завершённые минуты за последние 7 дней, далее часы. Текущая история отстаёт не более чем на минуту при исправном сборе; API сообщает фактически доступный период, детализацию и пропуски. История начинается с включения нового native-сбора. Данные по доменам относятся только к соединениям, наблюдаемым sing-box, и не восстанавливают ранее накопленные общие счётчики.
|
||||||
|
|
||||||
|
Запись и запросы выполняются в отдельном рабочем потоке. Ошибка базы или переполнение ограниченной очереди отмечает историю как неполную, но не останавливает VPN или экспорт метрик. Для защиты от повторного учёта сохраняются позиции счётчиков: активные — пока нужны их исходные значения, закрытые — до 90 дней либо смены процесса sing-box. Размер зависит не только от доменов, но и от числа соединений; это не база фиксированного размера. Освобождённые страницы переиспользуются без обязательного немедленного уменьшения файла.
|
||||||
|
|
||||||
## Prometheus и Grafana
|
## Prometheus и Grafana
|
||||||
|
|
||||||
|
Prometheus необязателен. Он хранит только экспортируемые метрики по политике своего владельца, а не копию всей SQLite. Полную доменную/IP-детализацию внешнего архива этот релиз не обещает. Нет синхронизации баз, автоматического восполнения пропущенных scrape, восстановления SQLite из Prometheus или переключения интерфейса на него. Очистка локальной истории не удаляет внешнюю; отсутствие Prometheus не продлевает локальные 90 дней.
|
||||||
|
|
||||||
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
Gateway публикует уже накопленные Harbor traffic counters по адресу `http://<gateway>:3456/metrics`. Scrape не запускает дополнительный netfilter read и не меняет сохранённое состояние. Harbor обновляет snapshot раз в 15 секунд, поэтому рекомендуемый начальный scrape interval и refresh dashboard — 30 секунд:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -302,17 +312,17 @@ scrape_configs:
|
|||||||
- targets: ["<gateway>:3456"]
|
- targets: ["<gateway>:3456"]
|
||||||
```
|
```
|
||||||
|
|
||||||
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
`harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. Gauge `harbor_device_applied_policy{device_id}` показывает последнюю применённую policy: `0` для Direct и `1` для VPN. История начинается с первого Prometheus scrape после обновления и не восстанавливается задним числом. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels.
|
||||||
|
|
||||||
Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход.
|
Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход.
|
||||||
|
|
||||||
Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и VPN / Direct внутри sing-box за этот период. Для стандартного диапазона, который заканчивается сейчас, карточки скорости показывают текущее значение. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; он управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей: она показывает все устройства с ненулевым трафиком, сортируется в обе стороны и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация отдельно показывает точки входа Gateway / Proxy и Direct IPv4 мимо sing-box. Автообновление настроено на 30 секунд; индикатор показывает возраст самого старого из контуров общего, domain / sing-box и Direct IPv4 трафика, предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
Dashboard начинает со скорости скачивания и отправки в конце выбранного периода, общего трафика и фактического VPN / Direct за этот период. Под обзором полоса `Применённый режим` показывает applied policy устройства, а график `Фактический VPN / Direct` независимо показывает маршрут наблюдённых байтов. Поэтому компьютер с policy Direct, браузер которого использует Harbor Proxy, остаётся Direct на полосе режима, но его proxy-соединения учитываются в VPN. Единый фильтр `Устройства` управляет режимом, скоростью, общим трафиком, сервисами, доменами и технической детализацией. Таблица «Все устройства за период» намеренно остаётся общей и выбирает устройство в том же фильтре. Блок «Куда уходит трафик» показывает основные назначения и Top-15 доменов без пагинации. Свёрнутая техническая детализация показывает `source × outbound`, включая `proxy · vpn`, и раздельные Direct-пути через sing-box и Linux мимо sing-box. Автообновление настроено на 30 секунд; индикатор предупреждает после 60 секунд и считает данные устаревшими после 120 секунд.
|
||||||
|
|
||||||
В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; данные всё равно хранятся в памяти только до перезапуска, а историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`.
|
В `snapshot` и `shadow` domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды. В `native` dataplane получает полный lifecycle, включая короткие соединения и финальный хвост; существующая проекция экспортируемых domain/outbound counters хранится в памяти до перезапуска, а необязательный Prometheus независимо сохраняет полученные метрики. Полный поток также поступает в отдельную локальную `traffic.sqlite`; её очистка не сбрасывает эту проекцию. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`.
|
||||||
|
|
||||||
Состояние collector и сравнение `shadow` экспортируются отдельными bounded gauges `harbor_traffic_collector_*` и `harbor_traffic_shadow_*`. Они не содержат UUID, IP, домены или пользовательские имена и не заменяют canonical traffic counters.
|
Состояние collector и сравнение `shadow` экспортируются отдельными bounded gauges `harbor_traffic_collector_*` и `harbor_traffic_shadow_*`. Они не содержат UUID, IP, домены или пользовательские имена и не заменяют canonical traffic counters.
|
||||||
|
|
||||||
Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split.
|
Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker считает логические TCP/UDP bytes без tunnel overhead. Основные Grafana panels складывают их только как приблизительную пользовательскую оценку непересекающихся Direct-путей; техническая секция сохраняет значения раздельными. Эту сумму нельзя считать точным provider или wire total. Snapshot polling может пропустить короткие соединения и финальный хвост; native lifecycle закрывает этот разрыв только для трафика, вошедшего в sing-box. IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в route split.
|
||||||
|
|
||||||
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
|
||||||
|
|
||||||
@@ -383,6 +393,8 @@ docker compose -f docker-compose.client.local.yml up -d --build
|
|||||||
|
|
||||||
### Команды npm
|
### Команды npm
|
||||||
|
|
||||||
|
Для сборки и backend закреплён Node **24.21.0** (`.node-version`); используется встроенная SQLite без ORM. `npm run check:runtime` проверяет точную Node-версию, движок SQLite не старше 3.51.3 и точность 64-битных счётчиков. Та же проверка выполняется в сборочных и конечных Docker-образах. При использовании fnm: `fnm use 24.21.0`.
|
||||||
|
|
||||||
| Команда | Назначение |
|
| Команда | Назначение |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
|
| `npm ci` | Установить точные версии зависимостей из `package-lock.json` |
|
||||||
@@ -406,4 +418,8 @@ docker compose -f docker-compose.client.local.yml up -d --build
|
|||||||
|
|
||||||
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
|
Подписка, выбранный сервер и состояние подключения хранятся в именованных Docker volumes. Поэтому обычные команды `restart`, `down`, обновление проекта и повторная сборка не удаляют настройки.
|
||||||
|
|
||||||
|
На каждом Mac/Gateway свои `harbor.sqlite` (настройки, подписки, устройства, правила, накопленные счётчики и журнал) и `traffic.sqlite` (ограниченная история). Журнал сохраняет прежний предел 30 дней/10 000 событий; настройки не подчиняются retention истории. Секреты, hardware ID, генерируемый конфиг и кеш sing-box остаются файлами.
|
||||||
|
|
||||||
|
Первый запуск транзакционно импортирует прежние JSON, сохраняя IDs, revisions и исходные значения счётчиков. После успеха SQLite становится единственным рабочим хранилищем; исходные JSON остаются неизменными резервными копиями, без параллельной записи. Повреждение или неизвестная версия останавливает миграцию без обнуления. Старый бинарник не читает новые данные: простой downgrade вернул бы устаревшие JSON. Правила backup и восстановления описаны в [state recovery](docs/recovery/state-recovery.md).
|
||||||
|
|
||||||
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.
|
Не публикуйте файл `.env`, ссылку подписки и содержимое Docker volumes. `.env` уже исключён из Git.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
SING_BOX_API_SECRET: /var/lib/sing-box/api.secret
|
SING_BOX_API_SECRET: /var/lib/sing-box/api.secret
|
||||||
SING_BOX_RUNTIME_CONFIG: /var/lib/sing-box/runtime-config.json
|
SING_BOX_RUNTIME_CONFIG: /var/lib/sing-box/runtime-config.json
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-snapshot}
|
SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-3456}:${PORT:-3456}"
|
- "${PORT:-3456}:${PORT:-3456}"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Harbor application state v1
|
# Harbor application state v1
|
||||||
|
|
||||||
`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is `state.json` schema v8; React keeps only drafts, disclosure, focus, animation and transport freshness.
|
`GET /api/state` is the canonical Harbor domain snapshot. Successful mutations return the same snapshot as `state`. The persisted owner is the `state` JSON document in `harbor.sqlite` (currently document schema v10). React keeps only drafts, disclosure, focus, animation and transport freshness.
|
||||||
|
|
||||||
An abbreviated snapshot:
|
An abbreviated snapshot:
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ Enabling failover while VPN is stopped validates a temporary dual-channel candid
|
|||||||
|
|
||||||
The dual config keeps one stable inbound and a sing-box selector with `interrupt_exist_connections: false`. A switch changes the outbound for new connections only. Before an automatic switch, the existing `/connections` observer measures VPN byte deltas over a bounded 10-second window. Active or unknown traffic blocks the switch; the public snapshot contains only aggregate speed, connection count and at most three safe device/service labels.
|
The dual config keeps one stable inbound and a sing-box selector with `interrupt_exist_connections: false`. A switch changes the outbound for new connections only. Before an automatic switch, the existing `/connections` observer measures VPN byte deltas over a bounded 10-second window. Active or unknown traffic blocks the switch; the public snapshot contains only aggregate speed, connection count and at most three safe device/service labels.
|
||||||
|
|
||||||
Failover mutations use `PUT /api/failover`, `POST /api/failover/pause` and `POST /api/failover/switch`. Important user events are stored separately in `activity-journal.json` and read through `GET /api/activity-journal`. The journal is not a second state owner, contains no provider URLs or raw diagnostics, uses stable ID cursors and prunes entries after 30 days.
|
Failover mutations use `PUT /api/failover`, `POST /api/failover/pause` and `POST /api/failover/switch`. Important user events are separate rows in the `harbor.sqlite` journal table and read through `GET /api/activity-journal`. The journal is not a second state owner, contains no provider URLs or raw diagnostics, uses stable ID cursors and retains at most 30 days and 10,000 entries.
|
||||||
|
|
||||||
## Compatibility and migration
|
## Compatibility and migration
|
||||||
|
|
||||||
@@ -115,6 +115,16 @@ Schema v5 migrates the legacy singleton and `subscription-cache.json` into one p
|
|||||||
|
|
||||||
Schema v6 adds the routing-rule outbound. Rules read from schemas v0-v5 migrate to `outbound: "direct"` in their existing order and both desired/applied arrays are normalized together. A schema-v6 rule without a valid outbound is rejected rather than silently rewritten. Schema v7 adds canonical connectivity-diagnostics settings. Schema v8 adds a disabled failover policy, empty runtime history and no applied dual config, so upgrading does not start monitoring or change traffic.
|
Schema v6 adds the routing-rule outbound. Rules read from schemas v0-v5 migrate to `outbound: "direct"` in their existing order and both desired/applied arrays are normalized together. A schema-v6 rule without a valid outbound is rejected rather than silently rewritten. Schema v7 adds canonical connectivity-diagnostics settings. Schema v8 adds a disabled failover policy, empty runtime history and no applied dual config, so upgrading does not start monitoring or change traffic.
|
||||||
|
|
||||||
Migration atomically backs up the previous `state.json`. After the embedded profile is committed, Harbor also backs up and removes the legacy subscription cache so there is one persisted owner. Invalid legacy cache/config returns to a truthful stopped first-run state instead of starting stale generated config.
|
The initial SQLite migration imports settings, devices and journal in one transaction, including a legacy subscription cache when needed. Original JSON files remain unchanged as transition-time backups, with no parallel writes. Invalid input or a conflicting cache owner aborts migration without replacing state or starting stale configuration. Subsequent starts use only SQLite. Current document normalizers also preserve traffic-display settings and the device inventory's tag catalogue.
|
||||||
|
|
||||||
The old HTTP projection remains bounded for one release. Schema v8 persistence is not downgrade-compatible: stop Harbor and restore the `state.json.backup-v<fromVersion>-*` matching the rollback binary instead of deploying old code over v8 data. Rolling back before profiles still also requires the matching legacy subscription-cache backup.
|
The existing HTTP compatibility projection is unchanged. A pre-SQLite binary cannot read current persistence: restore a complete compatible backup or explicitly export current data before downgrading. Old JSON files do not contain post-migration changes. See [state recovery](../recovery/state-recovery.md).
|
||||||
|
|
||||||
|
## Local traffic history
|
||||||
|
|
||||||
|
`GET /api/traffic/history` reads only the local collector's `traffic.sqlite`, through a worker and, on a split Gateway, the existing control/dataplane socket. It accepts `range=24h|7d|30d|90d`, `level=service|domain|hostname|ip`, parent filters `service/domain/hostname`, `originId`, `route=all|vpn|direct|other`, `search`, `offset` and an optional `until` timestamp in milliseconds. Pages contain at most 100 groups. Bytes are decimal strings, preserving integers above JavaScript's safe-number range.
|
||||||
|
|
||||||
|
The response reports the requested/effective period, first available observation, minute/hour boundary, current collector state, gap count and partial coverage. `query.until` is the effective end of a complete bucket; use it for matching drilldown and pagination. Current history can lag by one minute. Data older than 7 days is hourly; retention and rollup can change available granularity between requests.
|
||||||
|
|
||||||
|
History starts with the new collector, not with previously accumulated device counters. Full observed hostnames and IPs remain distinct; service grouping is a local presentation classification, not proof of ownership of an IP. Unknown domains remain unknown. A history storage error reports unavailable/partial data without stopping VPN or exported metrics.
|
||||||
|
|
||||||
|
Prometheus is optional, independent and contains only exported metrics—not a copy of this database. There is no synchronization, automatic UI fallback, scrape backfill or restoration from Prometheus. Local cleanup does not delete external history or change its retention.
|
||||||
|
|||||||
@@ -1,64 +1,39 @@
|
|||||||
# Harbor state recovery
|
# Harbor state recovery
|
||||||
|
|
||||||
Harbor keeps the existing data directory and `state.json` path. The current persisted format is `schemaVersion: 8`: schema v2 introduced local route rules, v3 added rule enabled state, v4 added stable server IDs, v5 embeds the canonical `profiles[]` collection with desired/applied profile identity, v6 adds an explicit `vpn` or `direct` outbound to every route rule, v7 stores connectivity-diagnostics settings, and v8 adds Gateway failover state.
|
## Storage owners
|
||||||
|
|
||||||
## Atomic writes
|
Harbor uses the existing data directory. `harbor.sqlite` is the only working owner of settings, profiles, subscriptions, device inventory and accumulated device counters. Settings and devices are versioned JSON documents inside SQLite (currently state schema 10 and inventory schema 3); the journal is a separate indexed table with the existing 30-day/10,000-event limit.
|
||||||
|
|
||||||
Persistent files are written to a unique temporary file in the same directory, flushed with `fsync`, closed and atomically renamed over the target. A failure before rename leaves the previous target untouched and removes the temporary file.
|
`traffic.sqlite` is separate, replaceable working history: 90 days, completed minute buckets for the latest 7 days and hourly buckets before that. Removing history does not reset settings or exported counters. Prometheus is optional and independently retains only the metrics it scrapes; it cannot restore this database.
|
||||||
|
|
||||||
Profile/server switching prepares candidate config and runtime before the final state publication. If any later step fails, Harbor restores the previous config, runtime and canonical state.
|
Secrets, hardware identity, generated configuration and sing-box's own cache remain files.
|
||||||
|
|
||||||
## Migration to profiles
|
## Atomic writes and migration
|
||||||
|
|
||||||
On startup, a legacy state is normalized before the process starts. Harbor creates one profile named `Основной`, moves the provider URL/config and metadata into it, and preserves unambiguous desired/applied server identity. A legacy state explicitly marked stopped clears stale applied residue.
|
SQLite uses WAL, FULL synchronous commits and a five-second busy timeout. A document mutation runs its read and write in one transaction. Journal append/deduplication/pruning is transactional. Profile/server switching still prepares candidate configuration and runtime before publishing the canonical state.
|
||||||
|
|
||||||
Before replacing state Harbor saves the original beside it:
|
Before the first successful SQLite startup, Harbor imports `state.json`, `devices.json`, `activity-journal.json` and, when required by a pre-profile schema, `subscription-cache.json` in one transaction. Existing normalizers preserve revision, stable IDs, ordered rules and decimal-string counters. A null optional subscription cache is valid.
|
||||||
|
|
||||||
```text
|
The import marker commits with all imported records. An unsupported version, invalid input or conflicting cache owner aborts the entire import. Harbor does not erase settings, rename damaged originals, start stale configuration or silently return to first-run. Correct the reported original and retry only after making a backup.
|
||||||
state.json.backup-v4-2026-08-11T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
After a valid profile has been committed, the raw legacy cache is saved and removed as a second owner:
|
After a successful import, the original JSON files remain unchanged under their original names as transition-time backups. They are never read or written as current state again. Changing them does not change Harbor. A corrupt or unsupported SQLite database does not fall back to those stale JSON files.
|
||||||
|
|
||||||
```text
|
## Backup and recovery
|
||||||
subscription-cache.json.backup-v1-2026-08-11T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
An invalid legacy provider config is backed up but not started. Harbor removes stale generated config and returns to a stopped first-run state.
|
Stop both control and collector processes before offline recovery. On a Gateway this means the control and dataplane components; stop the Mac backend for Mac recovery.
|
||||||
|
|
||||||
## Migration to ordered VPN/Direct rules
|
1. Preserve the whole data directory, including any `-wal` and `-shm` files, before changing anything.
|
||||||
|
2. Restore a matching backup of `harbor.sqlite` and any necessary secret/config files. Do not mix a database with another backup's WAL.
|
||||||
|
3. Start the same compatible release and inspect `GET /api/state` before applying a profile.
|
||||||
|
|
||||||
When schemas v0-v5 are read, Harbor preserves the order of `routeRules` and `appliedRouteRules` and adds `outbound: "direct"` to legacy entries before atomically committing schema v6. The original file is preserved using its actual source version, for example:
|
For online backups use SQLite's backup API; copying only a live `.sqlite` file can omit committed WAL data. Offline copies after a clean stop are simpler.
|
||||||
|
|
||||||
```text
|
To discard only working traffic history, stop the collector and move its `traffic.sqlite` plus any associated `traffic.sqlite-wal` and `traffic.sqlite-shm` aside together. Leave `harbor.sqlite` untouched. A new collector database starts a new coverage period; there is no automatic Prometheus backfill. Do not unlink an open database.
|
||||||
state.json.backup-v5-2026-08-17T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
After migration, malformed schema-v6 rules are rejected; Harbor does not reinterpret a missing or unknown outbound as direct.
|
Deletion/retention makes pages reusable; it does not necessarily shrink the physical file immediately. Traffic retention and compaction run in the worker, outside connection processing.
|
||||||
|
|
||||||
## Migration to failover
|
## Downgrade
|
||||||
|
|
||||||
Schemas v0-v7 migrate to v8 with failover disabled, empty switch history and no applied dual config. Migration does not start probes, enable traffic accounting or change the single-channel runtime. The original state is preserved as `state.json.backup-v<fromVersion>-*` before the atomic replacement.
|
A pre-SQLite binary ignores `harbor.sqlite`. Merely starting it would revive old JSON settings and lose all changes since the cutover. Automatic downgrade is unsupported.
|
||||||
|
|
||||||
The separate `activity-journal.json` is created on the first important event. It uses the same atomic write and corrupt-file isolation mechanism as state, retains at most 30 days, and can be removed while Harbor is stopped without affecting subscriptions, routing or VPN startup.
|
Either restore a complete pre-upgrade backup deliberately, accepting the loss of subsequent changes, or first export current state into the exact schema required by the older binary. No automatic export/downgrade tool is provided. Preserve the SQLite backup in either case; do not overwrite current state with stale JSON as a recovery shortcut.
|
||||||
|
|
||||||
## Corrupt JSON
|
|
||||||
|
|
||||||
If `state.json` cannot be parsed, Harbor renames the exact damaged bytes to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
state.json.corrupt-2026-08-11T12-00-00-000Z
|
|
||||||
```
|
|
||||||
|
|
||||||
It then creates a valid empty current-schema state and reports storage recovery. A corrupt legacy subscription cache is preserved with the same suffix and is never used to start stale config.
|
|
||||||
|
|
||||||
## Manual recovery and downgrade
|
|
||||||
|
|
||||||
Perform recovery while Harbor is stopped:
|
|
||||||
|
|
||||||
1. Copy the whole data directory.
|
|
||||||
2. Inspect the intended backup with `jq . <backup-file>`.
|
|
||||||
3. Restore only matching state/cache backups to their original filenames.
|
|
||||||
4. Start Harbor and verify `GET /api/state` before applying a profile.
|
|
||||||
|
|
||||||
A pre-v8 binary cannot interpret failover state. Restore `state.json.backup-v<fromVersion>-*` matching the rollback binary; deploying old code over schema v8 is not safe. A rollback to pre-v5 additionally requires the matching state and subscription-cache backups because that binary cannot interpret canonical profiles.
|
|
||||||
|
|||||||
@@ -196,14 +196,14 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Наблюдённые sing-box байты для выбранных устройств по фактически выбранному выходу. Direct IPv4 мимо sing-box учитывается отдельно в технической детализации.",
|
"description": "Фактический выход выбранных устройств за период. VPN включает трафик, вошедший через Gateway и явно настроенный Proxy. Direct приблизительно объединяет логические байты sing-box и IPv4 L3 packet bytes Linux; точная разбивка остаётся в технической детализации.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
"mode": "palette-classic"
|
"mode": "palette-classic"
|
||||||
},
|
},
|
||||||
"decimals": 1,
|
"decimals": 1,
|
||||||
"noValue": "Нет трафика",
|
"noValue": "Нет данных",
|
||||||
"unit": "bytes"
|
"unit": "bytes"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) > 0",
|
"expr": "((sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) or vector(0)) + (sum(increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) or vector(0))) > 0",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "Direct",
|
"legendFormat": "Direct",
|
||||||
"range": false,
|
"range": false,
|
||||||
@@ -382,7 +382,73 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Скорость трафика выбранных устройств. Окно усреднения адаптируется к масштабу времени; скачивание показано вверх, отправка — вниз. Это наблюдаемый Harbor трафик, а не скорость интернет-канала.",
|
"description": "Последняя применённая policy устройства по данным Prometheus: Direct или VPN. Это не маршрут каждого байта: устройство в Direct может одновременно использовать VPN через явно настроенный Harbor Proxy.",
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {
|
||||||
|
"mode": "thresholds"
|
||||||
|
},
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"options": {
|
||||||
|
"0": {
|
||||||
|
"color": "orange",
|
||||||
|
"index": 0,
|
||||||
|
"text": "Direct"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"color": "green",
|
||||||
|
"index": 1,
|
||||||
|
"text": "VPN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "value"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"noValue": "Нет данных"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"h": 4,
|
||||||
|
"w": 16,
|
||||||
|
"x": 0,
|
||||||
|
"y": 5
|
||||||
|
},
|
||||||
|
"id": 17,
|
||||||
|
"options": {
|
||||||
|
"alignValue": "left",
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom",
|
||||||
|
"showLegend": false
|
||||||
|
},
|
||||||
|
"mergeValues": true,
|
||||||
|
"rowHeight": 0.9,
|
||||||
|
"showValue": "always",
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "single",
|
||||||
|
"sort": "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "max by (device_id) (harbor_device_applied_policy{device_id=~\"$device_id\"}) * on (device_id) group_left (name, ip) max by (device_id, name, ip) (harbor_device_info)",
|
||||||
|
"legendFormat": "{{name}} · {{ip}}",
|
||||||
|
"range": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Применённый режим",
|
||||||
|
"type": "state-timeline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${DS_PROMETHEUS}"
|
||||||
|
},
|
||||||
|
"description": "Фактическая скорость по выходу выбранных устройств. VPN суммирует Gateway и Proxy. Direct приблизительно объединяет непересекающиеся sing-box и Linux IPv4 пути с разной byte semantics. Скачивание показано вверх, отправка вниз.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -414,14 +480,14 @@
|
|||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"matcher": {
|
"matcher": {
|
||||||
"id": "byName",
|
"id": "byRegexp",
|
||||||
"options": "Скачивание"
|
"options": "^VPN"
|
||||||
},
|
},
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
"id": "color",
|
"id": "color",
|
||||||
"value": {
|
"value": {
|
||||||
"fixedColor": "blue",
|
"fixedColor": "green",
|
||||||
"mode": "fixed"
|
"mode": "fixed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,8 +495,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matcher": {
|
"matcher": {
|
||||||
"id": "byName",
|
"id": "byRegexp",
|
||||||
"options": "Отправка"
|
"options": "^Direct"
|
||||||
},
|
},
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
@@ -442,6 +508,21 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byRegexp",
|
||||||
|
"options": "^Не определено"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "gray",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matcher": {
|
"matcher": {
|
||||||
"id": "byFrameRefID",
|
"id": "byFrameRefID",
|
||||||
@@ -453,6 +534,30 @@
|
|||||||
"value": "negative-Y"
|
"value": "negative-Y"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "D"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.transform",
|
||||||
|
"value": "negative-Y"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "F"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "custom.transform",
|
||||||
|
"value": "negative-Y"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -460,7 +565,7 @@
|
|||||||
"h": 12,
|
"h": 12,
|
||||||
"w": 16,
|
"w": 16,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 5
|
"y": 9
|
||||||
},
|
},
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -482,20 +587,48 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) > 0",
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"download\"}[$__rate_interval])) > 0",
|
||||||
"legendFormat": "Скачивание",
|
"legendFormat": "VPN · скачивание",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rate(harbor_device_traffic_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) > 0",
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"vpn\", direction=\"upload\"}[$__rate_interval])) > 0",
|
||||||
"legendFormat": "Отправка",
|
"legendFormat": "VPN · отправка",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "((sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\", direction=\"download\"}[$__rate_interval])) or vector(0)) + (sum(rate(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\", direction=\"download\"}[$__rate_interval])) or vector(0))) > 0",
|
||||||
|
"legendFormat": "Direct · скачивание",
|
||||||
|
"range": true,
|
||||||
|
"refId": "C"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "((sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\", direction=\"upload\"}[$__rate_interval])) or vector(0)) + (sum(rate(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\", direction=\"upload\"}[$__rate_interval])) or vector(0))) > 0",
|
||||||
|
"legendFormat": "Direct · отправка",
|
||||||
|
"range": true,
|
||||||
|
"refId": "D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"unknown\", direction=\"download\"}[$__rate_interval])) > 0",
|
||||||
|
"legendFormat": "Не определено · скачивание",
|
||||||
|
"range": true,
|
||||||
|
"refId": "E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(rate(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"unknown\", direction=\"upload\"}[$__rate_interval])) > 0",
|
||||||
|
"legendFormat": "Не определено · отправка",
|
||||||
|
"range": true,
|
||||||
|
"refId": "F"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Скорость трафика",
|
"title": "Фактический VPN / Direct",
|
||||||
"type": "timeseries"
|
"type": "timeseries"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -582,7 +715,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 12,
|
"h": 16,
|
||||||
"w": 8,
|
"w": 8,
|
||||||
"x": 16,
|
"x": 16,
|
||||||
"y": 5
|
"y": 5
|
||||||
@@ -648,7 +781,7 @@
|
|||||||
"h": 1,
|
"h": 1,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 17
|
"y": 21
|
||||||
},
|
},
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"panels": [],
|
"panels": [],
|
||||||
@@ -676,7 +809,7 @@
|
|||||||
"h": 15,
|
"h": 15,
|
||||||
"w": 9,
|
"w": 9,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 18
|
"y": 22
|
||||||
},
|
},
|
||||||
"id": 8,
|
"id": 8,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -775,7 +908,7 @@
|
|||||||
"h": 15,
|
"h": 15,
|
||||||
"w": 15,
|
"w": 15,
|
||||||
"x": 9,
|
"x": 9,
|
||||||
"y": 18
|
"y": 22
|
||||||
},
|
},
|
||||||
"id": 9,
|
"id": 9,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -837,7 +970,7 @@
|
|||||||
"h": 1,
|
"h": 1,
|
||||||
"w": 24,
|
"w": 24,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 33
|
"y": 37
|
||||||
},
|
},
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"panels": [
|
"panels": [
|
||||||
@@ -846,7 +979,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "Где выбранный трафик вошёл в Harbor: через Gateway или Proxy. Это техническая точка входа, а не выбор VPN / Direct.",
|
"description": "Как sing-box увидел трафик выбранных устройств: точка входа gateway или proxy и фактически выбранный outbound. Например, компьютер с policy Direct и браузерным Proxy появляется как proxy · vpn.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -856,44 +989,13 @@
|
|||||||
"noValue": "Нет данных",
|
"noValue": "Нет данных",
|
||||||
"unit": "bytes"
|
"unit": "bytes"
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": []
|
||||||
{
|
|
||||||
"matcher": {
|
|
||||||
"id": "byName",
|
|
||||||
"options": "gateway"
|
|
||||||
},
|
|
||||||
"properties": [
|
|
||||||
{
|
|
||||||
"id": "color",
|
|
||||||
"value": {
|
|
||||||
"fixedColor": "blue",
|
|
||||||
"mode": "fixed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": {
|
|
||||||
"id": "byName",
|
|
||||||
"options": "proxy"
|
|
||||||
},
|
|
||||||
"properties": [
|
|
||||||
{
|
|
||||||
"id": "color",
|
|
||||||
"value": {
|
|
||||||
"fixedColor": "purple",
|
|
||||||
"mode": "fixed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 7,
|
"h": 7,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 34
|
"y": 38
|
||||||
},
|
},
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -917,14 +1019,14 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (source) (increase(harbor_device_traffic_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
"expr": "sum by (source, outbound) (increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "{{source}}",
|
"legendFormat": "{{source}} · {{outbound}}",
|
||||||
"range": false,
|
"range": false,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Точки входа Gateway / Proxy",
|
"title": "Вход × выход sing-box",
|
||||||
"type": "bargauge"
|
"type": "bargauge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -932,7 +1034,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"description": "IPv4-пакеты выбранных устройств, которые Gateway направил напрямую вместо sing-box: policy Direct или работа при остановленном VPN runtime. Это L3 packet bytes с заголовками и retransmit; их нельзя складывать с sing-box tracker bytes.",
|
"description": "Два непересекающихся Direct-пути выбранных устройств. Через sing-box показаны логические TCP/UDP bytes; Linux мимо sing-box показывает IPv4 L3 packet bytes с заголовками и retransmit. Их сумма в основных панелях является только приблизительной оценкой.",
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"color": {
|
"color": {
|
||||||
@@ -942,13 +1044,44 @@
|
|||||||
"noValue": "Нет данных",
|
"noValue": "Нет данных",
|
||||||
"unit": "bytes"
|
"unit": "bytes"
|
||||||
},
|
},
|
||||||
"overrides": []
|
"overrides": [
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "A"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "orange",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": {
|
||||||
|
"id": "byFrameRefID",
|
||||||
|
"options": "B"
|
||||||
|
},
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"value": {
|
||||||
|
"fixedColor": "purple",
|
||||||
|
"mode": "fixed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"gridPos": {
|
"gridPos": {
|
||||||
"h": 7,
|
"h": 7,
|
||||||
"w": 12,
|
"w": 12,
|
||||||
"x": 12,
|
"x": 12,
|
||||||
"y": 34
|
"y": 38
|
||||||
},
|
},
|
||||||
"id": 16,
|
"id": 16,
|
||||||
"options": {
|
"options": {
|
||||||
@@ -972,14 +1105,22 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (direction) (increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
"expr": "sum(increase(harbor_device_singbox_tracked_bytes_total{device_id=~\"$device_id\", outbound=\"direct\"}[$__range])) > 0",
|
||||||
"instant": true,
|
"instant": true,
|
||||||
"legendFormat": "{{direction}}",
|
"legendFormat": "Через sing-box",
|
||||||
"range": false,
|
"range": false,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"editorMode": "code",
|
||||||
|
"expr": "sum(increase(harbor_device_direct_ipv4_packet_bytes_total{device_id=~\"$device_id\"}[$__range])) > 0",
|
||||||
|
"instant": true,
|
||||||
|
"legendFormat": "Linux мимо sing-box",
|
||||||
|
"range": false,
|
||||||
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Direct IPv4 мимо sing-box",
|
"title": "Direct раздельно",
|
||||||
"type": "bargauge"
|
"type": "bargauge"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -1008,7 +1149,7 @@
|
|||||||
"uid": "${DS_PROMETHEUS}"
|
"uid": "${DS_PROMETHEUS}"
|
||||||
},
|
},
|
||||||
"definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
|
"definition": "query_result(label_join(max by (device_id, name, ip) (harbor_device_info), \"display\", \" · \", \"name\", \"ip\"))",
|
||||||
"description": "Выберите все устройства или одно устройство. Фильтр управляет скоростью, общим трафиком, маршрутами, сервисами, доменами и технической детализацией.",
|
"description": "Выберите все устройства или одно устройство. Фильтр управляет применённым режимом, фактическим VPN/Direct, общим трафиком, сервисами, доменами и технической детализацией.",
|
||||||
"hide": 0,
|
"hide": 0,
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"label": "Устройства",
|
"label": "Устройства",
|
||||||
@@ -1044,6 +1185,6 @@
|
|||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"title": "Harbor Gateway: трафик",
|
"title": "Harbor Gateway: трафик",
|
||||||
"uid": "harbor-gateway-traffic",
|
"uid": "harbor-gateway-traffic",
|
||||||
"version": 9,
|
"version": 10,
|
||||||
"weekStart": "monday"
|
"weekStart": "monday"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+30
-27
@@ -14,6 +14,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tldts": "7.4.12",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -21,13 +22,15 @@
|
|||||||
"@bufbuild/buf": "1.47.2",
|
"@bufbuild/buf": "1.47.2",
|
||||||
"@bufbuild/protoc-gen-es": "2.6.0",
|
"@bufbuild/protoc-gen-es": "2.6.0",
|
||||||
"@csstools/selector-specificity": "6.0.0",
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
"@types/node": "22.19.17",
|
"@types/node": "24.13.4",
|
||||||
"@types/node18": "npm:@types/node@18.19.130",
|
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"postcss": "8.5.14",
|
"postcss": "8.5.14",
|
||||||
"postcss-selector-parser": "7.1.4",
|
"postcss-selector-parser": "7.1.4",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24.21.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1379,33 +1382,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.17",
|
"version": "24.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
||||||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node18": {
|
|
||||||
"name": "@types/node",
|
|
||||||
"version": "18.19.130",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
|
||||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~5.26.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node18/node_modules/undici-types": {
|
|
||||||
"version": "5.26.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
@@ -2246,6 +2231,24 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
|
||||||
|
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.4.12"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
|
||||||
|
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
@@ -2282,9 +2285,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-2
@@ -2,9 +2,14 @@
|
|||||||
"name": "vpn-proxy-gateway",
|
"name": "vpn-proxy-gateway",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "24.21.x"
|
||||||
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
"description": "Gateway-first VPN proxy control panel for sing-box TProxy deployments.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"check:runtime": "node scripts/check-sqlite-runtime.mjs",
|
||||||
|
"pretest": "npm run check:runtime",
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"generate:singbox-api": "XDG_CACHE_HOME=${TMPDIR:-/tmp}/harbor-buf-cache buf generate --template buf.gen.yaml",
|
"generate:singbox-api": "XDG_CACHE_HOME=${TMPDIR:-/tmp}/harbor-buf-cache buf generate --template buf.gen.yaml",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -25,6 +30,7 @@
|
|||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tldts": "7.4.12",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -32,8 +38,7 @@
|
|||||||
"@bufbuild/buf": "1.47.2",
|
"@bufbuild/buf": "1.47.2",
|
||||||
"@bufbuild/protoc-gen-es": "2.6.0",
|
"@bufbuild/protoc-gen-es": "2.6.0",
|
||||||
"@csstools/selector-specificity": "6.0.0",
|
"@csstools/selector-specificity": "6.0.0",
|
||||||
"@types/node": "22.19.17",
|
"@types/node": "24.13.4",
|
||||||
"@types/node18": "npm:@types/node@18.19.130",
|
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"postcss": "8.5.14",
|
"postcss": "8.5.14",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo manual)"
|
|||||||
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
IMAGE_TAG="${IMAGE_TAG:-${GIT_REF}-$(date +%Y%m%d%H%M%S)}"
|
||||||
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
GATEWAY_IMAGE="${GATEWAY_IMAGE:-${IMAGE_NAME}:${IMAGE_TAG}}"
|
||||||
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
BASE_IMAGE="${BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:20.19-alpine}"
|
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:24.21.0-bookworm}"
|
||||||
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
RUNTIME_BASE_SOURCE_IMAGE="${RUNTIME_BASE_SOURCE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
DOCKER_BUILD_PULL="${DOCKER_BUILD_PULL:-false}"
|
||||||
@@ -63,7 +63,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building image on ${BUILD_HOST}"
|
echo "Building image on ${BUILD_HOST}"
|
||||||
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1 || ! docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION} on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' . && docker run --rm --entrypoint sing-box '${GATEWAY_IMAGE}' version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'"
|
BUILD_COMMAND="set -e; echo 'Docker context:' \$(docker context show 2>/dev/null || true); docker info 2>/dev/null | sed -n '/HTTP Proxy:/p;/HTTPS Proxy:/p;/Name:/p'; cd '${BUILD_PATH}'; if ! docker image inspect '${BASE_IMAGE}' >/dev/null 2>&1 || ! docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; then if [ '${AUTO_BUILD_RUNTIME_BASE}' = 'true' ]; then echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION}; building it now.'; BASE_IMAGE='${RUNTIME_BASE_SOURCE_IMAGE}' RUNTIME_BASE_IMAGE='${BASE_IMAGE}' SINGBOX_VERSION='${SINGBOX_VERSION}' ./scripts/build-runtime-base.sh; else echo 'Runtime base image ${BASE_IMAGE} is missing or does not contain sing-box ${SINGBOX_VERSION} on ${BUILD_HOST}.'; echo 'Seed it once with: ./scripts/build-runtime-base.sh'; exit 1; fi; fi; docker run --rm '${BASE_IMAGE}' sh -lc \"command -v npm >/dev/null && sing-box version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'\"; node scripts/check-sqlite-runtime.mjs && npm ci && npm run build:production && docker build --pull='${DOCKER_BUILD_PULL}' --build-arg NODE_BUILD_IMAGE='${NODE_BUILD_IMAGE}' --build-arg BASE_IMAGE='${BASE_IMAGE}' --build-arg SINGBOX_VERSION='${SINGBOX_VERSION}' --build-arg INSTALL_RUNTIME_DEPS='${INSTALL_RUNTIME_DEPS}' --build-arg INSTALL_SINGBOX='${INSTALL_SINGBOX}' -t '${GATEWAY_IMAGE}' . && docker run --rm --entrypoint sing-box '${GATEWAY_IMAGE}' version 2>&1 | grep -Fx 'sing-box version ${SINGBOX_VERSION}'"
|
||||||
if [ "${BUILD_HOST}" = "local" ]; then
|
if [ "${BUILD_HOST}" = "local" ]; then
|
||||||
bash -lc "${BUILD_COMMAND}"
|
bash -lc "${BUILD_COMMAND}"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
BASE_IMAGE="${BASE_IMAGE:-mirror.gcr.io/library/debian:bookworm-slim}"
|
||||||
|
NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE:-node:24.21.0-bookworm}"
|
||||||
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
RUNTIME_BASE_IMAGE="${RUNTIME_BASE_IMAGE:-vpn-proxy-runtime-base:bookworm-slim}"
|
||||||
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
SINGBOX_VERSION="${SINGBOX_VERSION:-1.14.0-rc.5}"
|
||||||
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
APT_MIRROR="${APT_MIRROR:-http://mirror.yandex.ru/debian}"
|
||||||
@@ -18,6 +19,7 @@ if [ -n "${HTTP_PROXY}" ]; then echo "HTTP proxy: ${HTTP_PROXY}"; fi
|
|||||||
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
if [ -n "${HTTPS_PROXY}" ]; then echo "HTTPS proxy: ${HTTPS_PROXY}"; fi
|
||||||
|
|
||||||
docker build \
|
docker build \
|
||||||
|
--build-arg NODE_BUILD_IMAGE="${NODE_BUILD_IMAGE}" \
|
||||||
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||||
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
--build-arg SINGBOX_VERSION="${SINGBOX_VERSION}" \
|
||||||
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
--build-arg APT_MIRROR="${APT_MIRROR}" \
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
assert.equal(process.versions.node, '24.21.0', 'Harbor requires the pinned Node 24.21.0 runtime');
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
try {
|
||||||
|
const version = db.prepare('SELECT sqlite_version() AS version').get().version;
|
||||||
|
const [major, minor, patch] = version.split('.').map(Number);
|
||||||
|
assert.ok(major > 3 || (major === 3 && (minor > 51 || (minor === 51 && patch >= 3))),
|
||||||
|
'Harbor requires SQLite >= 3.51.3 with the WAL-reset fix');
|
||||||
|
db.exec('CREATE TABLE probe (bytes INTEGER NOT NULL) STRICT');
|
||||||
|
db.prepare('INSERT INTO probe VALUES (?)').run(9007199254740993n);
|
||||||
|
const statement = db.prepare('SELECT bytes FROM probe');
|
||||||
|
statement.setReadBigInts(true);
|
||||||
|
assert.equal(statement.get().bytes, 9007199254740993n);
|
||||||
|
console.log(`Harbor runtime: Node ${process.versions.node}, SQLite ${version}, ${process.platform}/${process.arch}`);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: \${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
volumes:
|
volumes:
|
||||||
- vpn-proxy-data:/var/lib/vpn-proxy
|
- vpn-proxy-data:/var/lib/vpn-proxy
|
||||||
@@ -59,6 +60,7 @@ services:
|
|||||||
DATA_DIR: /var/lib/vpn-proxy
|
DATA_DIR: /var/lib/vpn-proxy
|
||||||
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
SING_BOX_CONFIG: /var/lib/vpn-proxy/sing-box-config.json
|
||||||
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
SING_BOX_CACHE: /var/lib/sing-box/cache.db
|
||||||
|
SING_BOX_TRAFFIC_SOURCE: \${SING_BOX_TRAFFIC_SOURCE:-native}
|
||||||
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
DATAPLANE_SOCKET: /run/vpn-proxy/dataplane.sock
|
||||||
ports:
|
ports:
|
||||||
- "\${PORT:-3456}:\${PORT:-3456}"
|
- "\${PORT:-3456}:\${PORT:-3456}"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function affectedComponents(files) {
|
|||||||
const add = (...components) => components.forEach((component) => affected.add(component));
|
const add = (...components) => components.forEach((component) => affected.add(component));
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file === VERSION_FILE) continue;
|
if (file === VERSION_FILE) continue;
|
||||||
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
if (/^(?:\.dockerignore$|\.node-version$|scripts\/check-sqlite-runtime\.mjs$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS);
|
||||||
else if (/^(src\/web\/|public\/|monitoring\/grafana\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
else if (/^(src\/web\/|public\/|monitoring\/grafana\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) {
|
||||||
add('macClient', 'gatewayClient');
|
add('macClient', 'gatewayClient');
|
||||||
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend');
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const noRuntimeImpact = [
|
|||||||
/^tools\/test-singbox-(?:client-rc|gateway-native-traffic|native-traffic)\.sh$/,
|
/^tools\/test-singbox-(?:client-rc|gateway-native-traffic|native-traffic)\.sh$/,
|
||||||
];
|
];
|
||||||
const foundation = [
|
const foundation = [
|
||||||
|
/^\.node-version$/,
|
||||||
|
/^scripts\/check-sqlite-runtime\.mjs$/,
|
||||||
/^\.dockerignore$/,
|
/^\.dockerignore$/,
|
||||||
/^\.gitea\/workflows\//,
|
/^\.gitea\/workflows\//,
|
||||||
/^Dockerfile(?:\.runtime-base)?$/,
|
/^Dockerfile(?:\.runtime-base)?$/,
|
||||||
@@ -33,6 +35,8 @@ const foundation = [
|
|||||||
/^tsconfig(?:\.[^.]+)?\.json$/,
|
/^tsconfig(?:\.[^.]+)?\.json$/,
|
||||||
];
|
];
|
||||||
const controlAndDataplane = [
|
const controlAndDataplane = [
|
||||||
|
/^src\/server\/services\/(?:sqlite|trafficHistoryStore|trafficHistoryService|trafficHistoryWorker)\.ts$/,
|
||||||
|
/^src\/shared\/trafficHistory\.ts$/,
|
||||||
/^buf\.gen\.yaml$/,
|
/^buf\.gen\.yaml$/,
|
||||||
/^proto\//,
|
/^proto\//,
|
||||||
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
new RegExp(`^src/server/main${CODE_EXTENSION}`),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { readNeighborSnapshot } from './adapters/neighbors.js';
|
|||||||
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
||||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
|
||||||
import {
|
import {
|
||||||
createDomainTrafficService,
|
createDomainTrafficService,
|
||||||
readSingboxConnections,
|
readSingboxConnections,
|
||||||
@@ -20,6 +21,8 @@ import {
|
|||||||
createLiveTrafficService,
|
createLiveTrafficService,
|
||||||
} from './services/liveTrafficService.js';
|
} from './services/liveTrafficService.js';
|
||||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||||
|
import { createTrafficHistoryService } from './services/trafficHistoryService.js';
|
||||||
|
import { parseTrafficHistoryQuery } from '../shared/trafficHistory.js';
|
||||||
|
|
||||||
const socketPath = settings.dataplaneSocket;
|
const socketPath = settings.dataplaneSocket;
|
||||||
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
|
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
|
||||||
@@ -57,6 +60,9 @@ const devicePolicy = createDevicePolicyService({
|
|||||||
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
const connectivityDiagnostics = createConnectivityDiagnosticsService({
|
||||||
proxyPort: settings.diagnosticsProxyPort,
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
});
|
});
|
||||||
|
const dnsDiagnostics = createDnsDiagnosticsService({
|
||||||
|
proxyPort: settings.diagnosticsProxyPort,
|
||||||
|
});
|
||||||
const failoverDiagnostics = {
|
const failoverDiagnostics = {
|
||||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||||
@@ -79,6 +85,10 @@ let liveTraffic = createLiveTrafficService({
|
|||||||
isRuntimeRunning: () => false,
|
isRuntimeRunning: () => false,
|
||||||
resolveOrigin,
|
resolveOrigin,
|
||||||
});
|
});
|
||||||
|
const trafficHistory = createTrafficHistoryService({
|
||||||
|
filePath: path.join(settings.dataDir, 'traffic.sqlite'),
|
||||||
|
source: () => liveTrafficSnapshot().source.state,
|
||||||
|
});
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let trafficTimer: NodeJS.Timeout | null = null;
|
let trafficTimer: NodeJS.Timeout | null = null;
|
||||||
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -277,6 +287,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
|||||||
if (req.method === 'GET' && req.url === '/traffic/live') {
|
if (req.method === 'GET' && req.url === '/traffic/live') {
|
||||||
return sendJson(res, 200, liveTrafficSnapshot());
|
return sendJson(res, 200, liveTrafficSnapshot());
|
||||||
}
|
}
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (req.method === 'GET' && url.pathname === '/traffic/history') {
|
||||||
|
return sendJson(res, 200, await trafficHistory.query(parseTrafficHistoryQuery(url.searchParams)));
|
||||||
|
}
|
||||||
if (req.method === 'GET' && req.url === '/device-policy') {
|
if (req.method === 'GET' && req.url === '/device-policy') {
|
||||||
return sendJson(res, 200, devicePolicy.snapshot());
|
return sendJson(res, 200, devicePolicy.snapshot());
|
||||||
}
|
}
|
||||||
@@ -292,6 +306,25 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
|||||||
target,
|
target,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/diagnostics/dns/catalog') {
|
||||||
|
const { customResolvers = [], customDomains = [] } = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await dnsDiagnostics.catalog(
|
||||||
|
Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.url === '/diagnostics/dns') {
|
||||||
|
const {
|
||||||
|
customResolvers = [], customDomains = [], domainId, resolverId = null,
|
||||||
|
} = record(await readJson(req));
|
||||||
|
return sendJson(res, 200, await dnsDiagnostics.run({
|
||||||
|
vpnAvailable: runtime.running,
|
||||||
|
customResolvers: Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
customDomains: Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
domainId,
|
||||||
|
resolverId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
if (req.method === 'POST' && req.url === '/failover/probe') {
|
if (req.method === 'POST' && req.url === '/failover/probe') {
|
||||||
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
const { role, services = [], target = null, timeoutMs = 6_000 } = record(await readJson(req));
|
||||||
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
if (role !== 'primary' && role !== 'reserve') throw new Error('Неизвестная failover role');
|
||||||
@@ -355,6 +388,7 @@ server.listen(socketPath, async () => {
|
|||||||
resolveOrigin,
|
resolveOrigin,
|
||||||
authorization: () => runtime.nativeApiSecret,
|
authorization: () => runtime.nativeApiSecret,
|
||||||
onProjection: (batch) => {
|
onProjection: (batch) => {
|
||||||
|
trafficHistory.enqueue(batch);
|
||||||
nativeDomainTraffic.ingestNative(batch);
|
nativeDomainTraffic.ingestNative(batch);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -396,6 +430,7 @@ async function shutdown() {
|
|||||||
if (trafficTimer) clearInterval(trafficTimer);
|
if (trafficTimer) clearInterval(trafficTimer);
|
||||||
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
||||||
await liveTraffic.stop();
|
await liveTraffic.stop();
|
||||||
|
await trafficHistory.close();
|
||||||
await runtime.shutdown();
|
await runtime.shutdown();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
fs.rmSync(socketPath, { force: true });
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { HarborError } from '../shared/errors.js';
|
import { HarborError } from '../shared/errors.js';
|
||||||
|
import { historyQueryParams, type TrafficHistoryQuery } from '../shared/trafficHistory.js';
|
||||||
|
|
||||||
type SendDataplaneRequest = (
|
type SendDataplaneRequest = (
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
@@ -74,6 +75,7 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
|
|||||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||||
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||||
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
|
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
|
||||||
|
observeTrafficHistory: (query: TrafficHistoryQuery) => send(socketPath, `/traffic/history?${historyQueryParams(query)}`, 'GET', null, 12_000),
|
||||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||||
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||||
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||||
@@ -83,6 +85,23 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
|
|||||||
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getDnsDiagnosticsCatalog: (customResolvers: unknown = [], customDomains: unknown = []) => (
|
||||||
|
send(socketPath, '/diagnostics/dns/catalog', 'POST', { customResolvers, customDomains })
|
||||||
|
),
|
||||||
|
runDnsDiagnostics: async (
|
||||||
|
customResolvers: unknown = [],
|
||||||
|
customDomains: unknown = [],
|
||||||
|
domainId: unknown,
|
||||||
|
resolverId: unknown = null,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await send(socketPath, '/diagnostics/dns', 'POST', {
|
||||||
|
customResolvers, customDomains, domainId, resolverId,
|
||||||
|
}, 40_000);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
|
}
|
||||||
|
},
|
||||||
checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000),
|
checkConfig: (config: unknown) => send(socketPath, '/config/check', 'POST', { config }, 15_000),
|
||||||
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
runFailoverProbe: (role: 'primary' | 'reserve', services: unknown, target: unknown, timeoutMs: number) => (
|
||||||
send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000)
|
send(socketPath, '/failover/probe', 'POST', { role, services, target, timeoutMs }, timeoutMs + 10_000)
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ interface ConnectivityDiagnosticsDependencies {
|
|||||||
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
update(mutator: (state: StoredState) => Record<string, unknown>): StoredState;
|
||||||
};
|
};
|
||||||
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
runDiagnostics(services: unknown, target: unknown): Promise<unknown>;
|
||||||
|
dnsCatalog(customResolvers: unknown, customDomains: unknown): Promise<unknown>;
|
||||||
|
runDnsDiagnostics(
|
||||||
|
customResolvers: unknown,
|
||||||
|
customDomains: unknown,
|
||||||
|
domainId: unknown,
|
||||||
|
resolverId: unknown,
|
||||||
|
): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
function diagnosticsResult(value: unknown): DiagnosticsResult {
|
||||||
@@ -66,21 +73,38 @@ export function createConnectivityDiagnosticsUseCase(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async dnsCatalog() {
|
||||||
|
const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics);
|
||||||
|
return dependencies.dnsCatalog(settings.customDnsResolvers, settings.customDnsDomains);
|
||||||
|
},
|
||||||
|
async runDns(domainId: unknown, resolverId: unknown) {
|
||||||
|
const settings = normalizeDiagnosticSettings(dependencies.state.read().diagnostics);
|
||||||
|
return dependencies.runDnsDiagnostics(
|
||||||
|
settings.customDnsResolvers,
|
||||||
|
settings.customDnsDomains,
|
||||||
|
domainId,
|
||||||
|
resolverId,
|
||||||
|
);
|
||||||
|
},
|
||||||
updateSettings(settings: unknown, expectedRevision: unknown) {
|
updateSettings(settings: unknown, expectedRevision: unknown) {
|
||||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
}
|
}
|
||||||
|
const current = dependencies.state.read();
|
||||||
|
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
let diagnostics: DiagnosticSettings;
|
let diagnostics: DiagnosticSettings;
|
||||||
try {
|
try {
|
||||||
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
const requested = settings && typeof settings === 'object' && !Array.isArray(settings)
|
||||||
? settings as Record<string, unknown>
|
? settings as Record<string, unknown>
|
||||||
: {};
|
: {};
|
||||||
diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true });
|
diagnostics = normalizeDiagnosticSettings({
|
||||||
|
...normalizeDiagnosticSettings(current.diagnostics),
|
||||||
|
...requested,
|
||||||
|
configured: true,
|
||||||
|
}, { strict: true });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new HarborError('REQUEST_INVALID', { cause });
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
}
|
}
|
||||||
const current = dependencies.state.read();
|
|
||||||
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
|
||||||
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
|
if (isDeepStrictEqual(current.diagnostics, diagnostics)) return;
|
||||||
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
dependencies.state.update((state) => ({ ...state, diagnostics }));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/
|
|||||||
import { sendJson } from '../response.js';
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
interface ConnectivityDiagnosticsRouteDependencies {
|
interface ConnectivityDiagnosticsRouteDependencies {
|
||||||
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'updateSettings'>;
|
diagnostics: Pick<ConnectivityDiagnosticsUseCase, 'run' | 'dnsCatalog' | 'runDns' | 'updateSettings'>;
|
||||||
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
readBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
||||||
sendState(res: ServerResponse): Promise<void>;
|
sendState(res: ServerResponse): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,15 @@ export function createConnectivityDiagnosticsRoute(
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (req.url === '/api/diagnostics/dns' && req.method === 'GET') {
|
||||||
|
sendJson(res, 200, await dependencies.diagnostics.dnsCatalog());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (req.url === '/api/diagnostics/dns' && req.method === 'POST') {
|
||||||
|
const { domainId, resolverId = null } = await dependencies.readBody(req);
|
||||||
|
sendJson(res, 200, await dependencies.diagnostics.runDns(domainId, resolverId));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') {
|
||||||
const { target = null } = await dependencies.readBody(req);
|
const { target = null } = await dependencies.readBody(req);
|
||||||
const result = await dependencies.diagnostics.run(target);
|
const result = await dependencies.diagnostics.run(target);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertLiveTrafficSnapshot,
|
assertLiveTrafficSnapshot,
|
||||||
type LiveTrafficSnapshot,
|
type LiveTrafficSnapshot,
|
||||||
} from '../../../shared/liveTraffic.js';
|
} from '../../../shared/liveTraffic.js';
|
||||||
|
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||||
import { HarborError } from '../../../shared/errors.js';
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import {
|
||||||
|
normalizeTrafficSettings,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../../../shared/trafficSettings.js';
|
||||||
import { sendJson } from '../response.js';
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
interface LiveTrafficReader {
|
interface LiveTrafficReader {
|
||||||
@@ -15,6 +21,11 @@ interface DeviceInventoryReader {
|
|||||||
snapshot(): unknown;
|
snapshot(): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TrafficSettingsState {
|
||||||
|
read(): { revision?: unknown; traffic?: TrafficSettings };
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? value as Record<string, unknown>
|
||||||
@@ -53,14 +64,20 @@ export function enrichLiveTrafficDeviceLabels(
|
|||||||
export function createLiveTrafficRoute({
|
export function createLiveTrafficRoute({
|
||||||
traffic,
|
traffic,
|
||||||
deviceInventory = null,
|
deviceInventory = null,
|
||||||
|
settingsState = null,
|
||||||
|
readBody = null,
|
||||||
|
sendState = null,
|
||||||
}: {
|
}: {
|
||||||
traffic: LiveTrafficReader | null;
|
traffic: LiveTrafficReader | null;
|
||||||
deviceInventory?: DeviceInventoryReader | null;
|
deviceInventory?: DeviceInventoryReader | null;
|
||||||
|
settingsState?: TrafficSettingsState | null;
|
||||||
|
readBody?: ((req: IncomingMessage) => Promise<Record<string, unknown>>) | null;
|
||||||
|
sendState?: ((res: ServerResponse) => Promise<void>) | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
if (pathname !== '/api/traffic/live') return false;
|
if (pathname === '/api/traffic/live') {
|
||||||
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
||||||
const enriched = deviceInventory
|
const enriched = deviceInventory
|
||||||
@@ -68,6 +85,31 @@ export function createLiveTrafficRoute({
|
|||||||
: snapshot;
|
: snapshot;
|
||||||
sendJson(res, 200, enriched);
|
sendJson(res, 200, enriched);
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (pathname === '/api/traffic/settings') {
|
||||||
|
if (req.method !== 'PUT' || !settingsState || !readBody || !sendState) {
|
||||||
|
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
}
|
||||||
|
const body = await readBody(req);
|
||||||
|
const expectedRevision = body.expectedRevision;
|
||||||
|
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||||
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
let settings: TrafficSettings;
|
||||||
|
try {
|
||||||
|
settings = normalizeTrafficSettings(body.settings, { strict: true });
|
||||||
|
} catch (cause) {
|
||||||
|
throw new HarborError('REQUEST_INVALID', { cause });
|
||||||
|
}
|
||||||
|
const current = settingsState.read();
|
||||||
|
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
|
if (!isDeepStrictEqual(current.traffic, settings)) {
|
||||||
|
settingsState.update((state) => ({ ...state, traffic: settings }));
|
||||||
|
}
|
||||||
|
await sendState(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import { assertTrafficHistorySnapshot, emptyTrafficHistory, parseTrafficHistoryQuery, type TrafficHistoryQuery } from '../../../shared/trafficHistory.js';
|
||||||
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
|
export function createTrafficHistoryRoute({ readHistory, deviceInventory }: {
|
||||||
|
readHistory: ((query: TrafficHistoryQuery) => Promise<unknown>) | null;
|
||||||
|
deviceInventory?: { snapshot(): unknown } | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
if (url.pathname !== '/api/traffic/history') return false;
|
||||||
|
if (req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
let query: TrafficHistoryQuery;
|
||||||
|
try { query = parseTrafficHistoryQuery(url.searchParams); }
|
||||||
|
catch (cause) { throw new HarborError('REQUEST_INVALID', { cause }); }
|
||||||
|
let snapshot;
|
||||||
|
try {
|
||||||
|
snapshot = readHistory ? assertTrafficHistorySnapshot(await readHistory(query)) : emptyTrafficHistory(query);
|
||||||
|
} catch {
|
||||||
|
snapshot = emptyTrafficHistory(query, 'stale');
|
||||||
|
snapshot.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
|
||||||
|
snapshot.coverage.partial = true;
|
||||||
|
}
|
||||||
|
const inventory = deviceInventory?.snapshot() as { devices?: Array<{ id: string; alias?: string; hostname?: string; ip?: string }> } | undefined;
|
||||||
|
const labels = new Map((inventory?.devices || []).map((device) => [device.id, device.alias || device.hostname || device.ip]));
|
||||||
|
snapshot.origins = snapshot.origins.map((origin) => ({ ...origin, label: labels.get(origin.id) || origin.label }));
|
||||||
|
sendJson(res, 200, snapshot);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+53
-136
@@ -39,21 +39,15 @@ import {
|
|||||||
} from '../shared/contracts/state.js';
|
} from '../shared/contracts/state.js';
|
||||||
import { serverIdentityKey } from '../shared/serverIdentity.js';
|
import { serverIdentityKey } from '../shared/serverIdentity.js';
|
||||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
import {
|
import { openHarborStorage } from './services/harborStorage.js';
|
||||||
atomicWriteFile,
|
|
||||||
createJsonStore,
|
|
||||||
createStateStore,
|
|
||||||
} from './services/stateStore.js';
|
|
||||||
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
import {
|
import {
|
||||||
createDeviceInventoryService,
|
createDeviceInventoryService,
|
||||||
createVendorLookup,
|
createVendorLookup,
|
||||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
|
||||||
migrateDeviceInventoryState,
|
|
||||||
type InventoryState,
|
|
||||||
} from './services/deviceInventoryService.js';
|
} from './services/deviceInventoryService.js';
|
||||||
import { buildVersionInfo } from './version.js';
|
import { buildVersionInfo } from './version.js';
|
||||||
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
|
||||||
|
import { createDnsDiagnosticsService } from './services/dnsDiagnosticsService.js';
|
||||||
import { createStateService } from './features/state/stateService.js';
|
import { createStateService } from './features/state/stateService.js';
|
||||||
import { createStateRoute } from './http/routes/stateRoute.js';
|
import { createStateRoute } from './http/routes/stateRoute.js';
|
||||||
import { sendError } from './http/response.js';
|
import { sendError } from './http/response.js';
|
||||||
@@ -85,6 +79,9 @@ import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.j
|
|||||||
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
||||||
import { createVersionRoute } from './http/routes/versionRoute.js';
|
import { createVersionRoute } from './http/routes/versionRoute.js';
|
||||||
import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js';
|
import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js';
|
||||||
|
import { createTrafficHistoryRoute } from './http/routes/trafficHistoryRoute.js';
|
||||||
|
import { createTrafficHistoryService } from './services/trafficHistoryService.js';
|
||||||
|
import type { LiveTrafficSourceState } from '../shared/liveTraffic.js';
|
||||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||||
import { createFailoverService } from './features/failover/failoverService.js';
|
import { createFailoverService } from './features/failover/failoverService.js';
|
||||||
import { createFailoverRoute } from './http/routes/failoverRoute.js';
|
import { createFailoverRoute } from './http/routes/failoverRoute.js';
|
||||||
@@ -110,50 +107,12 @@ function errorMessage(error: unknown) {
|
|||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
const stateFileExisted = fs.existsSync(settings.statePath);
|
const storage = openHarborStorage(settings.dataDir);
|
||||||
const legacyStateBytes = stateFileExisted
|
const stateStore = storage.state;
|
||||||
? fs.readFileSync(settings.statePath, 'utf8')
|
const deviceStore = storage.devices;
|
||||||
: null;
|
const initialStoredState = stateStore.read();
|
||||||
let legacyStateRecord: Record<string, unknown> = {};
|
if (storage.imported) console.log('[storage] SQLite migration committed; original JSON files retained as backups');
|
||||||
try {
|
const activityJournal = createActivityJournalService({ db: storage.db });
|
||||||
legacyStateRecord = record(legacyStateBytes === null ? null : JSON.parse(legacyStateBytes));
|
|
||||||
} catch {}
|
|
||||||
const legacyStateVersion = Number.isSafeInteger(legacyStateRecord.schemaVersion)
|
|
||||||
? Number(legacyStateRecord.schemaVersion)
|
|
||||||
: 0;
|
|
||||||
const legacySubscriptionCacheBytes = fs.existsSync(settings.subscriptionCachePath)
|
|
||||||
? fs.readFileSync(settings.subscriptionCachePath, 'utf8')
|
|
||||||
: null;
|
|
||||||
const subscriptionCacheStore = createJsonStore({
|
|
||||||
filePath: settings.subscriptionCachePath,
|
|
||||||
defaultValue: null,
|
|
||||||
});
|
|
||||||
const rawLegacySubscriptionCache = subscriptionCacheStore.read();
|
|
||||||
const legacyCacheRecord = record(rawLegacySubscriptionCache);
|
|
||||||
const legacyStateSubscriptionUrl = String(legacyStateRecord.subscriptionUrl || '').trim();
|
|
||||||
const legacyCacheSubscriptionUrl = String(legacyCacheRecord.url || '').trim();
|
|
||||||
const legacyCacheOwnerMismatch = legacyStateVersion < 5
|
|
||||||
&& Boolean(legacyCacheRecord.config)
|
|
||||||
&& (legacyStateSubscriptionUrl
|
|
||||||
? legacyCacheSubscriptionUrl !== legacyStateSubscriptionUrl
|
|
||||||
: !legacyCacheSubscriptionUrl);
|
|
||||||
let legacySubscriptionCache = rawLegacySubscriptionCache;
|
|
||||||
let legacySubscriptionCacheRejected = Boolean(subscriptionCacheStore.recovery);
|
|
||||||
if (legacyCacheOwnerMismatch) {
|
|
||||||
legacySubscriptionCache = null;
|
|
||||||
} else if (legacyCacheRecord.config) {
|
|
||||||
try {
|
|
||||||
legacySubscriptionCache = {
|
|
||||||
...legacyCacheRecord,
|
|
||||||
...normalizeSubscriptionConfig(legacyCacheRecord.config),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
legacySubscriptionCache = null;
|
|
||||||
legacySubscriptionCacheRejected = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const stateStore = createStateStore(settings.statePath, { legacySubscriptionCache });
|
|
||||||
const activityJournal = createActivityJournalService({ filePath: settings.activityJournalPath });
|
|
||||||
const appendJournal = (event: ActivityJournalEventInput) => {
|
const appendJournal = (event: ActivityJournalEventInput) => {
|
||||||
try {
|
try {
|
||||||
activityJournal.append(event);
|
activityJournal.append(event);
|
||||||
@@ -161,83 +120,6 @@ const appendJournal = (event: ActivityJournalEventInput) => {
|
|||||||
console.warn(`[journal] событие не сохранено: ${errorMessage(error)}`);
|
console.warn(`[journal] событие не сохранено: ${errorMessage(error)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const deviceStore = createJsonStore<InventoryState>({
|
|
||||||
filePath: settings.deviceStatePath,
|
|
||||||
defaultValue: migrateDeviceInventoryState({}),
|
|
||||||
migrate: migrateDeviceInventoryState,
|
|
||||||
initializeMissing: true,
|
|
||||||
backupWhen: () => true,
|
|
||||||
});
|
|
||||||
deviceStore.read();
|
|
||||||
if (deviceStore.migration) {
|
|
||||||
console.log(`[storage] devices migrated to v${DEVICE_INVENTORY_SCHEMA_VERSION}; backup: ${deviceStore.migration.backupPath}`);
|
|
||||||
}
|
|
||||||
if (deviceStore.recovery) {
|
|
||||||
console.warn(`[storage] corrupt devices recovered; backup: ${deviceStore.recovery.backupPath}`);
|
|
||||||
}
|
|
||||||
let initialStoredState = stateStore.read();
|
|
||||||
if (stateStore.migration) {
|
|
||||||
console.log(`[storage] state migrated to v${stateStore.migration.toVersion}; backup: ${stateStore.migration.backupPath}`);
|
|
||||||
}
|
|
||||||
if (stateStore.recovery) {
|
|
||||||
console.warn(`[storage] corrupt state recovered; backup: ${stateStore.recovery.backupPath}`);
|
|
||||||
}
|
|
||||||
if (subscriptionCacheStore.recovery) {
|
|
||||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
|
||||||
}
|
|
||||||
const rejectedLegacyMigration = legacySubscriptionCacheRejected
|
|
||||||
&& (
|
|
||||||
!stateFileExisted
|
|
||||||
|| Boolean(stateStore.recovery)
|
|
||||||
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
|
|
||||||
);
|
|
||||||
const mismatchedLegacyMigration = legacyCacheOwnerMismatch
|
|
||||||
&& (
|
|
||||||
!stateFileExisted
|
|
||||||
|| Boolean(stateStore.recovery)
|
|
||||||
|| Boolean(stateStore.migration && stateStore.migration.fromVersion < 5)
|
|
||||||
);
|
|
||||||
if (rejectedLegacyMigration) {
|
|
||||||
initialStoredState = stateStore.update((state) => ({
|
|
||||||
...state,
|
|
||||||
profiles: [],
|
|
||||||
desiredProfileId: '',
|
|
||||||
appliedProfileId: '',
|
|
||||||
appliedServerId: '',
|
|
||||||
appliedServerSnapshot: null,
|
|
||||||
connectionDesired: 'stopped',
|
|
||||||
}));
|
|
||||||
removeSingboxConfig();
|
|
||||||
} else if (mismatchedLegacyMigration) {
|
|
||||||
initialStoredState = stateStore.update((state) => ({
|
|
||||||
...state,
|
|
||||||
appliedProfileId: '',
|
|
||||||
appliedServerId: '',
|
|
||||||
appliedServerSnapshot: null,
|
|
||||||
connectionDesired: 'stopped',
|
|
||||||
}));
|
|
||||||
removeSingboxConfig();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
legacySubscriptionCacheBytes !== null
|
|
||||||
&& (
|
|
||||||
Boolean(subscriptionCacheStore.recovery)
|
|
||||||
|| (
|
|
||||||
Boolean(legacyCacheRecord.config)
|
|
||||||
&& (
|
|
||||||
legacySubscriptionCacheRejected
|
|
||||||
|| legacyCacheOwnerMismatch
|
|
||||||
|| normalizeStoredState(initialStoredState).profiles.some((profile) => profile.subscriptionConfig)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
const backupPath = subscriptionCacheStore.recovery?.backupPath
|
|
||||||
|| `${settings.subscriptionCachePath}.backup-v1-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
||||||
if (!subscriptionCacheStore.recovery) atomicWriteFile(backupPath, legacySubscriptionCacheBytes);
|
|
||||||
subscriptionCacheStore.remove();
|
|
||||||
console.log(`[storage] legacy subscription cache migrated; backup: ${backupPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readProfileConfig(profileId = '') {
|
function readProfileConfig(profileId = '') {
|
||||||
const state = normalizeStoredState(stateStore.read());
|
const state = normalizeStoredState(stateStore.read());
|
||||||
@@ -261,11 +143,17 @@ function selectRuntime() {
|
|||||||
throw new Error('Harbor runtime is not configured');
|
throw new Error('Harbor runtime is not configured');
|
||||||
}
|
}
|
||||||
const singboxRuntime = selectRuntime();
|
const singboxRuntime = selectRuntime();
|
||||||
const clientLiveTraffic = settings.appMode === 'client'
|
let clientLiveTraffic: ReturnType<typeof import('./services/liveTrafficService.js').createLiveTrafficService> | null = null;
|
||||||
|
const clientHistory = settings.appMode === 'client' ? createTrafficHistoryService({
|
||||||
|
filePath: path.join(settings.dataDir, 'traffic.sqlite'),
|
||||||
|
source: (): LiveTrafficSourceState => clientLiveTraffic?.snapshot().source.state || 'disabled',
|
||||||
|
}) : null;
|
||||||
|
clientLiveTraffic = settings.appMode === 'client'
|
||||||
? (await import('./services/liveTrafficService.js')).createLiveTrafficService({
|
? (await import('./services/liveTrafficService.js')).createLiveTrafficService({
|
||||||
port: settings.singboxNativeApiPort,
|
port: settings.singboxNativeApiPort,
|
||||||
enabled: settings.singboxTrafficSource === 'native',
|
enabled: settings.singboxTrafficSource === 'native',
|
||||||
isRuntimeRunning: () => Boolean(localRuntime?.running),
|
isRuntimeRunning: () => Boolean(localRuntime?.running),
|
||||||
|
onProjection: (batch) => clientHistory?.enqueue(batch),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const liveTraffic = clientLiveTraffic || (remoteDataplane ? {
|
const liveTraffic = clientLiveTraffic || (remoteDataplane ? {
|
||||||
@@ -312,6 +200,9 @@ const deviceInventory = settings.appMode === 'gateway'
|
|||||||
const localConnectivityDiagnostics = !remoteDataplane
|
const localConnectivityDiagnostics = !remoteDataplane
|
||||||
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
: null;
|
: null;
|
||||||
|
const localDnsDiagnostics = !remoteDataplane
|
||||||
|
? createDnsDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
|
||||||
|
: null;
|
||||||
const localFailoverDiagnostics = !remoteDataplane ? {
|
const localFailoverDiagnostics = !remoteDataplane ? {
|
||||||
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
primary: createConnectivityDiagnosticsService({ proxyPort: settings.failoverPrimaryProxyPort }),
|
||||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||||
@@ -350,12 +241,7 @@ function requireLocalConnectivityDiagnostics() {
|
|||||||
}
|
}
|
||||||
let deviceDiscoveryTimer: NodeJS.Timeout | null = null;
|
let deviceDiscoveryTimer: NodeJS.Timeout | null = null;
|
||||||
let controlOperation: Promise<unknown> = Promise.resolve();
|
let controlOperation: Promise<unknown> = Promise.resolve();
|
||||||
let operationState: OperationState = stateStore.recovery ? {
|
let operationState: OperationState = { kind: null, status: 'idle', startedAt: null, error: null };
|
||||||
kind: 'storage-recovery',
|
|
||||||
status: 'failed',
|
|
||||||
startedAt: stateStore.recovery.recoveredAt,
|
|
||||||
error: `Повреждённый state сохранён: ${path.basename(stateStore.recovery.backupPath)}`,
|
|
||||||
} : { kind: null, status: 'idle', startedAt: null, error: null };
|
|
||||||
let revision = normalizeStoredState(initialStoredState).revision;
|
let revision = normalizeStoredState(initialStoredState).revision;
|
||||||
const gatewayAutoService = createGatewayAutoService({
|
const gatewayAutoService = createGatewayAutoService({
|
||||||
appMode: settings.appMode,
|
appMode: settings.appMode,
|
||||||
@@ -503,6 +389,23 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({
|
|||||||
services,
|
services,
|
||||||
target,
|
target,
|
||||||
}),
|
}),
|
||||||
|
dnsCatalog: (customResolvers, customDomains) => remoteDataplane
|
||||||
|
? requireRemoteRuntime().getDnsDiagnosticsCatalog(customResolvers, customDomains)
|
||||||
|
: localDnsDiagnostics!.catalog(
|
||||||
|
Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
),
|
||||||
|
runDnsDiagnostics: async (customResolvers, customDomains, domainId, resolverId) => remoteDataplane
|
||||||
|
? requireRemoteRuntime().runDnsDiagnostics(
|
||||||
|
customResolvers, customDomains, domainId, resolverId,
|
||||||
|
)
|
||||||
|
: localDnsDiagnostics!.run({
|
||||||
|
vpnAvailable: Boolean((await singboxRuntime.refresh()).running),
|
||||||
|
customResolvers: Array.isArray(customResolvers) ? customResolvers : [],
|
||||||
|
customDomains: Array.isArray(customDomains) ? customDomains : [],
|
||||||
|
domainId,
|
||||||
|
resolverId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({
|
||||||
diagnostics: connectivityDiagnostics,
|
diagnostics: connectivityDiagnostics,
|
||||||
@@ -529,6 +432,17 @@ const versionRoute = createVersionRoute({
|
|||||||
const liveTrafficRoute = createLiveTrafficRoute({
|
const liveTrafficRoute = createLiveTrafficRoute({
|
||||||
traffic: liveTraffic,
|
traffic: liveTraffic,
|
||||||
deviceInventory: remoteDataplane ? deviceInventory : null,
|
deviceInventory: remoteDataplane ? deviceInventory : null,
|
||||||
|
settingsState: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
readBody,
|
||||||
|
sendState: (res) => stateRoute.send(res),
|
||||||
|
});
|
||||||
|
const trafficHistoryRoute = createTrafficHistoryRoute({
|
||||||
|
readHistory: clientHistory ? (query) => clientHistory.query(query)
|
||||||
|
: remoteRuntime ? (query) => remoteRuntime.observeTrafficHistory(query) : null,
|
||||||
|
deviceInventory,
|
||||||
});
|
});
|
||||||
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
||||||
validateSubscription: createValidateSubscription(fetchSubscription),
|
validateSubscription: createValidateSubscription(fetchSubscription),
|
||||||
@@ -984,6 +898,7 @@ async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
|||||||
if (await versionRoute.handle(req, res)) return;
|
if (await versionRoute.handle(req, res)) return;
|
||||||
|
|
||||||
if (await liveTrafficRoute.handle(req, res)) return;
|
if (await liveTrafficRoute.handle(req, res)) return;
|
||||||
|
if (await trafficHistoryRoute.handle(req, res)) return;
|
||||||
|
|
||||||
if (await sharedProxyRoute.handle(req, res)) return;
|
if (await sharedProxyRoute.handle(req, res)) return;
|
||||||
|
|
||||||
@@ -1035,7 +950,9 @@ async function shutdown() {
|
|||||||
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
||||||
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`));
|
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`));
|
||||||
await clientLiveTraffic?.stop().catch((error) => console.warn(`[control] traffic shutdown: ${errorMessage(error)}`));
|
await clientLiveTraffic?.stop().catch((error) => console.warn(`[control] traffic shutdown: ${errorMessage(error)}`));
|
||||||
|
await clientHistory?.close();
|
||||||
await serializeControl(() => singboxRuntime.shutdown());
|
await serializeControl(() => singboxRuntime.shutdown());
|
||||||
|
storage.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const COUNTER_PATTERN = /^\d+$/;
|
|||||||
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
|
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
|
||||||
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
|
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
|
||||||
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
|
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
|
||||||
|
const APPLIED_POLICY_VALUES: Readonly<Record<string, string>> = Object.freeze({ direct: '0', vpn: '1' });
|
||||||
const COLLECTOR_STATES = new Set([
|
const COLLECTOR_STATES = new Set([
|
||||||
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||||
]);
|
]);
|
||||||
@@ -93,6 +94,20 @@ export function renderPrometheusMetrics(value: unknown) {
|
|||||||
ip: device.ip || '',
|
ip: device.ip || '',
|
||||||
}, '1');
|
}, '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'# HELP harbor_device_applied_policy Current applied device policy: 0 Direct, 1 VPN.',
|
||||||
|
'# TYPE harbor_device_applied_policy gauge',
|
||||||
|
);
|
||||||
|
for (const device of devices) {
|
||||||
|
const appliedPolicy = String(device.appliedPolicy || '');
|
||||||
|
if (!Object.hasOwn(APPLIED_POLICY_VALUES, appliedPolicy)) {
|
||||||
|
throw new Error('Invalid applied device policy');
|
||||||
|
}
|
||||||
|
metric(lines, 'harbor_device_applied_policy', {
|
||||||
|
device_id: device.id,
|
||||||
|
}, APPLIED_POLICY_VALUES[appliedPolicy]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceTraffic = devices.flatMap((device) => [
|
const deviceTraffic = devices.flatMap((device) => [
|
||||||
|
|||||||
@@ -1,151 +1,75 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
import {
|
import {
|
||||||
ACTIVITY_JOURNAL_MAX_EVENTS,
|
ACTIVITY_JOURNAL_MAX_EVENTS,
|
||||||
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
||||||
normalizeActivityEventInput,
|
normalizeActivityEventInput,
|
||||||
normalizeStoredActivityEvent,
|
|
||||||
type ActivityJournalEvent,
|
type ActivityJournalEvent,
|
||||||
type ActivityJournalEventInput,
|
type ActivityJournalEventInput,
|
||||||
type ActivityJournalPage,
|
type ActivityJournalPage,
|
||||||
} from '../../shared/activityJournal.js';
|
} from '../../shared/activityJournal.js';
|
||||||
import { createJsonStore } from './stateStore.js';
|
import { transaction } from './sqlite.js';
|
||||||
|
|
||||||
interface JournalState {
|
export function createActivityJournalService({ db, now = () => new Date() }: {
|
||||||
schemaVersion: 1;
|
db: DatabaseSync;
|
||||||
events: ActivityJournalEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const migrateJournal = (value: unknown): JournalState => {
|
|
||||||
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
|
||||||
? value as Record<string, unknown>
|
|
||||||
: {};
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
events: (Array.isArray(candidate.events) ? candidate.events : [])
|
|
||||||
.map(normalizeStoredActivityEvent)
|
|
||||||
.filter((event): event is ActivityJournalEvent => Boolean(event)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createActivityJournalService({
|
|
||||||
filePath,
|
|
||||||
now = () => new Date(),
|
|
||||||
}: {
|
|
||||||
filePath: string;
|
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}) {
|
}) {
|
||||||
const store = createJsonStore<JournalState>({
|
|
||||||
filePath,
|
|
||||||
defaultValue: { schemaVersion: 1, events: [] },
|
|
||||||
migrate: migrateJournal,
|
|
||||||
});
|
|
||||||
let recoveryRecorded = false;
|
|
||||||
let writeFailed = false;
|
let writeFailed = false;
|
||||||
|
const insert = db.prepare('INSERT INTO journal(id, occurred_at, dedupe_key, value) VALUES (?, ?, ?, ?) ON CONFLICT(dedupe_key) DO NOTHING');
|
||||||
function retained(events: ActivityJournalEvent[]) {
|
function prune() {
|
||||||
const cutoff = now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000;
|
const cutoff = new Date(now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000).toISOString();
|
||||||
return events
|
db.prepare('DELETE FROM journal WHERE occurred_at < ?').run(cutoff);
|
||||||
.filter(({ occurredAt }) => Date.parse(occurredAt) >= cutoff)
|
db.prepare(`DELETE FROM journal WHERE sequence IN (
|
||||||
.slice(-ACTIVITY_JOURNAL_MAX_EVENTS);
|
SELECT sequence FROM journal ORDER BY sequence DESC LIMIT -1 OFFSET ?
|
||||||
|
)`).run(ACTIVITY_JOURNAL_MAX_EVENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function append(value: ActivityJournalEventInput) {
|
function append(value: ActivityJournalEventInput) {
|
||||||
const input = normalizeActivityEventInput(value);
|
const input = normalizeActivityEventInput(value);
|
||||||
const storedInput = input.dedupeKey ? {
|
const event: ActivityJournalEvent = {
|
||||||
...input,
|
...input,
|
||||||
dedupeKey: `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`,
|
|
||||||
} : input;
|
|
||||||
let appended: ActivityJournalEvent | null = null;
|
|
||||||
try {
|
|
||||||
store.update((state) => {
|
|
||||||
const events = retained(state.events);
|
|
||||||
if (storedInput.dedupeKey && events.some(({ dedupeKey }) => dedupeKey === storedInput.dedupeKey)) {
|
|
||||||
return { schemaVersion: 1, events };
|
|
||||||
}
|
|
||||||
appended = {
|
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
occurredAt: now().toISOString(),
|
occurredAt: now().toISOString(),
|
||||||
...storedInput,
|
dedupeKey: input.dedupeKey
|
||||||
|
? `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
return { schemaVersion: 1, events: retained([...events, appended]) };
|
try {
|
||||||
|
const appended = transaction(db, () => {
|
||||||
|
prune();
|
||||||
|
const { changes } = insert.run(event.id, event.occurredAt, event.dedupeKey, JSON.stringify(event));
|
||||||
|
prune();
|
||||||
|
return Number(changes) ? event : null;
|
||||||
});
|
});
|
||||||
writeFailed = false;
|
writeFailed = false;
|
||||||
|
return appended;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeFailed = true;
|
writeFailed = true;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return appended;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureRecoveryEvent() {
|
|
||||||
if (!store.recovery || recoveryRecorded) return;
|
|
||||||
append({
|
|
||||||
type: 'journal.recovered',
|
|
||||||
severity: 'warning',
|
|
||||||
source: 'storage',
|
|
||||||
dedupeKey: `journal.recovered:${store.recovery.recoveredAt}`,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
recoveryRecorded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
||||||
|
const base = { retentionDays: ACTIVITY_JOURNAL_RETENTION_DAYS, generatedAt: now().toISOString() } as const;
|
||||||
try {
|
try {
|
||||||
let state = store.read();
|
transaction(db, prune);
|
||||||
ensureRecoveryEvent();
|
|
||||||
if (store.recovery) state = store.read();
|
|
||||||
const retainedEvents = retained(state.events);
|
|
||||||
if (retainedEvents.length !== state.events.length) {
|
|
||||||
state = store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
|
||||||
}
|
|
||||||
const events = [...state.events].reverse();
|
|
||||||
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
||||||
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
||||||
const cursorIndex = cursor ? events.findIndex(({ id }) => id === cursor) : -1;
|
const before = cursor ? db.prepare('SELECT sequence FROM journal WHERE id = ?').get(cursor) : null;
|
||||||
if (cursor && cursorIndex < 0) return {
|
const rows = cursor && !before ? [] : db.prepare(`
|
||||||
events: [],
|
SELECT id, value FROM journal WHERE (? IS NULL OR sequence < ?) ORDER BY sequence DESC LIMIT ?
|
||||||
nextCursor: null,
|
`).all(before?.sequence ?? null, before?.sequence ?? null, limit + 1);
|
||||||
retentionDays: 30,
|
const selected = rows.slice(0, limit);
|
||||||
generatedAt: now().toISOString(),
|
|
||||||
storage: writeFailed
|
|
||||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
|
||||||
: { status: 'ready', errorCode: null },
|
|
||||||
};
|
|
||||||
const safeStart = cursorIndex + 1;
|
|
||||||
const selected = events.slice(safeStart, safeStart + limit);
|
|
||||||
return {
|
return {
|
||||||
events: selected.map((event) => ({ ...event, dedupeKey: null })),
|
...base,
|
||||||
nextCursor: safeStart + selected.length < events.length ? selected.at(-1)?.id || null : null,
|
events: selected.map((row) => ({ ...JSON.parse(String(row.value)) as ActivityJournalEvent, dedupeKey: null })),
|
||||||
retentionDays: 30,
|
nextCursor: rows.length > limit ? String(selected.at(-1)?.id) : null,
|
||||||
generatedAt: now().toISOString(),
|
|
||||||
storage: writeFailed
|
storage: writeFailed
|
||||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||||
: { status: 'ready', errorCode: null },
|
: { status: 'ready', errorCode: null },
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return { ...base, events: [], nextCursor: null, storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' } };
|
||||||
events: [],
|
|
||||||
nextCursor: null,
|
|
||||||
retentionDays: 30,
|
|
||||||
generatedAt: now().toISOString(),
|
|
||||||
storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
try {
|
|
||||||
const state = store.read();
|
|
||||||
const retainedEvents = retained(state.events);
|
|
||||||
if (retainedEvents.length !== state.events.length) {
|
|
||||||
store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
writeFailed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { append, page };
|
return { append, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
import { getServers as dnsGetServers } from 'node:dns';
|
||||||
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DNS_DIAGNOSTIC_DOMAINS,
|
||||||
|
DNS_DIAGNOSTIC_RESOLVERS,
|
||||||
|
type CustomDnsDomain,
|
||||||
|
type CustomDnsResolver,
|
||||||
|
type DnsDomainDefinition,
|
||||||
|
type DnsResolverDefinition,
|
||||||
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
|
type PathKind = 'direct' | 'vpn';
|
||||||
|
type RecordKind = 'A' | 'AAAA';
|
||||||
|
|
||||||
|
interface ParsedDnsResponse {
|
||||||
|
rcode: string;
|
||||||
|
truncated: boolean;
|
||||||
|
ipv4: string[];
|
||||||
|
ipv6: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireExchange {
|
||||||
|
response: Buffer;
|
||||||
|
transport: 'udp' | 'tcp' | 'doh';
|
||||||
|
latencyMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryPathResult {
|
||||||
|
status: string;
|
||||||
|
rcode: string | null;
|
||||||
|
ipv4: string[];
|
||||||
|
ipv6: string[];
|
||||||
|
latencyMs: number | null;
|
||||||
|
transport: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExchangeRequest {
|
||||||
|
path: PathKind;
|
||||||
|
resolver: DnsResolverDefinition;
|
||||||
|
packet: Buffer;
|
||||||
|
proxyPort: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClassicExchange = (request: ExchangeRequest) => Promise<WireExchange>;
|
||||||
|
type DohExchange = (request: ExchangeRequest) => Promise<WireExchange>;
|
||||||
|
type TcpExchange = (request: ExchangeRequest) => Promise<WireExchange>;
|
||||||
|
|
||||||
|
const RCODE = ['NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED'];
|
||||||
|
const MAX_DNS_PACKET_BYTES = 65_535;
|
||||||
|
const CLASSIC_TIMEOUT_MS = 2_500;
|
||||||
|
const DOH_TIMEOUT_MS = 5_000;
|
||||||
|
const MAX_PARALLEL_RESOLVERS = 3;
|
||||||
|
|
||||||
|
const BLOCKED_IPV4 = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
||||||
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16],
|
||||||
|
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24],
|
||||||
|
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
||||||
|
['224.0.0.0', 4], ['240.0.0.0', 4],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV4.addSubnet(address, prefix, 'ipv4');
|
||||||
|
const BLOCKED_IPV6 = new net.BlockList();
|
||||||
|
for (const [address, prefix] of [
|
||||||
|
['::', 128], ['::1', 128], ['64:ff9b:1::', 48], ['100::', 64],
|
||||||
|
['2001:db8::', 32], ['3fff::', 20], ['5f00::', 16],
|
||||||
|
['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
||||||
|
] as Array<[string, number]>) BLOCKED_IPV6.addSubnet(address, prefix, 'ipv6');
|
||||||
|
|
||||||
|
function isPublicAddress(address: string) {
|
||||||
|
const family = net.isIP(address);
|
||||||
|
if (family === 4) return !BLOCKED_IPV4.check(address, 'ipv4');
|
||||||
|
if (family === 6) return !BLOCKED_IPV6.check(address, 'ipv6');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeName(hostname: string) {
|
||||||
|
const labels = hostname.split('.');
|
||||||
|
const chunks = labels.map((label) => {
|
||||||
|
const value = Buffer.from(label, 'ascii');
|
||||||
|
if (!value.length || value.length > 63) throw new Error('invalid DNS hostname');
|
||||||
|
return Buffer.concat([Buffer.from([value.length]), value]);
|
||||||
|
});
|
||||||
|
return Buffer.concat([...chunks, Buffer.from([0])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDnsQuery(hostname: string, type: RecordKind, id: number) {
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
header.writeUInt16BE(id, 0);
|
||||||
|
header.writeUInt16BE(0x0100, 2);
|
||||||
|
header.writeUInt16BE(1, 4);
|
||||||
|
const question = Buffer.alloc(4);
|
||||||
|
question.writeUInt16BE(type === 'A' ? 1 : 28, 0);
|
||||||
|
question.writeUInt16BE(1, 2);
|
||||||
|
return Buffer.concat([header, encodeName(hostname), question]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readName(packet: Buffer, start: number) {
|
||||||
|
let offset = start;
|
||||||
|
let next = start;
|
||||||
|
let jumped = false;
|
||||||
|
const labels: string[] = [];
|
||||||
|
const visited = new Set<number>();
|
||||||
|
for (let depth = 0; depth < 64; depth += 1) {
|
||||||
|
if (offset >= packet.length) throw new Error('malformed DNS name');
|
||||||
|
const length = packet[offset];
|
||||||
|
if ((length & 0xc0) === 0xc0) {
|
||||||
|
if (offset + 1 >= packet.length) throw new Error('malformed DNS pointer');
|
||||||
|
const pointer = ((length & 0x3f) << 8) | packet[offset + 1];
|
||||||
|
if (visited.has(pointer)) throw new Error('recursive DNS pointer');
|
||||||
|
visited.add(pointer);
|
||||||
|
if (!jumped) next = offset + 2;
|
||||||
|
jumped = true;
|
||||||
|
offset = pointer;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (length & 0xc0) throw new Error('unsupported DNS label');
|
||||||
|
offset += 1;
|
||||||
|
if (length === 0) {
|
||||||
|
if (!jumped) next = offset;
|
||||||
|
return { name: labels.join('.'), next };
|
||||||
|
}
|
||||||
|
if (offset + length > packet.length) throw new Error('malformed DNS label');
|
||||||
|
labels.push(packet.subarray(offset, offset + length).toString('ascii'));
|
||||||
|
offset += length;
|
||||||
|
if (!jumped) next = offset;
|
||||||
|
}
|
||||||
|
throw new Error('DNS name is too deep');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv6Address(value: Buffer) {
|
||||||
|
const groups = [];
|
||||||
|
for (let offset = 0; offset < 16; offset += 2) groups.push(value.readUInt16BE(offset).toString(16));
|
||||||
|
return groups.join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDnsResponse(packet: Buffer, expectedId: number): ParsedDnsResponse {
|
||||||
|
if (packet.length < 12 || packet.length > MAX_DNS_PACKET_BYTES) throw new Error('malformed DNS response');
|
||||||
|
if (packet.readUInt16BE(0) !== expectedId || !(packet.readUInt16BE(2) & 0x8000)) {
|
||||||
|
throw new Error('unexpected DNS response');
|
||||||
|
}
|
||||||
|
const flags = packet.readUInt16BE(2);
|
||||||
|
const questionCount = packet.readUInt16BE(4);
|
||||||
|
const answerCount = packet.readUInt16BE(6);
|
||||||
|
let offset = 12;
|
||||||
|
for (let index = 0; index < questionCount; index += 1) {
|
||||||
|
offset = readName(packet, offset).next;
|
||||||
|
if (offset + 4 > packet.length) throw new Error('malformed DNS question');
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
const ipv4: string[] = [];
|
||||||
|
const ipv6: string[] = [];
|
||||||
|
for (let index = 0; index < answerCount; index += 1) {
|
||||||
|
offset = readName(packet, offset).next;
|
||||||
|
if (offset + 10 > packet.length) throw new Error('malformed DNS answer');
|
||||||
|
const type = packet.readUInt16BE(offset);
|
||||||
|
const dnsClass = packet.readUInt16BE(offset + 2);
|
||||||
|
const length = packet.readUInt16BE(offset + 8);
|
||||||
|
offset += 10;
|
||||||
|
if (offset + length > packet.length) throw new Error('malformed DNS data');
|
||||||
|
if (dnsClass === 1 && type === 1 && length === 4) ipv4.push([...packet.subarray(offset, offset + 4)].join('.'));
|
||||||
|
if (dnsClass === 1 && type === 28 && length === 16) ipv6.push(ipv6Address(packet.subarray(offset, offset + 16)));
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
const code = flags & 0x0f;
|
||||||
|
return {
|
||||||
|
rcode: RCODE[code] || `RCODE_${code}`,
|
||||||
|
truncated: Boolean(flags & 0x0200),
|
||||||
|
ipv4: [...new Set(ipv4)],
|
||||||
|
ipv6: [...new Set(ipv6)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolverEndpoint(endpoint: string) {
|
||||||
|
if (net.isIP(endpoint)) return { address: endpoint, port: 53 };
|
||||||
|
const ipv6 = /^\[([^\]]+)\]:(\d+)$/.exec(endpoint);
|
||||||
|
if (ipv6 && net.isIP(ipv6[1]) === 6) return { address: ipv6[1], port: Number(ipv6[2]) };
|
||||||
|
const ipv4 = /^([^:]+):(\d+)$/.exec(endpoint);
|
||||||
|
if (ipv4 && net.isIP(ipv4[1]) === 4) return { address: ipv4[1], port: Number(ipv4[2]) };
|
||||||
|
throw new Error('invalid DNS resolver');
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeoutError() {
|
||||||
|
const error = new Error('timeout');
|
||||||
|
Object.assign(error, { code: 'ETIMEDOUT' });
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function udpExchange(address: string, port: number, packet: Buffer, timeoutMs: number) {
|
||||||
|
const socket = dgram.createSocket(net.isIP(address) === 6 ? 'udp6' : 'udp4');
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.close();
|
||||||
|
reject(timeoutError());
|
||||||
|
}, timeoutMs);
|
||||||
|
socket.once('error', (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.close();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
socket.once('message', (message) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.close();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
socket.send(packet, port, address, (error) => {
|
||||||
|
if (!error) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.close();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readExact(socket: net.Socket, length: number) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (total < length) {
|
||||||
|
const chunk = socket.read(length - total) as Buffer | null;
|
||||||
|
if (chunk) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
total += chunk.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await Promise.race([
|
||||||
|
once(socket, 'readable'),
|
||||||
|
once(socket, 'error').then(([error]) => Promise.reject(error)),
|
||||||
|
once(socket, 'close').then(() => Promise.reject(new Error('connection closed'))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectSocket(host: string, port: number, timeoutMs: number) {
|
||||||
|
const socket = net.connect({ host, port });
|
||||||
|
socket.setTimeout(timeoutMs, () => socket.destroy(timeoutError()));
|
||||||
|
await Promise.race([
|
||||||
|
once(socket, 'connect'),
|
||||||
|
once(socket, 'error').then(([error]) => Promise.reject(error)),
|
||||||
|
]);
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function socksAddress(address: string, port: number) {
|
||||||
|
const family = net.isIP(address);
|
||||||
|
const portBytes = Buffer.alloc(2);
|
||||||
|
portBytes.writeUInt16BE(port, 0);
|
||||||
|
if (family === 4) return Buffer.concat([Buffer.from([1, ...address.split('.').map(Number)]), portBytes]);
|
||||||
|
if (family === 6) {
|
||||||
|
const bytes = Buffer.alloc(16);
|
||||||
|
const [head = '', tail = ''] = address.split('::');
|
||||||
|
const headGroups = head ? head.split(':') : [];
|
||||||
|
const tailGroups = tail ? tail.split(':') : [];
|
||||||
|
const groups = address.includes('::')
|
||||||
|
? [...headGroups, ...Array(8 - headGroups.length - tailGroups.length).fill('0'), ...tailGroups]
|
||||||
|
: headGroups;
|
||||||
|
if (groups.length !== 8) throw new Error('invalid IPv6 resolver');
|
||||||
|
groups.forEach((group, index) => bytes.writeUInt16BE(Number.parseInt(group || '0', 16), index * 2));
|
||||||
|
return Buffer.concat([Buffer.from([4]), bytes, portBytes]);
|
||||||
|
}
|
||||||
|
throw new Error('invalid SOCKS destination');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function socksHandshake(command: 1 | 3, address: string, port: number, proxyPort: number, timeoutMs: number) {
|
||||||
|
const socket = await connectSocket('127.0.0.1', proxyPort, timeoutMs);
|
||||||
|
socket.write(Buffer.from([5, 1, 0]));
|
||||||
|
const greeting = await readExact(socket, 2);
|
||||||
|
if (greeting[0] !== 5 || greeting[1] !== 0) throw new Error('SOCKS authentication failed');
|
||||||
|
socket.write(Buffer.concat([Buffer.from([5, command, 0]), socksAddress(address, port)]));
|
||||||
|
const header = await readExact(socket, 4);
|
||||||
|
if (header[0] !== 5 || header[1] !== 0) throw new Error('SOCKS connection failed');
|
||||||
|
const addressLength = header[3] === 1 ? 4 : header[3] === 4 ? 16 : header[3] === 3 ? (await readExact(socket, 1))[0] : 0;
|
||||||
|
if (!addressLength) throw new Error('invalid SOCKS response');
|
||||||
|
const bound = await readExact(socket, addressLength + 2);
|
||||||
|
const boundAddress = header[3] === 1
|
||||||
|
? [...bound.subarray(0, 4)].join('.')
|
||||||
|
: header[3] === 4
|
||||||
|
? ipv6Address(bound.subarray(0, 16))
|
||||||
|
: bound.subarray(0, addressLength).toString('ascii');
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
boundAddress: ['0.0.0.0', '::'].includes(boundAddress) ? '127.0.0.1' : boundAddress,
|
||||||
|
boundPort: bound.readUInt16BE(addressLength),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tcpDns(socket: net.Socket, packet: Buffer) {
|
||||||
|
const length = Buffer.alloc(2);
|
||||||
|
length.writeUInt16BE(packet.length, 0);
|
||||||
|
socket.write(Buffer.concat([length, packet]));
|
||||||
|
const size = (await readExact(socket, 2)).readUInt16BE(0);
|
||||||
|
if (!size || size > MAX_DNS_PACKET_BYTES) throw new Error('invalid DNS TCP response');
|
||||||
|
return readExact(socket, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultClassicExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise<WireExchange> {
|
||||||
|
const { address, port } = resolverEndpoint(resolver.endpoint);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
if (path === 'direct') {
|
||||||
|
const response = await udpExchange(address, port, packet, timeoutMs);
|
||||||
|
return { response, transport: 'udp', latencyMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
const control = await socksHandshake(3, '0.0.0.0', 0, proxyPort, timeoutMs);
|
||||||
|
try {
|
||||||
|
const target = socksAddress(address, port);
|
||||||
|
const request = Buffer.concat([Buffer.from([0, 0, 0]), target, packet]);
|
||||||
|
const response = await udpExchange(control.boundAddress, control.boundPort, request, timeoutMs);
|
||||||
|
const headerLength = response[3] === 1 ? 10 : response[3] === 4 ? 22 : response[3] === 3 ? 7 + response[4] : 0;
|
||||||
|
if (!headerLength || response[2] !== 0 || response.length <= headerLength) throw new Error('invalid SOCKS UDP response');
|
||||||
|
return { response: response.subarray(headerLength), transport: 'udp', latencyMs: Date.now() - startedAt };
|
||||||
|
} finally {
|
||||||
|
control.socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tcpFallback({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest) {
|
||||||
|
const { address, port } = resolverEndpoint(resolver.endpoint);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const socket = path === 'direct'
|
||||||
|
? await connectSocket(address, port, timeoutMs)
|
||||||
|
: (await socksHandshake(1, address, port, proxyPort, timeoutMs)).socket;
|
||||||
|
try {
|
||||||
|
return { response: await tcpDns(socket, packet), transport: 'tcp' as const, latencyMs: Date.now() - startedAt };
|
||||||
|
} finally {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function defaultDohExchange({ path, resolver, packet, proxyPort, timeoutMs }: ExchangeRequest): Promise<WireExchange> {
|
||||||
|
const endpoint = new URL(resolver.endpoint);
|
||||||
|
const marker = Buffer.from('\n__HARBOR_DOH_META__');
|
||||||
|
const args = [
|
||||||
|
'--silent', '--show-error', '--proto', '=https', '--connect-timeout', '3',
|
||||||
|
'--max-time', String(timeoutMs / 1_000), '--request', 'POST',
|
||||||
|
'--header', 'content-type: application/dns-message', '--header', 'accept: application/dns-message',
|
||||||
|
'--data-binary', '@-', '--output', '-', '--write-out', `${marker.toString()}%{http_code}:%{time_total}`,
|
||||||
|
...(path === 'vpn' ? ['--proxy', `http://127.0.0.1:${proxyPort}`] : ['--noproxy', '*']),
|
||||||
|
...(resolver.bootstrap ? ['--resolve', `${endpoint.hostname}:443:${resolver.bootstrap}`] : []),
|
||||||
|
resolver.endpoint,
|
||||||
|
];
|
||||||
|
const child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
const stdout: Buffer[] = [];
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
|
||||||
|
child.stdin.end(packet);
|
||||||
|
const [code] = await once(child, 'close') as [number | null];
|
||||||
|
const output = Buffer.concat(stdout);
|
||||||
|
const markerIndex = output.lastIndexOf(marker);
|
||||||
|
if (code !== 0 || markerIndex < 0) throw new Error(Buffer.concat(stderr).toString('utf8').trim() || 'DoH request failed');
|
||||||
|
const [statusText, secondsText] = output.subarray(markerIndex + marker.length).toString('ascii').split(':');
|
||||||
|
const status = Number(statusText);
|
||||||
|
if (status < 200 || status >= 300) throw new Error(`DoH HTTP ${status}`);
|
||||||
|
const response = output.subarray(0, markerIndex);
|
||||||
|
if (!response.length || response.length > MAX_DNS_PACKET_BYTES) throw new Error('invalid DoH response');
|
||||||
|
return { response, transport: 'doh', latencyMs: Math.round(Number(secondsText) * 1_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableResolvers(customResolvers: CustomDnsResolver[], getServers: () => string[]) {
|
||||||
|
const system = getServers().map((endpoint) => ({
|
||||||
|
id: `system-${crypto.createHash('sha256').update(endpoint).digest('hex').slice(0, 12)}`,
|
||||||
|
label: 'Системный DNS',
|
||||||
|
kind: 'dns' as const,
|
||||||
|
endpoint,
|
||||||
|
system: true,
|
||||||
|
}));
|
||||||
|
const custom: DnsResolverDefinition[] = customResolvers.map((resolver) => ({ ...resolver, custom: true }));
|
||||||
|
return [...system, ...DNS_DIAGNOSTIC_RESOLVERS, ...custom];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareResolver(resolver: DnsResolverDefinition, lookup: typeof dnsLookup) {
|
||||||
|
if (resolver.kind !== 'doh' || resolver.bootstrap) return resolver;
|
||||||
|
const endpoint = new URL(resolver.endpoint);
|
||||||
|
const addresses = await lookup(endpoint.hostname, { all: true, verbatim: true });
|
||||||
|
if (!addresses.length || addresses.some(({ address }) => !isPublicAddress(address))) {
|
||||||
|
throw new Error('DoH endpoint is not public');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...resolver,
|
||||||
|
bootstrap: addresses.find(({ family }) => family === 4)?.address || addresses[0].address,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function domains(customDomains: CustomDnsDomain[]): DnsDomainDefinition[] {
|
||||||
|
return [...DNS_DIAGNOSTIC_DOMAINS, ...customDomains.map((domain) => ({ ...domain, custom: true }))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCode(error: unknown) {
|
||||||
|
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
|
||||||
|
if (code === 'ETIMEDOUT' || (error instanceof Error && error.message === 'timeout')) return 'timeout';
|
||||||
|
const message = error instanceof Error ? error.message : '';
|
||||||
|
if (
|
||||||
|
message === 'DoH endpoint is not public'
|
||||||
|
|| message.startsWith('malformed DNS')
|
||||||
|
|| message.startsWith('unexpected DNS')
|
||||||
|
|| /^DoH HTTP \d{3}$/.test(message)
|
||||||
|
) return message;
|
||||||
|
return 'DNS request failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryType(
|
||||||
|
hostname: string,
|
||||||
|
type: RecordKind,
|
||||||
|
path: PathKind,
|
||||||
|
resolver: DnsResolverDefinition,
|
||||||
|
proxyPort: number,
|
||||||
|
classicExchange: ClassicExchange,
|
||||||
|
dohExchange: DohExchange,
|
||||||
|
tcpExchange: TcpExchange,
|
||||||
|
) {
|
||||||
|
const id = crypto.randomInt(0, 65_536);
|
||||||
|
const packet = buildDnsQuery(hostname, type, id);
|
||||||
|
const exchange = resolver.kind === 'doh' ? dohExchange : classicExchange;
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
let result = await exchange({
|
||||||
|
path, resolver, packet, proxyPort,
|
||||||
|
timeoutMs: resolver.kind === 'doh' ? DOH_TIMEOUT_MS : CLASSIC_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
let parsed = parseDnsResponse(result.response, id);
|
||||||
|
if (resolver.kind === 'dns' && parsed.truncated) {
|
||||||
|
result = await tcpExchange({ path, resolver, packet, proxyPort, timeoutMs: CLASSIC_TIMEOUT_MS });
|
||||||
|
parsed = parseDnsResponse(result.response, id);
|
||||||
|
}
|
||||||
|
return { ...parsed, latencyMs: result.latencyMs, transport: result.transport };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (errorCode(error) !== 'timeout') break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryPath(
|
||||||
|
hostname: string,
|
||||||
|
path: PathKind,
|
||||||
|
resolver: DnsResolverDefinition,
|
||||||
|
proxyPort: number,
|
||||||
|
classicExchange: ClassicExchange,
|
||||||
|
dohExchange: DohExchange,
|
||||||
|
tcpExchange: TcpExchange,
|
||||||
|
): Promise<QueryPathResult> {
|
||||||
|
const settled = await Promise.allSettled((['A', 'AAAA'] as RecordKind[]).map((type) => (
|
||||||
|
queryType(hostname, type, path, resolver, proxyPort, classicExchange, dohExchange, tcpExchange)
|
||||||
|
)));
|
||||||
|
const values = settled.flatMap((result) => result.status === 'fulfilled' ? [result.value] : []);
|
||||||
|
const errors = settled.flatMap((result) => result.status === 'rejected' ? [errorCode(result.reason)] : []);
|
||||||
|
const rcodes = [...new Set(values.map(({ rcode }) => rcode))];
|
||||||
|
const ipv4 = [...new Set(values.flatMap((value) => value.ipv4))];
|
||||||
|
const ipv6 = [...new Set(values.flatMap((value) => value.ipv6))];
|
||||||
|
const rcode = rcodes.find((value) => value !== 'NOERROR') || rcodes[0] || null;
|
||||||
|
const status = ipv4.length || ipv6.length
|
||||||
|
? 'answered'
|
||||||
|
: rcode === 'NXDOMAIN'
|
||||||
|
? 'nxdomain'
|
||||||
|
: rcode === 'SERVFAIL'
|
||||||
|
? 'servfail'
|
||||||
|
: values.length
|
||||||
|
? 'no-addresses'
|
||||||
|
: errors.every((error) => error === 'timeout')
|
||||||
|
? 'timeout'
|
||||||
|
: 'error';
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
rcode,
|
||||||
|
ipv4,
|
||||||
|
ipv6,
|
||||||
|
latencyMs: values.length ? Math.max(...values.map(({ latencyMs }) => latencyMs)) : null,
|
||||||
|
transport: [...new Set(values.map(({ transport }) => transport))].join('+') || null,
|
||||||
|
error: errors.length ? [...new Set(errors)].join('; ') : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressSet(result: QueryPathResult) {
|
||||||
|
return [...result.ipv4, ...result.ipv6].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparison(direct: QueryPathResult, vpn: QueryPathResult) {
|
||||||
|
const directOk = direct.status === 'answered' || direct.status === 'no-addresses' || direct.status === 'nxdomain';
|
||||||
|
const vpnOk = vpn.status === 'answered' || vpn.status === 'no-addresses' || vpn.status === 'nxdomain';
|
||||||
|
if (!directOk && !vpnOk) return 'failed';
|
||||||
|
if (directOk && !vpnOk) return 'direct-only';
|
||||||
|
if (!directOk && vpnOk) return 'vpn-only';
|
||||||
|
return JSON.stringify(addressSet(direct)) === JSON.stringify(addressSet(vpn)) && direct.rcode === vpn.rcode
|
||||||
|
? 'same'
|
||||||
|
: 'different';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDnsDiagnosticsService({
|
||||||
|
proxyPort,
|
||||||
|
getServers = dnsGetServers,
|
||||||
|
lookup = dnsLookup,
|
||||||
|
classicExchange = defaultClassicExchange,
|
||||||
|
dohExchange = defaultDohExchange,
|
||||||
|
tcpExchange = tcpFallback,
|
||||||
|
now = () => new Date().toISOString(),
|
||||||
|
}: {
|
||||||
|
proxyPort: number;
|
||||||
|
getServers?: () => string[];
|
||||||
|
lookup?: typeof dnsLookup;
|
||||||
|
classicExchange?: ClassicExchange;
|
||||||
|
dohExchange?: DohExchange;
|
||||||
|
tcpExchange?: TcpExchange;
|
||||||
|
now?: () => string;
|
||||||
|
}) {
|
||||||
|
async function catalog(customResolvers: CustomDnsResolver[] = [], customDomains: CustomDnsDomain[] = []) {
|
||||||
|
return {
|
||||||
|
resolvers: availableResolvers(customResolvers, getServers),
|
||||||
|
domains: domains(customDomains),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run({
|
||||||
|
vpnAvailable,
|
||||||
|
customResolvers = [],
|
||||||
|
customDomains = [],
|
||||||
|
domainId,
|
||||||
|
resolverId = null,
|
||||||
|
}: {
|
||||||
|
vpnAvailable: boolean;
|
||||||
|
customResolvers?: CustomDnsResolver[];
|
||||||
|
customDomains?: CustomDnsDomain[];
|
||||||
|
domainId: unknown;
|
||||||
|
resolverId?: unknown;
|
||||||
|
}) {
|
||||||
|
const available = await catalog(customResolvers, customDomains);
|
||||||
|
const domain = available.domains.find(({ id }) => id === domainId);
|
||||||
|
if (!domain) throw new Error('Unknown DNS diagnostic domain');
|
||||||
|
const selected = resolverId
|
||||||
|
? available.resolvers.filter(({ id }) => id === resolverId)
|
||||||
|
: available.resolvers;
|
||||||
|
if (!selected.length) throw new Error('Unknown DNS diagnostic resolver');
|
||||||
|
const results: unknown[] = new Array(selected.length);
|
||||||
|
let cursor = 0;
|
||||||
|
await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL_RESOLVERS, selected.length) }, async () => {
|
||||||
|
while (cursor < selected.length) {
|
||||||
|
const index = cursor;
|
||||||
|
cursor += 1;
|
||||||
|
const catalogResolver = selected[index];
|
||||||
|
let resolver: DnsResolverDefinition;
|
||||||
|
try {
|
||||||
|
resolver = await prepareResolver(catalogResolver, lookup);
|
||||||
|
} catch (error) {
|
||||||
|
const unavailable: QueryPathResult = {
|
||||||
|
status: 'error', rcode: null, ipv4: [], ipv6: [], latencyMs: null,
|
||||||
|
transport: null, error: errorCode(error),
|
||||||
|
};
|
||||||
|
results[index] = {
|
||||||
|
resolver: catalogResolver,
|
||||||
|
direct: unavailable,
|
||||||
|
vpn: vpnAvailable ? unavailable : { ...unavailable, status: 'vpn-off', error: 'VPN выключен' },
|
||||||
|
comparison: 'failed',
|
||||||
|
warning: null,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const directPromise = queryPath(domain.hostname, 'direct', resolver, proxyPort, classicExchange, dohExchange, tcpExchange);
|
||||||
|
const vpnPromise = vpnAvailable
|
||||||
|
? queryPath(domain.hostname, 'vpn', resolver, proxyPort, classicExchange, dohExchange, tcpExchange)
|
||||||
|
: Promise.resolve<QueryPathResult>({
|
||||||
|
status: 'vpn-off', rcode: null, ipv4: [], ipv6: [], latencyMs: null,
|
||||||
|
transport: null, error: 'VPN выключен',
|
||||||
|
});
|
||||||
|
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
|
||||||
|
const addresses = [...direct.ipv4, ...direct.ipv6, ...vpn.ipv4, ...vpn.ipv6];
|
||||||
|
results[index] = {
|
||||||
|
resolver: catalogResolver,
|
||||||
|
direct,
|
||||||
|
vpn,
|
||||||
|
comparison: comparison(direct, vpn),
|
||||||
|
warning: domain.builtIn && addresses.some((address) => !isPublicAddress(address))
|
||||||
|
? 'private-address'
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return { checkedAt: now(), domain, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { catalog, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DnsDiagnosticsService = ReturnType<typeof createDnsDiagnosticsService>;
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { normalizeStoredActivityEvent, type ActivityJournalEvent } from '../../shared/activityJournal.js';
|
||||||
|
import { normalizeSubscriptionConfig } from '../subscription.js';
|
||||||
|
import { migrateDeviceInventoryState } from './deviceInventoryService.js';
|
||||||
|
import { migrateStoredState } from './stateStore.js';
|
||||||
|
import { openSqlite, transaction } from './sqlite.js';
|
||||||
|
|
||||||
|
function object(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error('Invalid stored Harbor document');
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyDocument(directory: string, name: string) {
|
||||||
|
const file = path.join(directory, name);
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
// Never run the legacy JSON recovery writer during import: originals are the backup.
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
return value === null && name === 'subscription-cache.json' ? null : object(value);
|
||||||
|
}
|
||||||
|
catch { throw new Error(`Cannot migrate ${name}: invalid JSON document; original retained`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSqliteDocumentStore<T>(db: DatabaseSync, key: string, migrate: (value: unknown) => T) {
|
||||||
|
const select = db.prepare('SELECT value FROM documents WHERE key = ?');
|
||||||
|
const save = db.prepare('INSERT INTO documents(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||||
|
function read(): T {
|
||||||
|
const row = select.get(key);
|
||||||
|
if (!row || typeof row.value !== 'string') throw new Error(`Missing Harbor document: ${key}`);
|
||||||
|
return migrate(JSON.parse(row.value));
|
||||||
|
}
|
||||||
|
function write(value: T) {
|
||||||
|
const next = migrate(structuredClone(value));
|
||||||
|
save.run(key, JSON.stringify(next));
|
||||||
|
return structuredClone(next);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
read,
|
||||||
|
write,
|
||||||
|
update: (change: (value: T) => T) => transaction(db, () => {
|
||||||
|
const next = change(read());
|
||||||
|
if (next && typeof next === 'object' && 'then' in next) throw new TypeError('State store mutator must be synchronous');
|
||||||
|
return write(next);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openHarborStorage(directory: string) {
|
||||||
|
const db = openSqlite(path.join(directory, 'harbor.sqlite'));
|
||||||
|
let imported = false;
|
||||||
|
try {
|
||||||
|
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version);
|
||||||
|
if (version !== 0 && version !== 1) throw new Error(`Unsupported Harbor database version: ${version}`);
|
||||||
|
transaction(db, () => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (key TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(json_valid(value))) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS journal (
|
||||||
|
sequence INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE, occurred_at TEXT NOT NULL,
|
||||||
|
dedupe_key TEXT UNIQUE, value TEXT NOT NULL CHECK(json_valid(value))
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS journal_time ON journal(occurred_at);
|
||||||
|
`);
|
||||||
|
const initialized = db.prepare("SELECT value FROM documents WHERE key = 'storage-version'").get();
|
||||||
|
if (!initialized) {
|
||||||
|
// All inputs are read and normalized before any imported record is committed.
|
||||||
|
const rawState = legacyDocument(directory, 'state.json') || {};
|
||||||
|
const rawDevices = legacyDocument(directory, 'devices.json') || {};
|
||||||
|
const rawJournal = legacyDocument(directory, 'activity-journal.json');
|
||||||
|
const stateVersion = Number(rawState.schemaVersion || 0);
|
||||||
|
const legacyCache = stateVersion < 5 && !Array.isArray(rawState.profiles)
|
||||||
|
? legacyDocument(directory, 'subscription-cache.json')
|
||||||
|
: null;
|
||||||
|
let cache: unknown = null;
|
||||||
|
if (legacyCache?.config) {
|
||||||
|
const stateUrl = String(rawState.subscriptionUrl || '').trim();
|
||||||
|
const cacheUrl = String(legacyCache.url || '').trim();
|
||||||
|
if (!cacheUrl || (stateUrl && cacheUrl !== stateUrl)) {
|
||||||
|
throw new Error('Cannot migrate subscription cache: owner mismatch; originals retained');
|
||||||
|
}
|
||||||
|
cache = { ...legacyCache, ...normalizeSubscriptionConfig(legacyCache.config) };
|
||||||
|
}
|
||||||
|
const state = migrateStoredState(rawState, cache);
|
||||||
|
const devices = migrateDeviceInventoryState(rawDevices);
|
||||||
|
let events: ActivityJournalEvent[] = [];
|
||||||
|
if (rawJournal) {
|
||||||
|
if (rawJournal.schemaVersion !== 1 || !Array.isArray(rawJournal.events)) {
|
||||||
|
throw new Error('Unsupported activity journal document; original retained');
|
||||||
|
}
|
||||||
|
events = rawJournal.events.map((raw) => {
|
||||||
|
const event = normalizeStoredActivityEvent(raw);
|
||||||
|
if (!event) throw new Error('Invalid activity journal event; original retained');
|
||||||
|
return event;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const insert = db.prepare('INSERT INTO documents(key, value) VALUES (?, ?)');
|
||||||
|
insert.run('state', JSON.stringify(state));
|
||||||
|
insert.run('devices', JSON.stringify(devices));
|
||||||
|
const insertEvent = db.prepare('INSERT INTO journal(id, occurred_at, dedupe_key, value) VALUES (?, ?, ?, ?)');
|
||||||
|
for (const event of events) insertEvent.run(event.id, event.occurredAt, event.dedupeKey, JSON.stringify(event));
|
||||||
|
insert.run('storage-version', '1');
|
||||||
|
imported = true;
|
||||||
|
} else if (initialized.value !== '1') {
|
||||||
|
throw new Error('Unsupported Harbor storage version');
|
||||||
|
}
|
||||||
|
db.exec('PRAGMA user_version = 1');
|
||||||
|
});
|
||||||
|
const state = createSqliteDocumentStore(db, 'state', migrateStoredState);
|
||||||
|
const devices = createSqliteDocumentStore(db, 'devices', migrateDeviceInventoryState);
|
||||||
|
// Validate stored versions before startup. SQLite corruption never falls back to JSON.
|
||||||
|
state.read();
|
||||||
|
devices.read();
|
||||||
|
return { db, state, devices, imported, close: () => db.close() };
|
||||||
|
} catch (error) {
|
||||||
|
db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -639,10 +639,17 @@ function defaultClientFactory(port: number): NativeTrafficClient {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(milliseconds: number) {
|
function delay(milliseconds: number, signal: AbortSignal) {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
const timer = setTimeout(resolve, milliseconds);
|
const finish = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener('abort', finish);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timer = setTimeout(finish, milliseconds);
|
||||||
timer.unref();
|
timer.unref();
|
||||||
|
signal.addEventListener('abort', finish, { once: true });
|
||||||
|
if (signal.aborted) finish();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +667,7 @@ export function createLiveTrafficService({
|
|||||||
const ledger = createLiveTrafficLedger({ enabled, gateway, resolveOrigin });
|
const ledger = createLiveTrafficLedger({ enabled, gateway, resolveOrigin });
|
||||||
if (!enabled && unavailableError) ledger.markUnavailable(unavailableError);
|
if (!enabled && unavailableError) ledger.markUnavailable(unavailableError);
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
const stopController = new AbortController();
|
||||||
let controller: AbortController | null = null;
|
let controller: AbortController | null = null;
|
||||||
let running: Promise<void> | null = null;
|
let running: Promise<void> | null = null;
|
||||||
let failedProjection: NativeTrafficProjectionBatch | null = null;
|
let failedProjection: NativeTrafficProjectionBatch | null = null;
|
||||||
@@ -742,7 +750,7 @@ export function createLiveTrafficService({
|
|||||||
while (!stopped) {
|
while (!stopped) {
|
||||||
if (!isRuntimeRunning()) {
|
if (!isRuntimeRunning()) {
|
||||||
ledger.markStopped();
|
ledger.markStopped();
|
||||||
await delay(RETRY_MS);
|
await delay(RETRY_MS, stopController.signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
controller = new AbortController();
|
controller = new AbortController();
|
||||||
@@ -756,7 +764,7 @@ export function createLiveTrafficService({
|
|||||||
} finally {
|
} finally {
|
||||||
controller = null;
|
controller = null;
|
||||||
}
|
}
|
||||||
if (!stopped) await delay(RETRY_MS);
|
if (!stopped) await delay(RETRY_MS, stopController.signal);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -767,6 +775,7 @@ export function createLiveTrafficService({
|
|||||||
},
|
},
|
||||||
async stop() {
|
async stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
stopController.abort();
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
await running;
|
await running;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
export function openSqlite(filePath: string) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
const db = new DatabaseSync(filePath);
|
||||||
|
try {
|
||||||
|
fs.chmodSync(filePath, 0o600);
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON;');
|
||||||
|
return db;
|
||||||
|
} catch (error) {
|
||||||
|
db.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transaction<T>(db: DatabaseSync, change: () => T): T {
|
||||||
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
const result = change();
|
||||||
|
if (result && typeof result === 'object' && 'then' in result) {
|
||||||
|
throw new TypeError('SQLite transaction must be synchronous');
|
||||||
|
}
|
||||||
|
db.exec('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type NormalizedServer,
|
type NormalizedServer,
|
||||||
} from '../../shared/serverIdentity.js';
|
} from '../../shared/serverIdentity.js';
|
||||||
|
|
||||||
export const STATE_SCHEMA_VERSION = 8;
|
export const STATE_SCHEMA_VERSION = 10;
|
||||||
|
|
||||||
export interface AtomicWriteOptions {
|
export interface AtomicWriteOptions {
|
||||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { Worker } from 'node:worker_threads';
|
||||||
|
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||||
|
import {
|
||||||
|
assertTrafficHistorySnapshot,
|
||||||
|
emptyTrafficHistory,
|
||||||
|
type TrafficHistoryQuery,
|
||||||
|
} from '../../shared/trafficHistory.js';
|
||||||
|
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||||
|
import type { HistoryWorkerRequest } from './trafficHistoryWorker.js';
|
||||||
|
|
||||||
|
type Request = HistoryWorkerRequest extends infer R ? R extends { id: number } ? Omit<R, 'id'> : never : never;
|
||||||
|
const MAX_QUEUED_CONNECTIONS = 16_384;
|
||||||
|
interface Job {
|
||||||
|
id: number;
|
||||||
|
message: Request;
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTrafficHistoryService({ filePath, source }: {
|
||||||
|
filePath: string;
|
||||||
|
source: () => LiveTrafficSourceState;
|
||||||
|
}) {
|
||||||
|
let worker: Worker | null = null;
|
||||||
|
let ready = false;
|
||||||
|
let terminating: Promise<void> | null = null;
|
||||||
|
let active: Job | null = null;
|
||||||
|
const jobs: Job[] = [];
|
||||||
|
let watchdog: NodeJS.Timeout | undefined;
|
||||||
|
let readDeadline: NodeJS.Timeout | undefined;
|
||||||
|
let readers = 0;
|
||||||
|
let sequence = 0;
|
||||||
|
let pending: NativeTrafficProjectionBatch[] = [];
|
||||||
|
let queued = 0;
|
||||||
|
let missedSince: number | null = null;
|
||||||
|
let storageError = false;
|
||||||
|
let stopping = false;
|
||||||
|
let closed = false;
|
||||||
|
let flushing: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function clearDeadlines() {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
clearTimeout(readDeadline);
|
||||||
|
}
|
||||||
|
function failed(current: Worker) {
|
||||||
|
if (worker !== current) return;
|
||||||
|
const startupFailed = !ready;
|
||||||
|
worker = null;
|
||||||
|
ready = false;
|
||||||
|
clearDeadlines();
|
||||||
|
active?.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
active = null;
|
||||||
|
if (startupFailed) {
|
||||||
|
for (const job of jobs.splice(0)) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
}
|
||||||
|
// Native SQLite may still be finishing a call. Never start a second writer until it exits.
|
||||||
|
terminating = current.terminate().then(() => {}, () => {}).then(() => {
|
||||||
|
terminating = null;
|
||||||
|
dispatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ensureWorker() {
|
||||||
|
if (worker) return worker;
|
||||||
|
const current = new Worker(new URL('./trafficHistoryWorker.js', import.meta.url), { workerData: { filePath } });
|
||||||
|
worker = current;
|
||||||
|
watchdog = setTimeout(() => failed(current), 60_000);
|
||||||
|
current.on('message', (message: { ready?: boolean; id?: number; result?: unknown; error?: string }) => {
|
||||||
|
if (worker !== current) return;
|
||||||
|
if (message.ready) {
|
||||||
|
clearDeadlines();
|
||||||
|
ready = true;
|
||||||
|
dispatch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!active || active.id !== message.id) return;
|
||||||
|
const job = active;
|
||||||
|
active = null;
|
||||||
|
clearDeadlines();
|
||||||
|
if (message.error) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
else job.resolve(message.result);
|
||||||
|
dispatch();
|
||||||
|
});
|
||||||
|
current.on('error', () => failed(current));
|
||||||
|
current.on('exit', () => failed(current));
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
function dispatch() {
|
||||||
|
if (active || terminating || !jobs.length) return;
|
||||||
|
let current: Worker;
|
||||||
|
try { current = ensureWorker(); }
|
||||||
|
catch {
|
||||||
|
for (const job of jobs.splice(0)) job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ready) return;
|
||||||
|
const job = jobs.shift()!;
|
||||||
|
active = job;
|
||||||
|
if (job.message.kind === 'query') {
|
||||||
|
// A slow read only expires its caller. Keep the slot until SQL actually finishes.
|
||||||
|
readDeadline = setTimeout(() => job.reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE')), 5_000);
|
||||||
|
}
|
||||||
|
watchdog = setTimeout(() => failed(current), 60_000);
|
||||||
|
try { current.postMessage({ ...job.message, id: job.id }); }
|
||||||
|
catch { failed(current); }
|
||||||
|
}
|
||||||
|
function request(message: Request): Promise<unknown> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const job = { id: ++sequence, message, resolve, reject };
|
||||||
|
if (message.kind === 'ingest') jobs.unshift(job);
|
||||||
|
else jobs.push(job);
|
||||||
|
dispatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function enqueue(batch: NativeTrafficProjectionBatch) {
|
||||||
|
if (stopping) return;
|
||||||
|
if (queued + batch.connections.length > MAX_QUEUED_CONNECTIONS || pending.length >= 120) {
|
||||||
|
missedSince ??= Date.parse(batch.observedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.push(batch);
|
||||||
|
queued += batch.connections.length;
|
||||||
|
}
|
||||||
|
function flush(): Promise<void> {
|
||||||
|
if (closed) return Promise.resolve();
|
||||||
|
if (flushing) return flushing;
|
||||||
|
const batches = pending;
|
||||||
|
const missed = missedSince;
|
||||||
|
pending = [];
|
||||||
|
queued = 0;
|
||||||
|
missedSince = null;
|
||||||
|
flushing = request({ kind: 'ingest', batches, source: source(), missedSince: missed })
|
||||||
|
.then(() => { storageError = false; })
|
||||||
|
.catch(() => {
|
||||||
|
storageError = true;
|
||||||
|
missedSince = Math.min(missedSince ?? Infinity, missed ?? Infinity,
|
||||||
|
batches.length ? Date.parse(batches[0].observedAt) : Date.now());
|
||||||
|
// Recoverable cumulative counters will reconcile on the next batch. Lost closed flows
|
||||||
|
// stay an explicit gap; history must never backpressure the native/metrics collector.
|
||||||
|
})
|
||||||
|
.finally(() => { flushing = null; });
|
||||||
|
return flushing;
|
||||||
|
}
|
||||||
|
const timer = setInterval(() => { if (!stopping) void flush(); }, 1_000);
|
||||||
|
timer.unref();
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue,
|
||||||
|
flush,
|
||||||
|
async query(query: TrafficHistoryQuery) {
|
||||||
|
let admitted = false;
|
||||||
|
try {
|
||||||
|
if (stopping || readers >= 32) throw new Error('TRAFFIC_HISTORY_BUSY');
|
||||||
|
readers++;
|
||||||
|
admitted = true;
|
||||||
|
await flush();
|
||||||
|
if (stopping) throw new Error('TRAFFIC_HISTORY_UNAVAILABLE');
|
||||||
|
const result = assertTrafficHistorySnapshot(await request({ kind: 'query', query }));
|
||||||
|
result.source = source();
|
||||||
|
if (missedSince !== null || storageError) result.coverage.partial = true;
|
||||||
|
if (storageError) result.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
const result = emptyTrafficHistory(query, source());
|
||||||
|
result.storage = { status: 'error', errorCode: 'TRAFFIC_HISTORY_UNAVAILABLE' };
|
||||||
|
result.coverage.partial = true;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
if (admitted) readers--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
for (let index = jobs.length - 1; index >= 0; index--) {
|
||||||
|
if (jobs[index].message.kind === 'query') {
|
||||||
|
jobs.splice(index, 1)[0].reject(new Error('TRAFFIC_HISTORY_UNAVAILABLE'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await flushing;
|
||||||
|
await flush();
|
||||||
|
if (worker) {
|
||||||
|
try { await request({ kind: 'close' }); } catch { /* shutdown remains bounded */ }
|
||||||
|
const current = worker;
|
||||||
|
worker = null;
|
||||||
|
ready = false;
|
||||||
|
clearDeadlines();
|
||||||
|
await current?.terminate();
|
||||||
|
}
|
||||||
|
await terminating;
|
||||||
|
closed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import net from 'node:net';
|
||||||
|
import { domainToASCII } from 'node:url';
|
||||||
|
import { getDomain } from 'tldts';
|
||||||
|
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||||
|
import { emptyTrafficHistory, type TrafficHistoryQuery, type TrafficHistorySnapshot } from '../../shared/trafficHistory.js';
|
||||||
|
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||||
|
import { classifyDomain } from './domainTrafficService.js';
|
||||||
|
import { openSqlite, transaction } from './sqlite.js';
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
const MAX_INTEGER = (1n << 63n) - 1n;
|
||||||
|
|
||||||
|
function hostname(value: string | null) {
|
||||||
|
const name = domainToASCII((value || '').trim().replace(/\.$/, '')).toLowerCase();
|
||||||
|
return name.length <= 253 && !net.isIP(name)
|
||||||
|
&& name.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(part)) ? name : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openTrafficHistoryStore(filePath: string, now = Date.now) {
|
||||||
|
const db = openSqlite(filePath);
|
||||||
|
try {
|
||||||
|
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version);
|
||||||
|
if (version !== 0 && version !== 1) throw new Error(`Unsupported traffic database version: ${version}`);
|
||||||
|
transaction(db, () => db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS dimensions (
|
||||||
|
id INTEGER PRIMARY KEY, identity TEXT NOT NULL UNIQUE,
|
||||||
|
origin_id TEXT NOT NULL, origin_label TEXT NOT NULL, source_ip TEXT NOT NULL,
|
||||||
|
inbound TEXT NOT NULL, service TEXT NOT NULL, domain TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL, ip TEXT NOT NULL, route TEXT NOT NULL, outbound TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE TABLE IF NOT EXISTS buckets (
|
||||||
|
at INTEGER NOT NULL, resolution INTEGER NOT NULL, dimension_id INTEGER NOT NULL REFERENCES dimensions(id),
|
||||||
|
upload INTEGER NOT NULL CHECK(upload >= 0), download INTEGER NOT NULL CHECK(download >= 0),
|
||||||
|
PRIMARY KEY(at, resolution, dimension_id)
|
||||||
|
) STRICT, WITHOUT ROWID;
|
||||||
|
CREATE INDEX IF NOT EXISTS buckets_dimension_time ON buckets(dimension_id, at, upload, download);
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||||
|
identity TEXT PRIMARY KEY, upload INTEGER NOT NULL, download INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL, closed INTEGER NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
CREATE INDEX IF NOT EXISTS checkpoint_age ON checkpoints(last_seen);
|
||||||
|
CREATE INDEX IF NOT EXISTS checkpoint_active ON checkpoints(json_extract(identity, '$[1]')) WHERE closed = 0;
|
||||||
|
CREATE TABLE IF NOT EXISTS gaps (at INTEGER PRIMARY KEY, until_at INTEGER NOT NULL) STRICT;
|
||||||
|
PRAGMA user_version = 1;
|
||||||
|
`));
|
||||||
|
} catch (error) { db.close(); throw error; }
|
||||||
|
db.exec(`CREATE TEMP TABLE period_totals (
|
||||||
|
period TEXT NOT NULL, dimension_id INTEGER NOT NULL, upload INTEGER NOT NULL, download INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(period, dimension_id)
|
||||||
|
) STRICT, WITHOUT ROWID`);
|
||||||
|
const periods = new Map<string, { from: number; to: number }>();
|
||||||
|
const dropPeriod = db.prepare('DELETE FROM period_totals WHERE period = ?');
|
||||||
|
function periodTotals(from: number, to: number) {
|
||||||
|
const key = `${from}:${to}`;
|
||||||
|
if (!periods.has(key)) {
|
||||||
|
// Four visible ranges; old frozen pages are recomputed after eviction.
|
||||||
|
if (periods.size === 4) {
|
||||||
|
const oldest = periods.keys().next().value!;
|
||||||
|
dropPeriod.run(oldest);
|
||||||
|
periods.delete(oldest);
|
||||||
|
}
|
||||||
|
db.prepare(`INSERT INTO period_totals
|
||||||
|
SELECT ?, d.id, SUM(b.upload), SUM(b.download)
|
||||||
|
FROM dimensions d CROSS JOIN buckets b INDEXED BY buckets_dimension_time
|
||||||
|
ON b.dimension_id = d.id WHERE b.at >= ? AND b.at < ? GROUP BY d.id`).run(key, from, to);
|
||||||
|
periods.set(key, { from, to });
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
// SQLite's built-in lower() handles ASCII only; service labels also use Cyrillic.
|
||||||
|
db.function('lower_unicode', { deterministic: true }, (value) => String(value).toLowerCase());
|
||||||
|
const meta = (key: string) => db.prepare('SELECT value FROM meta WHERE key = ?').get(key)?.value as string | undefined;
|
||||||
|
const setMeta = db.prepare('INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||||
|
const checkpoint = db.prepare('SELECT upload, download, last_seen FROM checkpoints WHERE identity = ?');
|
||||||
|
checkpoint.setReadBigInts(true);
|
||||||
|
const saveCheckpoint = db.prepare(`INSERT INTO checkpoints VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(identity) DO UPDATE SET upload = excluded.upload, download = excluded.download,
|
||||||
|
last_seen = excluded.last_seen, closed = excluded.closed`);
|
||||||
|
const saveDimension = db.prepare(`INSERT INTO dimensions(identity, origin_id, origin_label, source_ip, inbound,
|
||||||
|
service, domain, hostname, ip, route, outbound) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(identity) DO UPDATE SET origin_label = excluded.origin_label RETURNING id`);
|
||||||
|
const saveBucket = db.prepare(`INSERT INTO buckets VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(at, resolution, dimension_id) DO UPDATE SET
|
||||||
|
upload = upload + excluded.upload, download = download + excluded.download`);
|
||||||
|
const saveGap = db.prepare('INSERT INTO gaps VALUES (?, ?) ON CONFLICT(at) DO UPDATE SET until_at = MAX(until_at, excluded.until_at)');
|
||||||
|
let lastMaintenance = 0;
|
||||||
|
|
||||||
|
function gap(from: number, to: number) {
|
||||||
|
saveGap.run(Math.floor(from / MINUTE) * MINUTE, Math.max(from, to));
|
||||||
|
}
|
||||||
|
// A reopened collector cannot prove that short-lived flows were observed while it was down.
|
||||||
|
const previousObservation = Number(meta('observed') || 0);
|
||||||
|
if (previousObservation) transaction(db, () => gap(previousObservation, now()));
|
||||||
|
function maintain(timestamp = now()) {
|
||||||
|
const hourCutoff = Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR;
|
||||||
|
const cutoff = Math.floor((timestamp - 90 * DAY) / HOUR) * HOUR;
|
||||||
|
transaction(db, () => {
|
||||||
|
db.prepare('DELETE FROM buckets WHERE at < ?').run(cutoff);
|
||||||
|
db.prepare(`INSERT INTO buckets(at, resolution, dimension_id, upload, download)
|
||||||
|
SELECT (at / ?) * ?, ?, dimension_id, SUM(upload), SUM(download)
|
||||||
|
FROM buckets WHERE resolution = ? AND at < ? GROUP BY (at / ?), dimension_id
|
||||||
|
ON CONFLICT(at, resolution, dimension_id) DO UPDATE SET
|
||||||
|
upload = upload + excluded.upload, download = download + excluded.download
|
||||||
|
`).run(HOUR, HOUR, HOUR, MINUTE, hourCutoff, HOUR);
|
||||||
|
db.prepare('DELETE FROM buckets WHERE resolution = ? AND at < ?').run(MINUTE, hourCutoff);
|
||||||
|
// Idle active flows can outlive retention; their cumulative baseline is still required.
|
||||||
|
// ponytail: closed tombstones can grow for 90 days; tighter pruning needs an upstream replay watermark.
|
||||||
|
db.prepare('DELETE FROM checkpoints WHERE closed = 1 AND last_seen < ?').run(cutoff);
|
||||||
|
db.prepare('DELETE FROM gaps WHERE until_at < ?').run(cutoff);
|
||||||
|
db.exec('DELETE FROM dimensions WHERE id NOT IN (SELECT dimension_id FROM buckets)');
|
||||||
|
db.exec('DELETE FROM period_totals');
|
||||||
|
});
|
||||||
|
periods.clear();
|
||||||
|
lastMaintenance = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingest(batches: NativeTrafficProjectionBatch[], source: LiveTrafficSourceState, missedSince: number | null = null) {
|
||||||
|
const timestamp = now();
|
||||||
|
const changedPeriods = new Set<string>();
|
||||||
|
transaction(db, () => {
|
||||||
|
const collectionStart = Number(meta('started') || batches[0] && Date.parse(batches[0].observedAt) || timestamp);
|
||||||
|
if (!meta('started') && batches.length) setMeta.run('started', String(collectionStart));
|
||||||
|
let lastAt = Number(meta('observed') || 0);
|
||||||
|
if (missedSince !== null) gap(missedSince, timestamp);
|
||||||
|
for (const batch of batches) {
|
||||||
|
const at = Date.parse(batch.observedAt);
|
||||||
|
if (!Number.isFinite(at) || !batch.epoch) continue;
|
||||||
|
if (meta('epoch') !== batch.epoch) {
|
||||||
|
// Projection batches are FIFO. A new sing-box epoch cannot replay old lifecycles.
|
||||||
|
db.exec('DELETE FROM checkpoints');
|
||||||
|
setMeta.run('epoch', batch.epoch);
|
||||||
|
}
|
||||||
|
if (batch.reset) db.prepare('UPDATE checkpoints SET closed = 1, last_seen = MAX(last_seen, ?) WHERE closed = 0').run(at);
|
||||||
|
if (lastAt && (batch.reset || at - lastAt > 5_000)) gap(lastAt, at);
|
||||||
|
for (const connection of batch.connections) {
|
||||||
|
const started = Date.parse(connection.startedAt);
|
||||||
|
if (!Number.isFinite(started)) continue;
|
||||||
|
if (connection.closedAt && Date.parse(connection.closedAt) < timestamp - 90 * DAY) continue;
|
||||||
|
const identity = JSON.stringify([batch.epoch, connection.id, connection.startedAt]);
|
||||||
|
const old = checkpoint.get(identity);
|
||||||
|
const upload = BigInt(connection.traffic.uploadBytes);
|
||||||
|
const download = BigInt(connection.traffic.downloadBytes);
|
||||||
|
if (upload < 0n || download < 0n || upload > MAX_INTEGER || download > MAX_INTEGER) {
|
||||||
|
gap(lastAt || at, at);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (old && at < Number(old.last_seen)) continue;
|
||||||
|
// Initial snapshots baseline pre-existing flows; they are not historical observations.
|
||||||
|
const baseline = !old && started < collectionStart;
|
||||||
|
let up = baseline ? 0n : upload - BigInt(String(old?.upload ?? 0));
|
||||||
|
let down = baseline ? 0n : download - BigInt(String(old?.download ?? 0));
|
||||||
|
if (up < 0n || down < 0n) {
|
||||||
|
gap(old ? Number(old.last_seen) : at, at);
|
||||||
|
up = up < 0n ? 0n : up;
|
||||||
|
down = down < 0n ? 0n : down;
|
||||||
|
}
|
||||||
|
saveCheckpoint.run(identity, old && upload < BigInt(String(old.upload)) ? old.upload : upload,
|
||||||
|
old && download < BigInt(String(old.download)) ? old.download : download,
|
||||||
|
at, connection.closedAt === null ? 0 : 1);
|
||||||
|
if (up === 0n && down === 0n) continue;
|
||||||
|
const host = hostname(connection.destination.domain);
|
||||||
|
const domain = host ? getDomain(host, { allowPrivateDomains: true }) || host : '';
|
||||||
|
const classification = classifyDomain(host);
|
||||||
|
const service = ['yandex.ru', 'yandex.com', 'yandex.net', 'yastatic.net'].includes(domain) ? 'Яндекс'
|
||||||
|
: classification && classification.service !== classification.domain ? classification.service : domain;
|
||||||
|
const origin = connection.origin.kind === 'this-mac' ? 'this-mac'
|
||||||
|
: connection.origin.id || `unknown:${connection.source.ip}`;
|
||||||
|
const ip = net.isIP(connection.destination.ip || '') ? connection.destination.ip! : '';
|
||||||
|
const dims = [origin, connection.source.ip, connection.inbound.tag, service, domain,
|
||||||
|
host, ip, connection.route.kind, connection.route.outbound || ''];
|
||||||
|
const dimension = saveDimension.get(JSON.stringify(dims), origin, connection.origin.label,
|
||||||
|
...dims.slice(1));
|
||||||
|
const resolution = at < Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR ? HOUR : MINUTE;
|
||||||
|
const bucketAt = Math.floor(at / resolution) * resolution;
|
||||||
|
saveBucket.run(bucketAt, resolution, dimension!.id, up, down);
|
||||||
|
for (const [key, period] of periods) {
|
||||||
|
if (bucketAt >= period.from && bucketAt < period.to) changedPeriods.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batch.closedIds.length) {
|
||||||
|
const closed = db.prepare(`UPDATE checkpoints SET closed = 1, last_seen = MAX(last_seen, ?)
|
||||||
|
WHERE closed = 0 AND json_extract(identity, '$[1]') IN (SELECT value FROM json_each(?))`)
|
||||||
|
.run(at, JSON.stringify(batch.closedIds));
|
||||||
|
if (closed.changes) gap(lastAt || at, at); // Terminal identity without final byte totals.
|
||||||
|
}
|
||||||
|
lastAt = Math.max(lastAt, at);
|
||||||
|
}
|
||||||
|
if (lastAt) setMeta.run('observed', String(lastAt));
|
||||||
|
if (source === 'degraded' || source === 'stale' || source === 'incompatible'
|
||||||
|
|| (source === 'connecting' && lastAt)) gap(lastAt || timestamp, timestamp);
|
||||||
|
setMeta.run('source', source);
|
||||||
|
for (const key of changedPeriods) dropPeriod.run(key);
|
||||||
|
});
|
||||||
|
for (const key of changedPeriods) {
|
||||||
|
periods.delete(key);
|
||||||
|
}
|
||||||
|
if (timestamp - lastMaintenance >= HOUR) maintain(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(input: TrafficHistoryQuery): TrafficHistorySnapshot {
|
||||||
|
const timestamp = now();
|
||||||
|
if (timestamp - lastMaintenance >= HOUR) maintain(timestamp);
|
||||||
|
const result = emptyTrafficHistory(input, (meta('source') || 'connecting') as LiveTrafficSourceState, timestamp);
|
||||||
|
const requestedTo = Date.parse(result.period.to);
|
||||||
|
const retentionStart = Math.floor((timestamp - 90 * DAY) / HOUR) * HOUR;
|
||||||
|
const from = Math.max(Date.parse(result.period.from), retentionStart);
|
||||||
|
const minuteFrom = Math.floor((timestamp - 7 * DAY) / HOUR) * HOUR;
|
||||||
|
const terminalResolution = requestedTo < minuteFrom ? HOUR : MINUTE;
|
||||||
|
const to = Math.floor(requestedTo / terminalResolution) * terminalResolution;
|
||||||
|
result.period.to = new Date(to).toISOString();
|
||||||
|
result.query.until = to;
|
||||||
|
// Bounds describe complete stored buckets; old data is explicitly hourly, not minute-precise.
|
||||||
|
const effectiveFrom = Math.min(to, Math.floor(from / (from < minuteFrom ? HOUR : MINUTE)) * (from < minuteFrom ? HOUR : MINUTE));
|
||||||
|
result.period.from = new Date(effectiveFrom).toISOString();
|
||||||
|
result.period.minuteFrom = new Date(minuteFrom).toISOString();
|
||||||
|
result.period.availableFrom = meta('started') ? new Date(Math.max(Number(meta('started')), retentionStart)).toISOString() : null;
|
||||||
|
const lastAt = Number(meta('observed') || 0);
|
||||||
|
result.coverage.lastObservedAt = lastAt ? new Date(lastAt).toISOString() : null;
|
||||||
|
result.coverage.gapCount = Number(db.prepare('SELECT COUNT(*) AS count FROM gaps WHERE until_at >= ? AND at < ?').get(effectiveFrom, to)?.count);
|
||||||
|
result.coverage.partial = result.coverage.gapCount > 0 || ['stale', 'degraded', 'incompatible'].includes(result.source)
|
||||||
|
|| (lastAt > 0 && result.source === 'live' && timestamp - lastAt > 5_000);
|
||||||
|
const period = periodTotals(effectiveFrom, to);
|
||||||
|
const where = ['b.period = ?'];
|
||||||
|
const args: Array<string | number> = [period];
|
||||||
|
for (const [column, value] of [
|
||||||
|
['origin_id', input.originId], ['service', input.service], ['domain', input.domain], ['hostname', input.hostname],
|
||||||
|
['route', input.route === 'all' ? '' : input.route],
|
||||||
|
]) if (value) { where.push(`d.${column} = ?`); args.push(value); }
|
||||||
|
// Empty domain is a real IP-only group. Parent filters need an explicit level, not truthiness alone.
|
||||||
|
if (input.level !== 'service') { where.push('d.service = ?'); args.push(input.service); }
|
||||||
|
if (input.level === 'hostname' || input.level === 'ip') { where.push('d.domain = ?'); args.push(input.domain); }
|
||||||
|
if (input.level === 'ip') { where.push('d.hostname = ?'); args.push(input.hostname); }
|
||||||
|
if (input.search) {
|
||||||
|
where.push("instr(lower_unicode(d.hostname || ' ' || d.ip || ' ' || d.service), ?) > 0"); args.push(input.search.toLowerCase());
|
||||||
|
}
|
||||||
|
const joined = `FROM period_totals b JOIN dimensions d ON d.id = b.dimension_id WHERE ${where.join(' AND ')}`;
|
||||||
|
const totals = db.prepare(`SELECT COALESCE(SUM(b.upload), 0) AS upload, COALESCE(SUM(b.download), 0) AS download ${joined}`);
|
||||||
|
totals.setReadBigInts(true);
|
||||||
|
const sum = totals.get(...args)!;
|
||||||
|
result.totals = { uploadBytes: String(sum.upload), downloadBytes: String(sum.download) };
|
||||||
|
const rows = db.prepare(`SELECT d.${input.level} AS key, SUM(b.upload) AS upload, SUM(b.download) AS download,
|
||||||
|
CASE WHEN COUNT(DISTINCT d.route) = 1 THEN MIN(d.route) ELSE 'mixed' END AS route
|
||||||
|
${joined} GROUP BY d.${input.level} ORDER BY SUM(b.upload) + SUM(b.download) DESC, key LIMIT 101 OFFSET ?`);
|
||||||
|
rows.setReadBigInts(true);
|
||||||
|
const page = rows.all(...args, input.offset);
|
||||||
|
result.rows = page.slice(0, 100).map((row) => ({
|
||||||
|
key: String(row.key), label: String(row.key) || (input.level === 'ip' ? 'IP неизвестен' : 'Без домена'),
|
||||||
|
uploadBytes: String(row.upload), downloadBytes: String(row.download), route: String(row.route) as 'mixed',
|
||||||
|
}));
|
||||||
|
result.nextOffset = page.length > 100 ? input.offset + 100 : null;
|
||||||
|
const origins = db.prepare(`SELECT d.origin_id AS id, MAX(d.origin_label) AS label FROM dimensions d
|
||||||
|
JOIN period_totals b ON b.dimension_id = d.id WHERE b.period = ?
|
||||||
|
GROUP BY d.origin_id ORDER BY id LIMIT 257`).all(period);
|
||||||
|
result.origins = origins.slice(0, 256).map((row) => ({ id: String(row.id), label: String(row.label) }));
|
||||||
|
result.originsTruncated = origins.length > 256;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return { ingest, query, maintain, close: () => db.close() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { parentPort, workerData } from 'node:worker_threads';
|
||||||
|
import type { LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||||
|
import type { TrafficHistoryQuery } from '../../shared/trafficHistory.js';
|
||||||
|
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||||
|
import { openTrafficHistoryStore } from './trafficHistoryStore.js';
|
||||||
|
|
||||||
|
export type HistoryWorkerRequest =
|
||||||
|
| { id: number; kind: 'ingest'; batches: NativeTrafficProjectionBatch[]; source: LiveTrafficSourceState; missedSince: number | null }
|
||||||
|
| { id: number; kind: 'query'; query: TrafficHistoryQuery }
|
||||||
|
| { id: number; kind: 'close' };
|
||||||
|
|
||||||
|
const store = openTrafficHistoryStore(workerData.filePath);
|
||||||
|
parentPort!.postMessage({ ready: true });
|
||||||
|
parentPort!.on('message', (message: HistoryWorkerRequest) => {
|
||||||
|
try {
|
||||||
|
let result: unknown = null;
|
||||||
|
if (message.kind === 'ingest') store.ingest(message.batches, message.source, message.missedSince);
|
||||||
|
else if (message.kind === 'query') result = store.query(message.query);
|
||||||
|
else store.close();
|
||||||
|
parentPort!.postMessage({ id: message.id, result });
|
||||||
|
if (message.kind === 'close') parentPort!.close();
|
||||||
|
} catch {
|
||||||
|
// Do not expose filesystem paths, SQL or native metadata through the public error.
|
||||||
|
parentPort!.postMessage({ id: message.id, error: 'TRAFFIC_HISTORY_UNAVAILABLE' });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -24,6 +24,40 @@ export const CONNECTIVITY_SITES = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
||||||
|
export const MAX_CUSTOM_DNS_RESOLVERS = 5;
|
||||||
|
export const MAX_CUSTOM_DNS_DOMAINS = 5;
|
||||||
|
|
||||||
|
export const DNS_DIAGNOSTIC_DOMAINS = Object.freeze([
|
||||||
|
{ id: 'youtube', label: 'YouTube', hostname: 'www.youtube.com', builtIn: true },
|
||||||
|
{ id: 'chatgpt', label: 'ChatGPT', hostname: 'chatgpt.com', builtIn: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DNS_DIAGNOSTIC_RESOLVERS = Object.freeze([
|
||||||
|
{ id: 'google-dns', label: 'Google DNS', kind: 'dns', endpoint: '8.8.8.8' },
|
||||||
|
{
|
||||||
|
id: 'google-doh',
|
||||||
|
label: 'Google DoH',
|
||||||
|
kind: 'doh',
|
||||||
|
endpoint: 'https://dns.google/dns-query',
|
||||||
|
bootstrap: '8.8.8.8',
|
||||||
|
},
|
||||||
|
{ id: 'cloudflare-dns', label: 'Cloudflare DNS', kind: 'dns', endpoint: '1.1.1.1' },
|
||||||
|
{
|
||||||
|
id: 'cloudflare-doh',
|
||||||
|
label: 'Cloudflare DoH',
|
||||||
|
kind: 'doh',
|
||||||
|
endpoint: 'https://cloudflare-dns.com/dns-query',
|
||||||
|
bootstrap: '1.1.1.1',
|
||||||
|
},
|
||||||
|
{ id: 'yandex-dns', label: 'Яндекс DNS', kind: 'dns', endpoint: '77.88.8.8' },
|
||||||
|
{
|
||||||
|
id: 'yandex-doh',
|
||||||
|
label: 'Яндекс DoH',
|
||||||
|
kind: 'doh',
|
||||||
|
endpoint: 'https://common.dot.dns.yandex.net/dns-query',
|
||||||
|
bootstrap: '77.88.8.8',
|
||||||
|
},
|
||||||
|
] satisfies DnsResolverDefinition[]);
|
||||||
|
|
||||||
export interface DiagnosticService {
|
export interface DiagnosticService {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,10 +65,43 @@ export interface DiagnosticService {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DnsResolverDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'dns' | 'doh';
|
||||||
|
endpoint: string;
|
||||||
|
bootstrap?: string;
|
||||||
|
system?: boolean;
|
||||||
|
custom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DnsDomainDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
builtIn?: boolean;
|
||||||
|
custom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomDnsResolver {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'dns' | 'doh';
|
||||||
|
endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomDnsDomain {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiagnosticSettings {
|
export interface DiagnosticSettings {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
customServices: DiagnosticService[];
|
customServices: DiagnosticService[];
|
||||||
hiddenServiceIds: string[];
|
hiddenServiceIds: string[];
|
||||||
|
customDnsResolvers: CustomDnsResolver[];
|
||||||
|
customDnsDomains: CustomDnsDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
@@ -62,6 +129,65 @@ function diagnosticService(value: unknown): DiagnosticService | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ipLiteral(value: string) {
|
||||||
|
if (!value || /[\s/?#@\[\]]/.test(value)) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(`http://${value.includes(':') ? `[${value}]` : value}/`);
|
||||||
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
return hostname === value.toLowerCase() && (value.includes(':') || /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dohEndpoint(value: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (
|
||||||
|
parsed.protocol !== 'https:'
|
||||||
|
|| parsed.username
|
||||||
|
|| parsed.password
|
||||||
|
|| parsed.search
|
||||||
|
|| parsed.hash
|
||||||
|
|| (parsed.port && parsed.port !== '443')
|
||||||
|
) return null;
|
||||||
|
return parsed.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customDnsResolver(value: unknown): CustomDnsResolver | null {
|
||||||
|
const candidate = record(value);
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||||
|
const kind = candidate.kind === 'dns' || candidate.kind === 'doh' ? candidate.kind : null;
|
||||||
|
const rawEndpoint = typeof candidate.endpoint === 'string' ? candidate.endpoint.trim() : '';
|
||||||
|
if (!/^custom-dns-[a-z0-9-]{1,72}$/i.test(id) || !label || label.length > 40 || !kind) return null;
|
||||||
|
const endpoint = kind === 'dns' ? (ipLiteral(rawEndpoint) ? rawEndpoint.toLowerCase() : null) : dohEndpoint(rawEndpoint);
|
||||||
|
return endpoint ? { id, label, kind, endpoint } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedHostname(value: unknown) {
|
||||||
|
const text = typeof value === 'string' ? value.trim().replace(/\.$/, '') : '';
|
||||||
|
if (!text || text.length > 253 || /[\s/?#@:]/.test(text)) return null;
|
||||||
|
try {
|
||||||
|
const hostname = new URL(`http://${text}/`).hostname.toLowerCase();
|
||||||
|
return hostname && hostname.length <= 253 ? hostname : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customDnsDomain(value: unknown): CustomDnsDomain | null {
|
||||||
|
const candidate = record(value);
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||||
|
const hostname = normalizedHostname(candidate.hostname);
|
||||||
|
if (!/^custom-domain-[a-z0-9-]{1,68}$/i.test(id) || !label || label.length > 40 || !hostname) return null;
|
||||||
|
return { id, label, hostname };
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeDiagnosticSettings(
|
export function normalizeDiagnosticSettings(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
{ strict = false }: { strict?: boolean } = {},
|
{ strict = false }: { strict?: boolean } = {},
|
||||||
@@ -78,17 +204,35 @@ export function normalizeDiagnosticSettings(
|
|||||||
const hiddenServiceIds = requestedHiddenIds
|
const hiddenServiceIds = requestedHiddenIds
|
||||||
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
|
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
|
||||||
.filter((id, index, ids) => ids.indexOf(id) === index);
|
.filter((id, index, ids) => ids.indexOf(id) === index);
|
||||||
|
const requestedDnsResolvers = Array.isArray(candidate.customDnsResolvers) ? candidate.customDnsResolvers : [];
|
||||||
|
const customDnsResolvers = requestedDnsResolvers
|
||||||
|
.map(customDnsResolver)
|
||||||
|
.filter((resolver): resolver is CustomDnsResolver => Boolean(resolver))
|
||||||
|
.filter((resolver, index, resolvers) => resolvers.findIndex(({ id }) => id === resolver.id) === index)
|
||||||
|
.slice(0, MAX_CUSTOM_DNS_RESOLVERS);
|
||||||
|
const requestedDnsDomains = Array.isArray(candidate.customDnsDomains) ? candidate.customDnsDomains : [];
|
||||||
|
const customDnsDomains = requestedDnsDomains
|
||||||
|
.map(customDnsDomain)
|
||||||
|
.filter((domain): domain is CustomDnsDomain => Boolean(domain))
|
||||||
|
.filter((domain, index, domains) => domains.findIndex(({ id }) => id === domain.id) === index)
|
||||||
|
.slice(0, MAX_CUSTOM_DNS_DOMAINS);
|
||||||
if (strict && (
|
if (strict && (
|
||||||
typeof candidate.configured !== 'boolean'
|
typeof candidate.configured !== 'boolean'
|
||||||
|| !Array.isArray(candidate.customServices)
|
|| !Array.isArray(candidate.customServices)
|
||||||
|| !Array.isArray(candidate.hiddenServiceIds)
|
|| !Array.isArray(candidate.hiddenServiceIds)
|
||||||
|| customServices.length !== requestedServices.length
|
|| customServices.length !== requestedServices.length
|
||||||
|| hiddenServiceIds.length !== requestedHiddenIds.length
|
|| hiddenServiceIds.length !== requestedHiddenIds.length
|
||||||
|
|| (candidate.customDnsResolvers !== undefined && !Array.isArray(candidate.customDnsResolvers))
|
||||||
|
|| (candidate.customDnsDomains !== undefined && !Array.isArray(candidate.customDnsDomains))
|
||||||
|
|| customDnsResolvers.length !== requestedDnsResolvers.length
|
||||||
|
|| customDnsDomains.length !== requestedDnsDomains.length
|
||||||
)) throw new TypeError('Invalid diagnostic settings');
|
)) throw new TypeError('Invalid diagnostic settings');
|
||||||
return {
|
return {
|
||||||
configured: candidate.configured === true,
|
configured: candidate.configured === true,
|
||||||
customServices,
|
customServices,
|
||||||
hiddenServiceIds,
|
hiddenServiceIds,
|
||||||
|
customDnsResolvers,
|
||||||
|
customDnsDomains,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
type FailoverRuntimeState,
|
type FailoverRuntimeState,
|
||||||
type FailoverSnapshot,
|
type FailoverSnapshot,
|
||||||
} from '../failover.js';
|
} from '../failover.js';
|
||||||
|
import {
|
||||||
|
normalizeTrafficSettings,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../trafficSettings.js';
|
||||||
|
|
||||||
export type HarborMode = 'client' | 'gateway';
|
export type HarborMode = 'client' | 'gateway';
|
||||||
export type ConnectionState = 'running' | 'stopped';
|
export type ConnectionState = 'running' | 'stopped';
|
||||||
@@ -93,6 +97,7 @@ export interface StateSnapshot {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
diagnostics: DiagnosticSettings;
|
diagnostics: DiagnosticSettings;
|
||||||
|
traffic: TrafficSettings;
|
||||||
failover: FailoverSnapshot;
|
failover: FailoverSnapshot;
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||||
@@ -133,6 +138,7 @@ export interface PersistedState extends Record<string, unknown> {
|
|||||||
connectionDesired?: ConnectionState;
|
connectionDesired?: ConnectionState;
|
||||||
gatewayAutoEnabled?: boolean;
|
gatewayAutoEnabled?: boolean;
|
||||||
diagnostics: DiagnosticSettings;
|
diagnostics: DiagnosticSettings;
|
||||||
|
traffic: TrafficSettings;
|
||||||
failoverPolicy: FailoverPolicy;
|
failoverPolicy: FailoverPolicy;
|
||||||
failoverRuntimeState: FailoverRuntimeState;
|
failoverRuntimeState: FailoverRuntimeState;
|
||||||
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
||||||
@@ -307,6 +313,7 @@ export function normalizeStoredState(value: unknown): StoredState {
|
|||||||
? state.routeRulesRevision
|
? state.routeRulesRevision
|
||||||
: 0,
|
: 0,
|
||||||
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
||||||
|
traffic: normalizeTrafficSettings(state.traffic),
|
||||||
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
||||||
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
||||||
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
||||||
@@ -410,6 +417,7 @@ export function createStateSnapshot({
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
diagnostics: stored.diagnostics,
|
diagnostics: stored.diagnostics,
|
||||||
|
traffic: stored.traffic,
|
||||||
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
||||||
@@ -449,12 +457,15 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
||||||
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
||||||
: rawCandidate;
|
: rawCandidate;
|
||||||
const candidateWithFailover = candidateWithDiagnostics && candidateWithDiagnostics.failover === undefined
|
const candidateWithTraffic = candidateWithDiagnostics && candidateWithDiagnostics.traffic === undefined
|
||||||
|
? { ...candidateWithDiagnostics, traffic: normalizeTrafficSettings(null) }
|
||||||
|
: candidateWithDiagnostics;
|
||||||
|
const candidateWithFailover = candidateWithTraffic && candidateWithTraffic.failover === undefined
|
||||||
? {
|
? {
|
||||||
...candidateWithDiagnostics,
|
...candidateWithTraffic,
|
||||||
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
|
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
|
||||||
}
|
}
|
||||||
: candidateWithDiagnostics;
|
: candidateWithTraffic;
|
||||||
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
||||||
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
||||||
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
||||||
@@ -512,6 +523,14 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const validTraffic = (traffic: TrafficSettings) => {
|
||||||
|
try {
|
||||||
|
normalizeTrafficSettings(traffic, { strict: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
const validFailover = (value: FailoverSnapshot) => {
|
const validFailover = (value: FailoverSnapshot) => {
|
||||||
const channel = (item: FailoverSnapshot['primary']) => (
|
const channel = (item: FailoverSnapshot['primary']) => (
|
||||||
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
|
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
|
||||||
@@ -573,6 +592,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
!nullableDate(candidate.connection.startedAt) ||
|
!nullableDate(candidate.connection.startedAt) ||
|
||||||
!nullableString(candidate.connection.lastError) ||
|
!nullableString(candidate.connection.lastError) ||
|
||||||
!validDiagnostics(candidate.diagnostics) ||
|
!validDiagnostics(candidate.diagnostics) ||
|
||||||
|
!validTraffic(candidate.traffic) ||
|
||||||
!candidate.failover ||
|
!candidate.failover ||
|
||||||
typeof candidate.failover.observationEpoch !== 'string' ||
|
typeof candidate.failover.observationEpoch !== 'string' ||
|
||||||
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { LiveTrafficSourceState } from './liveTraffic.js';
|
||||||
|
|
||||||
|
export const TRAFFIC_HISTORY_RANGES = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 } as const;
|
||||||
|
export type TrafficHistoryRange = keyof typeof TRAFFIC_HISTORY_RANGES;
|
||||||
|
export type TrafficHistoryLevel = 'service' | 'domain' | 'hostname' | 'ip';
|
||||||
|
export interface TrafficHistoryQuery {
|
||||||
|
range: TrafficHistoryRange;
|
||||||
|
level: TrafficHistoryLevel;
|
||||||
|
originId: string;
|
||||||
|
search: string;
|
||||||
|
route: 'all' | 'vpn' | 'direct' | 'other';
|
||||||
|
service: string;
|
||||||
|
domain: string;
|
||||||
|
hostname: string;
|
||||||
|
offset: number;
|
||||||
|
until: number | null;
|
||||||
|
}
|
||||||
|
export interface TrafficHistoryRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
uploadBytes: string;
|
||||||
|
downloadBytes: string;
|
||||||
|
route: 'vpn' | 'direct' | 'other' | 'mixed';
|
||||||
|
}
|
||||||
|
export interface TrafficHistorySnapshot {
|
||||||
|
apiVersion: 1;
|
||||||
|
generatedAt: string;
|
||||||
|
query: TrafficHistoryQuery;
|
||||||
|
period: { from: string; to: string; availableFrom: string | null; minuteFrom: string; retentionDays: 90 };
|
||||||
|
storage: { status: 'ready' | 'error'; errorCode: string | null };
|
||||||
|
source: LiveTrafficSourceState;
|
||||||
|
coverage: { partial: boolean; gapCount: number; lastObservedAt: string | null };
|
||||||
|
totals: { uploadBytes: string; downloadBytes: string };
|
||||||
|
rows: TrafficHistoryRow[];
|
||||||
|
nextOffset: number | null;
|
||||||
|
origins: Array<{ id: string; label: string }>;
|
||||||
|
originsTruncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrafficHistoryQuery(params: URLSearchParams): TrafficHistoryQuery {
|
||||||
|
const range = params.get('range') || '24h';
|
||||||
|
const level = params.get('level') || 'service';
|
||||||
|
const route = params.get('route') || 'all';
|
||||||
|
if (!Object.hasOwn(TRAFFIC_HISTORY_RANGES, range)
|
||||||
|
|| !['service', 'domain', 'hostname', 'ip'].includes(level)
|
||||||
|
|| !['all', 'vpn', 'direct', 'other'].includes(route)) throw new TypeError('Invalid history query');
|
||||||
|
const text = (key: string, max = 253) => {
|
||||||
|
const value = params.get(key) || '';
|
||||||
|
if (value.length > max || /[\x00-\x1f]/.test(value)) throw new TypeError('Invalid history filter');
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const offset = Number(params.get('offset') || 0);
|
||||||
|
const until = params.has('until') ? Number(params.get('until')) : null;
|
||||||
|
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|
||||||
|
|| (until !== null && (!Number.isSafeInteger(until) || until <= 0 || until > 8_640_000_000_000_000))) throw new TypeError('Invalid history page');
|
||||||
|
return {
|
||||||
|
range: range as TrafficHistoryRange, level: level as TrafficHistoryLevel,
|
||||||
|
route: route as TrafficHistoryQuery['route'], originId: text('originId', 128), search: text('search', 200).trim(),
|
||||||
|
service: text('service'), domain: text('domain'), hostname: text('hostname'), offset, until,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function historyQueryParams(query: TrafficHistoryQuery) {
|
||||||
|
return new URLSearchParams(Object.entries(query).filter(([, value]) => value !== null)
|
||||||
|
.map(([key, value]): [string, string] => [key, String(value)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyTrafficHistory(query: TrafficHistoryQuery, source: LiveTrafficSourceState = 'disabled', now = Date.now()): TrafficHistorySnapshot {
|
||||||
|
const to = Math.min(query.until ?? now, now);
|
||||||
|
return {
|
||||||
|
apiVersion: 1, generatedAt: new Date(now).toISOString(), query: { ...query, until: to },
|
||||||
|
period: { from: new Date(to - TRAFFIC_HISTORY_RANGES[query.range] * 86_400_000).toISOString(),
|
||||||
|
to: new Date(to).toISOString(), availableFrom: null,
|
||||||
|
minuteFrom: new Date(now - 7 * 86_400_000).toISOString(), retentionDays: 90 },
|
||||||
|
storage: { status: 'ready', errorCode: null }, source,
|
||||||
|
coverage: { partial: false, gapCount: 0, lastObservedAt: null },
|
||||||
|
totals: { uploadBytes: '0', downloadBytes: '0' }, rows: [], nextOffset: null, origins: [], originsTruncated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTrafficHistorySnapshot(value: unknown): TrafficHistorySnapshot {
|
||||||
|
if (!value || typeof value !== 'object') throw new TypeError('Invalid history snapshot');
|
||||||
|
const v = value as TrafficHistorySnapshot;
|
||||||
|
const iso = (s: unknown) => typeof s === 'string' && Number.isFinite(Date.parse(s));
|
||||||
|
const bytes = (s: unknown) => typeof s === 'string' && /^\d+$/.test(s);
|
||||||
|
if (v.apiVersion !== 1 || !iso(v.generatedAt) || !v.query || !v.period || !v.storage || !v.coverage || !v.totals
|
||||||
|
|| v.period.retentionDays !== 90 || !iso(v.period.from) || !iso(v.period.to) || !iso(v.period.minuteFrom)
|
||||||
|
|| !(v.period.availableFrom === null || iso(v.period.availableFrom))
|
||||||
|
|| !['ready', 'error'].includes(v.storage.status)
|
||||||
|
|| !(v.storage.errorCode === null || typeof v.storage.errorCode === 'string')
|
||||||
|
|| !['live', 'connecting', 'disabled', 'stopped', 'stale', 'degraded', 'incompatible'].includes(v.source)
|
||||||
|
|| typeof v.coverage.partial !== 'boolean' || !Number.isSafeInteger(v.coverage.gapCount) || v.coverage.gapCount < 0
|
||||||
|
|| !(v.coverage.lastObservedAt === null || iso(v.coverage.lastObservedAt))
|
||||||
|
|| !bytes(v.totals.uploadBytes) || !bytes(v.totals.downloadBytes)
|
||||||
|
|| !Array.isArray(v.rows) || v.rows.length > 100
|
||||||
|
|| !Array.isArray(v.origins) || v.origins.length > 256 || typeof v.originsTruncated !== 'boolean'
|
||||||
|
|| !(v.nextOffset === null || (Number.isSafeInteger(v.nextOffset) && v.nextOffset >= 0))) {
|
||||||
|
throw new TypeError('Invalid history snapshot');
|
||||||
|
}
|
||||||
|
parseTrafficHistoryQuery(historyQueryParams(v.query));
|
||||||
|
for (const row of v.rows) {
|
||||||
|
if (!row || typeof row.key !== 'string' || typeof row.label !== 'string'
|
||||||
|
|| row.key.length > 253 || row.label.length > 253
|
||||||
|
|| !bytes(row.uploadBytes) || !bytes(row.downloadBytes)
|
||||||
|
|| !['vpn', 'direct', 'other', 'mixed'].includes(row.route)) throw new TypeError('Invalid history row');
|
||||||
|
}
|
||||||
|
for (const origin of v.origins) {
|
||||||
|
if (!origin || typeof origin.id !== 'string' || typeof origin.label !== 'string') throw new TypeError('Invalid history origin');
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const TRAFFIC_RETENTION_OPTIONS = [5, 10, 30] as const;
|
||||||
|
|
||||||
|
export type TrafficGrouping = 'site' | 'device';
|
||||||
|
export type TrafficSort = 'popular' | 'recent';
|
||||||
|
export type TrafficRetentionSeconds = typeof TRAFFIC_RETENTION_OPTIONS[number];
|
||||||
|
|
||||||
|
export interface TrafficSettings {
|
||||||
|
grouping: TrafficGrouping;
|
||||||
|
sort: TrafficSort;
|
||||||
|
retentionSeconds: TrafficRetentionSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TRAFFIC_SETTINGS: TrafficSettings = {
|
||||||
|
grouping: 'site',
|
||||||
|
sort: 'popular',
|
||||||
|
retentionSeconds: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeTrafficSettings(
|
||||||
|
value: unknown,
|
||||||
|
{ strict = false }: { strict?: boolean } = {},
|
||||||
|
): TrafficSettings {
|
||||||
|
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const grouping = candidate.grouping;
|
||||||
|
const sort = candidate.sort;
|
||||||
|
const retentionSeconds = candidate.retentionSeconds;
|
||||||
|
if (strict && (
|
||||||
|
!['site', 'device'].includes(String(grouping))
|
||||||
|
|| !['popular', 'recent'].includes(String(sort))
|
||||||
|
|| !TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
|
||||||
|
)) throw new TypeError('Invalid traffic settings');
|
||||||
|
return {
|
||||||
|
grouping: grouping === 'device' ? 'device' : 'site',
|
||||||
|
sort: sort === 'recent' ? 'recent' : 'popular',
|
||||||
|
retentionSeconds: TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
|
||||||
|
? Number(retentionSeconds) as TrafficRetentionSeconds
|
||||||
|
: DEFAULT_TRAFFIC_SETTINGS.retentionSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.34.0',
|
macClient: '0.37.2',
|
||||||
gatewayClient: '0.36.0',
|
gatewayClient: '0.39.2',
|
||||||
gatewayBackend: '0.36.0',
|
gatewayBackend: '0.39.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
+17
-3
@@ -28,8 +28,11 @@ const componentActions = {
|
|||||||
setDevicePolicy: api.devices.setPolicy,
|
setDevicePolicy: api.devices.setPolicy,
|
||||||
pingServers: api.servers.ping,
|
pingServers: api.servers.ping,
|
||||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||||
|
loadDnsDiagnosticsCatalog: api.diagnostics.dnsCatalog,
|
||||||
|
runDnsDiagnostics: api.diagnostics.dns,
|
||||||
loadActivityJournal: api.activityJournal.page,
|
loadActivityJournal: api.activityJournal.page,
|
||||||
loadLiveTraffic: api.traffic.live,
|
loadLiveTraffic: api.traffic.live,
|
||||||
|
loadTrafficHistory: api.traffic.history,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface UiError {
|
interface UiError {
|
||||||
@@ -56,6 +59,7 @@ const operationErrorContext: Record<string, string> = {
|
|||||||
'subscription-refresh': 'subscription',
|
'subscription-refresh': 'subscription',
|
||||||
'subscription-forget': 'subscription',
|
'subscription-forget': 'subscription',
|
||||||
'route-rules': 'routing',
|
'route-rules': 'routing',
|
||||||
|
'traffic-settings': 'traffic',
|
||||||
'failover-save': 'failover',
|
'failover-save': 'failover',
|
||||||
'failover-pause': 'failover',
|
'failover-pause': 'failover',
|
||||||
'failover-resume': 'failover',
|
'failover-resume': 'failover',
|
||||||
@@ -109,9 +113,14 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadState();
|
let cancelled = false;
|
||||||
const timer = setInterval(loadState, 5000);
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
return () => clearInterval(timer);
|
const poll = async () => {
|
||||||
|
await loadState();
|
||||||
|
if (!cancelled) timer = setTimeout(poll, 5000);
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
return () => { cancelled = true; clearTimeout(timer); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -313,6 +322,11 @@ export function App() {
|
|||||||
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
||||||
'diagnostics',
|
'diagnostics',
|
||||||
)}
|
)}
|
||||||
|
onUpdateTrafficSettings={(settings: unknown) => run(
|
||||||
|
'trafficSettings',
|
||||||
|
() => api.traffic.updateSettings(settings, revisionRef.current),
|
||||||
|
'traffic',
|
||||||
|
)}
|
||||||
onSaveFailover={(policy: unknown) => run(
|
onSaveFailover={(policy: unknown) => run(
|
||||||
'failover',
|
'failover',
|
||||||
() => api.failover.save(policy, revisionRef.current),
|
() => api.failover.save(policy, revisionRef.current),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
|
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
|
||||||
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
|
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
|
||||||
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
|
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
|
||||||
|
import { historyQueryParams, type TrafficHistoryQuery } from '../../shared/trafficHistory.js';
|
||||||
|
|
||||||
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
type RequestOptions = Omit<RequestInit, 'headers'> & {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@@ -49,24 +50,25 @@ export async function request(
|
|||||||
options: RequestOptions = {},
|
options: RequestOptions = {},
|
||||||
fetchImpl: FetchImplementation = fetch,
|
fetchImpl: FetchImplementation = fetch,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
let response: JsonResponse;
|
const deadline = new AbortController();
|
||||||
|
const timer = setTimeout(() => deadline.abort(new HarborApiError({ code: 'CONTROL_UNREACHABLE' })),
|
||||||
|
(options.method || 'GET') === 'GET' ? 15_000 : 60_000);
|
||||||
|
const signal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal;
|
||||||
try {
|
try {
|
||||||
response = await fetchImpl(url, {
|
const response = await fetchImpl(url, {
|
||||||
...options,
|
...options,
|
||||||
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
if (record(error).name === 'AbortError') throw error;
|
|
||||||
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: unknown = {};
|
let data: unknown = {};
|
||||||
try {
|
try {
|
||||||
data = await response.json();
|
data = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
|
if (signal.aborted) throw signal.reason;
|
||||||
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
|
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
|
||||||
}
|
}
|
||||||
const payload = record(data);
|
const payload = record(data);
|
||||||
@@ -77,6 +79,13 @@ export async function request(
|
|||||||
throw new HarborApiError(errorPayload, response.status);
|
throw new HarborApiError(errorPayload, response.status);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) throw signal.reason;
|
||||||
|
if (error instanceof HarborApiError || record(error).name === 'AbortError') throw error;
|
||||||
|
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -202,6 +211,14 @@ export const api = {
|
|||||||
body: JSON.stringify({ target }),
|
body: JSON.stringify({ target }),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
dnsCatalog: () => request('/api/diagnostics/dns'),
|
||||||
|
dns: (domainId: string, resolverId: string | null = null) => request(
|
||||||
|
'/api/diagnostics/dns',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ domainId, resolverId }),
|
||||||
|
},
|
||||||
|
),
|
||||||
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
||||||
'/api/diagnostics/settings',
|
'/api/diagnostics/settings',
|
||||||
{
|
{
|
||||||
@@ -231,7 +248,15 @@ export const api = {
|
|||||||
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
||||||
},
|
},
|
||||||
traffic: {
|
traffic: {
|
||||||
|
history: (query: TrafficHistoryQuery, signal?: AbortSignal) => request(`/api/traffic/history?${historyQueryParams(query)}`, { signal }),
|
||||||
live: () => request('/api/traffic/live'),
|
live: () => request('/api/traffic/live'),
|
||||||
|
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
||||||
|
'/api/traffic/settings',
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ settings, expectedRevision }),
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
@@ -278,6 +303,7 @@ export function parseHarborState(value: unknown): HarborClientState {
|
|||||||
selection: snapshot.selection,
|
selection: snapshot.selection,
|
||||||
connection: snapshot.connection,
|
connection: snapshot.connection,
|
||||||
diagnostics: snapshot.diagnostics,
|
diagnostics: snapshot.diagnostics,
|
||||||
|
traffic: snapshot.traffic,
|
||||||
failover: snapshot.failover,
|
failover: snapshot.failover,
|
||||||
route: snapshot.route,
|
route: snapshot.route,
|
||||||
operation: snapshot.operation,
|
operation: snapshot.operation,
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ interface VersionBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ComponentActions {
|
interface ComponentActions {
|
||||||
|
loadTrafficHistory: import('../features/traffic/index.js').LoadTrafficHistory;
|
||||||
listDevices: () => Promise<unknown>;
|
listDevices: () => Promise<unknown>;
|
||||||
refreshDevices: () => Promise<unknown>;
|
refreshDevices: () => Promise<unknown>;
|
||||||
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
|
||||||
@@ -132,6 +133,8 @@ interface ComponentActions {
|
|||||||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||||
|
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
|
||||||
|
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
||||||
loadLiveTraffic: () => Promise<unknown>;
|
loadLiveTraffic: () => Promise<unknown>;
|
||||||
}
|
}
|
||||||
@@ -160,6 +163,7 @@ interface ClientOverviewPageProps {
|
|||||||
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
||||||
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||||||
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
||||||
|
onUpdateTrafficSettings: (settings: unknown) => Promise<unknown>;
|
||||||
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
||||||
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
||||||
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
||||||
@@ -475,6 +479,7 @@ export function ClientOverviewPage({
|
|||||||
onSetGatewayAuto,
|
onSetGatewayAuto,
|
||||||
onSaveRouteRules,
|
onSaveRouteRules,
|
||||||
onUpdateDiagnosticsSettings,
|
onUpdateDiagnosticsSettings,
|
||||||
|
onUpdateTrafficSettings,
|
||||||
onSaveFailover,
|
onSaveFailover,
|
||||||
onPauseFailover,
|
onPauseFailover,
|
||||||
onSwitchFailover,
|
onSwitchFailover,
|
||||||
@@ -596,6 +601,9 @@ export function ClientOverviewPage({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
isGateway,
|
isGateway,
|
||||||
loadLiveTraffic: actions.loadLiveTraffic,
|
loadLiveTraffic: actions.loadLiveTraffic,
|
||||||
|
loadHistory: actions.loadTrafficHistory,
|
||||||
|
settings: state.traffic,
|
||||||
|
updateSettings: onUpdateTrafficSettings,
|
||||||
});
|
});
|
||||||
const diagnosticsAvailable = hasSubscription;
|
const diagnosticsAvailable = hasSubscription;
|
||||||
const drawerControls = {
|
const drawerControls = {
|
||||||
@@ -964,6 +972,8 @@ export function ClientOverviewPage({
|
|||||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||||
feature={diagnosticsFeature}
|
feature={diagnosticsFeature}
|
||||||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||||
|
loadDnsDiagnosticsCatalog={actions.loadDnsDiagnosticsCatalog}
|
||||||
|
runDnsDiagnostics={actions.runDnsDiagnostics}
|
||||||
settings={state.diagnostics}
|
settings={state.diagnostics}
|
||||||
updateSettings={onUpdateDiagnosticsSettings}
|
updateSettings={onUpdateDiagnosticsSettings}
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from './connectivityResult.js';
|
} from './connectivityResult.js';
|
||||||
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
|
||||||
import { saveCustomDiagnosticService } from './customServiceAction.js';
|
import { saveCustomDiagnosticService } from './customServiceAction.js';
|
||||||
|
import { DnsDiagnosticsSection } from './DnsDiagnosticsSection.js';
|
||||||
|
|
||||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||||
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
||||||
@@ -36,7 +37,7 @@ interface IpSourceDefinition {
|
|||||||
|
|
||||||
type StatusValue = [className: string, label: string];
|
type StatusValue = [className: string, label: string];
|
||||||
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
|
||||||
type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
|
type UpdateSettings = (settings: Partial<DiagnosticSettings>) => Promise<unknown>;
|
||||||
|
|
||||||
function record(value: unknown): value is Record<string, unknown> {
|
function record(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -236,12 +237,16 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
|
|||||||
export function ConnectivityDiagnosticsPanel({
|
export function ConnectivityDiagnosticsPanel({
|
||||||
feature,
|
feature,
|
||||||
runConnectivityDiagnostics,
|
runConnectivityDiagnostics,
|
||||||
|
loadDnsDiagnosticsCatalog,
|
||||||
|
runDnsDiagnostics,
|
||||||
settings,
|
settings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
isGateway,
|
isGateway,
|
||||||
}: {
|
}: {
|
||||||
feature: DiagnosticsFeature;
|
feature: DiagnosticsFeature;
|
||||||
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
runConnectivityDiagnostics: RunConnectivityDiagnostics;
|
||||||
|
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
|
||||||
|
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
settings: DiagnosticSettings;
|
settings: DiagnosticSettings;
|
||||||
updateSettings: UpdateSettings;
|
updateSettings: UpdateSettings;
|
||||||
isGateway: boolean;
|
isGateway: boolean;
|
||||||
@@ -618,6 +623,14 @@ export function ConnectivityDiagnosticsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DnsDiagnosticsSection
|
||||||
|
open={open}
|
||||||
|
settings={settings}
|
||||||
|
updateSettings={updateSettings}
|
||||||
|
loadCatalog={loadDnsDiagnosticsCatalog}
|
||||||
|
runDiagnostics={runDnsDiagnostics}
|
||||||
|
/>
|
||||||
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_CUSTOM_DNS_DOMAINS,
|
||||||
|
MAX_CUSTOM_DNS_RESOLVERS,
|
||||||
|
type DiagnosticSettings,
|
||||||
|
type DnsDomainDefinition,
|
||||||
|
type DnsResolverDefinition,
|
||||||
|
} from '../../../shared/connectivityDiagnostics.js';
|
||||||
|
import { Tooltip } from '../../ui/Tooltip.js';
|
||||||
|
|
||||||
|
interface DnsPathResult {
|
||||||
|
status: string;
|
||||||
|
rcode: string | null;
|
||||||
|
ipv4: string[];
|
||||||
|
ipv6: string[];
|
||||||
|
latencyMs: number | null;
|
||||||
|
transport: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DnsRowResult {
|
||||||
|
resolver: DnsResolverDefinition;
|
||||||
|
direct: DnsPathResult;
|
||||||
|
vpn: DnsPathResult;
|
||||||
|
comparison: 'same' | 'different' | 'direct-only' | 'vpn-only' | 'failed';
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DnsRunResult {
|
||||||
|
checkedAt: string;
|
||||||
|
domain: DnsDomainDefinition;
|
||||||
|
results: DnsRowResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DnsCatalog {
|
||||||
|
domains: DnsDomainDefinition[];
|
||||||
|
resolvers: DnsResolverDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSettings = (settings: Partial<DiagnosticSettings>) => Promise<unknown>;
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown) {
|
||||||
|
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) throw new TypeError('Invalid DNS addresses');
|
||||||
|
return value as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolver(value: unknown): DnsResolverDefinition {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
typeof item.id !== 'string' || !item.id
|
||||||
|
|| typeof item.label !== 'string' || !item.label
|
||||||
|
|| !['dns', 'doh'].includes(String(item.kind))
|
||||||
|
|| typeof item.endpoint !== 'string' || !item.endpoint
|
||||||
|
) throw new TypeError('Invalid DNS resolver');
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
kind: item.kind as 'dns' | 'doh',
|
||||||
|
endpoint: item.endpoint,
|
||||||
|
...(item.bootstrap ? { bootstrap: String(item.bootstrap) } : {}),
|
||||||
|
system: item.system === true,
|
||||||
|
custom: item.custom === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function domain(value: unknown): DnsDomainDefinition {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
typeof item.id !== 'string' || !item.id
|
||||||
|
|| typeof item.label !== 'string' || !item.label
|
||||||
|
|| typeof item.hostname !== 'string' || !item.hostname
|
||||||
|
) throw new TypeError('Invalid DNS domain');
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
hostname: item.hostname,
|
||||||
|
builtIn: item.builtIn === true,
|
||||||
|
custom: item.custom === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathResult(value: unknown): DnsPathResult {
|
||||||
|
const item = record(value);
|
||||||
|
if (
|
||||||
|
typeof item.status !== 'string'
|
||||||
|
|| !(item.rcode === null || typeof item.rcode === 'string')
|
||||||
|
|| !(item.latencyMs === null || Number.isFinite(item.latencyMs))
|
||||||
|
|| !(item.transport === null || typeof item.transport === 'string')
|
||||||
|
|| !(item.error === null || typeof item.error === 'string')
|
||||||
|
) throw new TypeError('Invalid DNS path result');
|
||||||
|
return {
|
||||||
|
status: item.status,
|
||||||
|
rcode: item.rcode as string | null,
|
||||||
|
ipv4: stringArray(item.ipv4),
|
||||||
|
ipv6: stringArray(item.ipv6),
|
||||||
|
latencyMs: item.latencyMs as number | null,
|
||||||
|
transport: item.transport as string | null,
|
||||||
|
error: item.error as string | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCatalog(value: unknown): DnsCatalog {
|
||||||
|
const item = record(value);
|
||||||
|
if (!Array.isArray(item.domains) || !Array.isArray(item.resolvers)) throw new TypeError('Invalid DNS catalog');
|
||||||
|
return { domains: item.domains.map(domain), resolvers: item.resolvers.map(resolver) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRun(value: unknown): DnsRunResult {
|
||||||
|
const item = record(value);
|
||||||
|
if (typeof item.checkedAt !== 'string' || !Array.isArray(item.results)) throw new TypeError('Invalid DNS result');
|
||||||
|
return {
|
||||||
|
checkedAt: item.checkedAt,
|
||||||
|
domain: domain(item.domain),
|
||||||
|
results: item.results.map((value) => {
|
||||||
|
const row = record(value);
|
||||||
|
if (!['same', 'different', 'direct-only', 'vpn-only', 'failed'].includes(String(row.comparison))) {
|
||||||
|
throw new TypeError('Invalid DNS comparison');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resolver: resolver(row.resolver),
|
||||||
|
direct: pathResult(row.direct),
|
||||||
|
vpn: pathResult(row.vpn),
|
||||||
|
comparison: row.comparison as DnsRowResult['comparison'],
|
||||||
|
warning: row.warning === null ? null : String(row.warning),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMessage(value: unknown) {
|
||||||
|
const item = record(value);
|
||||||
|
return typeof item.message === 'string' ? item.message : 'DNS checker временно недоступен.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Refresh({
|
||||||
|
label,
|
||||||
|
running,
|
||||||
|
disabled,
|
||||||
|
compact = false,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
running: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return <span className={`client-diagnostics-refresh-wrap client-tooltip-anchor${compact ? ' client-diagnostics-row-refresh-wrap' : ''}`}>
|
||||||
|
<button
|
||||||
|
className={`client-diagnostics-refresh${compact ? ' client-diagnostics-row-refresh' : ''}${running ? ' is-running' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-busy={running}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Tooltip>{label}</Tooltip>
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathStatus(result: DnsPathResult | undefined) {
|
||||||
|
if (!result) return '—';
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'vpn-off': 'VPN выключен',
|
||||||
|
timeout: 'Timeout',
|
||||||
|
nxdomain: 'NXDOMAIN',
|
||||||
|
servfail: 'SERVFAIL',
|
||||||
|
error: 'Ошибка ответа',
|
||||||
|
'no-addresses': result.rcode || 'Нет адресов',
|
||||||
|
};
|
||||||
|
return labels[result.status] || result.error || result.rcode || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultCell({ result, running, route }: {
|
||||||
|
result: DnsPathResult | undefined;
|
||||||
|
running: boolean;
|
||||||
|
route: string;
|
||||||
|
}) {
|
||||||
|
const failed = result && !['answered', 'no-addresses', 'nxdomain'].includes(result.status);
|
||||||
|
return <span
|
||||||
|
className={`client-dns-result${failed ? ' is-error' : ''}${running ? ' is-running' : ''}`}
|
||||||
|
aria-label={`${route}: ${running ? 'Тестируем' : pathStatus(result)}`}
|
||||||
|
>
|
||||||
|
<span><b>A</b>{running && !result ? 'Тестируем' : result?.ipv4.join(', ') || '—'}</span>
|
||||||
|
<span><b>AAAA</b>{running && !result ? 'Тестируем' : result?.ipv6.join(', ') || '—'}</span>
|
||||||
|
<small>{running
|
||||||
|
? 'Тестируем'
|
||||||
|
: result?.latencyMs !== null && result?.latencyMs !== undefined
|
||||||
|
? `${result.latencyMs} мс · ${result.transport || 'DNS'}`
|
||||||
|
: pathStatus(result)}</small>
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparisonLabel: Record<DnsRowResult['comparison'], string> = {
|
||||||
|
same: 'совпадают',
|
||||||
|
different: 'различаются',
|
||||||
|
'direct-only': 'только Direct',
|
||||||
|
'vpn-only': 'только VPN',
|
||||||
|
failed: 'нет ответа',
|
||||||
|
};
|
||||||
|
|
||||||
|
function preserveAddresses(previous: DnsPathResult | undefined, next: DnsPathResult) {
|
||||||
|
return previous && !['answered', 'no-addresses', 'nxdomain'].includes(next.status)
|
||||||
|
? { ...next, ipv4: previous.ipv4, ipv6: previous.ipv6 }
|
||||||
|
: next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRow(previous: DnsRowResult | undefined, next: DnsRowResult): DnsRowResult {
|
||||||
|
return previous ? {
|
||||||
|
...next,
|
||||||
|
direct: preserveAddresses(previous.direct, next.direct),
|
||||||
|
vpn: preserveAddresses(previous.vpn, next.vpn),
|
||||||
|
} : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DnsDiagnosticsSection({
|
||||||
|
open,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
loadCatalog,
|
||||||
|
runDiagnostics,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
settings: DiagnosticSettings;
|
||||||
|
updateSettings: UpdateSettings;
|
||||||
|
loadCatalog: () => Promise<unknown>;
|
||||||
|
runDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
|
||||||
|
}) {
|
||||||
|
const [catalog, setCatalog] = useState<DnsCatalog | null>(null);
|
||||||
|
const [activeDomainId, setActiveDomainId] = useState('youtube');
|
||||||
|
const [cache, setCache] = useState<Record<string, Record<string, DnsRowResult>>>({});
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [activeResolverId, setActiveResolverId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [addingDomain, setAddingDomain] = useState(false);
|
||||||
|
const [domainName, setDomainName] = useState('');
|
||||||
|
const [domainHostname, setDomainHostname] = useState('');
|
||||||
|
const [addingResolver, setAddingResolver] = useState(false);
|
||||||
|
const [resolverName, setResolverName] = useState('');
|
||||||
|
const [resolverEndpoint, setResolverEndpoint] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const rows = useMemo(() => cache[activeDomainId] || {}, [cache, activeDomainId]);
|
||||||
|
|
||||||
|
async function refreshCatalog() {
|
||||||
|
const next = parseCatalog(await loadCatalog());
|
||||||
|
setCatalog(next);
|
||||||
|
if (!next.domains.some(({ id }) => id === activeDomainId)) setActiveDomainId(next.domains[0]?.id || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || catalog) return;
|
||||||
|
void refreshCatalog().catch((requestError) => setError(requestMessage(requestError)));
|
||||||
|
}, [open, catalog]);
|
||||||
|
|
||||||
|
async function run(resolverId: string | null = null) {
|
||||||
|
if (!catalog || !activeDomainId) return;
|
||||||
|
setRunning(true);
|
||||||
|
setActiveResolverId(resolverId);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = parseRun(await runDiagnostics(activeDomainId, resolverId));
|
||||||
|
setCache((current) => {
|
||||||
|
const previous = current[activeDomainId] || {};
|
||||||
|
const incoming = Object.fromEntries(result.results.map((row) => [
|
||||||
|
row.resolver.id,
|
||||||
|
mergeRow(previous[row.resolver.id], row),
|
||||||
|
]));
|
||||||
|
return { ...current, [activeDomainId]: resolverId ? { ...previous, ...incoming } : incoming };
|
||||||
|
});
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
setActiveResolverId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDomain(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const hostname = new URL(`http://${domainHostname.trim().replace(/\.$/, '')}/`).hostname;
|
||||||
|
const id = `custom-domain-${globalThis.crypto?.randomUUID?.() || Date.now()}`;
|
||||||
|
const saved = await updateSettings({
|
||||||
|
customDnsDomains: [...settings.customDnsDomains, {
|
||||||
|
id,
|
||||||
|
label: domainName.trim() || hostname,
|
||||||
|
hostname,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
if (saved === false) throw new Error('Не удалось сохранить домен.');
|
||||||
|
setDomainName('');
|
||||||
|
setDomainHostname('');
|
||||||
|
setAddingDomain(false);
|
||||||
|
setCatalog(null);
|
||||||
|
setActiveDomainId(id);
|
||||||
|
} catch (validationError) {
|
||||||
|
setError(validationError instanceof Error ? validationError.message : 'Проверьте домен.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResolver(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const endpoint = resolverEndpoint.trim();
|
||||||
|
const kind = endpoint.startsWith('https://') ? 'doh' : 'dns';
|
||||||
|
const saved = await updateSettings({
|
||||||
|
customDnsResolvers: [...settings.customDnsResolvers, {
|
||||||
|
id: `custom-dns-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||||
|
label: resolverName.trim() || endpoint,
|
||||||
|
kind,
|
||||||
|
endpoint,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
if (saved === false) throw new Error('Не удалось сохранить DNS.');
|
||||||
|
setResolverName('');
|
||||||
|
setResolverEndpoint('');
|
||||||
|
setAddingResolver(false);
|
||||||
|
setCatalog(null);
|
||||||
|
} catch (validationError) {
|
||||||
|
setError(validationError instanceof Error ? validationError.message : 'Проверьте DNS или DoH endpoint.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(kind: 'domain' | 'resolver', id: string) {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const saved = await updateSettings(kind === 'domain'
|
||||||
|
? { customDnsDomains: settings.customDnsDomains.filter((item) => item.id !== id) }
|
||||||
|
: { customDnsResolvers: settings.customDnsResolvers.filter((item) => item.id !== id) });
|
||||||
|
if (saved === false) throw new Error('Не удалось сохранить DNS settings.');
|
||||||
|
setCatalog(null);
|
||||||
|
setCache({});
|
||||||
|
} catch (requestError) {
|
||||||
|
setError(requestMessage(requestError));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = running || saving;
|
||||||
|
return <section className="client-diagnostics-section client-dns-section" aria-labelledby="diagnostic-dns-title">
|
||||||
|
<div className="client-diagnostics-section-title client-dns-title-row">
|
||||||
|
<span id="diagnostic-dns-title">DNS</span>
|
||||||
|
<Refresh label="Проверить DNS" running={running && !activeResolverId} disabled={blocked || !catalog} onClick={() => void run()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="client-dns-domain-row">
|
||||||
|
<label htmlFor="client-dns-domain">Домен</label>
|
||||||
|
<select
|
||||||
|
id="client-dns-domain"
|
||||||
|
value={activeDomainId}
|
||||||
|
disabled={blocked || !catalog?.domains.length}
|
||||||
|
onChange={(event) => setActiveDomainId(event.target.value)}
|
||||||
|
>
|
||||||
|
{(catalog?.domains || []).map((item) => <option key={item.id} value={item.id}>{item.label} · {item.hostname}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={blocked || addingDomain || settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS}
|
||||||
|
onClick={() => setAddingDomain(true)}
|
||||||
|
>+ Домен</button>
|
||||||
|
{catalog?.domains.find(({ id }) => id === activeDomainId)?.custom && <button
|
||||||
|
className="client-row-delete"
|
||||||
|
type="button"
|
||||||
|
aria-label="Удалить выбранный домен"
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => void remove('domain', activeDomainId)}
|
||||||
|
>×</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addingDomain && <form className="client-dns-inline-form" onSubmit={saveDomain}>
|
||||||
|
<input value={domainName} maxLength={40} placeholder="Название" aria-label="Название DNS-домена" onChange={(event) => setDomainName(event.target.value)} />
|
||||||
|
<input value={domainHostname} placeholder="example.com" aria-label="DNS-домен" required onChange={(event) => setDomainHostname(event.target.value)} />
|
||||||
|
<button type="submit" disabled={blocked}>Добавить</button>
|
||||||
|
<button type="button" aria-label="Отменить добавление домена" onClick={() => setAddingDomain(false)}>×</button>
|
||||||
|
</form>}
|
||||||
|
|
||||||
|
{error && <div className="client-diagnostics-error" role="alert"><span>{error}</span></div>}
|
||||||
|
|
||||||
|
<div className="client-dns-table" role="table" aria-busy={running}>
|
||||||
|
<div className="client-dns-header" role="row">
|
||||||
|
<span role="columnheader">DNS</span><span role="columnheader">Напрямую</span><span role="columnheader">VPN</span><span aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{(catalog?.resolvers || []).map((item) => {
|
||||||
|
const result = rows[item.id];
|
||||||
|
const rowRunning = running && (!activeResolverId || activeResolverId === item.id);
|
||||||
|
return <div key={item.id} className={`client-dns-row${rowRunning ? ' is-running' : ''}`} role="row" data-diagnostic-target={`dns:${item.id}`}>
|
||||||
|
<span className="client-dns-resolver client-diagnostics-row-name" role="rowheader">
|
||||||
|
<span><b>{item.label}</b><small>{item.kind === 'doh' ? 'DoH' : 'DNS'} · {item.endpoint}</small><small>{result?.warning === 'private-address' ? 'получен частный адрес' : result ? comparisonLabel[result.comparison] : 'не проверено'}</small></span>
|
||||||
|
<Refresh label={`Проверить: ${item.label}`} running={rowRunning && Boolean(activeResolverId)} disabled={blocked} compact onClick={() => void run(item.id)} />
|
||||||
|
</span>
|
||||||
|
<span role="cell"><ResultCell result={result?.direct} running={rowRunning} route={`Напрямую, ${item.label}`} /></span>
|
||||||
|
<span role="cell"><ResultCell result={result?.vpn} running={rowRunning} route={`VPN, ${item.label}`} /></span>
|
||||||
|
{item.custom ? <button
|
||||||
|
className="client-row-delete"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить DNS ${item.label}`}
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => void remove('resolver', item.id)}
|
||||||
|
>×</button> : <span aria-hidden="true" />}
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!catalog && !error && <p className="client-diagnostics-services-empty">Загружаем DNS…</p>}
|
||||||
|
<div className="client-row-add-slot client-diagnostics-add-slot">
|
||||||
|
<button
|
||||||
|
className="client-row-add"
|
||||||
|
type="button"
|
||||||
|
disabled={blocked || addingResolver || settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS}
|
||||||
|
onClick={() => setAddingResolver(true)}
|
||||||
|
>{settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS ? 'Лимит 5 DNS' : '+ Добавить DNS'}</button>
|
||||||
|
</div>
|
||||||
|
{addingResolver && <form className="client-dns-inline-form" onSubmit={saveResolver}>
|
||||||
|
<input value={resolverName} maxLength={40} placeholder="Название" aria-label="Название DNS-резолвера" onChange={(event) => setResolverName(event.target.value)} />
|
||||||
|
<input value={resolverEndpoint} placeholder="8.8.8.8 или https://…/dns-query" aria-label="DNS или DoH endpoint" required onChange={(event) => setResolverEndpoint(event.target.value)} />
|
||||||
|
<button type="submit" disabled={blocked}>Добавить</button>
|
||||||
|
<button type="button" aria-label="Отменить добавление DNS" onClick={() => setAddingResolver(false)}>×</button>
|
||||||
|
</form>}
|
||||||
|
<p className="client-dns-note">Разные ответы могут быть нормой для CDN.</p>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -482,6 +482,9 @@ export function FailoverPanel({
|
|||||||
const selectedId = channelOptions.find(({ target }) => (
|
const selectedId = channelOptions.find(({ target }) => (
|
||||||
target.profileId === draft[channel].profileId && target.serverId === draft[channel].serverId
|
target.profileId === draft[channel].profileId && target.serverId === draft[channel].serverId
|
||||||
))?.id || '';
|
))?.id || '';
|
||||||
|
const failureProgress = !checking && channel === 'primary' && snapshot.reason === 'failure-window'
|
||||||
|
? `${seconds(Math.max(0, Date.now() - Date.parse(snapshot.primary.stateSince || new Date().toISOString())))} / ${seconds(draft.failureWindowMs)} с`
|
||||||
|
: '';
|
||||||
const healthLabel = missing
|
const healthLabel = missing
|
||||||
? 'Сервер не найден'
|
? 'Сервер не найден'
|
||||||
: !draft.enabled
|
: !draft.enabled
|
||||||
@@ -490,15 +493,20 @@ export function FailoverPanel({
|
|||||||
? 'VPN выключен'
|
? 'VPN выключен'
|
||||||
: snapshot.activation === 'pending'
|
: snapshot.activation === 'pending'
|
||||||
? 'Проверим после запуска VPN'
|
? 'Проверим после запуска VPN'
|
||||||
: channel === 'primary' && snapshot.reason === 'failure-window'
|
: checking
|
||||||
? `Проверяем основной канал · ${seconds(Math.max(0, Date.now() - Date.parse(snapshot.primary.stateSince || new Date().toISOString())))} из ${seconds(draft.failureWindowMs)} с`
|
? `Проверяем ${channel === 'primary' ? 'основной' : 'резервный'} канал`
|
||||||
|
: failureProgress
|
||||||
|
? 'Проверяем основной канал'
|
||||||
: channel === 'primary' && snapshot.currentRole === 'reserve' && health === 'healthy' && snapshot.reason === 'recovery-hold'
|
: channel === 'primary' && snapshot.currentRole === 'reserve' && health === 'healthy' && snapshot.reason === 'recovery-hold'
|
||||||
? 'Проверяем восстановление'
|
? 'Проверяем восстановление'
|
||||||
: health === 'healthy' ? 'Работает' : health === 'unhealthy' ? 'Недоступен' : health === 'not-monitoring' ? 'Проверка выключена' : 'Ждёт проверки';
|
: health === 'healthy' ? 'Работает' : health === 'unhealthy' ? 'Недоступен' : health === 'not-monitoring' ? 'Проверка выключена' : 'Ждёт проверки';
|
||||||
return <div className="client-failover-channel-slot" key={channel}><article className="client-failover-channel" data-channel={channel}>
|
return <div className="client-failover-channel-slot" key={channel}><article className="client-failover-channel" data-channel={channel}>
|
||||||
<div className="client-failover-channel-title">
|
<div className="client-failover-channel-title">
|
||||||
<h3>{channel === 'primary' ? 'Основной' : 'Резервный'}</h3>
|
<h3>{channel === 'primary' ? 'Основной' : 'Резервный'}</h3>
|
||||||
<strong className={health === 'healthy' ? 'is-healthy' : health === 'unhealthy' || missing ? 'is-unhealthy' : ''}>{healthLabel}</strong>
|
</div>
|
||||||
|
<div className="client-failover-channel-status">
|
||||||
|
<strong className={healthLabel === 'Работает' ? 'is-healthy' : healthLabel === 'Недоступен' || missing ? 'is-unhealthy' : ''}>{healthLabel}</strong>
|
||||||
|
<span className="client-failover-channel-progress">{failureProgress}</span>
|
||||||
</div>
|
</div>
|
||||||
<FailoverPicker
|
<FailoverPicker
|
||||||
id={`client-failover-${channel}-options`}
|
id={`client-failover-${channel}-options`}
|
||||||
@@ -510,9 +518,6 @@ export function FailoverPanel({
|
|||||||
onOpenChange={(open) => setOpenPicker(open ? channel : '')}
|
onOpenChange={(open) => setOpenPicker(open ? channel : '')}
|
||||||
onChange={(optionId) => updateTarget(channel, optionId)}
|
onChange={(optionId) => updateTarget(channel, optionId)}
|
||||||
/>
|
/>
|
||||||
<span className={`client-failover-channel-checking${checking ? ' is-visible' : ''}`} aria-hidden="true">
|
|
||||||
Проверяем {channel === 'primary' ? 'основной' : 'резервный'} канал
|
|
||||||
</span>
|
|
||||||
</article>{channel === 'primary' && <div className="client-failover-channel-actions" aria-label="Действия с каналами">
|
</article>{channel === 'primary' && <div className="client-failover-channel-actions" aria-label="Действия с каналами">
|
||||||
<button
|
<button
|
||||||
className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`}
|
className={`client-failover-check-action client-tooltip-anchor${checking ? ' is-running' : ''}`}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
|
|||||||
paragraphs: [
|
paragraphs: [
|
||||||
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
|
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
|
||||||
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
|
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
|
||||||
'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.',
|
'Единый фильтр «Устройства» управляет applied policy, фактическим VPN/Direct, общим трафиком, сервисами и доменами. Policy Direct не запрещает VPN через явно настроенный Harbor Proxy: режим останется Direct, а proxy-байты появятся в VPN.',
|
||||||
],
|
],
|
||||||
steps: [
|
steps: [
|
||||||
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
||||||
@@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
|
|||||||
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
||||||
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
||||||
],
|
],
|
||||||
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками могут быть пропущены, неизвестный домен записывается как _unknown, а policy Direct виден только в отдельной Direct IPv4 metric.',
|
note: 'Историю хранит Prometheus. Основной Direct приблизительно объединяет sing-box и Linux IPv4 bytes с разной семантикой; точные значения остаются раздельными в технической секции. До первого scrape история applied policy отсутствует.',
|
||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function TrafficReveal({ open, id, className = '', children }: {
|
||||||
|
open: boolean; id?: string; className?: string; children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [present, setPresent] = useState(open);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open) setPresent(true);
|
||||||
|
const node = ref.current;
|
||||||
|
if (!node) return;
|
||||||
|
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
if (media.matches) {
|
||||||
|
node.style.opacity = open ? '1' : '0';
|
||||||
|
if (!open) setPresent(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const animation = node.animate({ opacity: open ? 1 : 0 }, {
|
||||||
|
duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards',
|
||||||
|
});
|
||||||
|
animation.onfinish = () => { if (!open) setPresent(false); };
|
||||||
|
const reduce = () => {
|
||||||
|
if (!media.matches) return;
|
||||||
|
animation.cancel();
|
||||||
|
node.style.opacity = open ? '1' : '0';
|
||||||
|
if (!open) setPresent(false);
|
||||||
|
};
|
||||||
|
media.addEventListener('change', reduce);
|
||||||
|
return () => {
|
||||||
|
node.style.opacity = getComputedStyle(node).opacity;
|
||||||
|
animation.cancel();
|
||||||
|
media.removeEventListener('change', reduce);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
return open || present ? <div ref={ref} id={id} className={className}
|
||||||
|
style={{ opacity: 0 }} aria-hidden={!open} inert={!open || undefined}
|
||||||
|
>{children}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficMenu({ label, active = false, closeOnSelect = false, children }: {
|
||||||
|
label: string; active?: boolean; closeOnSelect?: boolean; children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const trigger = useRef<HTMLButtonElement>(null);
|
||||||
|
const id = useId();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const outside = (event: PointerEvent) => {
|
||||||
|
if (!ref.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', outside);
|
||||||
|
return () => document.removeEventListener('pointerdown', outside);
|
||||||
|
}, [open]);
|
||||||
|
return <div className="client-traffic-menu" ref={ref} onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Escape' || !open) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
trigger.current?.focus();
|
||||||
|
}}>
|
||||||
|
<button ref={trigger} type="button" className="client-traffic-menu-trigger"
|
||||||
|
aria-expanded={open} aria-controls={id} data-active={active} title={label}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
>{label}</button>
|
||||||
|
<TrafficReveal open={open} id={id} className="client-traffic-menu-content">
|
||||||
|
<div onClick={(event) => {
|
||||||
|
if (!closeOnSelect || !(event.target as HTMLElement).closest('button')) return;
|
||||||
|
setOpen(false);
|
||||||
|
trigger.current?.focus();
|
||||||
|
}}>{children}</div>
|
||||||
|
</TrafficReveal>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficColumns() {
|
||||||
|
return <div className="client-traffic-columns" aria-hidden="true">
|
||||||
|
<span>Сайт</span><span>Скачивание</span><span>Отправка</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -5,13 +5,23 @@ import {
|
|||||||
type LiveTrafficConnection,
|
type LiveTrafficConnection,
|
||||||
type LiveTrafficSnapshot,
|
type LiveTrafficSnapshot,
|
||||||
} from '../../../shared/liveTraffic.js';
|
} from '../../../shared/liveTraffic.js';
|
||||||
|
import {
|
||||||
|
TRAFFIC_RETENTION_OPTIONS,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../../../shared/trafficSettings.js';
|
||||||
import { Drawer } from '../../ui/Drawer.js';
|
import { Drawer } from '../../ui/Drawer.js';
|
||||||
import { RailAction } from '../../ui/RailAction.js';
|
import { RailAction } from '../../ui/RailAction.js';
|
||||||
import { formatByteString } from '../../utils/format.js';
|
import { formatByteString } from '../../utils/format.js';
|
||||||
|
import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js';
|
||||||
|
import { TrafficHistoryPanel, type LoadTrafficHistory } from './TrafficHistoryPanel.js';
|
||||||
import {
|
import {
|
||||||
groupTrafficConnections,
|
groupTrafficConnections,
|
||||||
reconcileTrafficGroups,
|
reconcileTrafficGroups,
|
||||||
|
sortTrafficGroups,
|
||||||
|
summarizeTrafficOrigins,
|
||||||
|
trafficConnectionMatchesFilters,
|
||||||
trafficGroupMatches,
|
trafficGroupMatches,
|
||||||
|
trafficOriginId,
|
||||||
type DisplayedTrafficGroup,
|
type DisplayedTrafficGroup,
|
||||||
type TrafficConnectionGroup,
|
type TrafficConnectionGroup,
|
||||||
type TrafficQualityFilter,
|
type TrafficQualityFilter,
|
||||||
@@ -19,16 +29,16 @@ import {
|
|||||||
} from './trafficRows.js';
|
} from './trafficRows.js';
|
||||||
|
|
||||||
const POLL_MS = 1_000;
|
const POLL_MS = 1_000;
|
||||||
const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds';
|
|
||||||
const RETENTION_OPTIONS = [5, 10, 30] as const;
|
|
||||||
|
|
||||||
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
type RetentionSeconds = typeof RETENTION_OPTIONS[number];
|
|
||||||
|
|
||||||
interface TrafficFeatureOptions {
|
interface TrafficFeatureOptions {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
isGateway: boolean;
|
isGateway: boolean;
|
||||||
loadLiveTraffic: () => Promise<unknown>;
|
loadLiveTraffic: () => Promise<unknown>;
|
||||||
|
loadHistory: LoadTrafficHistory;
|
||||||
|
settings: TrafficSettings;
|
||||||
|
updateSettings: (settings: TrafficSettings) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||||
@@ -37,15 +47,6 @@ const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
|||||||
other: 'Другое',
|
other: 'Другое',
|
||||||
};
|
};
|
||||||
|
|
||||||
function storedRetentionSeconds(): RetentionSeconds {
|
|
||||||
try {
|
|
||||||
const value = Number(localStorage.getItem(RETENTION_STORAGE_KEY));
|
|
||||||
return RETENTION_OPTIONS.includes(value as RetentionSeconds) ? value as RetentionSeconds : 10;
|
|
||||||
} catch {
|
|
||||||
return 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function address(ip: string | null, port: number | null) {
|
function address(ip: string | null, port: number | null) {
|
||||||
if (!ip) return '—';
|
if (!ip) return '—';
|
||||||
return port === null ? ip : `${ip}:${port}`;
|
return port === null ? ip : `${ip}:${port}`;
|
||||||
@@ -63,9 +64,17 @@ function updatedAt(value: string | null | undefined) {
|
|||||||
}).format(date)}`;
|
}).format(date)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
|
export function useTrafficFeature({
|
||||||
|
enabled,
|
||||||
|
isGateway,
|
||||||
|
loadLiveTraffic,
|
||||||
|
loadHistory,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
}: TrafficFeatureOptions) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [view, setView] = useState<'live' | 'history'>('live');
|
||||||
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||||
const [requestState, setRequestState] = useState<RequestState>('idle');
|
const [requestState, setRequestState] = useState<RequestState>('idle');
|
||||||
const panelRef = useRef<HTMLElement>(null);
|
const panelRef = useRef<HTMLElement>(null);
|
||||||
@@ -79,7 +88,7 @@ export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: Traff
|
|||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !isOpen || paused) return undefined;
|
if (!enabled || !isOpen || paused || view !== 'live') return undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
setRequestState((current) => current === 'idle' ? 'loading' : current);
|
setRequestState((current) => current === 'idle' ? 'loading' : current);
|
||||||
@@ -103,7 +112,7 @@ export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: Traff
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [enabled, isOpen, paused, loadLiveTraffic]);
|
}, [enabled, isOpen, paused, view, loadLiveTraffic]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return undefined;
|
if (!isOpen) return undefined;
|
||||||
@@ -142,8 +151,13 @@ export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: Traff
|
|||||||
isGateway,
|
isGateway,
|
||||||
isOpen,
|
isOpen,
|
||||||
paused,
|
paused,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
loadHistory,
|
||||||
snapshot,
|
snapshot,
|
||||||
requestState,
|
requestState,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
panelRef,
|
panelRef,
|
||||||
toggleRef,
|
toggleRef,
|
||||||
closeRef,
|
closeRef,
|
||||||
@@ -185,6 +199,9 @@ export function TrafficToggle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupStatus(group: TrafficConnectionGroup) {
|
function groupStatus(group: TrafficConnectionGroup) {
|
||||||
|
if (group.origins.length > 1) {
|
||||||
|
return `${group.connections.length} соединений · ${group.origins.length} устройств · ${group.protocol}`;
|
||||||
|
}
|
||||||
if (group.connections.length === 1) {
|
if (group.connections.length === 1) {
|
||||||
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
|
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
|
||||||
}
|
}
|
||||||
@@ -207,7 +224,7 @@ function groupDestination(group: TrafficConnectionGroup) {
|
|||||||
return port === null ? domain : `${domain} · порт ${port}`;
|
return port === null ? domain : `${domain} · порт ${port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrafficGroupRow({
|
export function TrafficGroupRow({
|
||||||
group,
|
group,
|
||||||
expanded,
|
expanded,
|
||||||
exiting,
|
exiting,
|
||||||
@@ -224,9 +241,11 @@ function TrafficGroupRow({
|
|||||||
const onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
|
const onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
|
||||||
const source = onlyConnection
|
const source = onlyConnection
|
||||||
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
|
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
|
||||||
|
: group.origins.length > 1
|
||||||
|
? `${group.origins.length} устройств · соединений: ${group.connections.length}`
|
||||||
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
||||||
const chain = group.route.chain.length
|
const chain = group.route.chain.length
|
||||||
? group.route.chain.join(' → ')
|
? group.route.chain.join(' / ')
|
||||||
: group.route.outbound || '—';
|
: group.route.outbound || '—';
|
||||||
|
|
||||||
return <div
|
return <div
|
||||||
@@ -248,35 +267,36 @@ function TrafficGroupRow({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
<span className="client-traffic-identity">
|
<span className="client-traffic-identity">
|
||||||
<strong aria-label={group.connections.length > 1
|
<strong title={group.label}>{group.label}</strong>
|
||||||
? `${group.label}, соединений: ${group.connections.length}`
|
|
||||||
: undefined}
|
|
||||||
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
|
|
||||||
<small>{groupStatus(group)}</small>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="client-traffic-route" data-route={group.route.kind}>
|
<span className="client-traffic-download" aria-label={`Скачивание: ${group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : 'соединение завершено'}`}>
|
||||||
{routeLabels[group.route.kind]}
|
{group.activeCount > 0 ? `${formatByteString(group.traffic.downloadBytesPerSecond)}/с` : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="client-traffic-values">
|
<span className="client-traffic-upload" aria-label={`Отправка: ${group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : 'соединение завершено'}`}>
|
||||||
{group.activeCount > 0 && <strong>
|
{group.activeCount > 0 ? `${formatByteString(group.traffic.uploadBytesPerSecond)}/с` : '—'}
|
||||||
<span>↓ {formatByteString(group.traffic.downloadBytesPerSecond)}/с</span>
|
|
||||||
<span>↑ {formatByteString(group.traffic.uploadBytesPerSecond)}/с</span>
|
|
||||||
</strong>}
|
|
||||||
<small>
|
|
||||||
<span>↓ {formatByteString(group.traffic.downloadBytes)}</span>
|
|
||||||
<span>↑ {formatByteString(group.traffic.uploadBytes)}</span>
|
|
||||||
</small>
|
|
||||||
</span>
|
</span>
|
||||||
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<path d="m5 6 3 3 3-3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
{expanded && <dl id={detailsId} className="client-traffic-details">
|
<TrafficReveal open={expanded} id={detailsId}>
|
||||||
|
<dl className="client-traffic-details">
|
||||||
|
<div><dt>Состояние</dt><dd>{groupStatus(group)}</dd></div>
|
||||||
|
<div><dt>Маршрут</dt><dd>{routeLabels[group.route.kind]}</dd></div>
|
||||||
|
<div><dt>Скачано</dt><dd>{formatByteString(group.traffic.downloadBytes)}</dd></div>
|
||||||
|
<div><dt>Отправлено</dt><dd>{formatByteString(group.traffic.uploadBytes)}</dd></div>
|
||||||
|
{group.origins.length > 1 && group.origins.map((origin) => <div
|
||||||
|
className="client-traffic-origin-breakdown"
|
||||||
|
key={origin.id}
|
||||||
|
>
|
||||||
|
<dt>{origin.label}</dt>
|
||||||
|
<dd>
|
||||||
|
Скачано {formatByteString(origin.traffic.downloadBytes)} · отправлено {formatByteString(origin.traffic.uploadBytes)}
|
||||||
|
</dd>
|
||||||
|
</div>)}
|
||||||
<div><dt>Источник</dt><dd>{source}</dd></div>
|
<div><dt>Источник</dt><dd>{source}</dd></div>
|
||||||
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||||
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
||||||
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
||||||
</dl>}
|
</dl>
|
||||||
|
</TrafficReveal>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,25 +328,45 @@ function TrafficState({ feature }: { feature: TrafficFeature }) {
|
|||||||
|
|
||||||
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [deviceQuery, setDeviceQuery] = useState('');
|
||||||
|
const [selectedOriginId, setSelectedOriginId] = useState('');
|
||||||
|
const [settingsPending, setSettingsPending] = useState(false);
|
||||||
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
|
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
|
||||||
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
|
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
|
||||||
const [retentionSeconds, setRetentionSeconds] = useState<RetentionSeconds>(storedRetentionSeconds);
|
|
||||||
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
||||||
const [reducedMotion, setReducedMotion] = useState(() => (
|
const [reducedMotion, setReducedMotion] = useState(() => (
|
||||||
matchMedia('(prefers-reduced-motion: reduce)').matches
|
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
));
|
));
|
||||||
const [expandedId, setExpandedId] = useState('');
|
const [expandedId, setExpandedId] = useState('');
|
||||||
const snapshot = feature.snapshot;
|
const snapshot = feature.snapshot;
|
||||||
|
const { grouping, sort: sortMode, retentionSeconds } = feature.settings;
|
||||||
|
const ordering = JSON.stringify([query, selectedOriginId, routeFilter, qualityFilter, grouping, sortMode]);
|
||||||
|
const previousOrdering = useRef(ordering);
|
||||||
const sourceState = snapshot?.source.state;
|
const sourceState = snapshot?.source.state;
|
||||||
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
||||||
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
||||||
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
||||||
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
||||||
}), [snapshot, snapshotTime, retentionSeconds]);
|
}), [snapshot, snapshotTime, retentionSeconds]);
|
||||||
const trafficGroups = useMemo(() => groupTrafficConnections(retainedConnections), [retainedConnections]);
|
const filteredConnections = useMemo(() => retainedConnections.filter((connection) => (
|
||||||
const groups = useMemo(() => trafficGroups.filter((group) => (
|
trafficConnectionMatchesFilters(connection, routeFilter, qualityFilter)
|
||||||
trafficGroupMatches(group, query, routeFilter, qualityFilter)
|
)), [retainedConnections, routeFilter, qualityFilter]);
|
||||||
)), [trafficGroups, query, routeFilter, qualityFilter]);
|
const origins = useMemo(() => summarizeTrafficOrigins(filteredConnections), [filteredConnections]);
|
||||||
|
const matchingOrigins = useMemo(() => {
|
||||||
|
const needle = deviceQuery.trim().toLocaleLowerCase('ru-RU');
|
||||||
|
return needle ? origins.filter((origin) => (
|
||||||
|
[origin.label, origin.ip].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle))
|
||||||
|
)) : origins;
|
||||||
|
}, [origins, deviceQuery]);
|
||||||
|
const selectedConnections = useMemo(() => selectedOriginId
|
||||||
|
? filteredConnections.filter((connection) => trafficOriginId(connection) === selectedOriginId)
|
||||||
|
: filteredConnections, [filteredConnections, selectedOriginId]);
|
||||||
|
const trafficGroups = useMemo(() => (
|
||||||
|
groupTrafficConnections(selectedConnections, grouping)
|
||||||
|
), [selectedConnections, grouping]);
|
||||||
|
const groups = useMemo(() => sortTrafficGroups(trafficGroups.filter((group) => (
|
||||||
|
trafficGroupMatches(group, query, 'all', 'all')
|
||||||
|
)), sortMode), [trafficGroups, query, sortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
@@ -343,19 +383,29 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
const desiredIds = new Set(groups.map((group) => group.id));
|
const desiredIds = new Set(groups.map((group) => group.id));
|
||||||
setExpandedId((current) => desiredIds.has(current) ? current : '');
|
setExpandedId((current) => desiredIds.has(current) ? current : '');
|
||||||
}
|
}
|
||||||
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
|
const reorder = previousOrdering.current !== ordering;
|
||||||
}, [groups, reducedMotion, snapshot, sourceState]);
|
previousOrdering.current = ordering;
|
||||||
|
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate, !reorder));
|
||||||
|
}, [groups, reducedMotion, snapshot, sourceState, ordering]);
|
||||||
|
|
||||||
function selectRetention(seconds: RetentionSeconds) {
|
useEffect(() => {
|
||||||
setRetentionSeconds(seconds);
|
if (selectedOriginId && !origins.some(({ id }) => id === selectedOriginId)) {
|
||||||
|
setSelectedOriginId('');
|
||||||
|
}
|
||||||
|
}, [origins, selectedOriginId]);
|
||||||
|
|
||||||
|
async function saveSettings(settings: TrafficSettings) {
|
||||||
|
if (settingsPending) return;
|
||||||
|
setSettingsPending(true);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
|
await feature.updateSettings(settings);
|
||||||
} catch {
|
} finally {
|
||||||
// The setting remains available for this session when storage is unavailable.
|
setSettingsPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishExit(id: string) {
|
function finishExit(id: string) {
|
||||||
|
if (groups.some((group) => group.id === id)) return;
|
||||||
setDisplayedGroups((current) => current.filter((row) => (
|
setDisplayedGroups((current) => current.filter((row) => (
|
||||||
row.group.id !== id || !row.exiting
|
row.group.id !== id || !row.exiting
|
||||||
)));
|
)));
|
||||||
@@ -377,42 +427,49 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
open={feature.isOpen}
|
open={feature.isOpen}
|
||||||
labelledBy="client-traffic-title"
|
labelledBy="client-traffic-title"
|
||||||
closeLabel="Закрыть трафик"
|
closeLabel="Закрыть трафик"
|
||||||
|
closeText="Закрыть"
|
||||||
onClose={feature.close}
|
onClose={feature.close}
|
||||||
>
|
>
|
||||||
<header className="client-traffic-header">
|
<header className="client-traffic-header">
|
||||||
<div className="client-traffic-meta">
|
<h2 id="client-traffic-title">Трафик</h2>
|
||||||
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ</span>
|
<button type="button" className="client-traffic-pause" aria-pressed={feature.paused}
|
||||||
<time dateTime={snapshot?.observedAt || undefined}>{updatedAt(snapshot?.observedAt)}</time>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={feature.paused}
|
|
||||||
onClick={feature.togglePause}
|
onClick={feature.togglePause}
|
||||||
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
||||||
</div>
|
|
||||||
<h2 id="client-traffic-title">Трафик</h2>
|
|
||||||
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
<div className="client-traffic-tabs" role="group" aria-label="Режим трафика" data-view={feature.view}>
|
||||||
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
|
<button type="button" aria-pressed={feature.view === 'live'} onClick={() => feature.setView('live')}>Сейчас</button>
|
||||||
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
|
<button type="button" aria-pressed={feature.view === 'history'} onClick={() => feature.setView('history')}>История</button>
|
||||||
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
|
</div>
|
||||||
</div>}
|
<div className="client-traffic-views">
|
||||||
|
<section className={`client-traffic-view${feature.view === 'live' ? ' is-active' : ''}`}
|
||||||
<div className="client-traffic-tools">
|
aria-label="Сейчас" aria-hidden={feature.view !== 'live'} inert={feature.view !== 'live' || undefined}
|
||||||
|
>
|
||||||
|
<div className="client-traffic-toolbar">
|
||||||
<label className="client-traffic-search">
|
<label className="client-traffic-search">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<span className="client-live-region">Поиск соединений</span>
|
||||||
<circle cx="10.5" cy="10.5" r="6" />
|
<input type="search" value={query} aria-label="Найти сайт, IP или сервис" placeholder="Найти сайт"
|
||||||
<path d="m15 15 5 5" />
|
onChange={(event) => setQuery(event.target.value)} />
|
||||||
</svg>
|
|
||||||
<span className="client-live-region">Поиск трафика</span>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
aria-label="Найти домен, сервис или IP"
|
|
||||||
placeholder="Домен, сервис или IP"
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
{feature.isGateway && <TrafficMenu
|
||||||
|
label={origins.find((origin) => origin.id === selectedOriginId)?.label || 'Все устройства'}
|
||||||
|
active={Boolean(selectedOriginId)} closeOnSelect
|
||||||
|
>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={deviceQuery} aria-label="Найти устройство" placeholder="Найти устройство"
|
||||||
|
onChange={(event) => setDeviceQuery(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="client-traffic-device-list">
|
||||||
|
<button type="button" aria-pressed={!selectedOriginId} onClick={() => setSelectedOriginId('')}>Все устройства</button>
|
||||||
|
{matchingOrigins.map((origin) => <button type="button" key={origin.id}
|
||||||
|
aria-pressed={selectedOriginId === origin.id} onClick={() => setSelectedOriginId(origin.id)}
|
||||||
|
>{origin.label}</button>)}
|
||||||
|
</div>
|
||||||
|
{!matchingOrigins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
|
||||||
|
</TrafficMenu>}
|
||||||
|
<TrafficMenu label="Фильтры" active={routeFilter !== 'all' || qualityFilter !== 'all'}>
|
||||||
|
<div className="client-traffic-settings">
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Маршрут</span>
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||||
{([
|
{([
|
||||||
['all', 'Все'],
|
['all', 'Все'],
|
||||||
@@ -426,6 +483,9 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
onClick={() => setRouteFilter(value)}
|
onClick={() => setRouteFilter(value)}
|
||||||
>{label}</button>)}
|
>{label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Распознано</span>
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||||
{([
|
{([
|
||||||
['all', 'Все'],
|
['all', 'Все'],
|
||||||
@@ -438,24 +498,67 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
onClick={() => setQualityFilter(value)}
|
onClick={() => setQualityFilter(value)}
|
||||||
>{label}</button>)}
|
>{label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="client-traffic-retention">
|
</div>
|
||||||
<span>Показывать завершённые</span>
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Группировка</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Группировка соединений">
|
||||||
|
{([
|
||||||
|
['site', 'По сайтам'],
|
||||||
|
['device', 'По устройствам'],
|
||||||
|
] as const).map(([value, label]) => <button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
disabled={settingsPending}
|
||||||
|
aria-pressed={grouping === value}
|
||||||
|
onClick={() => void saveSettings({ ...feature.settings, grouping: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Сортировка</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Сортировка соединений">
|
||||||
|
{([
|
||||||
|
['popular', 'Популярные'],
|
||||||
|
['recent', 'Последние'],
|
||||||
|
] as const).map(([value, label]) => <button
|
||||||
|
type="button"
|
||||||
|
key={value}
|
||||||
|
disabled={settingsPending}
|
||||||
|
aria-pressed={sortMode === value}
|
||||||
|
onClick={() => void saveSettings({ ...feature.settings, sort: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="client-traffic-option-row">
|
||||||
|
<span>Завершённые</span>
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||||
{RETENTION_OPTIONS.map((seconds) => <button
|
{TRAFFIC_RETENTION_OPTIONS.map((seconds) => <button
|
||||||
type="button"
|
type="button"
|
||||||
key={seconds}
|
key={seconds}
|
||||||
|
disabled={settingsPending}
|
||||||
aria-pressed={retentionSeconds === seconds}
|
aria-pressed={retentionSeconds === seconds}
|
||||||
onClick={() => selectRetention(seconds)}
|
onClick={() => void saveSettings({ ...feature.settings, retentionSeconds: seconds })}
|
||||||
>{seconds} с</button>)}
|
>{seconds} с</button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{snapshot && <div className="client-traffic-summary">
|
||||||
|
<span>Распознано: {snapshot.summary.recognized}</span>
|
||||||
|
<span>Без домена: {snapshot.summary.unresolved}</span>
|
||||||
|
<time dateTime={snapshot.observedAt || undefined}>{updatedAt(snapshot.observedAt)}</time>
|
||||||
|
</div>}
|
||||||
|
<p className="client-traffic-honesty">{feature.isGateway
|
||||||
|
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
||||||
|
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}</p>
|
||||||
|
</TrafficMenu>
|
||||||
|
</div>
|
||||||
|
<TrafficColumns />
|
||||||
|
<div className="client-traffic-scroll">
|
||||||
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||||
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||||
</p>}
|
</p>}
|
||||||
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||||
Данные временно не обновляются. Показан последний полученный снимок.
|
Не удалось обновить. Показан последний полученный снимок.
|
||||||
</p>}
|
</p>}
|
||||||
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
||||||
className="client-traffic-notice"
|
className="client-traffic-notice"
|
||||||
@@ -464,7 +567,7 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
Инспектор переподключается. Показан последний полученный снимок.
|
Инспектор переподключается. Показан последний полученный снимок.
|
||||||
</p>}
|
</p>}
|
||||||
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||||
Часть трафика не удалось сопоставить с соединениями: ↓ {formatByteString(snapshot.source.unattributedDownloadBytes)} · ↑ {formatByteString(snapshot.source.unattributedUploadBytes)}.
|
Часть трафика не распознана.
|
||||||
</p>}
|
</p>}
|
||||||
|
|
||||||
<TrafficState feature={feature} />
|
<TrafficState feature={feature} />
|
||||||
@@ -492,12 +595,16 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
||||||
Снимок ограничен 256 соединениями; активные показаны первыми.
|
Показано не более 256 соединений.
|
||||||
</p>}
|
</p>}
|
||||||
<p className="client-traffic-honesty">
|
</div>
|
||||||
{feature.isGateway
|
</section>
|
||||||
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
<section className={`client-traffic-view${feature.view === 'history' ? ' is-active' : ''}`}
|
||||||
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
|
aria-label="История" aria-hidden={feature.view !== 'history'} inert={feature.view !== 'history' || undefined}
|
||||||
</p>
|
>
|
||||||
|
<TrafficHistoryPanel active={feature.isOpen && !feature.paused && feature.view === 'history'}
|
||||||
|
paused={feature.paused} isGateway={feature.isGateway} load={feature.loadHistory} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</Drawer>;
|
</Drawer>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useId, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
assertTrafficHistorySnapshot,
|
||||||
|
parseTrafficHistoryQuery,
|
||||||
|
type TrafficHistoryLevel,
|
||||||
|
type TrafficHistoryQuery,
|
||||||
|
type TrafficHistorySnapshot,
|
||||||
|
} from '../../../shared/trafficHistory.js';
|
||||||
|
import { formatByteString } from '../../utils/format.js';
|
||||||
|
import { TrafficColumns, TrafficMenu, TrafficReveal } from './TrafficControls.js';
|
||||||
|
|
||||||
|
export type LoadTrafficHistory = (query: TrafficHistoryQuery, signal?: AbortSignal) => Promise<unknown>;
|
||||||
|
const nextLevel: Record<TrafficHistoryLevel, TrafficHistoryLevel | null> = {
|
||||||
|
service: 'domain', domain: 'hostname', hostname: 'ip', ip: null,
|
||||||
|
};
|
||||||
|
const levelLabels = { service: 'Сервис', domain: 'Домен', hostname: 'Полное имя', ip: 'IP' };
|
||||||
|
|
||||||
|
function useHistory(active: boolean, query: TrafficHistoryQuery, load: LoadTrafficHistory) {
|
||||||
|
const key = JSON.stringify(query);
|
||||||
|
const [result, setResult] = useState<{ key: string; snapshot: TrafficHistorySnapshot | null; status: 'loading' | 'ready' | 'error' }>({
|
||||||
|
key, snapshot: null, status: 'loading',
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const snapshot = assertTrafficHistorySnapshot(await load(JSON.parse(key) as TrafficHistoryQuery, controller.signal));
|
||||||
|
if (snapshot.storage.status === 'error') throw new Error('History unavailable');
|
||||||
|
if (!controller.signal.aborted) setResult({ key, snapshot, status: 'ready' });
|
||||||
|
} catch {
|
||||||
|
if (!controller.signal.aborted) setResult((previous) => ({
|
||||||
|
key, snapshot: previous.key === key ? previous.snapshot : null, status: 'error',
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) timer = setTimeout(poll, 15_000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timer = setTimeout(poll, 200);
|
||||||
|
return () => { controller.abort(); clearTimeout(timer); };
|
||||||
|
}, [active, key, load]);
|
||||||
|
return result.key === key ? result : { key, snapshot: null, status: 'loading' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryRows({ snapshot, active, load }: { snapshot: TrafficHistorySnapshot; active: boolean; load: LoadTrafficHistory }) {
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const detailsId = useId();
|
||||||
|
const { query } = snapshot;
|
||||||
|
const childLevel = nextLevel[query.level];
|
||||||
|
return <div className="client-traffic-list" role="list" aria-label={`История: ${levelLabels[query.level]}`}>
|
||||||
|
{snapshot.rows.map((row, index) => <div className="client-traffic-history-row" role="listitem" key={row.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="client-traffic-connection-summary"
|
||||||
|
aria-expanded={childLevel ? expanded === row.key : undefined}
|
||||||
|
aria-controls={childLevel ? `${detailsId}-${index}` : undefined}
|
||||||
|
disabled={!childLevel}
|
||||||
|
onClick={() => setExpanded((current) => current === row.key ? null : row.key)}
|
||||||
|
>
|
||||||
|
<span className="client-traffic-identity"><strong title={row.label}>{row.label}</strong></span>
|
||||||
|
<span className="client-traffic-download" aria-label={`Скачивание: ${formatByteString(row.downloadBytes)}`}>{formatByteString(row.downloadBytes)}</span>
|
||||||
|
<span className="client-traffic-upload" aria-label={`Отправка: ${formatByteString(row.uploadBytes)}`}>{formatByteString(row.uploadBytes)}</span>
|
||||||
|
</button>
|
||||||
|
{childLevel && <TrafficReveal open={expanded === row.key} id={`${detailsId}-${index}`} className="client-traffic-history-children">
|
||||||
|
<HistoryBranch key={row.key} active={active && expanded === row.key} load={load} query={{
|
||||||
|
...query, level: childLevel, [query.level]: row.key, offset: 0,
|
||||||
|
}} />
|
||||||
|
</TrafficReveal>}
|
||||||
|
</div>)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryBranch({ active, query, load }: { active: boolean; query: TrafficHistoryQuery; load: LoadTrafficHistory }) {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const requested = useMemo(() => ({ ...query, offset }), [query, offset]);
|
||||||
|
const result = useHistory(active, requested, load);
|
||||||
|
return <>
|
||||||
|
{result.status !== 'ready' && <p className="client-traffic-history-status" role="status">
|
||||||
|
{result.status === 'loading' ? 'Загружаем…' : 'Данные временно недоступны.'}
|
||||||
|
</p>}
|
||||||
|
{result.snapshot && <>
|
||||||
|
<HistoryRows snapshot={result.snapshot} active={active} load={load} />
|
||||||
|
<HistoryPages snapshot={result.snapshot} offset={offset} change={setOffset} />
|
||||||
|
</>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryPages({ snapshot, offset, change }: { snapshot: TrafficHistorySnapshot; offset: number; change: (offset: number) => void }) {
|
||||||
|
if (!offset && snapshot.nextOffset === null) return null;
|
||||||
|
return <div className="client-traffic-filters" role="group" aria-label="Страницы истории">
|
||||||
|
<button type="button" disabled={!offset} onClick={() => change(Math.max(0, offset - 100))}>Назад</button>
|
||||||
|
<button type="button" disabled={snapshot.nextOffset === null} onClick={() => change(snapshot.nextOffset!)}>Далее</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrafficHistoryPanel({ active, paused = false, isGateway, load }: {
|
||||||
|
active: boolean; paused?: boolean; isGateway: boolean; load: LoadTrafficHistory;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState(() => parseTrafficHistoryQuery(new URLSearchParams()));
|
||||||
|
const [deviceSearch, setDeviceSearch] = useState('');
|
||||||
|
const result = useHistory(active, query, load);
|
||||||
|
const snapshot = result.snapshot;
|
||||||
|
const origins = (snapshot?.origins || []).filter((origin) => origin.label.toLocaleLowerCase('ru').includes(deviceSearch.toLocaleLowerCase('ru')));
|
||||||
|
function change(patch: Partial<TrafficHistoryQuery>) { setQuery((current) => ({ ...current, ...patch, offset: 0, until: null })); }
|
||||||
|
const status = paused ? 'История на паузе.'
|
||||||
|
: result.status === 'loading' ? 'Загружаем историю…'
|
||||||
|
: result.status === 'error' ? `История временно недоступна.${snapshot ? ' Показаны последние данные.' : ''}`
|
||||||
|
: snapshot?.source === 'disabled' ? 'Сбор выключен. Показана сохранённая история.'
|
||||||
|
: snapshot?.source === 'stopped' ? 'VPN остановлен. Показана сохранённая история.'
|
||||||
|
: snapshot?.source === 'connecting' ? 'Сборщик подключается. Показана сохранённая история.'
|
||||||
|
: snapshot?.coverage.partial ? 'В периоде есть пропуски.' : '';
|
||||||
|
return <>
|
||||||
|
<div className="client-traffic-toolbar">
|
||||||
|
<div className="client-traffic-filters client-traffic-periods" role="group" aria-label="Период истории">
|
||||||
|
{([['24h', '24 часа'], ['7d', '7 дней'], ['30d', '30 дней'], ['90d', '90 дней']] as const).map(([value, label]) => <button
|
||||||
|
type="button" key={value} aria-pressed={query.range === value} onClick={() => change({ range: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
{isGateway && <TrafficMenu
|
||||||
|
label={snapshot?.origins.find((origin) => origin.id === query.originId)?.label || (query.originId ? 'Выбрано устройство' : 'Все устройства')}
|
||||||
|
active={Boolean(query.originId)} closeOnSelect
|
||||||
|
>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={deviceSearch} aria-label="Найти устройство в истории" placeholder="Найти устройство"
|
||||||
|
onChange={(event) => setDeviceSearch(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="client-traffic-device-list">
|
||||||
|
<button type="button" aria-pressed={!query.originId} onClick={() => change({ originId: '' })}>Все устройства</button>
|
||||||
|
{origins.map((origin) => <button type="button" key={origin.id} aria-pressed={query.originId === origin.id}
|
||||||
|
onClick={() => change({ originId: origin.id })}
|
||||||
|
>{origin.label}</button>)}
|
||||||
|
</div>
|
||||||
|
{!origins.length && <p className="client-traffic-notice">Устройства не найдены.</p>}
|
||||||
|
{snapshot?.originsTruncated && <p className="client-traffic-notice">Показаны первые 256 устройств.</p>}
|
||||||
|
</TrafficMenu>}
|
||||||
|
<TrafficMenu label="Фильтры" active={Boolean(query.search) || query.route !== 'all'}>
|
||||||
|
<label className="client-traffic-search">
|
||||||
|
<input type="search" value={query.search} maxLength={200} aria-label="Найти сайт, IP или сервис в истории" placeholder="Найти сайт"
|
||||||
|
onChange={(event) => change({ search: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<div className="client-traffic-option-row"><span>Маршрут</span>
|
||||||
|
<div className="client-traffic-filters" role="group" aria-label="Маршрут в истории">
|
||||||
|
{([['all', 'Все'], ['vpn', 'VPN'], ['direct', 'Direct'], ['other', 'Другое']] as const).map(([value, label]) => <button
|
||||||
|
type="button" key={value} aria-pressed={query.route === value} onClick={() => change({ route: value })}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{snapshot && <>
|
||||||
|
<div className="client-traffic-summary" aria-label="Расход за выбранный период">
|
||||||
|
<span>Скачано {formatByteString(snapshot.totals.downloadBytes)}</span>
|
||||||
|
<span>Отправлено {formatByteString(snapshot.totals.uploadBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="client-traffic-honesty">
|
||||||
|
{`Данные до ${new Date(snapshot.period.to).toLocaleString('ru-RU')}.`}
|
||||||
|
{snapshot.period.availableFrom && ` Сбор с ${new Date(snapshot.period.availableFrom).toLocaleString('ru-RU')}.`}
|
||||||
|
{snapshot.coverage.lastObservedAt && ` Обновлено ${new Date(snapshot.coverage.lastObservedAt).toLocaleString('ru-RU')}.`}
|
||||||
|
{' '}IP без домена не означает распознанный сайт.
|
||||||
|
</p>
|
||||||
|
</>}
|
||||||
|
</TrafficMenu>
|
||||||
|
</div>
|
||||||
|
<TrafficColumns />
|
||||||
|
<div className="client-traffic-scroll">
|
||||||
|
{status && <p className="client-traffic-history-status" role="status">{status}
|
||||||
|
{snapshot && (paused || result.status === 'error') && ` Обновлено ${new Date(snapshot.generatedAt).toLocaleTimeString('ru-RU')}.`}
|
||||||
|
</p>}
|
||||||
|
{snapshot && <>
|
||||||
|
{snapshot.rows.length ? <HistoryRows key={JSON.stringify(query)} snapshot={snapshot} active={active} load={load} />
|
||||||
|
: <p className="client-traffic-state">{query.search || query.originId || query.route !== 'all'
|
||||||
|
? 'По выбранным фильтрам ничего не найдено.' : 'История пока пуста.'}</p>}
|
||||||
|
<HistoryPages snapshot={snapshot} offset={query.offset}
|
||||||
|
change={(offset) => setQuery((current) => ({ ...current, offset, until: snapshot.query.until }))} />
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export {
|
|||||||
useTrafficFeature,
|
useTrafficFeature,
|
||||||
type TrafficFeature,
|
type TrafficFeature,
|
||||||
} from './TrafficFeature.js';
|
} from './TrafficFeature.js';
|
||||||
|
export type { LoadTrafficHistory } from './TrafficHistoryPanel.js';
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
||||||
|
import type { TrafficGrouping, TrafficSort } from '../../../shared/trafficSettings.js';
|
||||||
import { byteString } from '../../utils/format.js';
|
import { byteString } from '../../utils/format.js';
|
||||||
|
|
||||||
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
|
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
|
||||||
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
|
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
|
||||||
|
export type { TrafficSort } from '../../../shared/trafficSettings.js';
|
||||||
|
|
||||||
|
export interface TrafficOriginSummary {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
ip: string | null;
|
||||||
|
kind: LiveTrafficConnection['origin']['kind'];
|
||||||
|
connections: number;
|
||||||
|
traffic: Pick<LiveTrafficConnection['traffic'], 'uploadBytes' | 'downloadBytes'>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrafficConnectionGroup {
|
export interface TrafficConnectionGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +26,7 @@ export interface TrafficConnectionGroup {
|
|||||||
origin: LiveTrafficConnection['origin'];
|
origin: LiveTrafficConnection['origin'];
|
||||||
destination: LiveTrafficConnection['destination'];
|
destination: LiveTrafficConnection['destination'];
|
||||||
destinationIps: string[];
|
destinationIps: string[];
|
||||||
|
origins: TrafficOriginSummary[];
|
||||||
traffic: LiveTrafficConnection['traffic'];
|
traffic: LiveTrafficConnection['traffic'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +41,11 @@ function normalizedDestination(connection: LiveTrafficConnection) {
|
|||||||
return { domain, ip };
|
return { domain, ip };
|
||||||
}
|
}
|
||||||
|
|
||||||
function trafficGroupId(connection: LiveTrafficConnection) {
|
export function trafficOriginId(connection: LiveTrafficConnection) {
|
||||||
|
return connection.origin.id || `${connection.origin.kind}:${connection.source.ip || 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trafficGroupId(connection: LiveTrafficConnection, grouping: TrafficGrouping) {
|
||||||
const { domain, ip } = normalizedDestination(connection);
|
const { domain, ip } = normalizedDestination(connection);
|
||||||
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
|
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
@@ -37,7 +53,9 @@ function trafficGroupId(connection: LiveTrafficConnection) {
|
|||||||
connection.destination.port,
|
connection.destination.port,
|
||||||
connection.network,
|
connection.network,
|
||||||
connection.protocol?.trim().toLowerCase() || null,
|
connection.protocol?.trim().toLowerCase() || null,
|
||||||
[connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance],
|
grouping === 'device'
|
||||||
|
? [connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance]
|
||||||
|
: null,
|
||||||
[
|
[
|
||||||
connection.route.kind,
|
connection.route.kind,
|
||||||
connection.route.scope,
|
connection.route.scope,
|
||||||
@@ -56,6 +74,13 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
|||||||
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
|
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
|
||||||
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
|
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
|
||||||
.toString();
|
.toString();
|
||||||
|
const origins = new Map<string, LiveTrafficConnection[]>();
|
||||||
|
for (const connection of connections) {
|
||||||
|
const originId = trafficOriginId(connection);
|
||||||
|
const members = origins.get(originId);
|
||||||
|
if (members) members.push(connection);
|
||||||
|
else origins.set(originId, [connection]);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: domain || ip || 'Назначение не определено',
|
label: domain || ip || 'Назначение не определено',
|
||||||
@@ -70,6 +95,19 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
|||||||
const address = destination.ip?.trim().toLowerCase();
|
const address = destination.ip?.trim().toLowerCase();
|
||||||
return address ? [address] : [];
|
return address ? [address] : [];
|
||||||
}))].sort((left, right) => left.localeCompare(right)),
|
}))].sort((left, right) => left.localeCompare(right)),
|
||||||
|
origins: [...origins.entries()].map(([originId, members]) => ({
|
||||||
|
id: originId,
|
||||||
|
label: members[0].origin.label,
|
||||||
|
ip: members[0].source.ip || null,
|
||||||
|
kind: members[0].origin.kind,
|
||||||
|
connections: members.length,
|
||||||
|
traffic: {
|
||||||
|
uploadBytes: sum('uploadBytes', members),
|
||||||
|
downloadBytes: sum('downloadBytes', members),
|
||||||
|
},
|
||||||
|
})).sort((left, right) => (
|
||||||
|
right.connections - left.connections || left.label.localeCompare(right.label)
|
||||||
|
)),
|
||||||
traffic: {
|
traffic: {
|
||||||
uploadBytes: sum('uploadBytes'),
|
uploadBytes: sum('uploadBytes'),
|
||||||
downloadBytes: sum('downloadBytes'),
|
downloadBytes: sum('downloadBytes'),
|
||||||
@@ -79,10 +117,13 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
|
export function groupTrafficConnections(
|
||||||
|
connections: LiveTrafficConnection[],
|
||||||
|
grouping: TrafficGrouping = 'device',
|
||||||
|
) {
|
||||||
const grouped = new Map<string, LiveTrafficConnection[]>();
|
const grouped = new Map<string, LiveTrafficConnection[]>();
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
const id = trafficGroupId(connection);
|
const id = trafficGroupId(connection, grouping);
|
||||||
const members = grouped.get(id);
|
const members = grouped.get(id);
|
||||||
if (members) members.push(connection);
|
if (members) members.push(connection);
|
||||||
else grouped.set(id, [connection]);
|
else grouped.set(id, [connection]);
|
||||||
@@ -90,6 +131,57 @@ export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
|
|||||||
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
|
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function summarizeTrafficOrigins(connections: LiveTrafficConnection[]): TrafficOriginSummary[] {
|
||||||
|
const groups = new Map<string, LiveTrafficConnection[]>();
|
||||||
|
for (const connection of connections) {
|
||||||
|
const id = trafficOriginId(connection);
|
||||||
|
const members = groups.get(id);
|
||||||
|
if (members) members.push(connection);
|
||||||
|
else groups.set(id, [connection]);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([id, members]) => ({
|
||||||
|
id,
|
||||||
|
label: members[0].origin.label,
|
||||||
|
ip: members[0].source.ip || null,
|
||||||
|
kind: members[0].origin.kind,
|
||||||
|
connections: members.length,
|
||||||
|
traffic: {
|
||||||
|
uploadBytes: members.reduce((total, item) => total + byteString(item.traffic.uploadBytes), 0n).toString(),
|
||||||
|
downloadBytes: members.reduce((total, item) => total + byteString(item.traffic.downloadBytes), 0n).toString(),
|
||||||
|
},
|
||||||
|
})).sort((left, right) => (
|
||||||
|
right.connections - left.connections || left.label.localeCompare(right.label) || left.id.localeCompare(right.id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trafficConnectionMatchesFilters(
|
||||||
|
connection: LiveTrafficConnection,
|
||||||
|
route: TrafficRouteFilter,
|
||||||
|
quality: TrafficQualityFilter,
|
||||||
|
) {
|
||||||
|
if (route !== 'all' && connection.route.kind !== route) return false;
|
||||||
|
const recognized = connection.destination.domain !== null;
|
||||||
|
return quality === 'all'
|
||||||
|
|| (quality === 'recognized' && recognized)
|
||||||
|
|| (quality === 'attention' && !recognized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestStartedAt(group: TrafficConnectionGroup) {
|
||||||
|
return group.connections.reduce((latest, connection) => (
|
||||||
|
connection.startedAt > latest ? connection.startedAt : latest
|
||||||
|
), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortTrafficGroups(groups: TrafficConnectionGroup[], mode: TrafficSort) {
|
||||||
|
return [...groups].sort((left, right) => {
|
||||||
|
const frequency = right.connections.length - left.connections.length;
|
||||||
|
const recent = newestStartedAt(right).localeCompare(newestStartedAt(left));
|
||||||
|
const primary = mode === 'popular' ? frequency || recent : recent || frequency;
|
||||||
|
if (primary) return primary;
|
||||||
|
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function trafficGroupMatches(
|
export function trafficGroupMatches(
|
||||||
group: TrafficConnectionGroup,
|
group: TrafficConnectionGroup,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -119,8 +211,18 @@ export function reconcileTrafficGroups(
|
|||||||
current: DisplayedTrafficGroup[],
|
current: DisplayedTrafficGroup[],
|
||||||
desired: TrafficConnectionGroup[],
|
desired: TrafficConnectionGroup[],
|
||||||
immediate: boolean,
|
immediate: boolean,
|
||||||
|
preserveOrder = false,
|
||||||
) {
|
) {
|
||||||
const next = desired.map((group) => ({ group, exiting: false }));
|
const next = desired.map((group) => ({ group, exiting: false }));
|
||||||
|
if (preserveOrder) {
|
||||||
|
const remaining = new Map(next.map((row) => [row.group.id, row]));
|
||||||
|
const retained = current.flatMap((row) => {
|
||||||
|
const updated = remaining.get(row.group.id);
|
||||||
|
remaining.delete(row.group.id);
|
||||||
|
return updated ? [updated] : immediate ? [] : [{ ...row, exiting: true }];
|
||||||
|
});
|
||||||
|
return [...retained, ...remaining.values()];
|
||||||
|
}
|
||||||
if (immediate) return next;
|
if (immediate) return next;
|
||||||
|
|
||||||
const desiredIds = new Set(desired.map((group) => group.id));
|
const desiredIds = new Set(desired.map((group) => group.id));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
||||||
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
||||||
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'failover';
|
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'trafficSettings' | 'failover';
|
||||||
|
|
||||||
export interface OperationState {
|
export interface OperationState {
|
||||||
status: 'running';
|
status: 'running';
|
||||||
@@ -22,6 +22,7 @@ const OPERATION_KEYS: readonly OperationKey[] = [
|
|||||||
'gatewayAuto',
|
'gatewayAuto',
|
||||||
'routeRules',
|
'routeRules',
|
||||||
'diagnosticsSettings',
|
'diagnosticsSettings',
|
||||||
|
'trafficSettings',
|
||||||
'failover',
|
'failover',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,205 @@
|
|||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-dns-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-title-row > .client-diagnostics-refresh-wrap {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row label,
|
||||||
|
.client-dns-domain-row button,
|
||||||
|
.client-dns-domain-row select,
|
||||||
|
.client-dns-inline-form input,
|
||||||
|
.client-dns-inline-form button {
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row label {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
box-shadow: 0 1px color-mix(in oklch, var(--client-border) 48%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row select:focus-visible {
|
||||||
|
box-shadow: 0 1px var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row button:not(.client-row-delete),
|
||||||
|
.client-dns-inline-form button {
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row button:disabled,
|
||||||
|
.client-dns-inline-form button:disabled {
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(90px, 0.65fr) minmax(0, 1.35fr) auto 28px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 2px;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
box-shadow: 0 1px transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input:focus {
|
||||||
|
box-shadow: 0 1px var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input::placeholder {
|
||||||
|
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-table {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header,
|
||||||
|
.client-dns-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 35% minmax(0, 1fr) minmax(0, 1fr) 28px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header {
|
||||||
|
padding: 7px 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header > span,
|
||||||
|
.client-dns-row > span {
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-row {
|
||||||
|
position: relative;
|
||||||
|
min-height: 92px;
|
||||||
|
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-row.is-running .client-dns-resolver {
|
||||||
|
color: var(--client-accent);
|
||||||
|
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-block: 10px;
|
||||||
|
color: var(--client-text);
|
||||||
|
transition: color 420ms ease, text-shadow 520ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver > span:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver b,
|
||||||
|
.client-dns-resolver small,
|
||||||
|
.client-dns-result {
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-resolver small {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result {
|
||||||
|
min-height: 72px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding-block: 10px;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-variant-numeric: var(--numeric-tabular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result > span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result b {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 42px;
|
||||||
|
margin-right: 5px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result small {
|
||||||
|
min-height: 1em;
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result.is-running small {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-result.is-error small {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-row > .client-row-delete {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-note {
|
||||||
|
margin: 5px 8px 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-label);
|
||||||
|
letter-spacing: var(--type-label-tracking);
|
||||||
|
text-transform: var(--type-label-transform);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.client-diagnostics-table th,
|
.client-diagnostics-table th,
|
||||||
.client-diagnostics-table td {
|
.client-diagnostics-table td {
|
||||||
@@ -461,4 +660,57 @@
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row label {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row select {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row button:not(.client-row-delete) {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-domain-row > .client-row-delete {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 44px;
|
||||||
|
padding-block: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form input,
|
||||||
|
.client-dns-inline-form button[type='submit'] {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-inline-form button:last-child {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1 / 4;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header,
|
||||||
|
.client-dns-row {
|
||||||
|
grid-template-columns: 32% minmax(0, 1fr) minmax(0, 1fr) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-dns-header > span,
|
||||||
|
.client-dns-row > span {
|
||||||
|
padding-inline: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
.client-failover-channels { display: grid; grid-template-columns: minmax(0, 1fr) 52px minmax(0, 1fr); gap: 12px; }
|
.client-failover-channels { display: grid; grid-template-columns: minmax(0, 1fr) 52px minmax(0, 1fr); gap: 12px; }
|
||||||
.client-failover-channel-slot { display: contents; }
|
.client-failover-channel-slot { display: contents; }
|
||||||
.client-failover-channel { position: relative; display: grid; align-content: start; gap: 7px; min-width: 0; }
|
.client-failover-channel { position: relative; display: grid; align-content: start; gap: 7px; min-width: 0; }
|
||||||
.client-failover-channel-title { min-height: 24px; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
.client-failover-channel-title { min-height: 24px; }
|
||||||
.client-failover-channel-title h3 { margin: 0; font: var(--type-data); font-variant-numeric: var(--numeric-tabular); letter-spacing: var(--type-data-tracking); text-transform: var(--type-data-transform); }
|
.client-failover-channel-title h3 { margin: 0; font: var(--type-data); font-variant-numeric: var(--numeric-tabular); letter-spacing: var(--type-data-tracking); text-transform: var(--type-data-transform); }
|
||||||
.client-failover-channel[data-channel='primary'] .client-failover-channel-title h3 { color: var(--harbor-connect); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-connect) 30%, transparent); }
|
.client-failover-channel[data-channel='primary'] .client-failover-channel-title h3 { color: var(--harbor-connect); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-connect) 30%, transparent); }
|
||||||
.client-failover-channel[data-channel='reserve'] .client-failover-channel-title h3 { color: var(--harbor-gateway); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-gateway) 30%, transparent); }
|
.client-failover-channel[data-channel='reserve'] .client-failover-channel-title h3 { color: var(--harbor-gateway); text-shadow: 0 0 9px color-mix(in oklch, var(--harbor-gateway) 30%, transparent); }
|
||||||
.client-failover-channel-title strong { min-width: 0; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); text-align: right; white-space: nowrap; transition: color 600ms ease, filter 600ms ease; }
|
.client-failover-channel-status { min-height: 32px; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: repeat(2, minmax(0, 1fr)); align-items: baseline; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); }
|
||||||
.client-failover-channel-title strong.is-healthy { color: var(--client-accent); }
|
.client-failover-channel-status strong { min-width: 0; white-space: nowrap; transition: color 600ms ease, filter 600ms ease; }
|
||||||
.client-failover-channel-title strong.is-unhealthy { color: oklch(0.68 0.15 28); }
|
.client-failover-channel-status strong.is-healthy { color: var(--client-accent); }
|
||||||
|
.client-failover-channel-status strong.is-unhealthy { color: oklch(0.68 0.15 28); }
|
||||||
|
.client-failover-channel-progress { min-width: 0; font-variant-numeric: var(--numeric-tabular); text-align: left; white-space: nowrap; }
|
||||||
.client-failover-picker { position: relative; min-width: 0; z-index: 1; }
|
.client-failover-picker { position: relative; min-width: 0; z-index: 1; }
|
||||||
.client-failover-channel-checking { min-height: 16px; color: var(--client-muted); font: var(--type-control); letter-spacing: var(--type-control-tracking); text-transform: var(--type-control-transform); opacity: 0; visibility: hidden; transition: opacity 180ms ease; }
|
|
||||||
.client-failover-channel-checking.is-visible { opacity: 1; visibility: visible; }
|
|
||||||
.client-failover-channel-actions { grid-column: 2; grid-row: 1; min-height: 66px; display: grid; place-content: center; gap: 2px; }
|
.client-failover-channel-actions { grid-column: 2; grid-row: 1; min-height: 66px; display: grid; place-content: center; gap: 2px; }
|
||||||
.client-failover-check-action, .client-failover-direction-action { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--client-accent); cursor: pointer; }
|
.client-failover-check-action, .client-failover-direction-action { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; background: transparent; color: var(--client-accent); cursor: pointer; }
|
||||||
.client-failover-direction-action.is-primary { color: var(--harbor-connect); }
|
.client-failover-direction-action.is-primary { color: var(--harbor-connect); }
|
||||||
@@ -101,5 +101,6 @@
|
|||||||
.client-failover-number-input:focus-visible { outline: 0; color: var(--client-accent); text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); }
|
.client-failover-number-input:focus-visible { outline: 0; color: var(--client-accent); text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); }
|
||||||
@keyframes failover-service-in { from { opacity: 0; filter: blur(5px); transform: translateY(-4px); } to { opacity: 1; filter: blur(0); transform: translateY(0); } }
|
@keyframes failover-service-in { from { opacity: 0; filter: blur(5px); transform: translateY(-4px); } to { opacity: 1; filter: blur(0); transform: translateY(0); } }
|
||||||
@keyframes failover-service-out { from { opacity: 1; filter: blur(0); transform: translateX(0) scale(1); } to { opacity: 0; filter: blur(6px); transform: translateX(16px) scale(.96); } }
|
@keyframes failover-service-out { from { opacity: 1; filter: blur(0); transform: translateX(0) scale(1); } to { opacity: 0; filter: blur(6px); transform: translateX(16px) scale(.96); } }
|
||||||
@media (max-width: 560px) { .client-failover-heading { align-items: flex-start; flex-wrap: wrap; } .client-failover-heading > h2 { min-width: 0; flex: 1 1 auto; } .client-failover-header-actions { flex: 1 1 100%; justify-content: flex-start; gap: 4px 10px; } .client-failover-channels { grid-template-columns: minmax(0, 1fr); gap: 12px; } .client-failover-channel-actions { grid-column: 1; grid-row: auto; min-height: 44px; display: flex; justify-content: center; gap: 8px; } .client-failover-service { grid-template-columns: minmax(0, 1fr) 32px; gap: 6px 10px; } .client-failover-service > .client-failover-number-setting { grid-column: 1; grid-row: 2; } .client-failover-remove-service { grid-column: 2; grid-row: 1 / span 2; } .client-failover-service-editor { grid-template-columns: 1fr; gap: 2px; } }
|
@media (max-width: 680px) { .client-failover-channels { grid-template-columns: minmax(0, 1fr); gap: 12px; } .client-failover-channel-actions { grid-column: 1; grid-row: auto; min-height: 44px; display: flex; justify-content: center; gap: 8px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .client-failover-channel-title strong, .client-failover-channel-checking, .client-failover-switch::before, .client-failover-switch > span, .client-failover-picker-trigger, .client-failover-picker-trigger::before, .client-failover-picker-trigger svg, .client-failover-picker-list, .client-failover-picker-list button, .client-failover-number-steps, .client-failover-number-control:hover .client-failover-number-steps, .client-failover-number-control:focus-within .client-failover-number-steps, .client-failover-remove-service, .client-failover-remove-lid, .client-failover-direction-action svg { transition: none; } .client-failover-check-action.is-running svg { animation: none; } .client-failover-service, .client-failover-service-editor { animation: none; } }
|
@media (max-width: 560px) { .client-failover-heading { align-items: flex-start; flex-wrap: wrap; } .client-failover-heading > h2 { min-width: 0; flex: 1 1 auto; } .client-failover-header-actions { flex: 1 1 100%; justify-content: flex-start; gap: 4px 10px; } .client-failover-service { grid-template-columns: minmax(0, 1fr) 32px; gap: 6px 10px; } .client-failover-service > .client-failover-number-setting { grid-column: 1; grid-row: 2; } .client-failover-remove-service { grid-column: 2; grid-row: 1 / span 2; } .client-failover-service-editor { grid-template-columns: 1fr; gap: 2px; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .client-failover-channel-status strong, .client-failover-switch::before, .client-failover-switch > span, .client-failover-picker-trigger, .client-failover-picker-trigger::before, .client-failover-picker-trigger svg, .client-failover-picker-list, .client-failover-picker-list button, .client-failover-number-steps, .client-failover-number-control:hover .client-failover-number-steps, .client-failover-number-control:focus-within .client-failover-number-steps, .client-failover-remove-service, .client-failover-remove-lid, .client-failover-direction-action svg { transition: none; } .client-failover-check-action.is-running svg { animation: none; } .client-failover-service, .client-failover-service-editor { animation: none; } }
|
||||||
|
|||||||
+424
-331
@@ -3,139 +3,183 @@
|
|||||||
height: 24px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-sheet {
|
.client-traffic {
|
||||||
padding: 54px 72px 72px 34px;
|
--font-size-control: 0.875rem;
|
||||||
|
--font-size-body: 0.875rem;
|
||||||
|
--font-size-data: 1rem;
|
||||||
|
--font-size-item-title: 1rem;
|
||||||
|
--font-size-drawer-title: 1.5rem;
|
||||||
|
--font-weight-bold: 500;
|
||||||
|
--font-weight-strong: 500;
|
||||||
|
--type-control: var(--font-weight-bold) var(--font-size-control)/var(--line-height-control) var(--font-family-client);
|
||||||
|
--type-body: var(--font-weight-body) var(--font-size-body)/var(--line-height-body) var(--font-family-client);
|
||||||
|
--type-data: var(--font-weight-strong) var(--font-size-data)/var(--line-height-data) var(--font-family-client);
|
||||||
|
--type-item-title: var(--font-weight-bold) var(--font-size-item-title)/var(--line-height-item-title) var(--font-family-client);
|
||||||
|
--type-drawer-title: var(--font-weight-bold) var(--font-size-drawer-title)/var(--line-height-drawer-title) var(--font-family-client);
|
||||||
|
--traffic-value-width: 112px;
|
||||||
|
width: min(800px, 100vw);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.client-traffic-sheet {
|
||||||
.client-traffic {
|
height: 100%;
|
||||||
width: 100vw;
|
min-height: 0;
|
||||||
}
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 112px 80px;
|
||||||
|
grid-template-rows: 44px 56px minmax(0, 1fr);
|
||||||
|
gap: 12px 16px;
|
||||||
|
padding: 28px 78px 24px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-header {
|
.client-traffic-header {
|
||||||
|
grid-area: 1 / 1 / 2 / 3;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 9px;
|
grid-template-columns: minmax(0, 1fr) 112px;
|
||||||
margin: 0 8px 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-meta {
|
|
||||||
min-height: 28px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
padding-right: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-meta > span,
|
|
||||||
.client-traffic-meta time {
|
|
||||||
color: var(--client-muted);
|
|
||||||
font: var(--type-label);
|
|
||||||
letter-spacing: var(--type-label-tracking);
|
|
||||||
text-transform: var(--type-label-transform);
|
|
||||||
font-variant-numeric: var(--numeric-tabular);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-meta time {
|
|
||||||
text-transform: var(--type-label-transform);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-meta button {
|
|
||||||
width: 96px;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--client-text);
|
|
||||||
font: var(--type-control);
|
|
||||||
letter-spacing: var(--type-control-tracking);
|
|
||||||
text-transform: var(--type-control-transform);
|
|
||||||
text-align: right;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-meta button:hover,
|
|
||||||
.client-traffic-meta button:focus-visible,
|
|
||||||
.client-traffic-meta button[aria-pressed='true'] {
|
|
||||||
color: var(--client-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-meta button:focus-visible {
|
|
||||||
outline: 2px solid var(--client-accent);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-header h2 {
|
.client-traffic-header h2 {
|
||||||
margin: 4px 0 0;
|
margin: 0;
|
||||||
font: var(--type-drawer-title);
|
font: var(--type-drawer-title);
|
||||||
letter-spacing: var(--type-drawer-title-tracking);
|
letter-spacing: var(--type-drawer-title-tracking);
|
||||||
text-transform: var(--type-drawer-title-transform);
|
text-transform: var(--type-drawer-title-transform);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-header p {
|
.client-traffic .client-drawer-close {
|
||||||
max-width: 44ch;
|
grid-area: 1 / 3;
|
||||||
|
position: static;
|
||||||
|
width: 80px;
|
||||||
|
height: 44px;
|
||||||
|
font: var(--type-control);
|
||||||
|
letter-spacing: var(--type-control-tracking);
|
||||||
|
text-transform: var(--type-control-transform);
|
||||||
|
text-align: right;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-pause,
|
||||||
|
.client-traffic-menu-trigger,
|
||||||
|
.client-traffic-tabs button,
|
||||||
|
.client-traffic-filters button,
|
||||||
|
.client-traffic-device-list button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font: var(--type-body);
|
font: var(--type-control);
|
||||||
letter-spacing: var(--type-body-tracking);
|
letter-spacing: var(--type-control-tracking);
|
||||||
text-transform: var(--type-body-transform);
|
text-transform: var(--type-control-transform);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-summary {
|
.client-traffic-pause {
|
||||||
display: grid;
|
width: 112px;
|
||||||
gap: 6px;
|
text-align: right;
|
||||||
margin: 0 8px 24px;
|
|
||||||
color: var(--client-text);
|
|
||||||
font: var(--type-data);
|
|
||||||
letter-spacing: var(--type-data-tracking);
|
|
||||||
text-transform: var(--type-data-transform);
|
|
||||||
font-variant-numeric: var(--numeric-tabular);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-summary span {
|
.client-traffic button:enabled:hover,
|
||||||
|
.client-traffic button[aria-pressed='true'],
|
||||||
|
.client-traffic-menu-trigger[data-active='true'],
|
||||||
|
.client-traffic-menu-trigger[aria-expanded='true'] {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic button:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-tabs {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: start;
|
||||||
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-summary b {
|
.client-traffic-tabs button {
|
||||||
min-width: 112px;
|
width: 88px;
|
||||||
color: var(--client-muted);
|
text-align: left;
|
||||||
font: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-tools {
|
.client-traffic-tabs::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 36px;
|
||||||
|
width: 68px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--client-accent);
|
||||||
|
transition: transform 200ms cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-tabs[data-view='history']::after {
|
||||||
|
transform: translateX(112px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-views {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-view {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1), visibility 0s 180ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-view.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-toolbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
margin: 0 8px 22px;
|
align-items: center;
|
||||||
|
gap: 12px 24px;
|
||||||
|
min-height: 56px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-toolbar > .client-traffic-menu:last-child {
|
||||||
|
grid-column: -2;
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-search {
|
.client-traffic-search {
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: 28px minmax(0, 1fr);
|
min-width: 0;
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid var(--client-border);
|
border-bottom: 1px solid var(--client-border);
|
||||||
color: var(--client-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-search:focus-within {
|
.client-traffic-search:focus-within {
|
||||||
border-bottom-color: var(--client-accent);
|
border-bottom-color: var(--client-accent);
|
||||||
color: var(--client-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-search svg {
|
|
||||||
width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
stroke-width: 1.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-search input {
|
.client-traffic-search input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 42px;
|
min-width: 0;
|
||||||
|
height: 40px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
@@ -151,74 +195,255 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-traffic-search input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu-trigger {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu-trigger[data-active='true'],
|
||||||
|
.client-traffic-menu-trigger[aria-expanded='true'] {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-menu-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
width: min(420px, 100%);
|
||||||
|
max-height: min(480px, calc(100dvh - 280px));
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--client-border);
|
||||||
|
background: var(--client-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-device-list {
|
||||||
|
display: grid;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-device-list button {
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-traffic-filters {
|
.client-traffic-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px 18px;
|
gap: 4px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-filters button {
|
.client-traffic-filters button {
|
||||||
min-height: 36px;
|
|
||||||
padding: 4px 0 2px;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
background: transparent;
|
|
||||||
color: var(--client-muted);
|
|
||||||
font: var(--type-control);
|
|
||||||
letter-spacing: var(--type-control-tracking);
|
|
||||||
text-transform: var(--type-control-transform);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-filters button:hover,
|
|
||||||
.client-traffic-filters button:focus-visible,
|
|
||||||
.client-traffic-filters button[aria-pressed='true'] {
|
|
||||||
color: var(--client-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-filters button[aria-pressed='true'] {
|
.client-traffic-filters button[aria-pressed='true'] {
|
||||||
border-bottom-color: var(--client-accent);
|
border-bottom-color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-filters button:focus-visible {
|
.client-traffic-periods {
|
||||||
outline: 2px solid var(--client-accent);
|
gap: 4px 12px;
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-retention {
|
.client-traffic-periods button {
|
||||||
min-height: 36px;
|
white-space: nowrap;
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-retention > span {
|
.client-traffic-option-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-option-row > span {
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font: var(--type-control);
|
font: var(--type-body);
|
||||||
letter-spacing: var(--type-control-tracking);
|
letter-spacing: var(--type-body-tracking);
|
||||||
text-transform: var(--type-control-transform);
|
text-transform: var(--type-body-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns,
|
||||||
|
.client-traffic-connection-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--traffic-value-width) var(--traffic-value-width);
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns {
|
||||||
|
flex: none;
|
||||||
|
min-height: 44px;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
border-bottom: 1px solid var(--client-border);
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: var(--type-body);
|
||||||
|
letter-spacing: var(--type-body-tracking);
|
||||||
|
text-transform: var(--type-body-transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns span:not(:first-child) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection,
|
||||||
|
.client-traffic-history-row {
|
||||||
|
min-width: 0;
|
||||||
|
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection {
|
||||||
|
animation: client-traffic-connection-in 180ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection.is-exiting {
|
||||||
|
pointer-events: none;
|
||||||
|
animation: client-traffic-connection-out 180ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection-summary {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-identity {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-identity strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
font: var(--type-item-title);
|
||||||
|
letter-spacing: var(--type-item-title-tracking);
|
||||||
|
text-transform: var(--type-item-title-transform);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection-summary:enabled:hover .client-traffic-identity strong,
|
||||||
|
.client-traffic-connection-summary[aria-expanded='true'] .client-traffic-identity strong {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-connection-summary:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-download,
|
||||||
|
.client-traffic-upload {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font: var(--type-data);
|
||||||
|
letter-spacing: var(--type-data-tracking);
|
||||||
|
text-transform: var(--type-data-transform);
|
||||||
|
font-variant-numeric: var(--numeric-tabular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-download {
|
||||||
|
color: var(--harbor-connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-upload {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-history-children {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details > div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 112px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details dt,
|
||||||
|
.client-traffic-details dd {
|
||||||
|
margin: 0;
|
||||||
|
font: var(--type-body);
|
||||||
|
letter-spacing: var(--type-body-tracking);
|
||||||
|
text-transform: var(--type-body-transform);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details dt {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-details dd {
|
||||||
|
color: var(--client-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-notice,
|
.client-traffic-notice,
|
||||||
|
.client-traffic-history-status,
|
||||||
.client-traffic-state,
|
.client-traffic-state,
|
||||||
.client-traffic-truncated,
|
.client-traffic-summary,
|
||||||
.client-traffic-honesty {
|
.client-traffic-honesty,
|
||||||
margin: 0 8px;
|
.client-traffic-truncated {
|
||||||
|
margin: 12px 0;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font: var(--type-control);
|
font: var(--type-body);
|
||||||
letter-spacing: var(--type-control-tracking);
|
letter-spacing: var(--type-body-tracking);
|
||||||
text-transform: var(--type-control-transform);
|
text-transform: var(--type-body-transform);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-notice {
|
.client-traffic-summary {
|
||||||
margin-bottom: 14px;
|
display: grid;
|
||||||
color: var(--client-accent);
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-notice.is-warning {
|
.client-traffic-notice.is-warning {
|
||||||
color: oklch(0.68 0.14 72);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-state {
|
.client-traffic-state {
|
||||||
@@ -231,239 +456,107 @@
|
|||||||
.client-traffic-skeleton {
|
.client-traffic-skeleton {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: 0 8px;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-skeleton span {
|
.client-traffic-skeleton span {
|
||||||
height: 58px;
|
height: 48px;
|
||||||
background: color-mix(in oklch, var(--client-border) 24%, transparent);
|
background: color-mix(in oklch, var(--client-border) 24%, transparent);
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-list {
|
|
||||||
display: grid;
|
|
||||||
margin: 0 8px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-connection {
|
|
||||||
min-width: 0;
|
|
||||||
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 58%, transparent);
|
|
||||||
animation: client-traffic-connection-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-connection.is-exiting {
|
|
||||||
pointer-events: none;
|
|
||||||
animation: client-traffic-connection-out 240ms cubic-bezier(0.4, 0, 1, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-connection-summary {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 68px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 54px 154px 18px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--client-text);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-connection-summary:hover,
|
|
||||||
.client-traffic-connection-summary:focus-visible {
|
|
||||||
outline: 0;
|
|
||||||
color: var(--client-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-connection-summary:focus-visible {
|
|
||||||
box-shadow: inset 0 0 0 2px var(--client-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-identity,
|
|
||||||
.client-traffic-values {
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-identity strong {
|
|
||||||
overflow: hidden;
|
|
||||||
font: var(--type-item-title);
|
|
||||||
letter-spacing: var(--type-item-title-tracking);
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-transform: var(--type-item-title-transform);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-identity small {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--client-muted);
|
|
||||||
font: var(--type-control);
|
|
||||||
letter-spacing: var(--type-control-tracking);
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-transform: var(--type-control-transform);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-route {
|
|
||||||
justify-self: start;
|
|
||||||
color: var(--client-muted);
|
|
||||||
font: var(--type-control);
|
|
||||||
letter-spacing: var(--type-control-tracking);
|
|
||||||
text-transform: var(--type-control-transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-route[data-route='vpn'] {
|
|
||||||
color: var(--harbor-connect);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-route[data-route='direct'] {
|
|
||||||
color: var(--harbor-gateway);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-values {
|
|
||||||
justify-items: end;
|
|
||||||
font-variant-numeric: var(--numeric-tabular);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-values strong,
|
|
||||||
.client-traffic-values small {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-values strong {
|
|
||||||
font: var(--type-control);
|
|
||||||
letter-spacing: var(--type-control-tracking);
|
|
||||||
text-transform: var(--type-control-transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-values small {
|
|
||||||
color: var(--client-muted);
|
|
||||||
font: var(--type-micro);
|
|
||||||
letter-spacing: var(--type-micro-tracking);
|
|
||||||
text-transform: var(--type-micro-transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-values span:first-child {
|
|
||||||
color: var(--harbor-connect);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-values span:last-child {
|
|
||||||
color: var(--harbor-gateway);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-chevron {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
stroke-width: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-details {
|
|
||||||
display: grid;
|
|
||||||
gap: 7px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 4px 0 16px;
|
|
||||||
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-details > div {
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 82px minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-details dt,
|
|
||||||
.client-traffic-details dd {
|
|
||||||
margin: 0;
|
|
||||||
font: var(--type-control);
|
|
||||||
letter-spacing: var(--type-control-tracking);
|
|
||||||
text-transform: var(--type-control-transform);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-details dt {
|
|
||||||
color: var(--client-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-details dd {
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
color: var(--client-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-truncated {
|
|
||||||
padding-top: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-traffic-honesty {
|
|
||||||
margin-top: 26px;
|
|
||||||
padding-top: 16px;
|
|
||||||
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes client-traffic-details-in {
|
|
||||||
from { opacity: 0; transform: translateY(-5px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes client-traffic-connection-in {
|
@keyframes client-traffic-connection-in {
|
||||||
from { opacity: 0; transform: translateY(-6px); }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes client-traffic-connection-out {
|
@keyframes client-traffic-connection-out {
|
||||||
from { opacity: 1; transform: translateY(0); }
|
from { opacity: 1; }
|
||||||
to { opacity: 0; transform: translateY(4px); }
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.client-traffic {
|
||||||
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.client-traffic-sheet {
|
.client-traffic-sheet {
|
||||||
padding: 40px 58px 60px 18px;
|
padding: 20px 64px 20px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-meta {
|
.client-traffic-toolbar {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
min-height: 92px;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-meta > span {
|
.client-traffic-toolbar > .client-traffic-search,
|
||||||
|
.client-traffic-periods {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.client-traffic-sheet {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 80px 68px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 56px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-header {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 80px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-pause {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic .client-drawer-close {
|
||||||
|
width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns,
|
||||||
.client-traffic-connection-summary {
|
.client-traffic-connection-summary {
|
||||||
grid-template-columns: minmax(0, 1fr) auto 18px;
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 8px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-values {
|
.client-traffic-columns > span:first-child,
|
||||||
|
.client-traffic-identity {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 2;
|
|
||||||
justify-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-values strong,
|
.client-traffic-columns {
|
||||||
.client-traffic-values small {
|
padding-bottom: 10px;
|
||||||
justify-content: flex-start;
|
}
|
||||||
|
|
||||||
|
.client-traffic-columns > span:nth-child(2),
|
||||||
|
.client-traffic-download {
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-details > div {
|
.client-traffic-details > div {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-traffic-history-children {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.client-traffic button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.client-traffic-connection,
|
.client-traffic *,
|
||||||
.client-traffic-details {
|
.client-traffic-tabs::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface DrawerBaseProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
sheetClassName?: string;
|
sheetClassName?: string;
|
||||||
closeLabel: string;
|
closeLabel: string;
|
||||||
|
closeText?: string;
|
||||||
onClose: () => unknown;
|
onClose: () => unknown;
|
||||||
leading?: ReactNode;
|
leading?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -30,6 +31,7 @@ export function Drawer({
|
|||||||
labelledBy,
|
labelledBy,
|
||||||
label,
|
label,
|
||||||
closeLabel,
|
closeLabel,
|
||||||
|
closeText = '×',
|
||||||
onClose,
|
onClose,
|
||||||
leading,
|
leading,
|
||||||
children,
|
children,
|
||||||
@@ -51,7 +53,7 @@ export function Drawer({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={closeLabel}
|
aria-label={closeLabel}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>×</button>
|
>{closeText}</button>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</aside>;
|
</aside>;
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ test('native traffic contracts and collector restart both Gateway processes', ()
|
|||||||
'src/server/generated/daemon/started_service_pb.ts',
|
'src/server/generated/daemon/started_service_pb.ts',
|
||||||
'src/server/services/liveTrafficService.ts',
|
'src/server/services/liveTrafficService.ts',
|
||||||
'src/shared/liveTraffic.ts',
|
'src/shared/liveTraffic.ts',
|
||||||
|
'.node-version',
|
||||||
|
'scripts/check-sqlite-runtime.mjs',
|
||||||
|
'src/server/services/sqlite.ts',
|
||||||
|
'src/server/services/trafficHistoryStore.ts',
|
||||||
|
'src/server/services/trafficHistoryService.ts',
|
||||||
|
'src/server/services/trafficHistoryWorker.ts',
|
||||||
|
'src/shared/trafficHistory.ts',
|
||||||
]) {
|
]) {
|
||||||
assert.deepEqual(classifyRuntimeImpact([file]), {
|
assert.deepEqual(classifyRuntimeImpact([file]), {
|
||||||
affectedComponents: ['control', 'dataplane'],
|
affectedComponents: ['control', 'dataplane'],
|
||||||
|
|||||||
@@ -5,8 +5,20 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
||||||
|
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
|
import { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
|
||||||
|
|
||||||
|
function serviceFor(t, filePath, now) {
|
||||||
|
const storage = openHarborStorage(path.dirname(filePath));
|
||||||
|
t.after(() => storage.close());
|
||||||
|
return createActivityJournalService({ db: storage.db, now });
|
||||||
|
}
|
||||||
|
function persisted(filePath) {
|
||||||
|
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'), { readOnly: true });
|
||||||
|
try { return { schemaVersion: 1, events: db.prepare('SELECT value FROM journal ORDER BY sequence').all().map((row) => JSON.parse(row.value)) }; }
|
||||||
|
finally { db.close(); }
|
||||||
|
}
|
||||||
function fixture(t) {
|
function fixture(t) {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-journal-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-journal-'));
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
@@ -24,7 +36,7 @@ const event = (dedupeKey, profileLabel = 'Home') => ({
|
|||||||
test('journal appends typed events, deduplicates and keeps stable newest-first cursors', (t) => {
|
test('journal appends typed events, deduplicates and keeps stable newest-first cursors', (t) => {
|
||||||
let clock = new Date('2026-08-19T10:00:00.000Z');
|
let clock = new Date('2026-08-19T10:00:00.000Z');
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
const service = serviceFor(t, filePath, () => clock);
|
||||||
service.append(event('refresh:1', 'One'));
|
service.append(event('refresh:1', 'One'));
|
||||||
clock = new Date('2026-08-19T10:01:00.000Z');
|
clock = new Date('2026-08-19T10:01:00.000Z');
|
||||||
service.append(event('refresh:2', 'Two'));
|
service.append(event('refresh:2', 'Two'));
|
||||||
@@ -38,20 +50,20 @@ test('journal appends typed events, deduplicates and keeps stable newest-first c
|
|||||||
const older = service.page(10, first.nextCursor);
|
const older = service.page(10, first.nextCursor);
|
||||||
assert.deepEqual(older.events.map(({ data }) => data.profileLabel), ['One']);
|
assert.deepEqual(older.events.map(({ data }) => data.profileLabel), ['One']);
|
||||||
assert.equal(first.events[0].dedupeKey, null);
|
assert.equal(first.events[0].dedupeKey, null);
|
||||||
const inode = fs.statSync(filePath).ino;
|
const inode = fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino;
|
||||||
assert.equal(service.page(10).events.length, 3);
|
assert.equal(service.page(10).events.length, 3);
|
||||||
assert.equal(fs.statSync(filePath).ino, inode);
|
assert.equal(fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino, inode);
|
||||||
assert.deepEqual(service.page(10, 'expired-cursor').events, []);
|
assert.deepEqual(service.page(10, 'expired-cursor').events, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('journal prunes events older than 30 days and rejects unsafe payloads', (t) => {
|
test('journal prunes events older than 30 days and rejects unsafe payloads', (t) => {
|
||||||
let clock = new Date('2026-07-01T00:00:00.000Z');
|
let clock = new Date('2026-07-01T00:00:00.000Z');
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
const service = serviceFor(t, filePath, () => clock);
|
||||||
service.append(event('old'));
|
service.append(event('old'));
|
||||||
clock = new Date('2026-08-19T00:00:00.000Z');
|
clock = new Date('2026-08-19T00:00:00.000Z');
|
||||||
assert.deepEqual(service.page().events, []);
|
assert.deepEqual(service.page().events, []);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')).events, []);
|
assert.deepEqual(persisted(filePath).events, []);
|
||||||
service.append(event('new'));
|
service.append(event('new'));
|
||||||
assert.throws(() => service.append({ ...event('unsafe'), data: { rawUrl: 'https://secret' } }), /Unsafe/);
|
assert.throws(() => service.append({ ...event('unsafe'), data: { rawUrl: 'https://secret' } }), /Unsafe/);
|
||||||
service.append({ ...event('ip'), data: { ...event('ip').data, host: '192.168.1.1' } });
|
service.append({ ...event('ip'), data: { ...event('ip').data, host: '192.168.1.1' } });
|
||||||
@@ -68,9 +80,9 @@ test('journal prunes events older than 30 days and rejects unsafe payloads', (t)
|
|||||||
'subscription.refreshed:user:pass',
|
'subscription.refreshed:user:pass',
|
||||||
]) service.append({ ...event('safe'), dedupeKey });
|
]) service.append({ ...event('safe'), dedupeKey });
|
||||||
service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:user:pass' });
|
service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:user:pass' });
|
||||||
const persisted = fs.readFileSync(filePath, 'utf8');
|
const persistedText = JSON.stringify(persisted(filePath));
|
||||||
assert.doesNotMatch(persisted, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
assert.doesNotMatch(persistedText, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
||||||
assert.match(persisted, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
|
assert.match(persistedText, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
|
||||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'https://user:pass@example.test/private?token=x' }), /dedupe/i);
|
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'https://user:pass@example.test/private?token=x' }), /dedupe/i);
|
||||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:private/path' }), /dedupe/i);
|
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:private/path' }), /dedupe/i);
|
||||||
});
|
});
|
||||||
@@ -84,36 +96,24 @@ test('journal persists the 10,000 event cap when opening an oversized store', (t
|
|||||||
...event(`event:${index}`),
|
...event(`event:${index}`),
|
||||||
}));
|
}));
|
||||||
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, events }));
|
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, events }));
|
||||||
const service = createActivityJournalService({
|
const service = serviceFor(t, filePath, () => new Date('2026-08-19T01:00:00.000Z'));
|
||||||
filePath,
|
|
||||||
now: () => new Date('2026-08-19T01:00:00.000Z'),
|
|
||||||
});
|
|
||||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).events.length, 10_000);
|
|
||||||
assert.equal(service.page(1).events.length, 1);
|
assert.equal(service.page(1).events.length, 1);
|
||||||
|
assert.equal(persisted(filePath).events.length, 10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('corrupt journal is isolated and recovery becomes a safe event', (t) => {
|
test('corrupt journal blocks import and leaves the original intact', (t) => {
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
fs.writeFileSync(filePath, '{broken');
|
fs.writeFileSync(filePath, '{broken');
|
||||||
const service = createActivityJournalService({
|
assert.throws(() => serviceFor(t, filePath), /Cannot migrate activity-journal.json/);
|
||||||
filePath,
|
assert.equal(fs.readFileSync(filePath, 'utf8'), '{broken');
|
||||||
now: () => new Date('2026-08-19T12:00:00.000Z'),
|
|
||||||
});
|
|
||||||
const page = service.page();
|
|
||||||
assert.equal(page.storage.status, 'ready');
|
|
||||||
assert.equal(page.events[0].type, 'journal.recovered');
|
|
||||||
assert.ok(fs.readdirSync(path.dirname(filePath)).some((name) => name.includes('.corrupt-')));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('journal exposes a latched write failure until a later append succeeds', (t) => {
|
test('journal exposes a latched write failure until a later append succeeds', (t) => {
|
||||||
const filePath = fixture(t);
|
const filePath = fixture(t);
|
||||||
const service = createActivityJournalService({ filePath });
|
const service = serviceFor(t, filePath);
|
||||||
service.append(event('before-error'));
|
service.append(event('before-error'));
|
||||||
const renameSync = fs.renameSync;
|
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'));
|
||||||
fs.renameSync = (source, target) => {
|
db.exec("CREATE TRIGGER reject_journal BEFORE INSERT ON journal BEGIN SELECT RAISE(ABORT, 'simulated journal failure'); END");
|
||||||
if (target === filePath) throw new Error('simulated journal write failure');
|
|
||||||
return renameSync(source, target);
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
assert.throws(() => service.append(event('lost')));
|
assert.throws(() => service.append(event('lost')));
|
||||||
const failed = service.page();
|
const failed = service.page();
|
||||||
@@ -122,7 +122,8 @@ test('journal exposes a latched write failure until a later append succeeds', (t
|
|||||||
assert.equal(failed.events.length, 1);
|
assert.equal(failed.events.length, 1);
|
||||||
assert.equal(failed.events[0].dedupeKey, null);
|
assert.equal(failed.events[0].dedupeKey, null);
|
||||||
} finally {
|
} finally {
|
||||||
fs.renameSync = renameSync;
|
db.exec('DROP TRIGGER reject_journal');
|
||||||
|
db.close();
|
||||||
}
|
}
|
||||||
service.append(event('recovered'));
|
service.append(event('recovered'));
|
||||||
assert.equal(service.page().storage.status, 'ready');
|
assert.equal(service.page().storage.status, 'ready');
|
||||||
@@ -145,7 +146,7 @@ test('schema version 1 keeps legacy recovery and accepts per-channel health even
|
|||||||
}],
|
}],
|
||||||
}));
|
}));
|
||||||
let clock = new Date('2026-08-19T10:00:00.000Z');
|
let clock = new Date('2026-08-19T10:00:00.000Z');
|
||||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
const service = serviceFor(t, filePath, () => clock);
|
||||||
const inputs = [
|
const inputs = [
|
||||||
['failover.primary_unavailable', 'primary', 'warning', 'probe-failed'],
|
['failover.primary_unavailable', 'primary', 'warning', 'probe-failed'],
|
||||||
['failover.primary_recovered', 'primary', 'info', 'probe-recovered'],
|
['failover.primary_recovered', 'primary', 'info', 'probe-recovered'],
|
||||||
@@ -173,10 +174,10 @@ test('schema version 1 keeps legacy recovery and accepts per-channel health even
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(page.events.at(-1).data, { role: 'primary', reason: 'primary-recovered' });
|
assert.deepEqual(page.events.at(-1).data, { role: 'primary', reason: 'primary-recovered' });
|
||||||
assert.equal(page.retentionDays, 30);
|
assert.equal(page.retentionDays, 30);
|
||||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
assert.equal(persisted(filePath).schemaVersion, 1);
|
||||||
});
|
});
|
||||||
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
|
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
|
||||||
const service = createActivityJournalService({ filePath: fixture(t) });
|
const service = serviceFor(t, fixture(t));
|
||||||
service.append(event('wire'));
|
service.append(event('wire'));
|
||||||
const page = service.page();
|
const page = service.page();
|
||||||
const parsed = assertActivityJournalPage({
|
const parsed = assertActivityJournalPage({
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ test('compiled dispatcher starts and stops control and dataplane contracts', asy
|
|||||||
assert.equal(page.status, 200);
|
assert.equal(page.status, 200);
|
||||||
assert.match(page.type, /^text\/html/);
|
assert.match(page.type, /^text\/html/);
|
||||||
assert.match(page.body, /<div id="root"><\/div>/);
|
assert.match(page.body, /<div id="root"><\/div>/);
|
||||||
|
const historyResponse = await fetch(`http://127.0.0.1:${port}/api/traffic/history?range=90d`);
|
||||||
|
const history = await historyResponse.json();
|
||||||
|
assert.equal(historyResponse.status, 200);
|
||||||
|
assert.equal(history.storage.status, 'ready');
|
||||||
|
assert.equal(history.period.retentionDays, 90);
|
||||||
|
assert.deepEqual(history.rows, []);
|
||||||
|
assert.equal(fs.existsSync(path.join(controlData, 'harbor.sqlite')), true);
|
||||||
await stop(control.child);
|
await stop(control.child);
|
||||||
|
|
||||||
const dataplane = start({
|
const dataplane = start({
|
||||||
@@ -123,6 +130,11 @@ test('compiled dispatcher starts and stops control and dataplane contracts', asy
|
|||||||
return response.status === 200 ? response.body : null;
|
return response.status === 200 ? response.body : null;
|
||||||
}, dataplane.child, dataplane.stderr);
|
}, dataplane.child, dataplane.stderr);
|
||||||
assert.equal(status.ready, true);
|
assert.equal(status.ready, true);
|
||||||
|
const gatewayHistory = await socketRequest(socketPath, '/traffic/history?range=7d');
|
||||||
|
assert.equal(gatewayHistory.status, 200);
|
||||||
|
assert.equal(gatewayHistory.body.storage.status, 'ready');
|
||||||
|
assert.equal(gatewayHistory.body.query.range, '7d');
|
||||||
|
assert.equal(fs.existsSync(path.join(dataplaneData, 'traffic.sqlite')), true);
|
||||||
await stop(dataplane.child);
|
await stop(dataplane.child);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,7 +160,7 @@ test('production paths use only the compiled dispatcher', () => {
|
|||||||
assert.doesNotMatch(entrypoint, /\/app\/src/);
|
assert.doesNotMatch(entrypoint, /\/app\/src/);
|
||||||
}
|
}
|
||||||
assert.match(workflow, /npm run build:production/);
|
assert.match(workflow, /npm run build:production/);
|
||||||
assert.match(workflow, /NODE_BUILD_IMAGE: mirror\.gcr\.io\/library\/node:20\.19-bookworm/);
|
assert.match(workflow, /NODE_BUILD_IMAGE: mirror\.gcr\.io\/library\/node:24\.21\.0-bookworm/);
|
||||||
assert.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/);
|
assert.match(workflow, /command -v npm[^']+command -v git[^']+test -x \/bin\/bash/);
|
||||||
assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/);
|
assert.doesNotMatch(workflow, /docker image inspect "\$\{\{ env\.NODE_BUILD_IMAGE \}\}"/);
|
||||||
assert.match(legacyBuild, /npm run build:production && docker build/);
|
assert.match(legacyBuild, /npm run build:production && docker build/);
|
||||||
|
|||||||
@@ -192,7 +192,15 @@ test('connectivity use case keeps applied priority, selected fallback and error
|
|||||||
test('diagnostics settings validate and replace one canonical revision', () => {
|
test('diagnostics settings validate and replace one canonical revision', () => {
|
||||||
let state = {
|
let state = {
|
||||||
revision: 7,
|
revision: 7,
|
||||||
diagnostics: { configured: false, customServices: [], hiddenServiceIds: [] },
|
diagnostics: {
|
||||||
|
configured: false,
|
||||||
|
customServices: [],
|
||||||
|
hiddenServiceIds: [],
|
||||||
|
customDnsResolvers: [{
|
||||||
|
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||||
|
}],
|
||||||
|
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const useCase = createConnectivityDiagnosticsUseCase({
|
const useCase = createConnectivityDiagnosticsUseCase({
|
||||||
state: {
|
state: {
|
||||||
@@ -212,6 +220,10 @@ test('diagnostics settings validate and replace one canonical revision', () => {
|
|||||||
configured: true,
|
configured: true,
|
||||||
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/status' }],
|
||||||
hiddenServiceIds: ['speedtest'],
|
hiddenServiceIds: ['speedtest'],
|
||||||
|
customDnsResolvers: [{
|
||||||
|
id: 'custom-dns-office', label: 'Office', kind: 'dns', endpoint: '192.168.1.1',
|
||||||
|
}],
|
||||||
|
customDnsDomains: [{ id: 'custom-domain-office', label: 'Office', hostname: 'office.example.com' }],
|
||||||
});
|
});
|
||||||
assert.equal(state.revision, 8);
|
assert.equal(state.revision, 8);
|
||||||
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
assert.throws(() => useCase.updateSettings({ customServices: [], hiddenServiceIds: [] }, 7), {
|
||||||
@@ -245,6 +257,11 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
calls.push(args);
|
calls.push(args);
|
||||||
return { checkedAt: 'now', vpn: { server: null } };
|
return { checkedAt: 'now', vpn: { server: null } };
|
||||||
},
|
},
|
||||||
|
dnsCatalog: async () => ({ resolvers: [{ id: 'google-dns' }], domains: [] }),
|
||||||
|
runDns: async (...args) => {
|
||||||
|
calls.push(['dns', ...args]);
|
||||||
|
return { checkedAt: 'dns-now', results: [] };
|
||||||
|
},
|
||||||
updateSettings: (...args) => calls.push(['settings', ...args]),
|
updateSettings: (...args) => calls.push(['settings', ...args]),
|
||||||
},
|
},
|
||||||
readBody: async () => {
|
readBody: async () => {
|
||||||
@@ -276,6 +293,16 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
assert.deepEqual(calls.at(-1), ['settings', body.settings, 4]);
|
||||||
assert.equal(settingsResponse.payload.success, true);
|
assert.equal(settingsResponse.payload.success, true);
|
||||||
|
|
||||||
|
const catalogResponse = routeResponse();
|
||||||
|
assert.equal(await route.handle({ method: 'GET', url: '/api/diagnostics/dns' }, catalogResponse), true);
|
||||||
|
assert.deepEqual(catalogResponse.payload, { resolvers: [{ id: 'google-dns' }], domains: [] });
|
||||||
|
|
||||||
|
body = { domainId: 'youtube', resolverId: 'google-dns' };
|
||||||
|
const dnsResponse = routeResponse();
|
||||||
|
assert.equal(await route.handle({ method: 'POST', url: '/api/diagnostics/dns' }, dnsResponse), true);
|
||||||
|
assert.deepEqual(calls.at(-1), ['dns', 'youtube', 'google-dns']);
|
||||||
|
assert.deepEqual(dnsResponse.payload, { checkedAt: 'dns-now', results: [] });
|
||||||
|
|
||||||
for (const [method, url] of [
|
for (const [method, url] of [
|
||||||
['GET', '/api/diagnostics/connectivity'],
|
['GET', '/api/diagnostics/connectivity'],
|
||||||
['POST', '/api/diagnostics/connectivity?target=all'],
|
['POST', '/api/diagnostics/connectivity?target=all'],
|
||||||
@@ -284,7 +311,7 @@ test('connectivity route preserves exact URL, defaults and raw response', async
|
|||||||
]) {
|
]) {
|
||||||
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
assert.equal(await route.handle({ method, url }, routeResponse()), false);
|
||||||
}
|
}
|
||||||
assert.equal(bodyReads, 3);
|
assert.equal(bodyReads, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
[{ id: 'custom-test', url: 'https://example.com' }],
|
[{ id: 'custom-test', url: 'https://example.com' }],
|
||||||
'site:custom-test',
|
'site:custom-test',
|
||||||
);
|
);
|
||||||
|
await client.getDnsDiagnosticsCatalog([{ id: 'dns' }], [{ id: 'domain' }]);
|
||||||
|
await client.runDnsDiagnostics([{ id: 'dns' }], [{ id: 'domain' }], 'youtube', 'google-dns');
|
||||||
await client.checkConfig({ outbounds: [] });
|
await client.checkConfig({ outbounds: [] });
|
||||||
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
await client.runFailoverProbe('primary', [], 'site:youtube', 9_000);
|
||||||
await client.readFailoverSelector();
|
await client.readFailoverSelector();
|
||||||
@@ -53,6 +55,8 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
'GET /device-policy /run/dataplane.sock',
|
'GET /device-policy /run/dataplane.sock',
|
||||||
'PUT /device-policy /run/dataplane.sock',
|
'PUT /device-policy /run/dataplane.sock',
|
||||||
'POST /diagnostics/connectivity /run/dataplane.sock',
|
'POST /diagnostics/connectivity /run/dataplane.sock',
|
||||||
|
'POST /diagnostics/dns/catalog /run/dataplane.sock',
|
||||||
|
'POST /diagnostics/dns /run/dataplane.sock',
|
||||||
'POST /config/check /run/dataplane.sock',
|
'POST /config/check /run/dataplane.sock',
|
||||||
'POST /failover/probe /run/dataplane.sock',
|
'POST /failover/probe /run/dataplane.sock',
|
||||||
'GET /failover/selector /run/dataplane.sock',
|
'GET /failover/selector /run/dataplane.sock',
|
||||||
@@ -68,10 +72,18 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
target: 'site:custom-test',
|
target: 'site:custom-test',
|
||||||
});
|
});
|
||||||
assert.equal(requests[8].timeoutMs, 25_000);
|
assert.equal(requests[8].timeoutMs, 25_000);
|
||||||
assert.deepEqual(requests[9].body, { config: { outbounds: [] } });
|
assert.deepEqual(requests[9].body, {
|
||||||
assert.deepEqual(requests[10].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||||
assert.equal(requests[10].timeoutMs, 19_000);
|
});
|
||||||
assert.deepEqual(requests[12].body, { role: 'reserve' });
|
assert.deepEqual(requests[10].body, {
|
||||||
|
customResolvers: [{ id: 'dns' }], customDomains: [{ id: 'domain' }],
|
||||||
|
domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.equal(requests[10].timeoutMs, 40_000);
|
||||||
|
assert.deepEqual(requests[11].body, { config: { outbounds: [] } });
|
||||||
|
assert.deepEqual(requests[12].body, { role: 'primary', services: [], target: 'site:youtube', timeoutMs: 9_000 });
|
||||||
|
assert.equal(requests[12].timeoutMs, 19_000);
|
||||||
|
assert.deepEqual(requests[14].body, { role: 'reserve' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connectivity diagnostics expose a retryable domain error', async () => {
|
test('connectivity diagnostics expose a retryable domain error', async () => {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ test('gateway deploy updates control without recreating dataplane', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Gateway native API credentials remain private to the dataplane volume', () => {
|
test('Gateway native API credentials remain private to the dataplane volume', () => {
|
||||||
assert.match(compose, /SING_BOX_TRAFFIC_SOURCE: \$\{SING_BOX_TRAFFIC_SOURCE:-snapshot\}/);
|
assert.equal(compose.split('SING_BOX_TRAFFIC_SOURCE: ${SING_BOX_TRAFFIC_SOURCE:-native}').length - 1, 2);
|
||||||
|
assert.equal(deploy.split('SING_BOX_TRAFFIC_SOURCE: \\${SING_BOX_TRAFFIC_SOURCE:-native}').length - 1, 2);
|
||||||
assert.match(compose, /vpn-proxy-dataplane:[\s\S]*SING_BOX_API_SECRET: \/var\/lib\/sing-box\/api\.secret[\s\S]*sing-box-cache:\/var\/lib\/sing-box/);
|
assert.match(compose, /vpn-proxy-dataplane:[\s\S]*SING_BOX_API_SECRET: \/var\/lib\/sing-box\/api\.secret[\s\S]*sing-box-cache:\/var\/lib\/sing-box/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
compose.match(/vpn-proxy-control:[\s\S]*?(?=\nvolumes:)/)?.[0] || '',
|
compose.match(/vpn-proxy-control:[\s\S]*?(?=\nvolumes:)/)?.[0] || '',
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDnsQuery,
|
||||||
|
createDnsDiagnosticsService,
|
||||||
|
parseDnsResponse,
|
||||||
|
} from '../../dist/server/services/dnsDiagnosticsService.js';
|
||||||
|
|
||||||
|
function wireResponse(query, { rcode = 0, truncated = false, address = null } = {}) {
|
||||||
|
const header = Buffer.alloc(12);
|
||||||
|
query.copy(header, 0, 0, 2);
|
||||||
|
header.writeUInt16BE(0x8180 | rcode | (truncated ? 0x0200 : 0), 2);
|
||||||
|
header.writeUInt16BE(1, 4);
|
||||||
|
header.writeUInt16BE(address ? 1 : 0, 6);
|
||||||
|
const question = query.subarray(12);
|
||||||
|
if (!address) return Buffer.concat([header, question]);
|
||||||
|
const type = query.readUInt16BE(query.length - 4);
|
||||||
|
const data = type === 1
|
||||||
|
? Buffer.from(address.split('.').map(Number))
|
||||||
|
: Buffer.from(address.split(':').flatMap((part) => {
|
||||||
|
const value = Number.parseInt(part || '0', 16);
|
||||||
|
return [value >> 8, value & 0xff];
|
||||||
|
}));
|
||||||
|
const answer = Buffer.alloc(12);
|
||||||
|
answer.writeUInt16BE(0xc00c, 0);
|
||||||
|
answer.writeUInt16BE(type, 2);
|
||||||
|
answer.writeUInt16BE(1, 4);
|
||||||
|
answer.writeUInt32BE(60, 6);
|
||||||
|
answer.writeUInt16BE(data.length, 10);
|
||||||
|
return Buffer.concat([header, question, answer, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressFor(packet, path = 'direct') {
|
||||||
|
return packet.readUInt16BE(packet.length - 4) === 1
|
||||||
|
? path === 'direct' ? '8.8.8.8' : '9.9.9.9'
|
||||||
|
: path === 'direct' ? '2001:db8:0:0:0:0:0:1' : '2001:db8:0:0:0:0:0:2';
|
||||||
|
}
|
||||||
|
|
||||||
|
test('DNS wire codec parses compressed A/AAAA and rejects malformed packets', () => {
|
||||||
|
const aQuery = buildDnsQuery('www.youtube.com', 'A', 17);
|
||||||
|
const aaaaQuery = buildDnsQuery('www.youtube.com', 'AAAA', 18);
|
||||||
|
assert.deepEqual(parseDnsResponse(wireResponse(aQuery, { address: '8.8.8.8' }), 17), {
|
||||||
|
rcode: 'NOERROR', truncated: false, ipv4: ['8.8.8.8'], ipv6: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseDnsResponse(wireResponse(aaaaQuery, {
|
||||||
|
address: '2001:db8:0:0:0:0:0:1',
|
||||||
|
}), 18).ipv6, ['2001:db8:0:0:0:0:0:1']);
|
||||||
|
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 3 }), 17).rcode, 'NXDOMAIN');
|
||||||
|
assert.equal(parseDnsResponse(wireResponse(aQuery, { rcode: 2 }), 17).rcode, 'SERVFAIL');
|
||||||
|
assert.throws(() => parseDnsResponse(Buffer.from([0, 1]), 1), /malformed/);
|
||||||
|
assert.throws(() => parseDnsResponse(wireResponse(aQuery), 99), /unexpected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DNS checker keeps Direct and VPN independent and never probes VPN when it is off', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const classicExchange = async ({ path: route, resolver, packet }) => {
|
||||||
|
calls.push([route, resolver.endpoint, packet.readUInt16BE(packet.length - 4)]);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'udp',
|
||||||
|
latencyMs: route === 'direct' ? 10 : 20,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080, getServers: () => [], classicExchange,
|
||||||
|
now: () => '2026-09-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
const running = await service.run({ vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns' });
|
||||||
|
assert.deepEqual(calls.map(([route]) => route).sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||||
|
assert.equal(running.results[0].comparison, 'different');
|
||||||
|
assert.deepEqual(running.results[0].direct.ipv4, ['8.8.8.8']);
|
||||||
|
assert.deepEqual(running.results[0].vpn.ipv6, ['2001:db8:0:0:0:0:0:2']);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
const stopped = await service.run({ vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns' });
|
||||||
|
assert.deepEqual(calls.map(([route]) => route), ['direct', 'direct']);
|
||||||
|
assert.equal(stopped.results[0].vpn.status, 'vpn-off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DNS checker retries timeout once and uses TCP fallback on a truncated response', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const timedOut = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
classicExchange: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
const error = new Error('timeout');
|
||||||
|
error.code = 'ETIMEDOUT';
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const timeoutResult = await timedOut.run({
|
||||||
|
vpnAvailable: false, domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.equal(attempts, 4);
|
||||||
|
assert.equal(timeoutResult.results[0].direct.status, 'timeout');
|
||||||
|
|
||||||
|
const fallbackCalls = [];
|
||||||
|
const fallback = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
classicExchange: async ({ packet }) => ({
|
||||||
|
response: wireResponse(packet, { truncated: true }), transport: 'udp', latencyMs: 2,
|
||||||
|
}),
|
||||||
|
tcpExchange: async ({ path: route, packet }) => {
|
||||||
|
fallbackCalls.push(route);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'tcp', latencyMs: 4,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fallbackResult = await fallback.run({
|
||||||
|
vpnAvailable: true, domainId: 'youtube', resolverId: 'google-dns',
|
||||||
|
});
|
||||||
|
assert.deepEqual(fallbackCalls.sort(), ['direct', 'direct', 'vpn', 'vpn']);
|
||||||
|
assert.equal(fallbackResult.results[0].direct.transport, 'tcp');
|
||||||
|
assert.equal(fallbackResult.results[0].vpn.transport, 'tcp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom DoH is pinned only to public bootstrap addresses', async () => {
|
||||||
|
const resolver = {
|
||||||
|
id: 'custom-dns-office', label: 'Office DoH', kind: 'doh', endpoint: 'https://dns.example/dns-query',
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
lookup: async () => [{ address: '93.184.216.34', family: 4 }],
|
||||||
|
dohExchange: async ({ path: route, resolver: pinned, packet }) => {
|
||||||
|
calls.push([route, pinned.bootstrap]);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: 'doh', latencyMs: 7,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.run({
|
||||||
|
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['direct', '93.184.216.34'], ['direct', '93.184.216.34'],
|
||||||
|
['vpn', '93.184.216.34'], ['vpn', '93.184.216.34'],
|
||||||
|
]);
|
||||||
|
assert.equal(result.results[0].direct.transport, 'doh');
|
||||||
|
|
||||||
|
let requests = 0;
|
||||||
|
const blocked = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => [],
|
||||||
|
lookup: async () => [{ address: '127.0.0.1', family: 4 }],
|
||||||
|
dohExchange: async () => { requests += 1; throw new Error('unexpected'); },
|
||||||
|
});
|
||||||
|
const blockedResult = await blocked.run({
|
||||||
|
vpnAvailable: true, customResolvers: [resolver], domainId: 'chatgpt', resolverId: resolver.id,
|
||||||
|
});
|
||||||
|
assert.equal(requests, 0);
|
||||||
|
assert.equal(blockedResult.results[0].direct.error, 'DoH endpoint is not public');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full DNS run includes system and built-in rows with at most three resolver workers', async () => {
|
||||||
|
const active = new Map();
|
||||||
|
let maximumResolvers = 0;
|
||||||
|
const exchange = async ({ path: route, resolver, packet }) => {
|
||||||
|
active.set(resolver.id, (active.get(resolver.id) || 0) + 1);
|
||||||
|
maximumResolvers = Math.max(maximumResolvers, active.size);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||||
|
const count = active.get(resolver.id) - 1;
|
||||||
|
if (count) active.set(resolver.id, count);
|
||||||
|
else active.delete(resolver.id);
|
||||||
|
return {
|
||||||
|
response: wireResponse(packet, { address: addressFor(packet, route) }),
|
||||||
|
transport: resolver.kind === 'doh' ? 'doh' : 'udp',
|
||||||
|
latencyMs: 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const service = createDnsDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
getServers: () => ['192.168.1.1'],
|
||||||
|
classicExchange: exchange,
|
||||||
|
dohExchange: exchange,
|
||||||
|
});
|
||||||
|
const catalog = await service.catalog();
|
||||||
|
assert.equal(catalog.resolvers[0].system, true);
|
||||||
|
assert.equal(catalog.resolvers.length, 7);
|
||||||
|
assert.deepEqual(catalog.domains.map(({ hostname }) => hostname), ['www.youtube.com', 'chatgpt.com']);
|
||||||
|
|
||||||
|
const result = await service.run({ vpnAvailable: false, domainId: 'youtube' });
|
||||||
|
assert.equal(result.results.length, 7);
|
||||||
|
assert.ok(maximumResolvers <= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default transports enforce direct bypass, VPN proxy, binary DoH and no redirects', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.resolve(import.meta.dirname, '../../src/server/services/dnsDiagnosticsService.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(source, /path === 'direct'[\s\S]*udpExchange\(address, port/);
|
||||||
|
assert.match(source, /socksHandshake\(3,[\s\S]*socksHandshake\(1,/);
|
||||||
|
assert.match(source, /'--noproxy', '\*'/);
|
||||||
|
assert.match(source, /'--proxy', `http:\/\/127\.0\.0\.1:\$\{proxyPort\}`/);
|
||||||
|
assert.match(source, /'--data-binary', '@-'/);
|
||||||
|
assert.match(source, /'--resolve'/);
|
||||||
|
assert.doesNotMatch(source, /--location/);
|
||||||
|
});
|
||||||
@@ -136,6 +136,22 @@ test('domain traffic accumulates connection deltas by device, service and source
|
|||||||
downloadBytes: '70',
|
downloadBytes: '70',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(service.snapshot().routes, [
|
||||||
|
{
|
||||||
|
deviceId: id,
|
||||||
|
source: 'gateway',
|
||||||
|
outbound: 'vpn',
|
||||||
|
uploadBytes: '22',
|
||||||
|
downloadBytes: '200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deviceId: id,
|
||||||
|
source: 'proxy',
|
||||||
|
outbound: 'vpn',
|
||||||
|
uploadBytes: '22',
|
||||||
|
downloadBytes: '260',
|
||||||
|
},
|
||||||
|
]);
|
||||||
assert.deepEqual(service.snapshot().attributionEvents, {
|
assert.deepEqual(service.snapshot().attributionEvents, {
|
||||||
unresolved_host: '1',
|
unresolved_host: '1',
|
||||||
unknown_device: '1',
|
unknown_device: '1',
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
|
||||||
|
import { migrateStoredState } from '../../dist/server/services/stateStore.js';
|
||||||
|
import { migrateDeviceInventoryState } from '../../dist/server/services/deviceInventoryService.js';
|
||||||
|
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-sqlite-'));
|
||||||
|
const stores = [];
|
||||||
|
t.after(() => {
|
||||||
|
for (const store of stores) if (store.db.isOpen) store.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return { directory, open: () => { const store = openHarborStorage(directory); stores.push(store); return store; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('atomic import preserves revisions, rules, device checkpoints and journal; JSON becomes backup only', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const state = migrateStoredState({ revision: 41, routeRules: [{ type: 'domain_suffix', value: 'example.org', enabled: true }] });
|
||||||
|
const mac = 'aa:bb:cc:dd:ee:ff';
|
||||||
|
const devices = migrateDeviceInventoryState({ revision: 17, traffic: {
|
||||||
|
baselinesByMac: { [mac]: { epoch: 'kernel-1', uploadBytes: '9007199254740993', downloadBytes: '123' } },
|
||||||
|
totalsByMac: { [mac]: { uploadBytes: '9007199254740993', downloadBytes: '987', observedAt: '2026-09-10T10:00:00.000Z' } },
|
||||||
|
} });
|
||||||
|
const event = { id: '00000000-0000-4000-8000-000000000001', occurredAt: new Date().toISOString(),
|
||||||
|
type: 'connection.stopped', severity: 'info', source: 'connection', dedupeKey: null, data: {} };
|
||||||
|
for (const [name, value] of [['state.json', state], ['devices.json', devices], ['activity-journal.json', { schemaVersion: 1, events: [event] }]]) {
|
||||||
|
fs.writeFileSync(path.join(f.directory, name), JSON.stringify(value));
|
||||||
|
}
|
||||||
|
const originals = ['state.json', 'devices.json', 'activity-journal.json'].map((name) => fs.readFileSync(path.join(f.directory, name), 'utf8'));
|
||||||
|
let store = f.open();
|
||||||
|
assert.equal(store.imported, true);
|
||||||
|
assert.deepEqual(store.state.read(), state);
|
||||||
|
assert.deepEqual(store.devices.read(), devices);
|
||||||
|
assert.deepEqual(createActivityJournalService({ db: store.db }).page().events, [event]);
|
||||||
|
store.state.update((value) => ({ ...value, revision: 42 }));
|
||||||
|
store.devices.update((value) => ({ ...value, revision: 18 }));
|
||||||
|
store.close();
|
||||||
|
// Even broken obsolete files cannot override or break the canonical SQL state.
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), '{obsolete');
|
||||||
|
store = f.open();
|
||||||
|
assert.equal(store.imported, false);
|
||||||
|
assert.equal(store.state.read().revision, 42);
|
||||||
|
assert.equal(store.devices.read().revision, 18);
|
||||||
|
assert.equal(store.devices.read().traffic.baselinesByMac[mac].uploadBytes, '9007199254740993');
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'devices.json'), 'utf8'), originals[1]);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'activity-journal.json'), 'utf8'), originals[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed import leaves no partial documents and can be retried after fixing the original', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ revision: 9 }));
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'devices.json'), '{broken');
|
||||||
|
assert.throws(f.open, /Cannot migrate devices.json/);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'devices.json'), 'utf8'), '{broken');
|
||||||
|
const inspect = new DatabaseSync(path.join(f.directory, 'harbor.sqlite'));
|
||||||
|
assert.equal(inspect.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'documents'").get().n, 0);
|
||||||
|
inspect.close();
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'devices.json'), '{}');
|
||||||
|
assert.equal(f.open().state.read().revision, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an optional legacy null subscription cache migrates without changing the backup', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), JSON.stringify({ schemaVersion: 4, revision: 7 }));
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'subscription-cache.json'), 'null');
|
||||||
|
assert.equal(f.open().state.read().revision, 7);
|
||||||
|
assert.equal(fs.readFileSync(path.join(f.directory, 'subscription-cache.json'), 'utf8'), 'null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a database constraint failure rolls back the entire import including journal and marker', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const event = { id: '00000000-0000-4000-8000-000000000001', occurredAt: new Date().toISOString(),
|
||||||
|
type: 'connection.stopped', severity: 'info', source: 'connection', dedupeKey: null, data: {} };
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'activity-journal.json'), JSON.stringify({ schemaVersion: 1, events: [event, event] }));
|
||||||
|
assert.throws(f.open, /UNIQUE/);
|
||||||
|
const db = new DatabaseSync(path.join(f.directory, 'harbor.sqlite'));
|
||||||
|
assert.equal(db.prepare('PRAGMA user_version').get().user_version, 0);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table'").get().n, 0);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('durable DB corruption, future versions and invalid synchronous mutations never fall back to old JSON', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const store = f.open();
|
||||||
|
assert.throws(() => store.state.update(async (state) => state), /synchronous/);
|
||||||
|
assert.equal(store.state.read().revision, 0);
|
||||||
|
store.db.exec('PRAGMA user_version = 99');
|
||||||
|
store.close();
|
||||||
|
fs.writeFileSync(path.join(f.directory, 'state.json'), '{}');
|
||||||
|
assert.throws(f.open, /Unsupported Harbor database/);
|
||||||
|
});
|
||||||
@@ -206,3 +206,48 @@ test('route is unavailable when no traffic collector exists', async () => {
|
|||||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PUT persists validated traffic settings with revision protection and returns canonical state', async () => {
|
||||||
|
let state = { revision: 4, traffic: { grouping: 'site', sort: 'popular', retentionSeconds: 10 } };
|
||||||
|
let sent = false;
|
||||||
|
const route = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: {
|
||||||
|
read: () => state,
|
||||||
|
update: (mutator) => { state = { ...mutator(state), revision: state.revision + 1 }; },
|
||||||
|
},
|
||||||
|
readBody: async () => ({
|
||||||
|
settings: { grouping: 'device', sort: 'recent', retentionSeconds: 30 },
|
||||||
|
expectedRevision: 4,
|
||||||
|
}),
|
||||||
|
sendState: async (res) => { sent = true; res.end(JSON.stringify(state)); },
|
||||||
|
});
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
assert.equal(await route.handle({ method: 'PUT', url: '/api/traffic/settings' }, res), true);
|
||||||
|
assert.equal(sent, true);
|
||||||
|
assert.deepEqual(state.traffic, { grouping: 'device', sort: 'recent', retentionSeconds: 30 });
|
||||||
|
assert.equal(state.revision, 5);
|
||||||
|
|
||||||
|
const conflict = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||||
|
readBody: async () => ({ settings: state.traffic, expectedRevision: 4 }),
|
||||||
|
sendState: async () => {},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
conflict.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||||
|
(error) => error.code === 'STATE_CONFLICT',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||||
|
readBody: async () => ({ settings: { grouping: 'guess' }, expectedRevision: 5 }),
|
||||||
|
sendState: async () => {},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
invalid.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const snapshot = {
|
|||||||
proxyUploadBytes: '0',
|
proxyUploadBytes: '0',
|
||||||
proxyDownloadBytes: '0',
|
proxyDownloadBytes: '0',
|
||||||
proxyTrafficObservedAt: null,
|
proxyTrafficObservedAt: null,
|
||||||
|
appliedPolicy: 'direct',
|
||||||
}],
|
}],
|
||||||
directTraffic: {
|
directTraffic: {
|
||||||
observedAt,
|
observedAt,
|
||||||
@@ -80,6 +81,12 @@ const snapshot = {
|
|||||||
outbound: 'vpn',
|
outbound: 'vpn',
|
||||||
uploadBytes: '55',
|
uploadBytes: '55',
|
||||||
downloadBytes: '666',
|
downloadBytes: '666',
|
||||||
|
}, {
|
||||||
|
deviceId: 'dev_0011223344556677',
|
||||||
|
source: 'proxy',
|
||||||
|
outbound: 'vpn',
|
||||||
|
uploadBytes: '12',
|
||||||
|
downloadBytes: '345',
|
||||||
}],
|
}],
|
||||||
series: [{
|
series: [{
|
||||||
deviceId: 'dev_0011223344556677',
|
deviceId: 'dev_0011223344556677',
|
||||||
@@ -98,6 +105,8 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
|
|||||||
assert.match(output, /# TYPE harbor_traffic_bytes_total counter/);
|
assert.match(output, /# TYPE harbor_traffic_bytes_total counter/);
|
||||||
assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/);
|
assert.match(output, /harbor_traffic_bytes_total\{source="gateway"\} 9007199254740993/);
|
||||||
assert.match(output, /harbor_device_info\{device_id="dev_0011223344556677",name="ТВ \\"Зал\\"\\\\основной\\nэкран",ip="192\.168\.50\.7"\} 1/);
|
assert.match(output, /harbor_device_info\{device_id="dev_0011223344556677",name="ТВ \\"Зал\\"\\\\основной\\nэкран",ip="192\.168\.50\.7"\} 1/);
|
||||||
|
assert.match(output, /harbor_device_applied_policy\{device_id="dev_0011223344556677"\} 0/);
|
||||||
|
assert.doesNotMatch(output, /harbor_device_applied_policy\{[^\n]*(?:name|ip|mac|desired|status)=/);
|
||||||
assert.match(output, /harbor_device_traffic_bytes_total\{device_id="dev_0011223344556677",source="gateway",direction="upload"\} 9007199254740993/);
|
assert.match(output, /harbor_device_traffic_bytes_total\{device_id="dev_0011223344556677",source="gateway",direction="upload"\} 9007199254740993/);
|
||||||
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*name=/);
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*name=/);
|
||||||
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/);
|
assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/);
|
||||||
@@ -112,6 +121,7 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na
|
|||||||
assert.match(output, /harbor_singbox_tracked_bytes_total\{source="gateway",outbound="vpn",direction="download"\} 777/);
|
assert.match(output, /harbor_singbox_tracked_bytes_total\{source="gateway",outbound="vpn",direction="download"\} 777/);
|
||||||
assert.match(output, /# TYPE harbor_device_singbox_tracked_bytes_total counter/);
|
assert.match(output, /# TYPE harbor_device_singbox_tracked_bytes_total counter/);
|
||||||
assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="gateway",outbound="vpn",direction="upload"\} 55/);
|
assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="gateway",outbound="vpn",direction="upload"\} 55/);
|
||||||
|
assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="proxy",outbound="vpn",direction="download"\} 345/);
|
||||||
assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/);
|
assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/);
|
||||||
assert.match(output, /harbor_domain_traffic_last_observed_timestamp_seconds 1786183200/);
|
assert.match(output, /harbor_domain_traffic_last_observed_timestamp_seconds 1786183200/);
|
||||||
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/);
|
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/);
|
||||||
@@ -181,6 +191,19 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v
|
|||||||
const invalidShadow = structuredClone(snapshot);
|
const invalidShadow = structuredClone(snapshot);
|
||||||
invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5';
|
invalidShadow.domainTraffic.source.shadow.uploadDifferenceBytes = '1.5';
|
||||||
assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/);
|
assert.throws(() => renderPrometheusMetrics(invalidShadow), /Invalid Prometheus gauge/);
|
||||||
|
const invalidPolicy = structuredClone(snapshot);
|
||||||
|
invalidPolicy.devices[0].appliedPolicy = 'future';
|
||||||
|
assert.throws(() => renderPrometheusMetrics(invalidPolicy), /Invalid applied device policy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Prometheus applied policy gauge reports VPN without changing series identity', () => {
|
||||||
|
const vpn = structuredClone(snapshot);
|
||||||
|
vpn.devices[0].appliedPolicy = 'vpn';
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
renderPrometheusMetrics(vpn),
|
||||||
|
/harbor_device_applied_policy\{device_id="dev_0011223344556677"\} 1/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function routeResponse() {
|
function routeResponse() {
|
||||||
|
|||||||
@@ -223,6 +223,29 @@ for (const incompatible of [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('collector stop interrupts an active retry delay', async () => {
|
||||||
|
const service = createLiveTrafficService({
|
||||||
|
port: 19091,
|
||||||
|
enabled: true,
|
||||||
|
isRuntimeRunning: () => true,
|
||||||
|
clientFactory: () => ({
|
||||||
|
async getVersion() { return { version: '1.13.18', apiVersion: 4 }; },
|
||||||
|
async getStartedAt() { throw new Error('must not start'); },
|
||||||
|
subscribeConnections() { throw new Error('must not subscribe'); },
|
||||||
|
subscribeStatus() { throw new Error('must not subscribe'); },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
await waitFor(() => service.snapshot().source.state === 'incompatible');
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const stoppedBeforeNextTurn = await Promise.race([
|
||||||
|
service.stop().then(() => true),
|
||||||
|
new Promise((resolve) => setImmediate(() => resolve(false))),
|
||||||
|
]);
|
||||||
|
assert.equal(stoppedBeforeNextTurn, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('collector reconnects when either RC5 stream ends and cancels its sibling', async () => {
|
test('collector reconnects when either RC5 stream ends and cancels its sibling', async () => {
|
||||||
let factoryCalls = 0;
|
let factoryCalls = 0;
|
||||||
let authorizationCalls = 0;
|
let authorizationCalls = 0;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import http from 'node:http';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js';
|
import { buildGatewayPresence } from '../../dist/server/gatewayPresence.js';
|
||||||
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
import { normalizeSubscriptionConfig } from '../../dist/server/subscription.js';
|
||||||
@@ -151,6 +152,7 @@ async function startClientFixture(t, {
|
|||||||
hostNetwork,
|
hostNetwork,
|
||||||
gatewayPresencePort,
|
gatewayPresencePort,
|
||||||
trafficSource,
|
trafficSource,
|
||||||
|
expectMigrationError,
|
||||||
}) {
|
}) {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-startup-recovery-'));
|
||||||
const { binDirectory, markerPath } = fakeSingbox(directory);
|
const { binDirectory, markerPath } = fakeSingbox(directory);
|
||||||
@@ -188,6 +190,15 @@ async function startClientFixture(t, {
|
|||||||
await stopChild(child);
|
await stopChild(child);
|
||||||
fs.rmSync(directory, { recursive: true, force: true });
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
if (expectMigrationError) {
|
||||||
|
await assert.rejects(waitForState(port, child, () => stderr), expectMigrationError);
|
||||||
|
assert.notEqual(child.exitCode, 0);
|
||||||
|
assert.equal(fs.existsSync(markerPath), false);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'state.json'), 'utf8')), state);
|
||||||
|
assert.equal(fs.readFileSync(path.join(directory, 'subscription-cache.json'), 'utf8'), cacheContents);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, 'sing-box-config.json'), 'utf8')), config);
|
||||||
|
return { directory, markerPath };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
directory,
|
directory,
|
||||||
markerPath,
|
markerPath,
|
||||||
@@ -257,7 +268,9 @@ test('gateway-direct boot keeps the local proxy and diagnostics but omits every
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
|
const config = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'sing-box-config.json'), 'utf8'));
|
||||||
const stored = JSON.parse(fs.readFileSync(path.join(fixture.directory, 'state.json'), 'utf8'));
|
const db = new DatabaseSync(path.join(fixture.directory, 'harbor.sqlite'), { readOnly: true });
|
||||||
|
const stored = JSON.parse(db.prepare("SELECT value FROM documents WHERE key = 'state'").get().value);
|
||||||
|
db.close();
|
||||||
|
|
||||||
assert.equal(fixture.state.route.mode, 'gateway-direct');
|
assert.equal(fixture.state.route.mode, 'gateway-direct');
|
||||||
assert.deepEqual(fixture.state.route.activeLocalRules, []);
|
assert.deepEqual(fixture.state.route.activeLocalRules, []);
|
||||||
@@ -279,23 +292,15 @@ test('corrupt legacy cache with no canonical subscription fails closed instead o
|
|||||||
port: 443,
|
port: 443,
|
||||||
protocol: 'vless',
|
protocol: 'vless',
|
||||||
};
|
};
|
||||||
const fixture = await startClientFixture(t, {
|
await startClientFixture(t, {
|
||||||
state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' },
|
state: { schemaVersion: 4, revision: 2, connectionDesired: 'running' },
|
||||||
cacheContents: '{broken',
|
cacheContents: '{broken',
|
||||||
config: generatedConfig(staleServer),
|
config: generatedConfig(staleServer),
|
||||||
|
expectMigrationError: /Cannot migrate subscription-cache.json/,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(fixture.state.subscription.status, 'missing');
|
test('legacy cache owned by another URL aborts migration without mixing providers or changing originals', async (t) => {
|
||||||
assert.deepEqual(fixture.state.profiles, []);
|
|
||||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
|
||||||
assert.equal(fixture.state.connection.process, 'stopped');
|
|
||||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
|
||||||
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
|
|
||||||
name.startsWith('subscription-cache.json.corrupt-')
|
|
||||||
)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('legacy cache owned by another URL is backed up without mixing providers and boots stopped', async (t) => {
|
|
||||||
const stateServer = {
|
const stateServer = {
|
||||||
id: 'server-a',
|
id: 'server-a',
|
||||||
label: 'State server',
|
label: 'State server',
|
||||||
@@ -314,7 +319,8 @@ test('legacy cache owned by another URL is backed up without mixing providers an
|
|||||||
server: 'cache.example',
|
server: 'cache.example',
|
||||||
server_port: 8443,
|
server_port: 8443,
|
||||||
};
|
};
|
||||||
const fixture = await startClientFixture(t, {
|
await startClientFixture(t, {
|
||||||
|
expectMigrationError: /owner mismatch/,
|
||||||
state: {
|
state: {
|
||||||
schemaVersion: 4,
|
schemaVersion: 4,
|
||||||
revision: 2,
|
revision: 2,
|
||||||
@@ -331,20 +337,6 @@ test('legacy cache owned by another URL is backed up without mixing providers an
|
|||||||
}),
|
}),
|
||||||
config: generatedConfig(stateServer),
|
config: generatedConfig(stateServer),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(fixture.state.profiles.length, 1);
|
|
||||||
assert.equal(fixture.state.profiles[0].subscription.host, 'state.example/…');
|
|
||||||
assert.deepEqual(fixture.state.profiles[0].servers.map(({ id }) => id), [stateServer.id]);
|
|
||||||
assert.equal(JSON.stringify(fixture.state).includes('cache.example'), false);
|
|
||||||
assert.equal(fixture.state.connection.desired, 'stopped');
|
|
||||||
assert.equal(fixture.state.connection.process, 'stopped');
|
|
||||||
assert.equal(fixture.state.selection.appliedServerId, '');
|
|
||||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'subscription-cache.json')), false);
|
|
||||||
assert.ok(fs.readdirSync(fixture.directory).some((name) => (
|
|
||||||
name.startsWith('subscription-cache.json.backup-v1-')
|
|
||||||
)));
|
|
||||||
assert.equal(fs.existsSync(path.join(fixture.directory, 'sing-box-config.json')), false);
|
|
||||||
assert.equal(fs.existsSync(fixture.markerPath), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => {
|
test('boot rejects an existing config whose route mode disagrees with the current route', async (t) => {
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import http from 'node:http';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
function readState(directory) {
|
||||||
|
const db = new DatabaseSync(path.join(directory, 'harbor.sqlite'), { readOnly: true });
|
||||||
|
try { return JSON.parse(db.prepare("SELECT value FROM documents WHERE key = 'state'").get().value); }
|
||||||
|
finally { db.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertStateSnapshot,
|
assertStateSnapshot,
|
||||||
@@ -99,7 +106,7 @@ test('state v1 projects legacy storage through the canonical profile snapshot',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('startup discards a rejected cached subscription and returns to first-run', async (t) => {
|
test('startup rejects an invalid legacy subscription without changing migration originals', async (t) => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const subscriptionUrl = 'https://provider.example/disabled';
|
const subscriptionUrl = 'https://provider.example/disabled';
|
||||||
@@ -141,16 +148,11 @@ test('startup discards a rejected cached subscription and returns to first-run',
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = await waitForState(port, child, () => stderr);
|
await assert.rejects(waitForState(port, child, () => stderr), /Harbor exited early/);
|
||||||
assert.equal(state.subscription.status, 'missing');
|
assert.notEqual(child.exitCode, 0);
|
||||||
assert.equal(state.hasSubscription, false);
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), true);
|
||||||
assert.deepEqual(state.servers, []);
|
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), '{}');
|
||||||
assert.ok(state.route.localRules.some((rule) => (
|
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, subscriptionUrl);
|
||||||
rule.type === 'domain_suffix' && rule.value === 'example.org' && rule.enabled
|
|
||||||
)));
|
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'sing-box-config.json')), false);
|
|
||||||
assert.equal(child.exitCode, null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
|
test('data invariant: canonical profile API mutates one snapshot and preserves migrated profile data', async (t) => {
|
||||||
@@ -305,15 +307,15 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal(initial.route.localRulesRevision, 0);
|
assert.equal(initial.route.localRulesRevision, 0);
|
||||||
assert.equal(initial.route.localRulesPendingRestart, false);
|
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||||
const migratedState = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
const migratedState = readState(dir);
|
||||||
assert.equal(migratedState.schemaVersion, STATE_SCHEMA_VERSION);
|
assert.equal(migratedState.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(migratedState.profiles.length, 1);
|
assert.equal(migratedState.profiles.length, 1);
|
||||||
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
assert.equal(migratedState.profiles[0].subscriptionUrl, subscriptionUrl);
|
||||||
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
assert.equal(migratedState.profiles[0].desiredServerId, testServerId);
|
||||||
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
|
assert.equal(migratedState.profiles[0].subscriptionConfig.outbounds[0].tag, testServerId);
|
||||||
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
|
assert.equal(Object.hasOwn(migratedState, 'subscriptionUrl'), false);
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), true);
|
||||||
assert.ok(fs.readdirSync(dir).some((name) => name.startsWith('subscription-cache.json.backup-v1-')));
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')), { url: subscriptionUrl, config });
|
||||||
const stateKeys = Object.keys(initial).sort();
|
const stateKeys = Object.keys(initial).sort();
|
||||||
assert.deepEqual(stateKeys, [
|
assert.deepEqual(stateKeys, [
|
||||||
'apiVersion',
|
'apiVersion',
|
||||||
@@ -339,6 +341,7 @@ setInterval(() => {}, 60_000);
|
|||||||
'singboxStartedAt',
|
'singboxStartedAt',
|
||||||
'subscription',
|
'subscription',
|
||||||
'subscriptionHost',
|
'subscriptionHost',
|
||||||
|
'traffic',
|
||||||
'userInfo',
|
'userInfo',
|
||||||
]);
|
]);
|
||||||
let revision = initial.revision;
|
let revision = initial.revision;
|
||||||
@@ -372,7 +375,7 @@ setInterval(() => {}, 60_000);
|
|||||||
|
|
||||||
const primaryProfileId = initial.profiles[0].id;
|
const primaryProfileId = initial.profiles[0].id;
|
||||||
const preservedPrimary = structuredClone(
|
const preservedPrimary = structuredClone(
|
||||||
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).profiles[0],
|
readState(dir).profiles[0],
|
||||||
);
|
);
|
||||||
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
const preservedConfig = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
|
||||||
for (const [pathname, expectedCode] of [
|
for (const [pathname, expectedCode] of [
|
||||||
@@ -393,9 +396,9 @@ setInterval(() => {}, 60_000);
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
assert.equal(failedAdd.payload.error.code, expectedCode);
|
assert.equal(failedAdd.payload.error.code, expectedCode);
|
||||||
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
const storedAfterFailure = readState(dir);
|
||||||
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
|
assert.deepEqual(storedAfterFailure.profiles, [preservedPrimary]);
|
||||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
|
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
|
||||||
preservedConfig,
|
preservedConfig,
|
||||||
@@ -474,7 +477,7 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
|
assert.equal(added.state.selection.desiredProfileId, primaryProfileId);
|
||||||
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
|
assert.equal(added.state.profiles.find(({ id }) => id === workProfileId).desiredServerId, '');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'))
|
readState(dir)
|
||||||
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
|
.profiles.find(({ id }) => id === workProfileId).subscriptionConfig.outbounds[0].tag,
|
||||||
testServerId,
|
testServerId,
|
||||||
);
|
);
|
||||||
@@ -516,7 +519,7 @@ setInterval(() => {}, 60_000);
|
|||||||
);
|
);
|
||||||
assert.equal(failedRefresh.response.status, 400);
|
assert.equal(failedRefresh.response.status, 400);
|
||||||
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
|
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
|
||||||
const storedAfterFailedRefresh = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
|
const storedAfterFailedRefresh = readState(dir);
|
||||||
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
|
const staleWorkProfile = storedAfterFailedRefresh.profiles.find(({ id }) => id === workProfileId);
|
||||||
assert.equal(staleWorkProfile.desiredServerId, testServerId);
|
assert.equal(staleWorkProfile.desiredServerId, testServerId);
|
||||||
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
|
assert.equal(staleWorkProfile.lastRefreshErrorCode, 'SUBSCRIPTION_INVALID');
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ test('schema v5 migrates rules and diagnostics settings with an exact backup', (
|
|||||||
configured: false,
|
configured: false,
|
||||||
customServices: [],
|
customServices: [],
|
||||||
hiddenServiceIds: [],
|
hiddenServiceIds: [],
|
||||||
|
customDnsResolvers: [],
|
||||||
|
customDnsDomains: [],
|
||||||
});
|
});
|
||||||
assert.equal(migrated.routeRulesRevision, 7);
|
assert.equal(migrated.routeRulesRevision, 7);
|
||||||
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
assert.deepEqual(migrated.routeRules.map(({ outbound }) => outbound), ['direct', 'direct']);
|
||||||
@@ -137,17 +139,47 @@ test('schema v7 migrates failover disabled without losing canonical state', (t)
|
|||||||
});
|
});
|
||||||
const migrated = store.read();
|
const migrated = store.read();
|
||||||
|
|
||||||
assert.equal(migrated.schemaVersion, 8);
|
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(migrated.revision, 19);
|
assert.equal(migrated.revision, 19);
|
||||||
assert.equal(migrated.routeRulesRevision, 4);
|
assert.equal(migrated.routeRulesRevision, 4);
|
||||||
assert.equal(migrated.failoverPolicy.enabled, false);
|
assert.equal(migrated.failoverPolicy.enabled, false);
|
||||||
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
||||||
assert.equal(migrated.appliedFailoverPolicy, null);
|
assert.equal(migrated.appliedFailoverPolicy, null);
|
||||||
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
||||||
|
assert.deepEqual(migrated.traffic, { grouping: 'site', sort: 'popular', retentionSeconds: 10 });
|
||||||
assert.equal(store.migration.fromVersion, 7);
|
assert.equal(store.migration.fromVersion, 7);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('schema v9 adds empty DNS settings atomically and preserves the exact backup', (t) => {
|
||||||
|
const filePath = fixture(t);
|
||||||
|
const legacy = {
|
||||||
|
schemaVersion: 9,
|
||||||
|
revision: 23,
|
||||||
|
routeRules: [],
|
||||||
|
appliedRouteRules: [],
|
||||||
|
diagnostics: {
|
||||||
|
configured: true,
|
||||||
|
customServices: [{ id: 'custom-status', label: 'Status', url: 'https://example.com/' }],
|
||||||
|
hiddenServiceIds: ['youtube'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bytes = JSON.stringify(legacy);
|
||||||
|
fs.writeFileSync(filePath, bytes);
|
||||||
|
|
||||||
|
const store = createStateStore(filePath, {
|
||||||
|
now: () => new Date('2026-09-01T12:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const migrated = store.read();
|
||||||
|
|
||||||
|
assert.equal(migrated.schemaVersion, 10);
|
||||||
|
assert.equal(migrated.revision, 23);
|
||||||
|
assert.deepEqual(migrated.diagnostics.customDnsResolvers, []);
|
||||||
|
assert.deepEqual(migrated.diagnostics.customDnsDomains, []);
|
||||||
|
assert.equal(store.migration.fromVersion, 9);
|
||||||
|
assert.equal(fs.readFileSync(store.migration.backupPath, 'utf8'), bytes);
|
||||||
|
});
|
||||||
|
|
||||||
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
test('schema v6 rejects missing or unknown outbound without rewriting source bytes', (t) => {
|
||||||
for (const [name, rule] of [
|
for (const [name, rule] of [
|
||||||
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
['missing', { type: 'domain', value: 'example.com', enabled: true }],
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { openTrafficHistoryStore } from '../../dist/server/services/trafficHistoryStore.js';
|
||||||
|
import { createTrafficHistoryService } from '../../dist/server/services/trafficHistoryService.js';
|
||||||
|
import { parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
test('large history preserves totals and serves concurrent readers through cleanup and filters', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-history-load-'));
|
||||||
|
const file = path.join(directory, 'traffic.sqlite');
|
||||||
|
const at = Math.floor(Date.now() / 60_000) * 60_000;
|
||||||
|
const minutes = process.env.HARBOR_HISTORY_LOAD === '1' ? 3000 : 300;
|
||||||
|
let service;
|
||||||
|
t.after(async () => {
|
||||||
|
await service?.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
openTrafficHistoryStore(file).close();
|
||||||
|
const db = new DatabaseSync(file);
|
||||||
|
try {
|
||||||
|
db.exec(`BEGIN;
|
||||||
|
WITH RECURSIVE n(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM n WHERE i<2000)
|
||||||
|
INSERT INTO dimensions SELECT i,'dimension-'||i,'device-'||(i%20),'Device '||(i%20),
|
||||||
|
'192.0.2.1','tproxy-in','service-'||(i%250),'domain-'||(i%250)||'.test',
|
||||||
|
'host-'||i||'.test','203.0.113.'||(i%250),'vpn','vpn-one' FROM n;`);
|
||||||
|
db.prepare(`WITH RECURSIVE minutes(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM minutes WHERE i<?)
|
||||||
|
INSERT INTO buckets SELECT ?-i*60000,60000,d.id,100,200 FROM minutes
|
||||||
|
CROSS JOIN dimensions d WHERE d.id<=1000`).run(minutes, at);
|
||||||
|
db.prepare('INSERT INTO buckets SELECT ?,60000,id,100,200 FROM dimensions WHERE id>1000').run(at - 91 * 86_400_000);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
// Exercise the additive index upgrade on a populated pre-fix database.
|
||||||
|
db.exec('DROP INDEX buckets_dimension_time');
|
||||||
|
} finally { db.close(); }
|
||||||
|
|
||||||
|
service = createTrafficHistoryService({ filePath: file, source: () => 'live' });
|
||||||
|
const query = parseTrafficHistoryQuery(new URLSearchParams(`range=7d&until=${at}`));
|
||||||
|
const start = performance.now();
|
||||||
|
const concurrent = await Promise.all(Array.from({ length: 4 }, () => service.query(query)));
|
||||||
|
const concurrentMs = performance.now() - start;
|
||||||
|
for (const result of concurrent) {
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.equal(result.totals.downloadBytes, String(minutes * 1000 * 200));
|
||||||
|
assert.equal(result.origins.length, 20);
|
||||||
|
assert.equal(result.nextOffset, 100);
|
||||||
|
}
|
||||||
|
const timings = [];
|
||||||
|
for (const [filter, dimensions] of [
|
||||||
|
[{ originId: 'device-1' }, 50],
|
||||||
|
[{ search: 'host-99' }, 11],
|
||||||
|
[{ level: 'ip', service: 'service-1', domain: 'domain-1.test', hostname: 'host-1.test' }, 1],
|
||||||
|
[{ offset: 300 }, 1000],
|
||||||
|
]) {
|
||||||
|
const begin = performance.now();
|
||||||
|
const result = await service.query({ ...query, ...filter });
|
||||||
|
timings.push({ filter, ms: performance.now() - begin });
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.equal(result.totals.downloadBytes, String(minutes * dimensions * 200));
|
||||||
|
if (filter.offset) assert.equal(result.rows.length, 0);
|
||||||
|
}
|
||||||
|
t.diagnostic(JSON.stringify({ buckets: minutes * 1000, concurrentMs, timings }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createTrafficHistoryRoute } from '../../dist/server/http/routes/trafficHistoryRoute.js';
|
||||||
|
import { emptyTrafficHistory, parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
function response() {
|
||||||
|
return { writeHead(status) { this.status = status; }, end(body) { this.payload = JSON.parse(body); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('history API reads only its local source, enriches labels and returns explicit unavailable coverage', async () => {
|
||||||
|
const route = createTrafficHistoryRoute({
|
||||||
|
readHistory: async (query) => ({ ...emptyTrafficHistory(query, 'live'), origins: [{ id: 'dev-a', label: 'IP' }] }),
|
||||||
|
deviceInventory: { snapshot: () => ({ devices: [{ id: 'dev-a', alias: 'Ноутбук' }] }) },
|
||||||
|
});
|
||||||
|
const res = response();
|
||||||
|
assert.equal(await route.handle({ method: 'GET', url: '/api/traffic/history?range=90d&search=yandex' }, res), true);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.payload.query.search, 'yandex');
|
||||||
|
assert.equal(res.payload.period.retentionDays, 90);
|
||||||
|
assert.equal(res.payload.origins[0].label, 'Ноутбук');
|
||||||
|
const failed = createTrafficHistoryRoute({ readHistory: async () => { throw Error('disk'); } });
|
||||||
|
await failed.handle({ method: 'GET', url: '/api/traffic/history' }, res);
|
||||||
|
assert.equal(res.payload.storage.status, 'error');
|
||||||
|
assert.equal(res.payload.coverage.partial, true);
|
||||||
|
await createTrafficHistoryRoute({ readHistory: null }).handle({ method: 'GET', url: '/api/traffic/history' }, res);
|
||||||
|
assert.equal(res.payload.source, 'disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history API rejects invalid queries and mutation methods before reading storage', async () => {
|
||||||
|
let reads = 0;
|
||||||
|
const route = createTrafficHistoryRoute({ readHistory: async (query) => { reads++; return emptyTrafficHistory(query); } });
|
||||||
|
for (const params of ['range=forever', 'level=ip%3BDROP', 'route=no', 'offset=-1', 'until=Infinity', 'search=%00', `search=${'x'.repeat(201)}`]) {
|
||||||
|
await assert.rejects(route.handle({ method: 'GET', url: `/api/traffic/history?${params}` }, response()),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID');
|
||||||
|
}
|
||||||
|
await assert.rejects(route.handle({ method: 'DELETE', url: '/api/traffic/history' }, response()),
|
||||||
|
(error) => error.code === 'ENDPOINT_NOT_FOUND');
|
||||||
|
assert.equal(await route.handle({ method: 'GET', url: '/api/state' }, response()), false);
|
||||||
|
assert.equal(reads, 0);
|
||||||
|
assert.equal(parseTrafficHistoryQuery(new URLSearchParams('range=90d')).range, '90d');
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { Worker } from 'node:worker_threads';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createTrafficHistoryService } from '../../dist/server/services/trafficHistoryService.js';
|
||||||
|
import { openTrafficHistoryStore } from '../../dist/server/services/trafficHistoryStore.js';
|
||||||
|
import { emptyTrafficHistory, parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
const query = parseTrafficHistoryQuery(new URLSearchParams());
|
||||||
|
async function fixture(t) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-history-queue-'));
|
||||||
|
const service = createTrafficHistoryService({ filePath: path.join(directory, 'traffic.sqlite'), source: () => 'live' });
|
||||||
|
await service.flush();
|
||||||
|
const post = Worker.prototype.postMessage;
|
||||||
|
const sent = [];
|
||||||
|
const waiting = [];
|
||||||
|
const held = [];
|
||||||
|
t.mock.method(Worker.prototype, 'postMessage', function (message) {
|
||||||
|
sent.push(message.kind);
|
||||||
|
if (message.kind !== 'query') return post.call(this, message);
|
||||||
|
const job = { worker: this, message };
|
||||||
|
if (waiting.length) waiting.shift()(job);
|
||||||
|
else held.push(job);
|
||||||
|
});
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
t.after(async () => {
|
||||||
|
t.mock.restoreAll();
|
||||||
|
t.mock.timers.reset();
|
||||||
|
await service.close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return { service, sent, file: path.join(directory, 'traffic.sqlite'),
|
||||||
|
next: () => held.length ? Promise.resolve(held.shift()) : new Promise((resolve) => waiting.push(resolve)),
|
||||||
|
answer: ({ worker, message }) => worker.emit('message', { id: message.id, result: emptyTrafficHistory(message.query, 'live') }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('queued reads get their own execution budget and do not reset the collector', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const results = Array.from({ length: 3 }, () => f.service.query(query));
|
||||||
|
const first = await f.next();
|
||||||
|
const worker = first.worker;
|
||||||
|
assert.equal(f.sent.filter((kind) => kind === 'query').length, 1);
|
||||||
|
t.mock.timers.tick(4_000);
|
||||||
|
f.answer(first);
|
||||||
|
for (let index = 1; index < 3; index++) {
|
||||||
|
const next = await f.next();
|
||||||
|
assert.equal(next.worker, worker);
|
||||||
|
t.mock.timers.tick(4_000);
|
||||||
|
f.answer(next);
|
||||||
|
}
|
||||||
|
assert.deepEqual((await Promise.all(results)).map((result) => result.storage.status), ['ready', 'ready', 'ready']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a slow read expires alone, retains its active slot, and lets pending writes go next', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const firstResult = f.service.query(query);
|
||||||
|
const otherResult = f.service.query(query);
|
||||||
|
const first = await f.next();
|
||||||
|
t.mock.timers.tick(5_000);
|
||||||
|
assert.equal((await firstResult).storage.status, 'error');
|
||||||
|
const write = f.service.flush();
|
||||||
|
assert.equal(f.sent.filter((kind) => kind === 'query').length, 1);
|
||||||
|
const before = f.sent.length;
|
||||||
|
f.answer(first);
|
||||||
|
await write;
|
||||||
|
const second = await f.next();
|
||||||
|
assert.deepEqual(f.sent.slice(before), ['ingest', 'query']);
|
||||||
|
assert.equal(first.worker, second.worker);
|
||||||
|
f.answer(second);
|
||||||
|
const result = await otherResult;
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.equal(result.coverage.partial, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a full read queue reserves write capacity and shutdown rejects queued and future reads', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const results = Array.from({ length: 32 }, () => f.service.query(query));
|
||||||
|
const first = await f.next();
|
||||||
|
assert.equal((await f.service.query(query)).storage.status, 'error');
|
||||||
|
const write = f.service.flush();
|
||||||
|
const closing = f.service.close();
|
||||||
|
f.answer(first);
|
||||||
|
await write;
|
||||||
|
await closing;
|
||||||
|
const snapshots = await Promise.all(results);
|
||||||
|
assert.equal(snapshots[0].storage.status, 'ready');
|
||||||
|
assert.ok(snapshots.slice(1).every((result) => result.storage.status === 'error'));
|
||||||
|
const sent = f.sent.length;
|
||||||
|
assert.equal((await f.service.query(query)).storage.status, 'error');
|
||||||
|
await f.service.flush();
|
||||||
|
assert.equal(f.sent.length, sent);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hard recovery waits for the previous worker to exit before starting a replacement', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const firstResult = f.service.query(query);
|
||||||
|
const otherResult = f.service.query(query);
|
||||||
|
const first = await f.next();
|
||||||
|
const terminate = Worker.prototype.terminate;
|
||||||
|
let release;
|
||||||
|
let stopping = 0;
|
||||||
|
const gate = new Promise((resolve) => { release = resolve; });
|
||||||
|
t.mock.method(Worker.prototype, 'terminate', function () {
|
||||||
|
stopping++;
|
||||||
|
return gate.then(() => terminate.call(this));
|
||||||
|
});
|
||||||
|
t.mock.timers.tick(60_000);
|
||||||
|
assert.equal((await firstResult).storage.status, 'error');
|
||||||
|
assert.equal(stopping, 1);
|
||||||
|
assert.equal(f.sent.filter((kind) => kind === 'query').length, 1);
|
||||||
|
release();
|
||||||
|
const second = await f.next();
|
||||||
|
assert.notEqual(second.worker, first.worker);
|
||||||
|
f.answer(second);
|
||||||
|
assert.equal((await otherResult).storage.status, 'ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shutdown persists real counters queued behind an occupied read and survives reopen', async (t) => {
|
||||||
|
const f = await fixture(t);
|
||||||
|
const reading = f.service.query(query);
|
||||||
|
const first = await f.next();
|
||||||
|
const at = new Date(Date.now() - 120_000).toISOString();
|
||||||
|
f.service.enqueue({ epoch: 'shutdown', observedAt: at, reset: false, closedIds: [], connections: [{
|
||||||
|
id: 'persist-on-close', startedAt: at, closedAt: at,
|
||||||
|
traffic: { uploadBytes: '17', downloadBytes: '29' },
|
||||||
|
destination: { domain: 'example.org', ip: '203.0.113.1' },
|
||||||
|
source: { ip: '192.0.2.1' }, inbound: { tag: 'tproxy-in' },
|
||||||
|
origin: { kind: 'device', id: 'device-1', label: 'Laptop' },
|
||||||
|
route: { kind: 'vpn', outbound: 'vpn-one' },
|
||||||
|
}] });
|
||||||
|
const closing = f.service.close();
|
||||||
|
f.answer(first);
|
||||||
|
await reading;
|
||||||
|
await closing;
|
||||||
|
const store = openTrafficHistoryStore(f.file);
|
||||||
|
try {
|
||||||
|
assert.deepEqual(store.query(query).totals, { uploadBytes: '17', downloadBytes: '29' });
|
||||||
|
} finally { store.close(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { openTrafficHistoryStore } from '../../dist/server/services/trafficHistoryStore.js';
|
||||||
|
import { createTrafficHistoryService } from '../../dist/server/services/trafficHistoryService.js';
|
||||||
|
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
|
||||||
|
import { createDomainTrafficService } from '../../dist/server/services/domainTrafficService.js';
|
||||||
|
import { assertTrafficHistorySnapshot, parseTrafficHistoryQuery } from '../../dist/shared/trafficHistory.js';
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const base = Date.parse('2026-06-01T10:00:00.000Z');
|
||||||
|
const query = (fields = {}) => ({ ...parseTrafficHistoryQuery(new URLSearchParams('range=90d')), ...fields });
|
||||||
|
function connection(id, up, down, fields = {}) {
|
||||||
|
return {
|
||||||
|
id, startedAt: new Date(base + 1_000).toISOString(), closedAt: null,
|
||||||
|
inbound: { tag: 'tproxy-in', type: 'tproxy' }, network: 'tcp', protocol: 'tls',
|
||||||
|
source: { ip: '192.0.2.10', port: 50_000 },
|
||||||
|
destination: { domain: 'www.yandex.ru', ip: '203.0.113.10', port: 443, provenance: 'sing-box' },
|
||||||
|
origin: { kind: 'device', id: 'dev_0123456789abcdef', label: 'Laptop', provenance: 'source-ip' },
|
||||||
|
route: { kind: 'vpn', scope: 'local-sing-box', outbound: 'vpn-one', outboundType: 'vless', chain: [], rule: null },
|
||||||
|
traffic: { uploadBytes: String(up), downloadBytes: String(down), uploadBytesPerSecond: '0', downloadBytesPerSecond: '0' },
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const batch = (at, connections = [], reset = false, epoch = 'sing-box-1') => ({ epoch,
|
||||||
|
observedAt: new Date(at).toISOString(), connections, reset, closedIds: connections.filter((c) => c.closedAt).map((c) => c.id) });
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-traffic-sql-'));
|
||||||
|
const closers = new Set();
|
||||||
|
t.after(async () => {
|
||||||
|
for (const close of closers) await close();
|
||||||
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
return { directory, file: path.join(directory, 'traffic.sqlite'), register(store) {
|
||||||
|
const original = store.close;
|
||||||
|
store.close = () => { closers.delete(store.close); return original(); };
|
||||||
|
closers.add(store.close);
|
||||||
|
return store;
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('SQL checkpoints survive reopen, reset/replayed CLOSED and same-UUID new lifecycles without double count', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
let clock = base;
|
||||||
|
let store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 10, 20)])], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 15, 27)])], 'live');
|
||||||
|
store.close();
|
||||||
|
store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [connection('a', 15, 27)], true)], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
const closed = connection('a', 18, 30, { closedAt: new Date(clock).toISOString() });
|
||||||
|
store.ingest([batch(clock, [closed])], 'live');
|
||||||
|
store.close();
|
||||||
|
store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [closed], true)], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '18', downloadBytes: '30' });
|
||||||
|
store.ingest([batch(clock, [connection('a', 7, 9, { startedAt: new Date(clock).toISOString() })])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '25', downloadBytes: '39' });
|
||||||
|
const result = assertTrafficHistorySnapshot(store.query(query()));
|
||||||
|
assert.equal(result.rows[0].label, 'Яндекс');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full hostname, PSL registered domain, separate IP/device/routes and IP-only destinations remain queryable', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [
|
||||||
|
connection('a', 10, 20),
|
||||||
|
connection('b', 3, 4, { destination: { domain: 'mail.yandex.com', ip: '203.0.113.20' } }),
|
||||||
|
connection('c', 5, 6, { destination: { domain: 'api.example.co.uk', ip: '203.0.113.30' } }),
|
||||||
|
connection('d', 7, 8, { destination: { domain: null, ip: '2001:db8::1' }, origin: { kind: 'unknown', id: null, label: 'Unknown' } }),
|
||||||
|
])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
const roots = store.query(query());
|
||||||
|
assert.deepEqual(new Set(roots.rows.map((row) => row.key)), new Set(['Яндекс', 'example.co.uk', '']));
|
||||||
|
const domains = store.query(query({ level: 'domain', service: 'Яндекс' }));
|
||||||
|
assert.deepEqual(new Set(domains.rows.map((row) => row.key)), new Set(['yandex.ru', 'yandex.com']));
|
||||||
|
assert.equal(store.query(query({ level: 'hostname', service: 'Яндекс', domain: 'yandex.ru' })).rows[0].key, 'www.yandex.ru');
|
||||||
|
const ip = store.query(query({ level: 'ip', service: 'Яндекс', domain: 'yandex.ru', hostname: 'www.yandex.ru' }));
|
||||||
|
assert.equal(ip.rows[0].key, '203.0.113.10');
|
||||||
|
assert.equal(store.query(query({ level: 'ip', service: '', domain: '', hostname: '' })).rows[0].key, '2001:db8::1');
|
||||||
|
assert.equal(store.query(query({ originId: 'unknown:192.0.2.10' })).totals.downloadBytes, '8');
|
||||||
|
assert.equal(store.query(query({ search: "x' OR 1=1 --" })).rows.length, 0);
|
||||||
|
assert.equal(store.query(query({ search: 'яндекс' })).totals.downloadBytes, '24');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial snapshot is a baseline and downtime is explicitly partial', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [connection('old', 1_000, 2_000, { startedAt: new Date(base - DAY).toISOString() })], true)], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '0');
|
||||||
|
store.ingest([batch(clock, [connection('old', 1_010, 2_020, { startedAt: new Date(base - DAY).toISOString() })])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
store.ingest([batch(clock, [connection('old', 1_040, 2_050, { startedAt: new Date(base - DAY).toISOString() })], true)], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '40', downloadBytes: '50' });
|
||||||
|
assert.equal(store.query(query()).coverage.partial, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counter regression retains high watermarks and reconnect counts new post-collection lifecycles', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
for (const total of [100, 90, 100]) {
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', total, total)], true)], 'live');
|
||||||
|
}
|
||||||
|
clock += 60_000;
|
||||||
|
const result = store.query(query());
|
||||||
|
assert.deepEqual(result.totals, { uploadBytes: '100', downloadBytes: '100' });
|
||||||
|
assert.equal(result.coverage.partial, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fast collector reopen marks missing coverage even if the next source state is live', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
let store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
store.close(); clock += 1_000;
|
||||||
|
store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [connection('a', 10, 20)], true)], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.equal(store.query(query()).coverage.gapCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frozen history cutoff excludes its incomplete terminal bucket before and after hourly rollup', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 100, 200)])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 110, 220)])], 'live');
|
||||||
|
const frozen = store.query(query());
|
||||||
|
assert.equal(frozen.query.until, base + 60_000);
|
||||||
|
assert.equal(frozen.totals.uploadBytes, '100');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', 120, 240)])], 'live');
|
||||||
|
assert.deepEqual(store.query(frozen.query).totals, frozen.totals);
|
||||||
|
clock = base + 8 * DAY;
|
||||||
|
const hourly = store.query(query({ until: base + 3_600_000 + 30_000 }));
|
||||||
|
assert.equal(hourly.query.until, base + 3_600_000);
|
||||||
|
assert.equal(hourly.totals.uploadBytes, '120');
|
||||||
|
const expired = store.query(query({ until: base - 100 * DAY }));
|
||||||
|
assert.equal(expired.rows.length, 0);
|
||||||
|
assert.equal(expired.period.from, expired.period.to);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minute to hour rollup preserves exact bytes and identities; 90-day cleanup leaves settings and metrics untouched', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
const settings = f.register(openHarborStorage(f.directory));
|
||||||
|
settings.state.update((state) => ({ ...state, revision: 42 }));
|
||||||
|
const metrics = createDomainTrafficService({ observe: () => ({ connections: [] }), devices: () => [] });
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
const data = batch(clock, [connection('a', '9007199254740993', '500')]);
|
||||||
|
store.ingest([data], 'live'); metrics.ingestNative(data);
|
||||||
|
const metricsBefore = JSON.stringify(metrics.snapshot());
|
||||||
|
clock += 60_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', '9007199254741000', '550')])], 'live');
|
||||||
|
clock = base + 8 * DAY;
|
||||||
|
store.maintain(); store.maintain();
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '9007199254741000', downloadBytes: '550' });
|
||||||
|
const inspect = new DatabaseSync(f.file);
|
||||||
|
assert.deepEqual(inspect.prepare('SELECT DISTINCT resolution FROM buckets').all().map((r) => r.resolution), [3_600_000]);
|
||||||
|
inspect.close();
|
||||||
|
clock = base + 91 * DAY;
|
||||||
|
store.maintain();
|
||||||
|
assert.equal(store.query(query()).rows.length, 0);
|
||||||
|
assert.equal(settings.state.read().revision, 42);
|
||||||
|
assert.equal(JSON.stringify(metrics.snapshot()), metricsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed bucket transaction does not advance checkpoints, and retry counts exactly once', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
const blocker = new DatabaseSync(f.file);
|
||||||
|
blocker.exec("CREATE TRIGGER reject_bucket BEFORE INSERT ON buckets BEGIN SELECT RAISE(ABORT, 'simulated disk failure'); END");
|
||||||
|
const data = batch(clock, [connection('a', 10, 20)]);
|
||||||
|
assert.throws(() => store.ingest([data], 'live'), /simulated/);
|
||||||
|
assert.equal(blocker.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 0);
|
||||||
|
blocker.exec('DROP TRIGGER reject_bucket'); blocker.close();
|
||||||
|
store.ingest([data], 'live'); store.ingest([data], 'live'); clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(query()).totals, { uploadBytes: '10', downloadBytes: '20' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle active checkpoints survive retention; a new runtime epoch removes old checkpoints but not buckets', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('idle', 100, 200)])], 'live');
|
||||||
|
clock += 91 * DAY;
|
||||||
|
store.maintain();
|
||||||
|
store.ingest([batch(clock, [connection('idle', 110, 220)])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '10');
|
||||||
|
store.ingest([batch(clock, [], true, 'sing-box-2')], 'live');
|
||||||
|
const db = new DatabaseSync(f.file);
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 0);
|
||||||
|
db.close();
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset disappearance and terminal identities retire checkpoints without forgetting still-active baselines', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live'); clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('gone', 10, 20), connection('idle', 100, 200), connection('terminal', 7, 8)])], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([{ ...batch(clock), closedIds: ['terminal'] }], 'live');
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('idle', 100, 200)], true)], 'live');
|
||||||
|
clock += 91 * DAY;
|
||||||
|
store.maintain();
|
||||||
|
const db = new DatabaseSync(f.file);
|
||||||
|
assert.deepEqual(db.prepare("SELECT json_extract(identity, '$[1]') AS id, closed FROM checkpoints").all()
|
||||||
|
.map((row) => ({ ...row })), [{ id: 'idle', closed: 0 }]);
|
||||||
|
db.close();
|
||||||
|
store.ingest([batch(clock, [connection('idle', 110, 220)])], 'live');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.equal(store.query(query()).totals.uploadBytes, '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('worker ingestion is independent of Prometheus and errors remain isolated; many closed events are not UI-capped', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const service = f.register(createTrafficHistoryService({ filePath: f.file, source: () => 'live' }));
|
||||||
|
const at = Date.now() - 120_000;
|
||||||
|
service.enqueue(batch(at, [], true));
|
||||||
|
service.enqueue(batch(at + 1, Array.from({ length: 2_049 }, (_, index) => connection(`closed-${index}`, 1, 2, {
|
||||||
|
startedAt: new Date(at + 1).toISOString(), closedAt: new Date(at + 1).toISOString(),
|
||||||
|
}))));
|
||||||
|
let ticked = false;
|
||||||
|
setImmediate(() => { ticked = true; });
|
||||||
|
const result = await service.query(query());
|
||||||
|
assert.equal(ticked, true);
|
||||||
|
assert.equal(result.storage.status, 'ready');
|
||||||
|
assert.deepEqual(result.totals, { uploadBytes: '2049', downloadBytes: '4098' });
|
||||||
|
const invalid = f.register(createTrafficHistoryService({ filePath: f.directory, source: () => 'live' }));
|
||||||
|
assert.doesNotThrow(() => invalid.enqueue(batch(at, [])));
|
||||||
|
assert.equal((await invalid.query(query())).storage.status, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounded 90-day history performance sample reports write/query size without claiming a hardware guarantee', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
const start = performance.now();
|
||||||
|
for (let day = 0; day < 90; day++) {
|
||||||
|
clock = base + day * DAY + 1_000;
|
||||||
|
store.ingest([batch(clock, Array.from({ length: 100 }, (_, index) => connection(`${day}-${index}`, 100, 1_000, {
|
||||||
|
startedAt: new Date(clock).toISOString(), destination: { domain: `host${index}.example.org`, ip: `203.0.113.${index + 1}` },
|
||||||
|
})))], 'live');
|
||||||
|
}
|
||||||
|
const writeMs = performance.now() - start;
|
||||||
|
clock += 60_000;
|
||||||
|
const queryStart = performance.now();
|
||||||
|
const result = store.query(query());
|
||||||
|
const queryMs = performance.now() - queryStart;
|
||||||
|
assert.equal(result.totals.downloadBytes, '9000000');
|
||||||
|
const sizes = Object.fromEntries(['', '-wal'].map((suffix) => [suffix || 'db', fs.statSync(f.file + suffix).size]));
|
||||||
|
t.diagnostic(JSON.stringify({ samples: 9_000, simulatedDays: 90, writeMs, queryMs, sizes }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('high-churn sample measures closed-lifecycle storage and epoch reclamation', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
const start = performance.now();
|
||||||
|
for (let page = 0; page < 100; page++) {
|
||||||
|
clock += 1_000;
|
||||||
|
store.ingest([batch(clock, Array.from({ length: 1_000 }, (_, index) => connection(`${page}-${index}`, 100, 1_000, {
|
||||||
|
startedAt: new Date(clock).toISOString(), closedAt: new Date(clock).toISOString(),
|
||||||
|
})))], 'live');
|
||||||
|
}
|
||||||
|
const writeMs = performance.now() - start;
|
||||||
|
clock += 60_000;
|
||||||
|
const startQuery = performance.now();
|
||||||
|
assert.equal(store.query(query()).totals.downloadBytes, '100000000');
|
||||||
|
const queryMs = performance.now() - startQuery;
|
||||||
|
const db = new DatabaseSync(f.file);
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 100_000);
|
||||||
|
const bytes = db.prepare('PRAGMA page_count').get().page_count * db.prepare('PRAGMA page_size').get().page_size;
|
||||||
|
store.ingest([batch(clock, [], true, 'sing-box-next')], 'live');
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM checkpoints').get().n, 0);
|
||||||
|
const reusableBytes = db.prepare('PRAGMA freelist_count').get().freelist_count * db.prepare('PRAGMA page_size').get().page_size;
|
||||||
|
db.close();
|
||||||
|
t.diagnostic(JSON.stringify({ closedLifecycles: 100_000, writeMs, queryMs, bytes, reusableBytes }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history searches each destination once, and cached periods stay exact after late data and rollback', (t) => {
|
||||||
|
const f = fixture(t); let clock = base;
|
||||||
|
let searched = 0;
|
||||||
|
const registerFunction = DatabaseSync.prototype.function;
|
||||||
|
t.mock.method(DatabaseSync.prototype, 'function', function (name, options, callback) {
|
||||||
|
return registerFunction.call(this, name, options, name === 'lower_unicode'
|
||||||
|
? (value) => { searched++; return callback(value); } : callback);
|
||||||
|
});
|
||||||
|
const store = f.register(openTrafficHistoryStore(f.file, () => clock));
|
||||||
|
store.ingest([batch(clock, [], true)], 'live');
|
||||||
|
for (let minute = 0; minute < 60; minute++) {
|
||||||
|
clock = base + minute * 60_000 + 1_000;
|
||||||
|
store.ingest([batch(clock, [connection('a', (minute + 1) * 10, (minute + 1) * 20)])], 'live');
|
||||||
|
}
|
||||||
|
clock = base + 60 * 60_000;
|
||||||
|
const searchedQuery = query({ search: 'яндекс' });
|
||||||
|
assert.deepEqual(store.query(searchedQuery).totals, { uploadBytes: '600', downloadBytes: '1200' });
|
||||||
|
assert.ok(searched <= 2, `one destination must not be searched per time bucket (${searched} calls)`);
|
||||||
|
store.ingest([batch(clock + 1, [connection('a', 601, 1202)])], 'live');
|
||||||
|
assert.equal(store.query(searchedQuery).totals.uploadBytes, '600');
|
||||||
|
store.ingest([batch(base + 30_000, [connection('late', 7, 9)])], 'live');
|
||||||
|
assert.deepEqual(store.query(searchedQuery).totals, { uploadBytes: '607', downloadBytes: '1209' });
|
||||||
|
assert.throws(() => store.ingest([batch(base + 40_000, [
|
||||||
|
connection('rolled-back', 50, 80), connection('invalid', 'invalid', 1),
|
||||||
|
])], 'live'));
|
||||||
|
assert.equal(store.query(searchedQuery).totals.uploadBytes, '607');
|
||||||
|
clock += 60_000;
|
||||||
|
assert.deepEqual(store.query(searchedQuery).totals, { uploadBytes: '608', downloadBytes: '1211' });
|
||||||
|
store.maintain();
|
||||||
|
assert.equal(store.query(searchedQuery).totals.uploadBytes, '608');
|
||||||
|
});
|
||||||
@@ -47,6 +47,9 @@ test('version paths map to the components actually shipped by this repository',
|
|||||||
'gatewayBackend',
|
'gatewayBackend',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(affectedComponents(['scripts/runtime-impact.mjs']), ['gatewayBackend']);
|
assert.deepEqual(affectedComponents(['scripts/runtime-impact.mjs']), ['gatewayBackend']);
|
||||||
|
for (const file of ['.node-version', 'scripts/check-sqlite-runtime.mjs']) {
|
||||||
|
assert.deepEqual(affectedComponents([file]), ['macClient', 'gatewayClient', 'gatewayBackend']);
|
||||||
|
}
|
||||||
assert.deepEqual(affectedComponents(['README.md', 'test/server/version.test.js']), []);
|
assert.deepEqual(affectedComponents(['README.md', 'test/server/version.test.js']), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -143,8 +143,12 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
|||||||
assert.equal(await invoke(), payload);
|
assert.equal(await invoke(), payload);
|
||||||
const [actualUrl, actualOptions] = calls.at(-1);
|
const [actualUrl, actualOptions] = calls.at(-1);
|
||||||
assert.equal(actualUrl, url);
|
assert.equal(actualUrl, url);
|
||||||
assert.deepEqual(actualOptions, {
|
assert.ok(actualOptions.signal instanceof AbortSignal);
|
||||||
...options,
|
assert.equal(actualOptions.signal.aborted, false);
|
||||||
|
const { signal: requestedSignal, ...requestOptions } = options;
|
||||||
|
const { signal: actualSignal, ...fetchOptions } = actualOptions;
|
||||||
|
assert.deepEqual(fetchOptions, {
|
||||||
|
...requestOptions,
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,7 +166,9 @@ test('request preserves caller headers, AbortError identity and JSON fallbacks',
|
|||||||
received = [url, options];
|
received = [url, options];
|
||||||
return { ok: true, status: 200, json: async () => value };
|
return { ok: true, status: 200, json: async () => value };
|
||||||
}), value);
|
}), value);
|
||||||
assert.deepEqual(received, ['/api/test', {
|
assert.ok(received[1].signal instanceof AbortSignal);
|
||||||
|
const { signal, ...options } = received[1];
|
||||||
|
assert.deepEqual([received[0], options], ['/api/test', {
|
||||||
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
headers: { 'content-type': 'application/custom', 'x-harbor': 'yes' },
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
@@ -188,3 +194,40 @@ test('request preserves caller headers, AbortError identity and JSON fallbacks',
|
|||||||
(error) => error.code === 'CONTROL_UNREACHABLE' && error.status === 503,
|
(error) => error.code === 'CONTROL_UNREACHABLE' && error.status === 503,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('API deadlines cover headers and response bodies, while caller cancellation keeps its reason', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const stalled = (signal) => new Promise((resolve, reject) => {
|
||||||
|
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||||
|
});
|
||||||
|
for (const phase of ['headers', 'body']) {
|
||||||
|
let signal;
|
||||||
|
const pending = request('/api/state', {}, async (url, options) => {
|
||||||
|
signal = options.signal;
|
||||||
|
return phase === 'headers' ? stalled(signal)
|
||||||
|
: { ok: true, status: 200, json: () => stalled(signal) };
|
||||||
|
});
|
||||||
|
const checked = assert.rejects(pending, (error) => error.code === 'CONTROL_UNREACHABLE' && error.retryable);
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(14_999);
|
||||||
|
assert.equal(signal.aborted, false);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await checked;
|
||||||
|
}
|
||||||
|
const caller = new AbortController();
|
||||||
|
const reason = new DOMException('left the page', 'AbortError');
|
||||||
|
const pending = request('/api/test', { signal: caller.signal }, (url, options) => stalled(options.signal));
|
||||||
|
const checked = assert.rejects(pending, (error) => error === reason);
|
||||||
|
caller.abort(reason);
|
||||||
|
await checked;
|
||||||
|
let commandSignal;
|
||||||
|
const command = request('/api/diagnostics/dns', { method: 'POST' }, (url, options) => {
|
||||||
|
commandSignal = options.signal;
|
||||||
|
return stalled(commandSignal);
|
||||||
|
});
|
||||||
|
const commandChecked = assert.rejects(command, (error) => error.code === 'CONTROL_UNREACHABLE');
|
||||||
|
t.mock.timers.tick(59_999);
|
||||||
|
assert.equal(commandSignal.aborted, false);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
await commandChecked;
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { parse } from '@babel/parser';
|
||||||
|
|
||||||
const index = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
|
const index = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
|
||||||
const main = readFileSync(new URL('../../src/web/main.tsx', import.meta.url), 'utf8');
|
const main = readFileSync(new URL('../../src/web/main.tsx', import.meta.url), 'utf8');
|
||||||
@@ -23,3 +24,44 @@ test('App remains the exported composition component without bootstrap side effe
|
|||||||
assert.match(app, /<ClientOverviewPage/);
|
assert.match(app, /<ClientOverviewPage/);
|
||||||
assert.match(app, /<StaleBanner/);
|
assert.match(app, /<StaleBanner/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('state polling waits for completion and stops after cleanup', async (t) => {
|
||||||
|
const compiled = readFileSync(new URL('../../.test-dist/src/web/App.js', import.meta.url), 'utf8');
|
||||||
|
const component = parse(compiled, { sourceType: 'module' }).program.body
|
||||||
|
.find((node) => node.declaration?.id?.name === 'App').declaration;
|
||||||
|
const effect = component.body.body.map((node) => node.expression)
|
||||||
|
.filter((node) => node?.callee?.name === 'useEffect')
|
||||||
|
.map((node) => node.arguments[0])
|
||||||
|
.find((node) => compiled.slice(node.start, node.end).includes('loadState('));
|
||||||
|
// Execute the shipped effect with a deferred request; no DOM or copied polling loop.
|
||||||
|
const setup = new Function('loadState', `return (${compiled.slice(effect.start, effect.end)})`);
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||||
|
let calls = 0;
|
||||||
|
let finish;
|
||||||
|
const start = setup(() => {
|
||||||
|
calls++;
|
||||||
|
return new Promise((resolve) => { finish = resolve; });
|
||||||
|
});
|
||||||
|
const cleanup = start();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
t.mock.timers.tick(40_000);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
finish();
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(4_999);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
t.mock.timers.tick(1);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
cleanup();
|
||||||
|
finish();
|
||||||
|
await Promise.resolve();
|
||||||
|
t.mock.timers.tick(40_000);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
|
||||||
|
const cleanupScheduled = start();
|
||||||
|
finish();
|
||||||
|
await Promise.resolve();
|
||||||
|
cleanupScheduled();
|
||||||
|
t.mock.timers.tick(40_000);
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const panel = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/Conn
|
|||||||
const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8');
|
const model = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/connectivityResult.ts'), 'utf8');
|
||||||
const customServiceAction = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/customServiceAction.ts'), 'utf8');
|
const customServiceAction = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/customServiceAction.ts'), 'utf8');
|
||||||
const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8');
|
const boundary = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/index.ts'), 'utf8');
|
||||||
|
const dns = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DnsDiagnosticsSection.tsx'), 'utf8');
|
||||||
|
|
||||||
const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true };
|
const ip = { source: 'cloudflare', address: '198.51.100.10', extra: true };
|
||||||
const site = { id: 'google', status: 'available', httpStatus: 204, latencyMs: 120 };
|
const site = { id: 'google', status: 'available', httpStatus: 204, latencyMs: 120 };
|
||||||
@@ -122,3 +123,19 @@ test('network identity is one stable compact row for Direct and VPN', () => {
|
|||||||
assert.match(panel, /else if \(path\.network == null\) status = \['is-muted', '—'\][\s\S]*if \(!status && !identity && !location\) status = \['is-error', 'Нет ответа'\]/);
|
assert.match(panel, /else if \(path\.network == null\) status = \['is-muted', '—'\][\s\S]*if \(!status && !identity && !location\) status = \['is-error', 'Нет ответа'\]/);
|
||||||
assert.doesNotMatch(panel, /traceroute|tracepath/);
|
assert.doesNotMatch(panel, /traceroute|tracepath/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('DNS diagnostics reuse the drawer owner, canonical settings and independent row refresh', () => {
|
||||||
|
assert.match(panel, /<DnsDiagnosticsSection[\s\S]*settings=\{settings\}[\s\S]*updateSettings=\{updateSettings\}/);
|
||||||
|
assert.match(panel, /<DnsDiagnosticsSection[\s\S]*loadCatalog=\{loadDnsDiagnosticsCatalog\}[\s\S]*runDiagnostics=\{runDnsDiagnostics\}/);
|
||||||
|
assert.match(dns, /<select[\s\S]*id="client-dns-domain"[\s\S]*value=\{activeDomainId\}/);
|
||||||
|
assert.match(dns, /setCache\(\(current\)[\s\S]*mergeRow\(previous\[row\.resolver\.id\], row\)/);
|
||||||
|
assert.match(dns, /function preserveAddresses[\s\S]*ipv4: previous\.ipv4, ipv6: previous\.ipv6/);
|
||||||
|
assert.match(dns, /aria-busy=\{running\}/);
|
||||||
|
assert.match(dns, /<Refresh label=\{`Проверить: \$\{item\.label\}`\}[\s\S]*onClick=\{\(\) => void run\(item\.id\)\}/);
|
||||||
|
assert.match(dns, /updateSettings\([\s\S]*customDnsDomains/);
|
||||||
|
assert.match(dns, /updateSettings\([\s\S]*customDnsResolvers/);
|
||||||
|
assert.match(dns, /settings\.customDnsDomains\.length >= MAX_CUSTOM_DNS_DOMAINS/);
|
||||||
|
assert.match(dns, /settings\.customDnsResolvers\.length >= MAX_CUSTOM_DNS_RESOLVERS/);
|
||||||
|
assert.match(dns, /<b>A<\/b>[\s\S]*<b>AAAA<\/b>/);
|
||||||
|
assert.match(dns, /Разные ответы могут быть нормой для CDN/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ test('failover settings use Harbor switches, a two-column channel table and plai
|
|||||||
assert.match(feature, /\['primary', 'reserve'\][\s\S]*client-failover-\$\{channel\}-options[\s\S]*updateTarget\(channel, optionId\)/);
|
assert.match(feature, /\['primary', 'reserve'\][\s\S]*client-failover-\$\{channel\}-options[\s\S]*updateTarget\(channel, optionId\)/);
|
||||||
assert.match(feature, /channelLabel[\s\S]*profile\.trim\(\) === server\.trim\(\)[\s\S]*`\$\{profile} · \$\{server}`/);
|
assert.match(feature, /channelLabel[\s\S]*profile\.trim\(\) === server\.trim\(\)[\s\S]*`\$\{profile} · \$\{server}`/);
|
||||||
assert.match(feature, /client-failover-channels[\s\S]*<article className="client-failover-channel"[\s\S]*<h3>\{channel === 'primary' \? 'Основной' : 'Резервный'}/);
|
assert.match(feature, /client-failover-channels[\s\S]*<article className="client-failover-channel"[\s\S]*<h3>\{channel === 'primary' \? 'Основной' : 'Резервный'}/);
|
||||||
assert.match(feature, /client-failover-channel-checking[\s\S]*Проверяем \{channel === 'primary' \? 'основной' : 'резервный'} канал/);
|
assert.match(feature, /client-failover-channel-status[\s\S]*client-failover-channel-progress/);
|
||||||
|
assert.match(feature, /checking[\s\S]*`Проверяем \$\{channel === 'primary' \? 'основной' : 'резервный'\} канал`/);
|
||||||
assert.match(feature, /client-live-region[\s\S]*Проверяем основной и резервный каналы/);
|
assert.match(feature, /client-live-region[\s\S]*Проверяем основной и резервный каналы/);
|
||||||
assert.match(feature, /ArrowDown[\s\S]*Home[\s\S]*End[\s\S]*Escape/);
|
assert.match(feature, /ArrowDown[\s\S]*Home[\s\S]*End[\s\S]*Escape/);
|
||||||
assert.match(feature, /role="listbox"[\s\S]*role="option"/);
|
assert.match(feature, /role="listbox"[\s\S]*role="option"/);
|
||||||
@@ -76,7 +77,7 @@ test('failover settings use Harbor switches, a two-column channel table and plai
|
|||||||
assert.match(styles, /\.client-failover-channels \{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 52px minmax\(0, 1fr\)/);
|
assert.match(styles, /\.client-failover-channels \{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 52px minmax\(0, 1fr\)/);
|
||||||
assert.match(styles, /\.client-failover-channel-actions \{[^}]*grid-column:\s*2[^}]*grid-row:\s*1/);
|
assert.match(styles, /\.client-failover-channel-actions \{[^}]*grid-column:\s*2[^}]*grid-row:\s*1/);
|
||||||
assert.match(styles, /\.client-failover-check-action, \.client-failover-direction-action \{[^}]*width:\s*44px[^}]*height:\s*44px/);
|
assert.match(styles, /\.client-failover-check-action, \.client-failover-direction-action \{[^}]*width:\s*44px[^}]*height:\s*44px/);
|
||||||
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-failover-channels \{ grid-template-columns:\s*minmax\(0, 1fr\)/);
|
assert.match(styles, /@media \(max-width: 680px\)[\s\S]*\.client-failover-channels \{ grid-template-columns:\s*minmax\(0, 1fr\)/);
|
||||||
assert.match(styles, /\.client-failover-picker-list[\s\S]*opacity:\s*0[\s\S]*\.client-failover-picker\.is-open[\s\S]*opacity:\s*1/);
|
assert.match(styles, /\.client-failover-picker-list[\s\S]*opacity:\s*0[\s\S]*\.client-failover-picker\.is-open[\s\S]*opacity:\s*1/);
|
||||||
assert.match(styles, /failover-service-out 300ms[\s\S]*@media \(prefers-reduced-motion: reduce\)/);
|
assert.match(styles, /failover-service-out 300ms[\s\S]*@media \(prefers-reduced-motion: reduce\)/);
|
||||||
assert.match(styles, /\.client-failover-number-setting \{[^}]*display:\s*flex[^}]*flex-wrap:\s*wrap/);
|
assert.match(styles, /\.client-failover-number-setting \{[^}]*display:\s*flex[^}]*flex-wrap:\s*wrap/);
|
||||||
@@ -92,13 +93,15 @@ test('failover settings use Harbor switches, a two-column channel table and plai
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('active channel and checks stay attached to their channel columns without a duplicate runtime block', () => {
|
test('active channel and checks stay attached to their channel columns without a duplicate runtime block', () => {
|
||||||
assert.match(feature, /Проверяем основной канал ·/);
|
assert.match(feature, /failureProgress[\s\S]*`\$\{seconds\([\s\S]*\)} \/ \$\{seconds\(draft\.failureWindowMs\)} с`/);
|
||||||
|
assert.match(feature, /failureProgress[\s\S]*'Проверяем основной канал'/);
|
||||||
assert.match(feature, /client-failover-direction-action[\s\S]*disabled=\{blocked \|\| !snapshot.enabled \|\| snapshot.activation !== 'active' \|\| !switchRole\}/);
|
assert.match(feature, /client-failover-direction-action[\s\S]*disabled=\{blocked \|\| !snapshot.enabled \|\| snapshot.activation !== 'active' \|\| !switchRole\}/);
|
||||||
assert.match(feature, /className=\{`client-failover-direction-action client-tooltip-anchor\$\{role === 'primary'[\s\S]*role === 'reserve'/);
|
assert.match(feature, /className=\{`client-failover-direction-action client-tooltip-anchor\$\{role === 'primary'[\s\S]*role === 'reserve'/);
|
||||||
assert.match(feature, /<svg className=\{role === 'primary' \? 'is-primary' : role === 'reserve' \? 'is-reserve' : undefined\}/);
|
assert.match(feature, /<svg className=\{role === 'primary' \? 'is-primary' : role === 'reserve' \? 'is-reserve' : undefined\}/);
|
||||||
assert.doesNotMatch(feature, /client-failover-runtime|Сейчас используется|Новые подключения:/);
|
assert.doesNotMatch(feature, /client-failover-runtime|Сейчас используется|Новые подключения:/);
|
||||||
|
assert.doesNotMatch(feature, /client-failover-channel-checking/);
|
||||||
assert.doesNotMatch(styles, /\.client-failover-runtime/);
|
assert.doesNotMatch(styles, /\.client-failover-runtime/);
|
||||||
assert.match(styles, /\.client-failover-channel-checking \{[^}]*min-height:\s*16px[^}]*opacity:\s*0[^}]*visibility:\s*hidden/);
|
assert.match(styles, /\.client-failover-channel-status \{[^}]*min-height:\s*32px[^}]*grid-template-columns:\s*minmax\(0, 1fr\)[^}]*grid-template-rows:\s*repeat\(2, minmax\(0, 1fr\)\)/);
|
||||||
assert.match(styles, /\.client-failover-channel-checking\.is-visible \{[^}]*opacity:\s*1[^}]*visibility:\s*visible/);
|
assert.match(styles, /\.client-failover-channel-progress \{[^}]*min-width:\s*0[^}]*text-align:\s*left/);
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[^{]*\{[^}]*\.client-failover-number-control:hover \.client-failover-number-steps[^}]*transition:\s*none/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[^{]*\{[^}]*\.client-failover-number-control:hover \.client-failover-number-steps[^}]*transition:\s*none/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
|
import { TrafficGroupRow } from '../../.test-dist/src/web/features/traffic/TrafficFeature.js';
|
||||||
|
import { HistoryRows } from '../../.test-dist/src/web/features/traffic/TrafficHistoryPanel.js';
|
||||||
|
import { emptyTrafficHistory, parseTrafficHistoryQuery } from '../../.test-dist/src/shared/trafficHistory.js';
|
||||||
|
|
||||||
import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js';
|
import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js';
|
||||||
import {
|
import {
|
||||||
groupTrafficConnections,
|
groupTrafficConnections,
|
||||||
reconcileTrafficGroups,
|
reconcileTrafficGroups,
|
||||||
|
sortTrafficGroups,
|
||||||
|
summarizeTrafficOrigins,
|
||||||
|
trafficConnectionMatchesFilters,
|
||||||
trafficGroupMatches,
|
trafficGroupMatches,
|
||||||
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
|
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
|
||||||
|
|
||||||
@@ -20,6 +29,8 @@ const rowModel = source('src/web/features/traffic/trafficRows.ts');
|
|||||||
const boundary = source('src/web/features/traffic/index.ts');
|
const boundary = source('src/web/features/traffic/index.ts');
|
||||||
const styles = source('src/web/styles/features/traffic.css');
|
const styles = source('src/web/styles/features/traffic.css');
|
||||||
const primitives = source('src/web/styles/primitives.css');
|
const primitives = source('src/web/styles/primitives.css');
|
||||||
|
const history = source('src/web/features/traffic/TrafficHistoryPanel.tsx');
|
||||||
|
const controls = source('src/web/features/traffic/TrafficControls.tsx');
|
||||||
|
|
||||||
const validSnapshot = {
|
const validSnapshot = {
|
||||||
apiVersion: 1,
|
apiVersion: 1,
|
||||||
@@ -150,7 +161,7 @@ test('Mac and Gateway traffic drawers use one feature boundary and the cached re
|
|||||||
|
|
||||||
test('traffic polling runs every second only while the drawer is open and unpaused', () => {
|
test('traffic polling runs every second only while the drawer is open and unpaused', () => {
|
||||||
assert.match(feature, /const POLL_MS = 1_000/);
|
assert.match(feature, /const POLL_MS = 1_000/);
|
||||||
assert.match(feature, /if \(!enabled \|\| !isOpen \|\| paused\) return undefined/);
|
assert.match(feature, /if \(!enabled \|\| !isOpen \|\| paused \|\| view !== 'live'\) return undefined/);
|
||||||
assert.match(feature, /assertLiveTrafficSnapshot\(await loadLiveTraffic\(\)\)/);
|
assert.match(feature, /assertLiveTrafficSnapshot\(await loadLiveTraffic\(\)\)/);
|
||||||
assert.match(feature, /setSnapshot\(next\)[\s\S]*setRequestState\('ready'\)/);
|
assert.match(feature, /setSnapshot\(next\)[\s\S]*setRequestState\('ready'\)/);
|
||||||
assert.match(feature, /catch \{[\s\S]*setRequestState\('error'\)/);
|
assert.match(feature, /catch \{[\s\S]*setRequestState\('error'\)/);
|
||||||
@@ -172,10 +183,10 @@ test('traffic drawer exposes the requested truthful states and accessible contro
|
|||||||
'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.',
|
'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.',
|
||||||
'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.',
|
'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.',
|
||||||
]) assert.match(feature, new RegExp(copy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
]) assert.match(feature, new RegExp(copy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
assert.match(feature, /\{feature\.isGateway \? 'GATEWAY' : 'MAC'\} · \{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ/);
|
|
||||||
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
|
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
|
||||||
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
|
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
|
||||||
assert.match(feature, /type="search"[\s\S]*aria-label="Найти домен, сервис или IP"/);
|
assert.match(feature, /aria-label="Найти устройство"/);
|
||||||
|
assert.match(feature, /aria-label="Найти сайт, IP или сервис"/);
|
||||||
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
|
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
|
||||||
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
|
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
|
||||||
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
|
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
|
||||||
@@ -183,24 +194,24 @@ test('traffic drawer exposes the requested truthful states and accessible contro
|
|||||||
assert.match(feature, /const \[expandedId, setExpandedId\] = useState\(''\)/);
|
assert.match(feature, /const \[expandedId, setExpandedId\] = useState\(''\)/);
|
||||||
assert.match(feature, /aria-expanded=\{expanded\}[\s\S]*aria-controls=\{detailsId\}/);
|
assert.match(feature, /aria-expanded=\{expanded\}[\s\S]*aria-controls=\{detailsId\}/);
|
||||||
assert.match(feature, /Источник[\s\S]*Назначение[\s\S]*Правило[\s\S]*Цепочка/);
|
assert.match(feature, /Источник[\s\S]*Назначение[\s\S]*Правило[\s\S]*Цепочка/);
|
||||||
assert.doesNotMatch(feature, /closeConnection|reroute|history|sessionStorage/);
|
assert.doesNotMatch(feature, /closeConnection|reroute|sessionStorage/);
|
||||||
|
assert.match(feature, /TrafficHistoryPanel/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('traffic retention is local, bounded to approved choices and uses the frozen server observation clock', () => {
|
test('traffic retention and grouping use canonical server settings and the frozen server observation clock', () => {
|
||||||
assert.match(feature, /const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds'/);
|
assert.doesNotMatch(feature, /localStorage|sessionStorage/);
|
||||||
assert.match(feature, /const RETENTION_OPTIONS = \[5, 10, 30\] as const/);
|
assert.match(feature, /TRAFFIC_RETENTION_OPTIONS\.map/);
|
||||||
assert.match(feature, /Number\(localStorage\.getItem\(RETENTION_STORAGE_KEY\)\)/);
|
assert.match(feature, /feature\.updateSettings\(settings\)/);
|
||||||
assert.match(feature, /RETENTION_OPTIONS\.includes\(value as RetentionSeconds\)[\s\S]*: 10/);
|
assert.match(api, /updateSettings:[\s\S]*\/api\/traffic\/settings[\s\S]*expectedRevision/);
|
||||||
assert.match(feature, /localStorage\.setItem\(RETENTION_STORAGE_KEY, String\(seconds\)\)/);
|
assert.match(app, /onUpdateTrafficSettings=[\s\S]*api\.traffic\.updateSettings/);
|
||||||
assert.match(feature, /Показывать завершённые/);
|
assert.match(feature, /Завершённые/);
|
||||||
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
|
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
|
||||||
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
|
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
|
||||||
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
|
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
|
||||||
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
|
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
|
||||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
|
||||||
assert.match(feature, /trafficGroups\.filter\([\s\S]*trafficGroupMatches/);
|
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||||||
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
|
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
|
||||||
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('traffic groups combine compatible UUIDs with exact byte sums and whole-group search', () => {
|
test('traffic groups combine compatible UUIDs with exact byte sums and whole-group search', () => {
|
||||||
@@ -254,6 +265,75 @@ test('traffic groups combine compatible UUIDs with exact byte sums and whole-gro
|
|||||||
assert.equal(trafficGroupMatches(groups[0], '', 'direct', 'all'), false);
|
assert.equal(trafficGroupMatches(groups[0], '', 'direct', 'all'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('traffic groups sort by bounded frequency or latest start without changing the groups', () => {
|
||||||
|
const groups = groupTrafficConnections([
|
||||||
|
trafficConnection('latest', {
|
||||||
|
startedAt: '2026-08-31T09:59:00.000Z',
|
||||||
|
destination: { domain: 'latest.test' },
|
||||||
|
}),
|
||||||
|
...['a', 'b', 'c'].map((id, index) => trafficConnection(`popular-${id}`, {
|
||||||
|
startedAt: `2026-08-31T09:0${index}:00.000Z`,
|
||||||
|
destination: { domain: 'popular.test' },
|
||||||
|
})),
|
||||||
|
...['a', 'b'].map((id) => trafficConnection(`frequent-recent-${id}`, {
|
||||||
|
startedAt: '2026-08-31T09:59:00.000Z',
|
||||||
|
destination: { domain: 'frequent-recent.test' },
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
const original = groups.map(({ label }) => label);
|
||||||
|
|
||||||
|
assert.deepEqual(sortTrafficGroups(groups, 'popular').map(({ label }) => label), [
|
||||||
|
'popular.test',
|
||||||
|
'frequent-recent.test',
|
||||||
|
'latest.test',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(sortTrafficGroups(groups, 'recent').map(({ label }) => label), [
|
||||||
|
'frequent-recent.test',
|
||||||
|
'latest.test',
|
||||||
|
'popular.test',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(groups.map(({ label }) => label), original);
|
||||||
|
assert.match(feature, /const \{ grouping, sort: sortMode, retentionSeconds \} = feature\.settings/);
|
||||||
|
assert.match(feature, /role="group" aria-label="Сортировка соединений"/);
|
||||||
|
assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/);
|
||||||
|
assert.match(feature, /aria-pressed=\{sortMode === value\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site grouping combines devices with exact per-device totals while device grouping keeps them separate', () => {
|
||||||
|
const first = trafficConnection('phone', {
|
||||||
|
origin: { kind: 'device', id: 'dev_0000000000000001', label: 'Телефон', provenance: 'source-ip' },
|
||||||
|
source: { ip: '192.168.50.10' },
|
||||||
|
destination: { domain: 'youtube.com' },
|
||||||
|
traffic: { uploadBytes: '10', downloadBytes: '20' },
|
||||||
|
});
|
||||||
|
const second = trafficConnection('tv', {
|
||||||
|
origin: { kind: 'device', id: 'dev_0000000000000002', label: 'ТВ', provenance: 'source-ip' },
|
||||||
|
source: { ip: '192.168.50.11' },
|
||||||
|
destination: { domain: 'youtube.com' },
|
||||||
|
traffic: { uploadBytes: '30', downloadBytes: '40' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(groupTrafficConnections([first, second], 'device').length, 2);
|
||||||
|
const [site] = groupTrafficConnections([first, second], 'site');
|
||||||
|
assert.equal(site.connections.length, 2);
|
||||||
|
assert.equal(site.origins.length, 2);
|
||||||
|
assert.deepEqual(site.traffic, {
|
||||||
|
uploadBytes: '40',
|
||||||
|
downloadBytes: '60',
|
||||||
|
uploadBytesPerSecond: '2',
|
||||||
|
downloadBytesPerSecond: '4',
|
||||||
|
});
|
||||||
|
assert.deepEqual(site.origins.map(({ label, connections, traffic }) => ({ label, connections, traffic })), [
|
||||||
|
{ label: 'ТВ', connections: 1, traffic: { uploadBytes: '30', downloadBytes: '40' } },
|
||||||
|
{ label: 'Телефон', connections: 1, traffic: { uploadBytes: '10', downloadBytes: '20' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ranked = summarizeTrafficOrigins([first, first, second]);
|
||||||
|
assert.deepEqual(ranked.map(({ label, connections }) => [label, connections]), [['Телефон', 2], ['ТВ', 1]]);
|
||||||
|
assert.equal(trafficConnectionMatchesFilters(first, 'vpn', 'recognized'), true);
|
||||||
|
assert.equal(trafficConnectionMatchesFilters(first, 'direct', 'all'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
|
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
|
||||||
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
|
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
|
||||||
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
|
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
|
||||||
@@ -308,9 +388,9 @@ test('traffic groups stay mounted and inert through exit while the same group ca
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
|
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
|
||||||
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
|
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
|
||||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
|
||||||
assert.match(feature, /trafficGroupMatches\(group, query, routeFilter, qualityFilter\)/);
|
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||||||
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
|
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate, !reorder\)/);
|
||||||
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
|
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
|
||||||
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
|
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
|
||||||
assert.match(feature, /event\.target === event\.currentTarget[\s\S]*event\.animationName === 'client-traffic-connection-out'/);
|
assert.match(feature, /event\.target === event\.currentTarget[\s\S]*event\.animationName === 'client-traffic-connection-out'/);
|
||||||
@@ -318,31 +398,83 @@ test('traffic groups stay mounted and inert through exit while the same group ca
|
|||||||
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*media\.addEventListener\('change', update\)[\s\S]*media\.removeEventListener\('change', update\)/);
|
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*media\.addEventListener\('change', update\)[\s\S]*media\.removeEventListener\('change', update\)/);
|
||||||
assert.match(feature, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/);
|
assert.match(feature, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/);
|
||||||
assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/);
|
assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/);
|
||||||
assert.match(feature, /group\.connections\.length > 1[\s\S]*×\$\{group\.connections\.length\}/);
|
|
||||||
assert.match(feature, /group\.activeCount > 0 && <strong>/);
|
|
||||||
assert.match(feature, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/);
|
assert.match(feature, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/);
|
||||||
assert.match(feature, /<b>Активных распознано<\/b>/);
|
|
||||||
assert.match(feature, /<b>Активных требует внимания<\/b>/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('traffic styling preserves the shared drawer geometry and minimal motion', () => {
|
test('polling updates values without reshuffling readable rows, while explicit sorting can reorder', () => {
|
||||||
|
const groups = groupTrafficConnections([
|
||||||
|
trafficConnection('a', { destination: { domain: 'a.test' } }),
|
||||||
|
trafficConnection('b', { destination: { domain: 'b.test' } }),
|
||||||
|
trafficConnection('c', { destination: { domain: 'c.test' } }),
|
||||||
|
]);
|
||||||
|
const [a, b, c] = groups;
|
||||||
|
const updated = { ...b, traffic: { ...b.traffic, downloadBytes: '9007199254740993' } };
|
||||||
|
for (const immediate of [false, true]) {
|
||||||
|
let rows = reconcileTrafficGroups([], [a, b], immediate, true);
|
||||||
|
rows = reconcileTrafficGroups(rows, [updated, c, a], immediate, true);
|
||||||
|
assert.deepEqual(rows.map(({ group }) => group.label), ['a.test', 'b.test', 'c.test']);
|
||||||
|
assert.equal(rows[1].group.traffic.downloadBytes, '9007199254740993');
|
||||||
|
const sorted = reconcileTrafficGroups(rows, [c, updated, a], immediate, false);
|
||||||
|
assert.deepEqual(sorted.map(({ group }) => group.label), ['c.test', 'b.test', 'a.test']);
|
||||||
|
rows = reconcileTrafficGroups(rows, [c, a], immediate, true);
|
||||||
|
assert.equal(rows.some((row) => row.group.id === b.id), !immediate);
|
||||||
|
rows = reconcileTrafficGroups(rows, [a, updated, c], immediate, true);
|
||||||
|
assert.equal(rows.find((row) => row.group.id === b.id).exiting, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('minimal traffic rows distinguish current rates from historical totals and completed connections', () => {
|
||||||
|
const [group] = groupTrafficConnections([trafficConnection('current', { traffic: {
|
||||||
|
downloadBytes: '9007199254740993', uploadBytes: '1048576',
|
||||||
|
downloadBytesPerSecond: '2097152', uploadBytesPerSecond: '1024',
|
||||||
|
} })]);
|
||||||
|
const render = (group, expanded = false) => renderToStaticMarkup(createElement(TrafficGroupRow, {
|
||||||
|
group, expanded, exiting: false, onExited() {}, onToggle() {},
|
||||||
|
}));
|
||||||
|
const current = render(group);
|
||||||
|
assert.match(current, /aria-label="Скачивание: 2,0 МБ\/с"/);
|
||||||
|
assert.match(current, /aria-label="Отправка: 1,0 КБ\/с"/);
|
||||||
|
assert.doesNotMatch(current, /<svg|<img|<dl|8,0 ПБ|tls/);
|
||||||
|
assert.match(render(group, true), /<dt>Скачано<\/dt><dd>8,0 ПБ<\/dd>/);
|
||||||
|
const completed = render({ ...group, activeCount: 0, recentCount: 1 });
|
||||||
|
assert.match(completed, /aria-label="Скачивание: соединение завершено"/);
|
||||||
|
assert.doesNotMatch(completed, /МБ\/с/);
|
||||||
|
const snapshot = emptyTrafficHistory(parseTrafficHistoryQuery(new URLSearchParams()));
|
||||||
|
snapshot.rows = [{ key: 'example.com', label: 'example.com', downloadBytes: '9007199254740993', uploadBytes: '1048576', route: 'vpn' }];
|
||||||
|
const historical = renderToStaticMarkup(createElement(HistoryRows, {
|
||||||
|
snapshot, active: true, load() { throw new Error('Collapsed history must not load details'); },
|
||||||
|
}));
|
||||||
|
assert.match(historical, /aria-label="Скачивание: 8,0 ПБ"/);
|
||||||
|
assert.match(historical, /aria-label="Отправка: 1,0 МБ"/);
|
||||||
|
assert.doesNotMatch(historical, /<svg|<img|\/с|VPN|Загружаем/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traffic modes retain mounted state and stop hidden polling; menu Escape stays inside the drawer', () => {
|
||||||
|
assert.doesNotMatch(feature, /view === 'history' && <TrafficHistoryPanel/);
|
||||||
|
assert.match(feature, /<TrafficHistoryPanel active=\{feature.isOpen && !feature.paused && feature.view === 'history'\}/);
|
||||||
|
assert.match(feature, /aria-hidden=\{feature.view !== 'history'\} inert=\{feature.view !== 'history' \|\| undefined\}/);
|
||||||
|
assert.match(history, /if \(!active\) return/);
|
||||||
|
assert.match(history, /controller.abort\(\); clearTimeout\(timer\)/);
|
||||||
|
assert.match(history, /until: snapshot.query.until/);
|
||||||
|
assert.match(controls, /event.stopPropagation\(\);[\s\S]*trigger.current\?\.focus\(\)/);
|
||||||
|
assert.match(controls, /aria-hidden=\{!open\} inert=\{!open \|\| undefined\}/);
|
||||||
|
assert.match(controls, /getComputedStyle\(node\).opacity;[\s\S]*animation.cancel\(\)/);
|
||||||
|
assert.match(controls, /media.addEventListener\('change', reduce\)/);
|
||||||
|
assert.match(controls, /media.removeEventListener\('change', reduce\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traffic styling reserves readable columns and one list scroll owner with calm motion', () => {
|
||||||
assert.match(styles, /\.client-traffic-toggle svg \{[\s\S]*width: 24px;[\s\S]*height: 24px/);
|
assert.match(styles, /\.client-traffic-toggle svg \{[\s\S]*width: 24px;[\s\S]*height: 24px/);
|
||||||
assert.match(primitives, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
|
assert.match(primitives, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
|
||||||
assert.match(styles, /@media \(max-width: 768px\) \{[\s\S]*\.client-traffic \{[\s\S]*width: 100vw/);
|
assert.match(styles, /width: min\(800px, 100vw\)/);
|
||||||
assert.doesNotMatch(styles, /overflow-y:\s*(?:auto|scroll)/);
|
assert.match(styles, /@media \(max-width: 480px\)/);
|
||||||
assert.match(styles, /\.client-traffic-meta button \{[\s\S]*width: 96px/);
|
assert.match(styles, /\.client-traffic-scroll \{[\s\S]*min-height: 0;[\s\S]*overflow-y: auto/);
|
||||||
assert.match(styles, /\.client-traffic-details \{[\s\S]*animation: client-traffic-details-in 180ms/);
|
assert.match(styles, /\.client-traffic-pause \{[\s\S]*width: 112px/);
|
||||||
assert.match(styles, /\.client-traffic-connection \{[\s\S]*animation: client-traffic-connection-in 420ms/);
|
assert.match(styles, /\.client-traffic-columns,[\s\S]*grid-template-columns: minmax\(0, 1fr\) var\(--traffic-value-width\) var\(--traffic-value-width\)/);
|
||||||
assert.match(styles, /\.client-traffic-connection\.is-exiting \{[\s\S]*animation: client-traffic-connection-out 240ms/);
|
|
||||||
const detailMotion = /@keyframes client-traffic-details-in \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
|
|
||||||
assert.match(detailMotion, /opacity:/);
|
|
||||||
assert.match(detailMotion, /translateY/);
|
|
||||||
assert.doesNotMatch(detailMotion, /height|width|margin|padding|scale|filter/);
|
|
||||||
for (const name of ['client-traffic-connection-in', 'client-traffic-connection-out']) {
|
for (const name of ['client-traffic-connection-in', 'client-traffic-connection-out']) {
|
||||||
const rowMotion = new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`).exec(styles)?.[1] || '';
|
const rowMotion = new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`).exec(styles)?.[1] || '';
|
||||||
assert.match(rowMotion, /opacity:/);
|
assert.match(rowMotion, /opacity:/);
|
||||||
assert.match(rowMotion, /translateY/);
|
assert.doesNotMatch(rowMotion, /transform|height|width|margin|padding|scale|filter/);
|
||||||
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
|
|
||||||
}
|
}
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details \{[\s\S]*animation: none/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*animation: none;[\s\S]*transition: none/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,15 +43,16 @@ test('Grafana dashboard uses a compact user-first layout and one device scope',
|
|||||||
const services = byId.get(8);
|
const services = byId.get(8);
|
||||||
const domains = byId.get(9);
|
const domains = byId.get(9);
|
||||||
const totalTraffic = byId.get(1);
|
const totalTraffic = byId.get(1);
|
||||||
const trafficBySource = byId.get(4);
|
const sourceOutbounds = byId.get(4);
|
||||||
const singboxOutbounds = byId.get(15);
|
const singboxOutbounds = byId.get(15);
|
||||||
const directPackets = byId.get(16);
|
const directPaths = byId.get(16);
|
||||||
|
const appliedPolicy = byId.get(17);
|
||||||
const technical = byId.get(13);
|
const technical = byId.get(13);
|
||||||
|
|
||||||
assert.equal(dashboard.title, 'Harbor Gateway: трафик');
|
assert.equal(dashboard.title, 'Harbor Gateway: трафик');
|
||||||
assert.equal(dashboard.refresh, '30s');
|
assert.equal(dashboard.refresh, '30s');
|
||||||
assert.equal(dashboard.version, 9);
|
assert.equal(dashboard.version, 10);
|
||||||
assert.deepEqual(dashboard.panels.map(({ id }) => id), [10, 6, 7, 1, 15, 2, 3, 5, 12, 8, 9, 13]);
|
assert.deepEqual(dashboard.panels.map(({ id }) => id), [10, 6, 7, 1, 15, 2, 17, 3, 5, 12, 8, 9, 13]);
|
||||||
assert.deepEqual(dashboard.panels.filter(({ type }) => type === 'row').map(({ title }) => title), [
|
assert.deepEqual(dashboard.panels.filter(({ type }) => type === 'row').map(({ title }) => title), [
|
||||||
'Обзор',
|
'Обзор',
|
||||||
'Куда уходит трафик',
|
'Куда уходит трафик',
|
||||||
@@ -62,11 +63,12 @@ test('Grafana dashboard uses a compact user-first layout and one device scope',
|
|||||||
assert.equal(byId.has(11), false);
|
assert.equal(byId.has(11), false);
|
||||||
assert.equal(byId.has(14), false);
|
assert.equal(byId.has(14), false);
|
||||||
|
|
||||||
assert.deepEqual(speed.gridPos, { h: 12, w: 16, x: 0, y: 5 });
|
assert.deepEqual(appliedPolicy.gridPos, { h: 4, w: 16, x: 0, y: 5 });
|
||||||
assert.deepEqual(devices.gridPos, { h: 12, w: 8, x: 16, y: 5 });
|
assert.deepEqual(speed.gridPos, { h: 12, w: 16, x: 0, y: 9 });
|
||||||
assert.deepEqual(services.gridPos, { h: 15, w: 9, x: 0, y: 18 });
|
assert.deepEqual(devices.gridPos, { h: 16, w: 8, x: 16, y: 5 });
|
||||||
assert.deepEqual(domains.gridPos, { h: 15, w: 15, x: 9, y: 18 });
|
assert.deepEqual(services.gridPos, { h: 15, w: 9, x: 0, y: 22 });
|
||||||
assert.deepEqual(technical.gridPos, { h: 1, w: 24, x: 0, y: 33 });
|
assert.deepEqual(domains.gridPos, { h: 15, w: 15, x: 9, y: 22 });
|
||||||
|
assert.deepEqual(technical.gridPos, { h: 1, w: 24, x: 0, y: 37 });
|
||||||
assert.equal(devices.options.footer.enablePagination, false);
|
assert.equal(devices.options.footer.enablePagination, false);
|
||||||
assert.equal(domains.options.footer.enablePagination, false);
|
assert.equal(domains.options.footer.enablePagination, false);
|
||||||
|
|
||||||
@@ -88,16 +90,35 @@ test('Grafana dashboard uses a compact user-first layout and one device scope',
|
|||||||
log: 2,
|
log: 2,
|
||||||
type: 'symlog',
|
type: 'symlog',
|
||||||
});
|
});
|
||||||
assert.deepEqual(speed.targets.map(({ refId }) => refId), ['A', 'B']);
|
assert.equal(speed.title, 'Фактический VPN / Direct');
|
||||||
|
assert.deepEqual(speed.targets.map(({ refId }) => refId), ['A', 'B', 'C', 'D', 'E', 'F']);
|
||||||
assert.ok(speed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.includes('[$__rate_interval]') && !expr.includes('[5m]')));
|
assert.ok(speed.targets.every(({ expr }) => expr.includes('device_id=~"$device_id"') && expr.includes('[$__rate_interval]') && !expr.includes('[5m]')));
|
||||||
assert.ok(speed.fieldConfig.overrides.some((override) => (
|
assert.ok(speed.targets.filter(({ refId }) => ['A', 'B', 'E', 'F'].includes(refId))
|
||||||
override.matcher.id === 'byFrameRefID'
|
.every(({ expr }) => expr.includes('harbor_device_singbox_tracked_bytes_total')));
|
||||||
&& override.matcher.options === 'B'
|
assert.ok(speed.targets.every(({ expr }) => !expr.includes('source=')));
|
||||||
&& override.properties.some(({ id, value }) => id === 'custom.transform' && value === 'negative-Y')
|
assert.ok(speed.targets.filter(({ refId }) => ['C', 'D'].includes(refId)).every(({ expr }) => (
|
||||||
|
expr.includes('harbor_device_singbox_tracked_bytes_total')
|
||||||
|
&& expr.includes('harbor_device_direct_ipv4_packet_bytes_total')
|
||||||
|
&& expr.includes('or vector(0)')
|
||||||
)));
|
)));
|
||||||
|
assert.deepEqual(speed.fieldConfig.overrides.filter((override) => (
|
||||||
|
override.matcher.id === 'byFrameRefID'
|
||||||
|
&& override.properties.some(({ id, value }) => id === 'custom.transform' && value === 'negative-Y')
|
||||||
|
)).map(({ matcher }) => matcher.options), ['B', 'D', 'F']);
|
||||||
assert.equal(speed.options.legend.placement, 'right');
|
assert.equal(speed.options.legend.placement, 'right');
|
||||||
assert.deepEqual(speed.options.legend.calcs, ['lastNotNull', 'mean', 'max']);
|
assert.deepEqual(speed.options.legend.calcs, ['lastNotNull', 'mean', 'max']);
|
||||||
|
|
||||||
|
assert.equal(appliedPolicy.type, 'state-timeline');
|
||||||
|
assert.equal(appliedPolicy.title, 'Применённый режим');
|
||||||
|
assert.match(appliedPolicy.description, /Direct[\s\S]*VPN через явно настроенный Harbor Proxy/);
|
||||||
|
assert.match(appliedPolicy.targets[0].expr, /harbor_device_applied_policy\{device_id=~"\$device_id"\}/);
|
||||||
|
assert.match(appliedPolicy.targets[0].expr, /group_left \(name, ip\)[\s\S]*harbor_device_info/);
|
||||||
|
assert.deepEqual(appliedPolicy.fieldConfig.defaults.mappings[0].options, {
|
||||||
|
0: { color: 'orange', index: 0, text: 'Direct' },
|
||||||
|
1: { color: 'green', index: 1, text: 'VPN' },
|
||||||
|
});
|
||||||
|
assert.equal(appliedPolicy.fieldConfig.defaults.noValue, 'Нет данных');
|
||||||
|
|
||||||
assert.deepEqual(singboxOutbounds.targets.map(({ legendFormat }) => legendFormat), ['VPN', 'Direct', 'Не определено']);
|
assert.deepEqual(singboxOutbounds.targets.map(({ legendFormat }) => legendFormat), ['VPN', 'Direct', 'Не определено']);
|
||||||
assert.ok(singboxOutbounds.targets.every(({ expr }) => (
|
assert.ok(singboxOutbounds.targets.every(({ expr }) => (
|
||||||
expr.includes('harbor_device_singbox_tracked_bytes_total')
|
expr.includes('harbor_device_singbox_tracked_bytes_total')
|
||||||
@@ -105,11 +126,20 @@ test('Grafana dashboard uses a compact user-first layout and one device scope',
|
|||||||
&& expr.includes('[$__range]')
|
&& expr.includes('[$__range]')
|
||||||
)));
|
)));
|
||||||
assert.match(singboxOutbounds.targets[0].expr, /outbound="vpn"/);
|
assert.match(singboxOutbounds.targets[0].expr, /outbound="vpn"/);
|
||||||
assert.match(singboxOutbounds.targets[1].expr, /outbound="direct"/);
|
assert.doesNotMatch(singboxOutbounds.targets[0].expr, /source=/);
|
||||||
|
assert.match(singboxOutbounds.targets[1].expr, /harbor_device_singbox_tracked_bytes_total[\s\S]*outbound="direct"/);
|
||||||
|
assert.match(singboxOutbounds.targets[1].expr, /harbor_device_direct_ipv4_packet_bytes_total/);
|
||||||
|
assert.match(singboxOutbounds.targets[1].expr, /or vector\(0\)/);
|
||||||
assert.match(singboxOutbounds.targets[2].expr, /outbound="unknown"/);
|
assert.match(singboxOutbounds.targets[2].expr, /outbound="unknown"/);
|
||||||
assert.match(directPackets.targets[0].expr, /harbor_device_direct_ipv4_packet_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]/);
|
assert.equal(singboxOutbounds.fieldConfig.defaults.noValue, 'Нет данных');
|
||||||
assert.match(directPackets.description, /нельзя складывать с sing-box tracker bytes/);
|
assert.equal(sourceOutbounds.title, 'Вход × выход sing-box');
|
||||||
assert.match(trafficBySource.targets[0].expr, /harbor_device_traffic_bytes_total\{device_id=~"\$device_id"\}\[\$__range\]/);
|
assert.match(sourceOutbounds.targets[0].expr, /sum by \(source, outbound\)[\s\S]*harbor_device_singbox_tracked_bytes_total/);
|
||||||
|
assert.equal(sourceOutbounds.targets[0].legendFormat, '{{source}} · {{outbound}}');
|
||||||
|
assert.match(sourceOutbounds.description, /proxy · vpn/);
|
||||||
|
assert.deepEqual(directPaths.targets.map(({ legendFormat }) => legendFormat), ['Через sing-box', 'Linux мимо sing-box']);
|
||||||
|
assert.match(directPaths.targets[0].expr, /harbor_device_singbox_tracked_bytes_total[\s\S]*outbound="direct"/);
|
||||||
|
assert.match(directPaths.targets[1].expr, /harbor_device_direct_ipv4_packet_bytes_total/);
|
||||||
|
assert.match(directPaths.description, /приблизительной оценкой/);
|
||||||
|
|
||||||
assert.match(devices.targets[0].expr, /^\(sum by \(device_id\)[\s\S]*increase\(harbor_device_traffic_bytes_total\[\$__range\]\)[\s\S]*> 0[\s\S]*group_left \(name, ip\)/);
|
assert.match(devices.targets[0].expr, /^\(sum by \(device_id\)[\s\S]*increase\(harbor_device_traffic_bytes_total\[\$__range\]\)[\s\S]*> 0[\s\S]*group_left \(name, ip\)/);
|
||||||
assert.doesNotMatch(devices.targets[0].expr, /topk/);
|
assert.doesNotMatch(devices.targets[0].expr, /topk/);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const routing = fs.readFileSync(path.join(root, 'src/web/features/routing/Routin
|
|||||||
const devices = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
const devices = fs.readFileSync(path.join(root, 'src/web/features/devices/DevicesFeature.tsx'), 'utf8');
|
||||||
const diagnosticsFeature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
|
const diagnosticsFeature = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DiagnosticsFeature.tsx'), 'utf8');
|
||||||
const diagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
|
const diagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx'), 'utf8');
|
||||||
|
const dnsDiagnostics = fs.readFileSync(path.join(root, 'src/web/features/diagnostics/DnsDiagnosticsSection.tsx'), 'utf8');
|
||||||
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
const instructions = fs.readFileSync(path.join(root, 'src/web/features/instructions/InstructionsFeature.tsx'), 'utf8');
|
||||||
|
|
||||||
function rule(selector, source = styles) {
|
function rule(selector, source = styles) {
|
||||||
@@ -104,7 +105,8 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
|
|||||||
assert.match(mobile, /\.client-secondary-menu \{[\s\S]*right:\s*8px/);
|
assert.match(mobile, /\.client-secondary-menu \{[\s\S]*right:\s*8px/);
|
||||||
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
|
assert.match(mobile, /\.client-power-control > \.harbor-brand \{[\s\S]*scale\(1\.35\)/);
|
||||||
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
|
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
|
||||||
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-failover-heading \{[\s\S]*flex-wrap:\s*wrap[\s\S]*\.client-failover-channel-actions \{[\s\S]*grid-column:\s*1[\s\S]*grid-row:\s*auto[\s\S]*display:\s*flex[\s\S]*justify-content:\s*center/);
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-failover-heading \{[\s\S]*flex-wrap:\s*wrap/);
|
||||||
|
assert.match(styles, /@media \(max-width: 680px\)[\s\S]*\.client-failover-channel-actions \{[\s\S]*grid-column:\s*1[\s\S]*grid-row:\s*auto[\s\S]*display:\s*flex[\s\S]*justify-content:\s*center/);
|
||||||
assert.match(styles, /\.client-failover-channel-actions \{[^}]*grid-column:\s*2[^}]*display:\s*grid/);
|
assert.match(styles, /\.client-failover-channel-actions \{[^}]*grid-column:\s*2[^}]*display:\s*grid/);
|
||||||
assert.match(rule('.client-drawer'), /width:\s*min\(580px, 100vw\)/);
|
assert.match(rule('.client-drawer'), /width:\s*min\(580px, 100vw\)/);
|
||||||
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
|
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
|
||||||
@@ -263,6 +265,15 @@ test('responsive motion has a reduced-motion fallback', () => {
|
|||||||
assert.doesNotMatch(styles, /\.client-rail-action:active:not\(:disabled\) svg/);
|
assert.doesNotMatch(styles, /\.client-rail-action:active:not\(:disabled\) svg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('DNS keeps three result columns while its add forms stack on narrow screens', () => {
|
||||||
|
assert.match(styles, /\.client-dns-row \{[\s\S]*min-height:\s*92px/);
|
||||||
|
assert.match(styles, /\.client-dns-result \{[\s\S]*min-height:\s*72px/);
|
||||||
|
assert.match(styles, /\.client-dns-header,\s*\.client-dns-row \{[\s\S]*grid-template-columns:\s*35% minmax\(0, 1fr\) minmax\(0, 1fr\) 28px/);
|
||||||
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-dns-inline-form \{[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\) 44px/);
|
||||||
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-dns-row \{[\s\S]*grid-template-columns:\s*32% minmax\(0, 1fr\) minmax\(0, 1fr\) 44px/);
|
||||||
|
assert.match(dnsDiagnostics, /role="table" aria-busy=\{running\}/);
|
||||||
|
});
|
||||||
|
|
||||||
test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => {
|
test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => {
|
||||||
const tooltip = rule('.client-tooltip');
|
const tooltip = rule('.client-tooltip');
|
||||||
|
|
||||||
|
|||||||
@@ -40,26 +40,26 @@ const expectedImports = [
|
|||||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
const acceptedLedger = {
|
const acceptedLedger = {
|
||||||
counts: {
|
counts: {
|
||||||
cascadeEdges: 1126,
|
cascadeEdges: 1210,
|
||||||
customProperties: 115,
|
customProperties: 128,
|
||||||
declarations: 4676,
|
declarations: 4842,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 55,
|
keyframes: 54,
|
||||||
media: 22,
|
media: 24,
|
||||||
rules: 1262,
|
rules: 1321,
|
||||||
variableReferences: 1214,
|
variableReferences: 1244,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: '1f3c75839bd37bb312b9aed2987ed61571e5148f8b23c8288424cb193c7a8dda',
|
cascadeEdges: '224ac4ef12ba44e54b737ce00812315090f9f401cc8836e3eba483284b71da25',
|
||||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
customProperties: '35cecb86835ecd0f51e703424bb4440ddf291138d97eeb070b880706a8efc6a1',
|
||||||
declarations: '616fc7cb72a9db509f6b41e1808118c2e2be7da6ce4c078dfa2eb4a963f0dbb1',
|
declarations: 'c1b1061f8cf328f5f266da23f8f72ed9878f9f3db057fa586ee7d69781b62db1',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||||
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
keyframes: '4178d589addd5be04ff4d89a5fd93527427795f62d583527e063e0a541f70bd0',
|
||||||
ruleDeclarationSequences: '3a65866ea25506c6fd79b68bfbbdeddd495f6957dbd99dec6a814b816ba4cca2',
|
ruleDeclarationSequences: '7659444a0a4871d15b7702f886c3730f02ea8fdfa54dc5659465ecc4cd1743a6',
|
||||||
selectors: '56e1c67b35230649a3c69b2aea67c1929580700f1b4357ee05dfb11012d72655',
|
selectors: 'e15851f9c14afb747e8c7d5969586d1c2193305dbd9ddc809020eae87d2514af',
|
||||||
variableReferences: '71722a81fa16b3586ae9ee5890f79176729dfdf7fe58c4d4994755f8059aa034',
|
variableReferences: '3f5340864200e324948e4387a4bb534e82732ed91bc4e3958081890cf7bb854e',
|
||||||
witnesses: '2272ed9b07e02edd232c33a971c4cc5171f6a612ccaa36418933f0fd6bb465c1',
|
witnesses: '27f96ddab7b128a8924c7a733244523fe007a34ba91d192f63ba19a50c38d4a5',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
|||||||
|
|
||||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
const witnesses = readStyleWitnesses(root);
|
||||||
assert.equal(witnesses.length, 1292);
|
assert.equal(witnesses.length, 1463);
|
||||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||||
@@ -221,11 +221,7 @@ test('accepted stylesheet has pinned declaration, selector, keyframe, variable,
|
|||||||
|
|
||||||
test('every live production selector has an expanded DOM witness', () => {
|
test('every live production selector has an expanded DOM witness', () => {
|
||||||
const unmatched = selectorsWithoutWitness(readStyleSource(root), readStyleWitnesses(root));
|
const unmatched = selectorsWithoutWitness(readStyleSource(root), readStyleWitnesses(root));
|
||||||
assert.deepEqual(unmatched, [
|
assert.deepEqual(unmatched, []);
|
||||||
'.client-diagnostics-section-title button',
|
|
||||||
'.client-diagnostics-section-title button:disabled',
|
|
||||||
'.client-diagnostics-section-title button:focus-visible',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => {
|
test('JSX witness expansion follows cross-file components, render props, ReactNode slots, portals, and imperative classes', () => {
|
||||||
@@ -319,6 +315,12 @@ test('JSX witnesses keep real multi-class collisions and exclude impossible elem
|
|||||||
assert.equal(conservativeSibling.counts.cascadeEdges, 1);
|
assert.equal(conservativeSibling.counts.cascadeEdges, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('JSX witness recursion requires an explicit finite bound for the four-level history tree', () => {
|
||||||
|
const sources = [{ file: '/fixture/App.tsx', source: 'export function App() { return <Branch />; } function Branch() { return <div className="branch"><Branch /></div>; }' }];
|
||||||
|
assert.throws(() => createStyleWitnesses(sources), /Recursive JSX witness/);
|
||||||
|
assert.equal(createStyleWitnesses(sources, { recursionLimits: { Branch: 4 } }).length, 4);
|
||||||
|
});
|
||||||
|
|
||||||
test('selector proof uses the observed level-four grammar and exact specificity', () => {
|
test('selector proof uses the observed level-four grammar and exact specificity', () => {
|
||||||
const fixtures = [
|
const fixtures = [
|
||||||
['#root', [{ a: 1, b: 0, c: 0 }]],
|
['#root', [{ a: 1, b: 0, c: 0 }]],
|
||||||
@@ -326,6 +328,8 @@ test('selector proof uses the observed level-four grammar and exact specificity'
|
|||||||
['.client-instructions-toggle:not(.client-devices-toggle):not(.client-diagnostics-toggle)', [{ a: 0, b: 3, c: 0 }]],
|
['.client-instructions-toggle:not(.client-devices-toggle):not(.client-diagnostics-toggle)', [{ a: 0, b: 3, c: 0 }]],
|
||||||
[".client-power[aria-checked='true']::before", [{ a: 0, b: 2, c: 1 }]],
|
[".client-power[aria-checked='true']::before", [{ a: 0, b: 2, c: 1 }]],
|
||||||
['.client-failover-number-setting input::-webkit-inner-spin-button', [{ a: 0, b: 1, c: 2 }]],
|
['.client-failover-number-setting input::-webkit-inner-spin-button', [{ a: 0, b: 1, c: 2 }]],
|
||||||
|
['.client-traffic-search input::-webkit-search-cancel-button', [{ a: 0, b: 1, c: 2 }]],
|
||||||
|
['.client-traffic button:enabled:hover', [{ a: 0, b: 3, c: 1 }]],
|
||||||
['.client-instruction-block:nth-child(n)', [{ a: 0, b: 2, c: 0 }]],
|
['.client-instruction-block:nth-child(n)', [{ a: 0, b: 2, c: 0 }]],
|
||||||
['.client-confirmation-actions button:hover:not(:disabled), #root', [
|
['.client-confirmation-actions button:hover:not(:disabled), #root', [
|
||||||
{ a: 0, b: 3, c: 1 },
|
{ a: 0, b: 3, c: 1 },
|
||||||
@@ -409,8 +413,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
|||||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||||
|
|
||||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||||
assert.deepEqual(assets, ['index-tJmxnB8a.css']);
|
assert.deepEqual(assets, ['index-DVxr9dv9.css']);
|
||||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||||
assert.equal(built.byteLength, 176543);
|
assert.equal(built.byteLength, 183862);
|
||||||
assert.equal(sha256(built), '38a283204c656164c38c17aa03f41c931544e05b5732248807d911b2645bf970');
|
assert.equal(sha256(built), 'b717ce3c169f63cd28d08fd708758e2d4fe5e85587a649dae15d1cf30ce778a0');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ function bindCallParameters(callable, argumentsList, callerEnvironment, callerFi
|
|||||||
return environment;
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
|
export function createStyleWitnesses(sources, { entry = 'App', recursionLimits = {} } = {}) {
|
||||||
const files = sources.map(({ file, source }) => ({ file, source, parsed: parseWitnessFile(file, source) }));
|
const files = sources.map(({ file, source }) => ({ file, source, parsed: parseWitnessFile(file, source) }));
|
||||||
const { definitions, definitionNodes } = componentDefinitions(files);
|
const { definitions, definitionNodes } = componentDefinitions(files);
|
||||||
const entryDefinition = definitions.get(entry);
|
const entryDefinition = definitions.get(entry);
|
||||||
@@ -361,7 +361,11 @@ export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
|
|||||||
const dynamicByFile = dynamicClassBindings(files);
|
const dynamicByFile = dynamicClassBindings(files);
|
||||||
const witnesses = [];
|
const witnesses = [];
|
||||||
const expand = (definition, ancestors, ancestorUnknown, environment, stack) => {
|
const expand = (definition, ancestors, ancestorUnknown, environment, stack) => {
|
||||||
if (stack.includes(definition)) throw new TypeError(`Recursive JSX witness component: ${definition.node.id?.name || definition.file}`);
|
if (stack.includes(definition)) {
|
||||||
|
const limit = recursionLimits[definition.node.id?.name];
|
||||||
|
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`Recursive JSX witness component: ${definition.node.id?.name || definition.file}`);
|
||||||
|
if (stack.filter((item) => item === definition).length >= limit) return;
|
||||||
|
}
|
||||||
const nextStack = [...stack, definition];
|
const nextStack = [...stack, definition];
|
||||||
const entryEnvironment = withLocalJsxBindings(definition.node.body, environment, definitionNodes, definition.file);
|
const entryEnvironment = withLocalJsxBindings(definition.node.body, environment, definitionNodes, definition.file);
|
||||||
const visit = (
|
const visit = (
|
||||||
@@ -474,7 +478,8 @@ export function readStyleWitnesses(root) {
|
|||||||
file,
|
file,
|
||||||
source: fs.readFileSync(file, 'utf8'),
|
source: fs.readFileSync(file, 'utf8'),
|
||||||
}));
|
}));
|
||||||
return createStyleWitnesses(files);
|
// History has exactly service/domain/hostname/IP levels, not arbitrary JSX recursion.
|
||||||
|
return createStyleWitnesses(files, { recursionLimits: { HistoryRows: 4, HistoryBranch: 3, TrafficReveal: 3 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const OBSERVED_PROPERTIES = new Set(`
|
const OBSERVED_PROPERTIES = new Set(`
|
||||||
@@ -494,7 +499,7 @@ margin margin-bottom margin-inline margin-left margin-right margin-top max-heigh
|
|||||||
min-height min-width mix-blend-mode opacity order outline outline-offset overflow
|
min-height min-width mix-blend-mode opacity order outline outline-offset overflow
|
||||||
overflow-wrap overflow-x overflow-y overscroll-behavior padding padding-block
|
overflow-wrap overflow-x overflow-y overscroll-behavior padding padding-block
|
||||||
padding-bottom padding-inline padding-left padding-right padding-top place-content
|
padding-bottom padding-inline padding-left padding-right padding-top place-content
|
||||||
place-items pointer-events position right row-gap scrollbar-width stroke stroke-dasharray
|
place-items pointer-events position right row-gap scrollbar-width scrollbar-gutter stroke stroke-dasharray
|
||||||
stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align
|
stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align
|
||||||
text-decoration text-overflow text-shadow text-transform text-underline-offset top
|
text-decoration text-overflow text-shadow text-transform text-underline-offset top
|
||||||
touch-action transform transform-box transform-origin transition transition-delay user-select
|
touch-action transform transform-box transform-origin transition transition-delay user-select
|
||||||
@@ -520,9 +525,9 @@ const PROPERTY_FAMILIES = new Map([
|
|||||||
const SUPPORTED_SELECTOR_NODES = new Set(['attribute', 'class', 'combinator', 'id', 'pseudo', 'selector', 'tag', 'universal']);
|
const SUPPORTED_SELECTOR_NODES = new Set(['attribute', 'class', 'combinator', 'id', 'pseudo', 'selector', 'tag', 'universal']);
|
||||||
const SUPPORTED_COMBINATORS = new Set([' ', '+', '>']);
|
const SUPPORTED_COMBINATORS = new Set([' ', '+', '>']);
|
||||||
const SUPPORTED_PSEUDOS = new Set([
|
const SUPPORTED_PSEUDOS = new Set([
|
||||||
':-webkit-autofill', '::-webkit-inner-spin-button', '::-webkit-outer-spin-button', '::-webkit-scrollbar',
|
':-webkit-autofill', '::-webkit-inner-spin-button', '::-webkit-outer-spin-button', '::-webkit-scrollbar', '::-webkit-search-cancel-button',
|
||||||
'::after', '::before', '::marker', '::placeholder',
|
'::after', '::before', '::marker', '::placeholder',
|
||||||
'::view-transition-group', '::view-transition-new', '::view-transition-old', ':active', ':disabled',
|
'::view-transition-group', '::view-transition-new', '::view-transition-old', ':active', ':disabled', ':enabled',
|
||||||
':first-child', ':focus', ':focus-visible', ':focus-within', ':has', ':hover', ':last-child', ':not',
|
':first-child', ':focus', ':focus-visible', ':focus-within', ':has', ':hover', ':last-child', ':not',
|
||||||
':nth-child', ':root',
|
':nth-child', ':root',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('all repeated client primitive consumers use the shared owners', () => {
|
|||||||
.map(({ source }) => source)
|
.map(({ source }) => source)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
assert.equal((production.match(/<Tooltip\b/g) || []).length, 16);
|
assert.equal((production.match(/<Tooltip\b/g) || []).length, 17);
|
||||||
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
|
assert.equal((production.match(/<CopyButton\b/g) || []).length, 2);
|
||||||
assert.equal((production.match(/<RailAction\b/g) || []).length, 8);
|
assert.equal((production.match(/<RailAction\b/g) || []).length, 8);
|
||||||
assert.equal((production.match(/<Drawer\b/g) || []).length, 8);
|
assert.equal((production.match(/<Drawer\b/g) || []).length, 8);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["node18"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/server/**/*", "src/shared/**/*"],
|
"include": ["src/server/**/*", "src/shared/**/*"],
|
||||||
"exclude": ["dist", "node_modules", "src/web"]
|
"exclude": ["dist", "node_modules", "src/web"]
|
||||||
|
|||||||
+3
-1
@@ -7,10 +7,11 @@
|
|||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": ".test-dist",
|
"outDir": ".test-dist",
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"types": ["node18", "vite/client"]
|
"types": ["node", "vite/client"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/shared/**/*",
|
"src/shared/**/*",
|
||||||
|
"src/web/App.tsx",
|
||||||
"src/web/api/**/*",
|
"src/web/api/**/*",
|
||||||
"src/web/state/harborReducer.ts",
|
"src/web/state/harborReducer.ts",
|
||||||
"src/web/state/operations.ts",
|
"src/web/state/operations.ts",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"src/web/features/instructions/prometheus.ts",
|
"src/web/features/instructions/prometheus.ts",
|
||||||
"src/web/features/routing/ruleReorderModel.ts",
|
"src/web/features/routing/ruleReorderModel.ts",
|
||||||
"src/web/features/traffic/trafficRows.ts",
|
"src/web/features/traffic/trafficRows.ts",
|
||||||
|
"src/web/features/traffic/*.tsx",
|
||||||
"monitoring/grafana/harbor-gateway.json"
|
"monitoring/grafana/harbor-gateway.json"
|
||||||
],
|
],
|
||||||
"exclude": [".test-dist", "dist", "node_modules"]
|
"exclude": [".test-dist", "dist", "node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user