From 66f2437ba077436e51c0552c3ade8b93ba238579 Mon Sep 17 00:00:00 2001 From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:39:10 +0300 Subject: [PATCH 1/6] docs: English README --- README.en.md | 212 ++++++++++++++++++++++++++++++++++++++ README.md | 2 + docs/releases/2.2.0.en.md | 39 +++++++ 3 files changed, 253 insertions(+) create mode 100644 README.en.md create mode 100644 docs/releases/2.2.0.en.md diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..cff8f3a --- /dev/null +++ b/README.en.md @@ -0,0 +1,212 @@ +**English** | [Русский](README.md) + +

+ NodeFlow — HAProxy Infrastructure Control +

+ +

+ Install · + Upgrade · + Screenshots · + What's new in 2.2.0 · + Issues · + Support +

+ +NodeFlow is a control panel for a fleet of HAProxy nodes doing TCP and SNI routing. The Panel (Go API + React UI + PostgreSQL) stores routes and immutable configuration revisions, while a Node Agent on each node connects to the Panel over mTLS on its own, validates and applies the HAProxy config, reports metrics and updates itself from signed releases. No manual editing of `haproxy.cfg` and no inbound access to nodes is needed for management. + +![Nodes overview](docs/images/nodes-overview.png) + +## Features + +- **SNI routing and fallback.** Shared listeners per port, multiple SNI names per route, a "Fallback" route for everything that did not match; IP, domain and Unix socket backends. +- **Multiple servers and DNS pools.** Up to 16 servers per route in "Pool" and "Primary + backups" modes; a domain can act as a DNS pool (all A/AAAA addresses, unreachable ones excluded), with separate weights for pool IPs. +- **Load balancing.** `roundrobin`, `static-rr`, `random`, `leastconn` and `leastping` ("Lowest latency"). Weights per server and per DNS pool IP, plus a cost (latency multiplier) for `leastping`; tolerance: servers with similar load or latency share clients by weight, and the Agent sets weights on the fly through the runtime API, without a reload. +- **Client stickiness.** IP hash (consistent or map-based, IPv6 grouped by prefix) or a table with TTL that survives reloads; the "Auto" size is derived from the node's RAM. +- **PROXY protocol.** Accepted from trusted IPs, subnets and domains (the Agent resolves them and updates the ACL without a reload), or "from everyone"; health checks with a PROXY header. +- **Rate limiting.** Upload/download limits per client IP — via HAProxy filters or in the kernel via nftables (`shaper_mode=kernel`), without losing splice. +- **Performance.** Zero-copy splice in both directions; the Agent raises `fs.pipe-max-size`, and the Panel renders 1 MiB buffers instead of 256 KiB. +- **Quotas and traffic accounting.** Monthly traffic for nodes, backends and routes; per-route quotas. +- **Safe apply.** Desired/actual revisions, a full `haproxy -c` check before the config is replaced, graceful reload with rollback. +- **`haproxy.cfg` editor.** Node page → "HAProxy configuration": Monaco with highlighting and autocompletion, directive hints on hover, live validation (unknown directives and options, stray characters, ports, backend references), a diff before applying and revision history. A `haproxy -c` error from the node is shown as text with links to lines; one button returns to the config generated from routes. +- **Node HAProxy settings.** `maxconn`, `nbthread` and the `connect`/`client`/`server` timeouts live in the node settings and apply to all routes via a reload without dropping connections; connection logs can be turned off there too. +- **Prometheus `/metrics`** in the Panel: node and route state, connections, traffic, revision sync (bearer token, CIDR allowlist). +- **Signed Agent updates.** Ed25519 + SHA-256, a monotonic sequence, atomic activation and automatic rollback. +- **mTLS.** A separate client certificate per node, rotation and staged renewal; the Agent's local API is reachable from loopback only. +- **UFW.** Listener port rules are built from the applied revision; other rules are left untouched. + +## Architecture + +![NodeFlow architecture](docs/brand/architecture.svg) + +The Agent opens the connection to the Panel itself: it receives revisions and commands and sends heartbeats with metrics. SSH is needed once — for the initial Agent installation. + +## Requirements + +- **Panel:** Ubuntu 22.04+/Debian 12+ (amd64 or arm64), 2 vCPU, 2 GB RAM, 15 GB disk, a domain with an A/AAAA record pointing to the server, open `80`, `443` and `4200/tcp`. The installer sets up Docker Engine with Compose and Caddy itself. +- **Nodes:** Linux amd64 or arm64 with systemd and HAProxy 3.x. Automatic installation from the Panel supports Ubuntu 24.04 (noble) and 26.04 (resolute) — the Panel installs HAProxy 3.4 from the official HAProxy Performance repository and does not downgrade a newer installed HAProxy — and Debian: if HAProxy is not present, the distribution's `haproxy` package is installed (on Debian 12 that is 2.6, so install HAProxy 3.x beforehand). nftables is needed for in-kernel rate limiting. SSH access as root or as a user with sudo, by password or key. + +## Quick install + +### Panel + +On a clean server: + +```bash +curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh +``` + +The installer will: + +1. ask for the domain and check that its DNS points to this server; +2. offer an access mode: Caddy cookie gate (an activation link sets a secure cookie) or none — the Panel admin token is required either way; +3. install Docker and **Caddy** — Caddy obtains a Let's Encrypt certificate itself and proxies HTTPS to the Panel, which listens only on `127.0.0.1:8080`; +4. download the install kit of the latest release, verify it against the SHA-256 that GitHub publishes for every release asset, take `compose.release.yaml` from it, and generate `.env`, the CA, the mTLS certificate and the update signing key in `/opt/nodeflow`; +5. run `docker compose pull && docker compose up -d` with the `ghcr.io/nodeflow-dev/nodeflow-panel:2.2.0` image — nothing is built on the server, and the image applies database migrations itself; +6. publish signed Node Agent releases (amd64 and arm64) and save the login credentials to `~/nodeflow-credentials.txt` (mode `0600`). + +The installer does not change the firewall. For unattended automation, pass `NODEFLOW_DOMAIN`, `NODEFLOW_AUTH_MODE=cookie|none` and, if needed, `NODEFLOW_VERSION=2.2.0` (defaults to the latest release): + +```bash +curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh \ + | sudo NODEFLOW_DOMAIN=panel.example.com NODEFLOW_AUTH_MODE=cookie sh +``` + +The installer is also included in the install kit (`NodeFlow-Panel--Agent--install-kit.tar.gz` in the release assets): unpack it and run `sudo ./install.sh` — then `compose.release.yaml` is taken from the kit, and only the Agent binaries for publishing to the Panel are downloaded from GitHub. + +The release assets are exactly three files: the install kit (installers, `compose.release.yaml`, a `.env` template, PKI and node scripts, the Agent systemd unit, instructions) and the `nodeflow-node-agent--linux-amd64` / `-linux-arm64` binaries. The Panel ships only as the `ghcr.io/nodeflow-dev/nodeflow-panel` image. There is no separate `SHA256SUMS`: the installers check every downloaded file against the SHA-256 that GitHub publishes for the asset (the `digest` field in the release API) and abort on a mismatch or a missing digest. + +### Nodes + +In the Panel: **Nodes → Add node** → IP, SSH port, user and password or key → verify the host fingerprint → **"Install Node Agent"**. The Panel connects over SSH once and installs the signed Agent, the updater and the node's mTLS certificate; from then on the node is managed only through the outbound mTLS channel on `4200/tcp`. + +For recovery without the Panel there is `install-node.sh`: it downloads the Agent for the node's architecture from the latest release (or from `NODEFLOW_VERSION`) and verifies it against the asset's SHA-256 from the GitHub API; the systemd unit is embedded in the script. It requires `curl`, `jq` or `python3`, and an installed HAProxy: + +```bash +curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/scripts/install-node.sh -o install-node.sh +sudo NODE_AGENT_TOKEN= bash install-node.sh +``` + +The same script is in the install kit: `scripts/install-node.sh`. + +### Manual installation with Docker Compose + +Run the commands as root (`sudo -i`): the `/opt/nodeflow` directory is closed to other users. + +```bash +v=2.2.0 +kit=NodeFlow-Panel-$v-Agent-$v-install-kit +curl -fsSLO "https://github.com/NodeFlow-dev/nodeflow/releases/download/v$v/$kit.tar.gz" +# compare with the asset digest on the release page or in the API: +curl -fsSL "https://api.github.com/repos/NodeFlow-dev/nodeflow/releases/tags/v$v" \ + | jq -r --arg n "$kit.tar.gz" '.assets[] | select(.name == $n) | .digest' +sha256sum "$kit.tar.gz" +tar -xzf "$kit.tar.gz" && cd "$kit" && sha256sum -c SHA256SUMS +install -d -m 0750 /opt/nodeflow +install -m 0644 compose.release.yaml /opt/nodeflow/compose.yaml +install -m 0600 nodeflow.env.example /opt/nodeflow/.env # fill in secrets and domain +install -d /opt/nodeflow/scripts && install -m 0755 scripts/init-*.sh /opt/nodeflow/scripts/ +/opt/nodeflow/scripts/init-mtls-pki.sh panel.example.com /opt/nodeflow # CA, mTLS, signing key +cd /opt/nodeflow && docker compose pull && docker compose up -d +curl -fsS http://127.0.0.1:8080/healthz +``` + +The Panel needs an HTTPS reverse proxy in front of it: ready-made Caddy and Nginx examples are in [`docs/install/reverse-proxy`](docs/install/reverse-proxy). Port `4200` is not proxied: nodes connect to it directly. + +## Upgrading from 1.0.x + +Run the same installer again on the Panel server: + +```bash +curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh +``` + +When it finds `/opt/nodeflow/.env`, it acts as an upgrader: it makes a `pg_dump` and a configuration archive in `/var/backups/nodeflow/`, keeps `.env`, `tls/`, `pki/` and the Caddy snippet `/etc/caddy/conf.d/nodeflow-panel.caddy`, moves the installation from a source build to the ghcr.io image, runs migrations and checks that the Panel responds with the new version. On failure the previous `compose.yaml` and `.env` are restored; the database dump is kept (migrations are not rolled back automatically). + +Then, in **Settings → Node Agent**, update the nodes to 2.2.0 — the updater replaces the Agent atomically and rolls it back if it fails to start. Self-update does not change the systemd unit, so on nodes installed by 1.0.x versions add permission to tune kernel pipe buffers once (without it splice stays at 256 KiB) — or reinstall the Agent from the node menu: + +```bash +sudo install -d /etc/systemd/system/nodeflow-node-agent.service.d +printf '[Service]\nReadWritePaths=-/etc/sysctl.d -/proc/sys/fs/pipe-max-size -/proc/sys/fs/pipe-user-pages-soft\n' \ + | sudo tee /etc/systemd/system/nodeflow-node-agent.service.d/20-kernel-pipes.conf +sudo systemctl daemon-reload && sudo systemctl restart nodeflow-node-agent +``` + +Details, behaviour changes and the list of migrations are in the [2.0.0 release notes](docs/releases/2.0.0.md) (Russian). + +## Screenshots + +### Node card + +![Node card](docs/images/node-detail.png) + +### Route editor with HAProxy preview + +![Route editor](docs/images/route-editor.png) + +### haproxy.cfg editor with hints and validation + +![haproxy.cfg editor](docs/images/haproxy-editor.png) + +### Node HAProxy settings + +![Node HAProxy settings](docs/images/node-settings-haproxy.png) + +### Traffic and top routes + +![Traffic](docs/images/traffic-overview.png) + +### Settings: Node Agent releases, Panel and Prometheus + +![Settings](docs/images/settings.png) + +## Building from source + +Requires Go 1.25+ and Node.js 22+. + +```bash +git clone https://github.com/NodeFlow-dev/nodeflow.git && cd nodeflow +(cd frontend && npm ci && npm run build) +./scripts/build-panel.sh # panel-api with embedded UI +go build -trimpath -o node-agent ./cmd/node-agent +go build -trimpath -o node-updater ./cmd/node-updater +go test ./... +``` + +Local setup: `cp .env.example .env`, fill in the secrets and run `docker compose up -d --build` — the root `compose.yaml` builds the image from `Dockerfile.panel`. PostgreSQL integration tests are enabled with the `NODEFLOW_TEST_DATABASE_URL` variable. + +## Documentation + +- Release notes: [2.2.0](docs/releases/2.2.0.en.md); in Russian: [2.1.0](docs/releases/2.1.0.md), [2.0.1](docs/releases/2.0.1.md), [2.0.0](docs/releases/2.0.0.md), [CHANGELOG](CHANGELOG.md) +- [Panel API](docs/api-contract.md) (Russian) +- [Node HAProxy settings and config editor](docs/features/haproxy-configuration.md) (Russian) +- [Architecture](docs/architecture.md) (Russian) +- [Initial node installation](docs/bootstrap-flow.md) (Russian) +- [Agent self-update](docs/node-self-update.md) (Russian) +- [Manual installation](docs/install/README-PANEL.txt) (Russian) + +## Security + +- The Node Agent is not exposed to the internet: its local API accepts only loopback traffic, and management goes over the outbound mTLS channel. +- Each node gets its own client certificate. +- Agent releases are verified by Ed25519 signature, SHA-256, platform and version sequence. +- By default the Panel listens only on `127.0.0.1`; exposing HTTP externally requires an explicit `ALLOW_INSECURE_HTTP=true`. +- Privileged actions are written to the audit log. + +Report vulnerabilities as described in [SECURITY.md](SECURITY.md), not through public Issues. + +## Feedback + +Bug reports, ideas and installation questions go to [Issues](../../issues). Include the Panel/Agent versions, the node's OS and HAProxy version, steps to reproduce and anonymized logs — without tokens, keys, certificates or client IP addresses. Pull request rules are in [CONTRIBUTING.md](CONTRIBUTING.md). + +## Support the project + +Support matters a lot for keeping NodeFlow updates coming. + +[Tribute](https://web.tribute.tg/d/Pat) + +Tron: `TNUe93tFxeHj4avBY8s3NWzjaiyZfPWD9T` + +## License + +© 2026 NodeFlow. All rights reserved. The source code is available for review, for self-hosting by NodeFlow users and for contributing to this repository. Redistribution, including of modified versions, and commercial use of the code require written permission from the copyright holder. Full text in [LICENSE](LICENSE). diff --git a/README.md b/README.md index a038b65..64c530a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[English](README.en.md) | **Русский** +

NodeFlow — HAProxy Infrastructure Control

diff --git a/docs/releases/2.2.0.en.md b/docs/releases/2.2.0.en.md new file mode 100644 index 0000000..7fb99cd --- /dev/null +++ b/docs/releases/2.2.0.en.md @@ -0,0 +1,39 @@ +# NodeFlow 2.2.0 + +Panel **2.2.0** · Node Agent **2.2.0** · HAProxy renderer **v21** + +Client stickiness in DNS pools with backup servers, copying routes between nodes, and a route template library. Two migrations (000056, 000057). The Agent is unchanged. Thanks to @fesevu (#19, #20). + +## New + +- **Client stickiness and `leastconn` in pools with backup servers (#19).** A DNS pool with a backup server now supports client stickiness (sticky) and `leastconn` balancing. A new failover route is created without stickiness by default. + - Limitations: in backup-server mode, `leastping`/`leastconn` with a tolerance greater than 0 are rejected; `leastping` combined with `source` stickiness is rejected too. + - A client stuck to a backup server stays on it until the stickiness entry expires, even after the primary server is back. +- **Copying routes between nodes (#20).** A route can be copied to another node. The copy is created as a disabled draft — check the ports and addresses and enable it manually. +- **Route template library (#20).** A route can be saved as a template and used to create new routes on any node. Templates store `schema_version`. Migration 000056 (`route_templates`). + +## Fixes + +- **Rendering of existing failover routes does not change.** Migration 000057 keeps the previous balancing for routes with the legacy `source_table`: such routes are republished once with an identical HAProxy config. Re-saving an old failover route in the UI also keeps its balancing. +- **Audit:** route copying (`route.copy`) and template actions (`route_template.*`) are written to the audit log. +- Clear error messages and names for copies/templates; the copy and template dialogs follow the panel's style. + +## Upgrading from 2.1.0 + +1. **Panel.** Run the installer again: + + ```bash + curl -fsSL https://raw.githubusercontent.com/NodeFlow-dev/nodeflow/main/install.sh | sudo sh + ``` + + Manually: set `NODEFLOW_VERSION=2.2.0` in `/opt/nodeflow/.env` and run `docker compose pull && docker compose up -d`. The image applies migrations 000056 and 000057 itself. +2. **Node Agent — optional.** The Agent code did not change in 2.2.0; everything works with Agent 2.1.x. To keep versions uniform: "Settings → Node Agent" → "Update to 2.2.0". + +Upgrading from 1.0.x? Read the [2.0.0 release notes](2.0.0.md) (Russian) first: they cover a one-time systemd drop-in for kernel pipe buffers. + +## Release files + +- `NodeFlow-Panel-2.2.0-Agent-2.2.0-install-kit.tar.gz` — install kit. +- `nodeflow-node-agent-2.2.0-linux-amd64`, `nodeflow-node-agent-2.2.0-linux-arm64` — Node Agent binaries. + +The Panel ships as the `ghcr.io/nodeflow-dev/nodeflow-panel:2.2.0` image (linux/amd64 and linux/arm64). The SHA-256 of each file is the `digest` field in the release API; `install.sh` and `install-node.sh` verify it themselves. From 4192fdb55c97ac6cfa333eff87c88f7bda4c3e50 Mon Sep 17 00:00:00 2001 From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:55:17 +0300 Subject: [PATCH 2/6] panel: stable error_code in route copy and template apply results The per-node result kept only Russian text, so the UI could not localize it. Add error_code (route_copy_failed, node_not_found, advanced_config_active, *_requires_agent_*); the error text stays for older clients. --- internal/panel/route_templates.go | 23 ++++++++++++++----- internal/panel/route_templates_review_test.go | 1 + internal/panel/route_templates_test.go | 1 + 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/panel/route_templates.go b/internal/panel/route_templates.go index 74fa521..815f01d 100644 --- a/internal/panel/route_templates.go +++ b/internal/panel/route_templates.go @@ -315,6 +315,9 @@ type routeCopyResult struct { NodeID string `json:"node_id"` Route *Route `json:"route,omitempty"` Error string `json:"error,omitempty"` + // ErrorCode is the stable machine-readable reason for Error (the UI + // localizes by code and falls back to Error for unknown codes). + ErrorCode string `json:"error_code,omitempty"` } func (a *API) copyRoute(w http.ResponseWriter, r *http.Request, nodeID, routeID string) { @@ -419,17 +422,25 @@ func (a *API) createRouteCopies(w http.ResponseWriter, r *http.Request, config r result := routeCopyResult{NodeID: id} if e != nil { // Do not expose database statements or internal errors to the browser. - result.Error = "Не удалось создать черновик на ноде" + result.Error, result.ErrorCode = "Не удалось создать черновик на ноде", "route_copy_failed" if errors.Is(e, ErrNotFound) { - result.Error = "Нода не найдена" + result.Error, result.ErrorCode = "Нода не найдена", "node_not_found" } if errors.Is(e, ErrAdvancedConfig) { - // Same message respondStore returns for a single route. - result.Error = ErrAdvancedConfig.Error() + // Same message and code respondStore returns for a single route. + result.Error, result.ErrorCode = ErrAdvancedConfig.Error(), "advanced_config_active" } switch e.(type) { - case *KernelShaperUnsupportedError, *LeastPingUnsupportedError, *IPWeightsUnsupportedError, *LeastConnToleranceUnsupportedError, *PPTrustedDomainsUnsupportedError: - result.Error = e.Error() + case *KernelShaperUnsupportedError: + result.Error, result.ErrorCode = e.Error(), kernelShaperRequiresAgentCode + case *LeastPingUnsupportedError: + result.Error, result.ErrorCode = e.Error(), leastPingRequiresAgentCode + case *IPWeightsUnsupportedError: + result.Error, result.ErrorCode = e.Error(), ipWeightsRequiresAgentCode + case *LeastConnToleranceUnsupportedError: + result.Error, result.ErrorCode = e.Error(), leastConnToleranceRequiresAgentCode + case *PPTrustedDomainsUnsupportedError: + result.Error, result.ErrorCode = e.Error(), ppTrustedDomainsRequiresAgentCode } } else { result.Route = &v diff --git a/internal/panel/route_templates_review_test.go b/internal/panel/route_templates_review_test.go index f8aef22..443dafb 100644 --- a/internal/panel/route_templates_review_test.go +++ b/internal/panel/route_templates_review_test.go @@ -40,6 +40,7 @@ func TestRouteCopyReviewFixes(t *testing.T) { require.Equal(t, "source (копия)", results[0].Route.Name) require.Equal(t, "source", results[1].Route.Name) require.Equal(t, ErrAdvancedConfig.Error(), results[2].Error) + require.Equal(t, "advanced_config_active", results[2].ErrorCode) event := base.auditEvents[len(base.auditEvents)-1] require.Equal(t, "route.copy", event.Action) require.Equal(t, "route", event.ResourceType) diff --git a/internal/panel/route_templates_test.go b/internal/panel/route_templates_test.go index b61aea7..a28d54e 100644 --- a/internal/panel/route_templates_test.go +++ b/internal/panel/route_templates_test.go @@ -129,6 +129,7 @@ func TestRouteTemplateAPIAndCopy(t *testing.T) { require.False(t, results[0].Route.Enabled) require.Equal(t, "draft", results[0].Route.DeploymentState) require.NotEmpty(t, results[1].Error) + require.NotEmpty(t, results[1].ErrorCode) require.Nil(t, results[1].Route) require.Len(t, s.copies, 1) require.Equal(t, "source", s.copies[0].Name) From 3f2b0ddd7a0d8dd35cff8d006ad44c4f7e8333e1 Mon Sep 17 00:00:00 2001 From: SHOKOV <74192268+SHOKOVprod@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:55:17 +0300 Subject: [PATCH 3/6] frontend: English UI localization (infrastructure and first screens) - Typed dictionaries src/locales/ru.ts (source of keys) and en.ts, t()/useT with interpolation and Intl.PluralRules plurals, no new dependency. - Language from localStorage nodeflow.lang, then navigator.languages, ru fallback; switcher in the sidebar and on the sign-in card. - Intl dates/numbers and byte/bitrate units follow the UI language. - API errors: known error.code values are localized, unknown codes keep the server message; route copy results use error_code. - Translated: app shell, sign-in, nodes overview, node detail, routes table, route editor with validation messages, copy and template dialogs. - tests/i18n.test.ts: identical keys, matching placeholders, plurals, language detection, error-code mapping. --- frontend/package.json | 2 +- frontend/src/app/App.tsx | 17 +- frontend/src/components/AppFrame.tsx | 21 +- frontend/src/components/LanguageMenu.tsx | 37 + frontend/src/components/LoginPanel.tsx | 12 +- frontend/src/components/PageHeader.tsx | 3 +- frontend/src/components/StateView.tsx | 3 +- .../src/features/node-detail/NodeKpiGrid.tsx | 41 +- .../node-detail/NodeOperationalPanels.tsx | 91 +- .../features/node-detail/NodeRoutesTable.tsx | 119 +-- .../src/features/node-detail/nodeSettings.ts | 9 +- .../src/features/node-detail/useNodeDetail.ts | 3 +- frontend/src/features/nodes/AddNodeDialog.tsx | 3 +- frontend/src/features/nodes/KpiStack.tsx | 27 +- frontend/src/features/nodes/NodesTable.tsx | 41 +- frontend/src/features/nodes/RoutesRanking.tsx | 15 +- frontend/src/features/nodes/TrafficChart.tsx | 37 +- frontend/src/features/nodes/model.ts | 5 +- .../features/routes/RouteLibraryDialog.tsx | 91 +- frontend/src/features/routes/model.ts | 183 ++-- frontend/src/features/routes/templates.ts | 14 +- .../src/features/settings/settings-ui.tsx | 5 +- frontend/src/i18n.ts | 89 ++ frontend/src/lib/api.ts | 5 +- frontend/src/lib/format.ts | 29 +- frontend/src/locales/en.ts | 806 +++++++++++++++++ frontend/src/locales/ru.ts | 812 ++++++++++++++++++ frontend/src/pages/HAProxyConfigPage.tsx | 5 +- frontend/src/pages/NodeDetailPage.tsx | 141 +-- frontend/src/pages/NodesOverviewPage.tsx | 69 +- frontend/src/pages/RouteEditorPage.tsx | 460 +++++----- frontend/src/pages/TrafficPage.tsx | 3 +- frontend/src/styles/global.css | 3 + frontend/tests/i18n.test.ts | 50 ++ frontend/tests/setup.ts | 15 + 35 files changed, 2555 insertions(+), 711 deletions(-) create mode 100644 frontend/src/components/LanguageMenu.tsx create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/locales/en.ts create mode 100644 frontend/src/locales/ru.ts create mode 100644 frontend/tests/i18n.test.ts create mode 100644 frontend/tests/setup.ts diff --git a/frontend/package.json b/frontend/package.json index 8d52542..d73c472 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "typecheck": "tsc -b --pretty false", "preview": "vite preview", - "test": "node --experimental-strip-types --test tests/*.test.ts" + "test": "node --experimental-strip-types --import ./tests/setup.ts --test tests/*.test.ts" }, "dependencies": { "@mantine/core": "^8.3.10", diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index adcf310..a63c28a 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { lazy, Suspense } from 'react'; import { AppFrame } from '../components/AppFrame'; import { demoSuffix } from '../lib/navigation'; +import { t } from '../i18n'; const NodesOverviewPage = lazy(() => import('../pages/NodesOverviewPage').then((module) => ({ default: module.NodesOverviewPage }))); const NodeDetailPage = lazy(() => import('../pages/NodeDetailPage').then((module) => ({ default: module.NodeDetailPage }))); @@ -17,14 +18,14 @@ export function App() { }> } /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> - }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> } /> diff --git a/frontend/src/components/AppFrame.tsx b/frontend/src/components/AppFrame.tsx index 7c8e4d2..9654416 100644 --- a/frontend/src/components/AppFrame.tsx +++ b/frontend/src/components/AppFrame.tsx @@ -5,13 +5,15 @@ import { useEffect, useRef, useState } from 'react'; import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { api } from '../lib/api'; import { preloadAppRoute, preloadInternalLink } from '../lib/navigation'; +import { LanguageMenu } from './LanguageMenu'; import { LoginPanel } from './LoginPanel'; import { NodeFlowLogo } from './NodeFlowLogo'; +import { t } from '../i18n'; const navigation = [ - { to: '/nodes', label: 'Ноды', icon: IconStack2 }, - { to: '/traffic', label: 'Трафик', icon: IconChartLine }, - { to: '/settings', label: 'Настройки', icon: IconSettings }, + { to: '/nodes', label: t('app.nodes'), icon: IconStack2 }, + { to: '/traffic', label: t('app.traffic'), icon: IconChartLine }, + { to: '/settings', label: t('app.settings'), icon: IconSettings }, ]; export function AppFrame() { @@ -74,7 +76,7 @@ export function AppFrame() { }; if (authState === 'checking') { - return
; + return
; } if (authState === 'unauthenticated') { @@ -97,10 +99,10 @@ export function AppFrame() { > - + - +
{navigation.map(({ to, label, icon: Icon }) => { @@ -120,13 +122,14 @@ export function AppFrame() { ); })} +
diff --git a/frontend/src/components/LanguageMenu.tsx b/frontend/src/components/LanguageMenu.tsx new file mode 100644 index 0000000..5e3cbb3 --- /dev/null +++ b/frontend/src/components/LanguageMenu.tsx @@ -0,0 +1,37 @@ +import { Menu, SegmentedControl } from '@mantine/core'; +import { IconLanguage } from '@tabler/icons-react'; +import { getLang, LANGS, setLang, t, type Lang } from '../i18n'; + +const labels: Record = { ru: t('app.languageRu'), en: t('app.languageEn') }; + +/** Sidebar language row above the account block, styled like a nav item. */ +export function LanguageMenu() { + const current = getLang(); + return ( + + + + + + {t('app.language')} + {LANGS.map((lang) => ( + setLang(lang)} rightSection={lang === current ? '✓' : null}> + {labels[lang]} + + ))} + + + ); +} + +/** Compact RU / EN switch for screens without the sidebar (sign-in). */ +export function LanguageSwitch() { + return ( + setLang(value as Lang)} aria-label={t('app.language')} + data={LANGS.map((lang) => ({ value: lang, label: lang.toUpperCase() }))} /> + ); +} diff --git a/frontend/src/components/LoginPanel.tsx b/frontend/src/components/LoginPanel.tsx index d5a8a64..288595e 100644 --- a/frontend/src/components/LoginPanel.tsx +++ b/frontend/src/components/LoginPanel.tsx @@ -2,7 +2,9 @@ import { Alert, Button, PasswordInput } from '@mantine/core'; import { useState, type FormEvent } from 'react'; import { IconAlertCircle, IconLock } from '@tabler/icons-react'; import { api } from '../lib/api'; +import { LanguageSwitch } from './LanguageMenu'; import { NodeFlowLogo } from './NodeFlowLogo'; +import { t } from '../i18n'; export function LoginPanel({ onSuccess }: { onSuccess: () => void }) { const [token, setToken] = useState(''); @@ -15,17 +17,17 @@ export function LoginPanel({ onSuccess }: { onSuccess: () => void }) { await api('/auth/login', { method: 'POST', body: JSON.stringify({ token: token.trim() }) }); setToken(''); onSuccess(); } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Не удалось войти'); + setError(reason instanceof Error ? reason.message : t('app.signInFailed')); } finally { setPending(false); } }; return (
- -

Вход в панель

Используйте административный токен. После входа он заменяется защищённой browser-сессией.

+
+

{t('app.signInToThePanel')}

{t('app.useTheAdminTokenAfter')}

{error && }>{error}} - setToken(event.currentTarget.value)} leftSection={} required autoFocus /> - + setToken(event.currentTarget.value)} leftSection={} required autoFocus /> +
); diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 94a497a..e12da22 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { t } from '../i18n'; interface PageHeaderProps { title: ReactNode; @@ -26,7 +27,7 @@ export function PageHeader({ return (
- {breadcrumb && } + {breadcrumb && }
{backAction &&
{backAction}
} {icon && } diff --git a/frontend/src/components/StateView.tsx b/frontend/src/components/StateView.tsx index 478fbd8..d537438 100644 --- a/frontend/src/components/StateView.tsx +++ b/frontend/src/components/StateView.tsx @@ -1,6 +1,7 @@ import { Button } from '@mantine/core'; import { IconAlertTriangle, IconDatabaseOff } from '@tabler/icons-react'; import type { ReactNode } from 'react'; +import { t } from '../i18n'; interface StateViewProps { title: string; @@ -21,5 +22,5 @@ export function StateView({ title, description, action, tone = 'empty' }: StateV } export function RetryButton({ onClick }: { onClick: () => void }) { - return ; + return ; } diff --git a/frontend/src/features/node-detail/NodeKpiGrid.tsx b/frontend/src/features/node-detail/NodeKpiGrid.tsx index dc36a4b..5d7c6f0 100644 --- a/frontend/src/features/node-detail/NodeKpiGrid.tsx +++ b/frontend/src/features/node-detail/NodeKpiGrid.tsx @@ -3,6 +3,7 @@ import { IconArrowDown, IconArrowUp, IconInfoCircle, IconShieldCheck } from '@ta import type { HeartbeatMetrics, NodeMetricSummary, TrafficHistorySample } from '../../lib/contracts'; import { formatBitrate, formatBytes, formatDuration, formatNumber, timeAgo } from '../../lib/format'; import { memoryUsage } from '../nodes/model'; +import { t } from '../../i18n'; interface NodeKpiGridProps { metrics?: HeartbeatMetrics; @@ -26,9 +27,9 @@ function MiniSparkline({ values }: { values: number[] }) { } function summaryHint(label: string, average?: number, delta?: number, sampleCount?: number, formatter: (value: number) => string = (value) => formatNumber(value, 1)) { - if (!sampleCount || average == null) return `${label}: история ещё собирается`; - const deltaCopy = delta == null ? 'дельта недоступна' : `дельта ${delta >= 0 ? '+' : ''}${formatter(delta)}`; - return `${label}: среднее ${formatter(average)}, ${deltaCopy}, точек ${sampleCount}`; + if (!sampleCount || average == null) return t('node.labelHistoryIsStillBeing', { label }); + const deltaCopy = delta == null ? t('node.deltaUnavailable') : t('node.deltaP0Formatter', { p0: delta >= 0 ? '+' : '', formatter: formatter(delta) }); + return t('node.labelAverageFormatterDeltacopySamples', { label, formatter: formatter(average), deltaCopy, sampleCount }); } function seriesSummary(values: number[]) { @@ -71,11 +72,11 @@ export function NodeKpiGrid({ metrics, history, summary, currentRx, currentTx, r const processCount = Number(metrics?.process_count); const processNames = (metrics?.process_names ?? []).filter(Boolean); const processHint = Number.isFinite(processCount) && processCount >= 0 - ? `Процессов: ${formatNumber(processCount)}${processNames.length ? `. Основные: ${processNames.join(', ')}` : ''}` - : processNames.length ? `Основные процессы: ${processNames.join(', ')}` : 'Список процессов ещё не получен'; + ? t('node.processesFormatnumberP1', { formatNumber: formatNumber(processCount), p1: processNames.length ? t('misc.mainP0', { p0: processNames.join(', ') }) : '' }) + : processNames.length ? t('node.mainProcessesP0', { p0: processNames.join(', ') }) : t('node.processListNotReceivedYet'); return ( -
+
`${formatNumber(value, 1)}%`)}>CPU {Number.isFinite(Number(metrics?.cpu_percent)) ? `${formatNumber(Number(metrics?.cpu_percent), 1)}%` : '—'} @@ -84,50 +85,50 @@ export function NodeKpiGrid({ metrics, history, summary, currentRx, currentTx, r
`${formatNumber(value, 1)}%`)}>RAM {formatBytes(memory.used)} / {formatBytes(memory.total)} - {memory.percent == null ? 'Нет данных' : `${formatNumber(memory.percent, 1)}% занято`} + {memory.percent == null ? t('node.noData') : t('node.p0Used', { p0: formatNumber(memory.percent, 1) })}
- Load (1м / 5м / 15м) + {t('node.load1m5m15m')} {loads.length ? loads.map((value) => formatNumber(value, 2)).join(' / ') : '—'} - текущий срез + {t('node.currentSnapshot')}
RX {formatBitrate(currentRx)} - {currentRx === null ? rateSampledAt ? `последняя точка ${timeAgo(rateSampledAt)}` : 'нет свежей точки' : 'HAProxy сейчас'} + {currentRx === null ? rateSampledAt ? t('node.latestSampleTimeago', { timeAgo: timeAgo(rateSampledAt) }) : t('node.noFreshSample') : t('node.haproxyNow')}
TX {formatBitrate(currentTx)} - {currentTx === null ? rateSampledAt ? `последняя точка ${timeAgo(rateSampledAt)}` : 'нет свежей точки' : 'HAProxy сейчас'} + {currentTx === null ? rateSampledAt ? t('node.latestSampleTimeago', { timeAgo: timeAgo(rateSampledAt) }) : t('node.noFreshSample') : t('node.haproxyNow')}
- Соединения + {t('nodes.connections')} {formatNumber(connectionsCurrent)} - {connectionRate == null && connectionsTotal == null ? 'Rate и total не получены' : `${connectionRate == null ? '— новых/с' : `${formatNumber(connectionRate)} новых/с`} · ${connectionsTotal == null ? '— всего' : `${formatNumber(connectionsTotal)} всего`}`} + {connectionRate == null && connectionsTotal == null ? t('node.rateAndTotalNotReceived') : `${t('node.newPerSecond', { rate: connectionRate == null ? '—' : formatNumber(connectionRate) })} · ${t('node.totalCount', { total: connectionsTotal == null ? '—' : formatNumber(connectionsTotal) })}`}
- TCP-сессии + {t('node.tcpSessions')} {formatNumber(sessionsCurrent)} - {sessionsTotal == null ? 'Total не получен' : `${formatNumber(sessionsTotal)} всего`} + {sessionsTotal == null ? t('node.totalNotReceived') : t('node.formatnumberTotal', { formatNumber: formatNumber(sessionsTotal) })}
- Время работы + {t('node.uptime')} {formatDuration(Number(metrics?.uptime_seconds))} - {Number.isFinite(processCount) ? `${formatNumber(processCount)} процессов` : 'без перезапуска Agent'} + {Number.isFinite(processCount) ? t('node.formatnumberProcesses', { formatNumber: formatNumber(processCount) }) : t('node.withoutAgentRestart')}
- mTLS соединение - {mtlsConnected ? 'На связи' : 'Нет свежего сигнала'} - последний heartbeat по защищённому каналу + {t('node.mtlsConnection')} + {mtlsConnected ? t('node.connected') : t('node.noFreshHeartbeat')} + {t('node.lastHeartbeatOverTheSecure')}
); diff --git a/frontend/src/features/node-detail/NodeOperationalPanels.tsx b/frontend/src/features/node-detail/NodeOperationalPanels.tsx index b14f478..4357a55 100644 --- a/frontend/src/features/node-detail/NodeOperationalPanels.tsx +++ b/frontend/src/features/node-detail/NodeOperationalPanels.tsx @@ -9,6 +9,7 @@ import type { import { formatDateTime, shortHAProxy, timeAgo } from '../../lib/format'; import { Surface } from '../../components/Surface'; import { heartbeatPlatform, platformLabel, releaseMatchesPlatform } from '../releases/platform'; +import { t, locale } from '../../i18n'; interface NodeOperationalPanelsProps { metrics?: HeartbeatMetrics; @@ -33,18 +34,18 @@ function auditCopy(entry: AuditEntry): string { const summary = entry.details && typeof entry.details.summary === 'string' ? entry.details.summary : ''; if (summary) return summary; const labels: Record = { - 'route.created': 'Создан маршрут', - 'route.updated': 'Изменён маршрут', - 'route.enabled': 'Маршрут включён', - 'route.disabled': 'Маршрут выключен', - 'route.deleted': 'Удалён маршрут', - 'route.change': 'Изменён маршрут', - 'agent.update.assigned': 'Назначено обновление Node Agent', - 'agent.update.assign': 'Назначено обновление Node Agent', - 'agent.update.rollback': 'Назначен откат Node Agent', - 'firewall.policy.updated': 'Изменена политика UFW', - 'firewall.policy': 'Изменена политика UFW', - 'node.update': 'Изменены параметры ноды', + 'route.created': t('node.routeCreated'), + 'route.updated': t('node.routeChanged'), + 'route.enabled': t('node.routeEnabled'), + 'route.disabled': t('node.routeDisabled'), + 'route.deleted': t('node.routeDeleted'), + 'route.change': t('node.routeChanged'), + 'agent.update.assigned': t('node.nodeAgentUpdateScheduled'), + 'agent.update.assign': t('node.nodeAgentUpdateScheduled'), + 'agent.update.rollback': t('node.nodeAgentRollbackScheduled'), + 'firewall.policy.updated': t('node.ufwPolicyChanged'), + 'firewall.policy': t('node.ufwPolicyChanged'), + 'node.update': t('node.nodeSettingsChanged'), }; return labels[entry.action] ?? entry.action.replaceAll('.', ' · '); } @@ -58,9 +59,9 @@ function AuditIcon({ action }: { action: string }) { function updateStateCopy(state?: string) { const values: Record = { - installed: 'Установлено', idle: 'Готов', assigned: 'Назначено', downloading: 'Загрузка', verifying: 'Проверка', activating: 'Установка', failed: 'Ошибка', rolled_back: 'Автооткат', + installed: t('node.installed'), idle: t('node.ready'), assigned: t('node.scheduled'), downloading: t('node.downloading'), verifying: t('node.verifying'), activating: t('node.installing'), failed: t('node.error'), rolled_back: t('node.autoRollback'), }; - return values[state ?? ''] ?? state ?? 'Нет данных'; + return values[state ?? ''] ?? state ?? t('node.noData'); } function releaseChoiceLabel(release: AgentRelease) { @@ -85,7 +86,7 @@ export function NodeOperationalPanels({ const doRollback = async () => { if (!rollbackRelease || !update) return; if (!platform || !releaseMatchesPlatform(rollbackRelease, platform)) { - setRollbackError('Платформа ноды неизвестна или изменилась. Обновите состояние перед откатом.'); + setRollbackError(t('node.nodePlatformIsUnknownOr')); return; } setRollbackBusy(true); @@ -94,7 +95,7 @@ export function NodeOperationalPanels({ await onRollback(rollbackRelease, update.actual_sequence, update.desired_release?.sequence ?? 0); setRollbackRelease(null); } catch (reason) { - setRollbackError(reason instanceof Error ? reason.message : 'Откат не назначен'); + setRollbackError(reason instanceof Error ? reason.message : t('node.rollbackNotScheduled')); } finally { setRollbackBusy(false); } @@ -102,95 +103,95 @@ export function NodeOperationalPanels({ return (
- +
Node Agent{agentVersion || '—'}
-
{updateStateCopy(update?.state)}{platform ? latest ? `${platformLabel(platform)} · доступна ${latest.version}` : `${platformLabel(platform)} · обновлений нет` : 'платформа не получена'}
+
{updateStateCopy(update?.state)}{platform ? latest ? t('node.platformlabelVersionAvailable', { platformLabel: platformLabel(platform), version: latest.version }) : t('node.platformlabelUpToDate', { platformLabel: platformLabel(platform) }) : t('node.platformNotReceived')}
{newerReleases.length > 1 ? ( - + - Выберите версию + {t('node.selectVersion')} {newerReleases.map((release) => ( } onClick={() => onAssignRelease(release)}>{releaseChoiceLabel(release)} ))} ) : ( - + )} {olderReleases.length > 1 ? ( - + - Выберите версию + {t('node.selectVersion')} {olderReleases.map((release) => ( } onClick={() => { setRollbackError(''); setRollbackRelease(release); }}>{releaseChoiceLabel(release)} ))} ) : ( - + )}
- {!platform &&

Назначение релиза заблокировано: Agent ещё не передал os и arch.

} + {!platform &&

{t('node.releaseSchedulingIsBlockedThe')}

} {(partialErrors.update || partialErrors.releases || update?.last_error) &&

{update?.last_error || partialErrors.update || partialErrors.releases}

}
-
Политика UFW{firewall?.mode === 'apply' ? 'Автооткрытие разрешено' : firewall?.mode === 'observe' ? 'Только проверка' : firewall?.mode === 'off' ? 'Не управляется' : '—'}
-
Порты по плану{listenerPorts.length ? listenerPorts.join(', ') : 'нет активных'}
+
{t('node.ufwPolicy')}{firewall?.mode === 'apply' ? t('node.autoOpenAllowed') : firewall?.mode === 'observe' ? t('node.checkOnly') : firewall?.mode === 'off' ? t('node.notManaged') : '—'}
+
{t('node.plannedPorts')}{listenerPorts.length ? listenerPorts.join(', ') : t('node.noneActive')}
onFirewallMode(value as FirewallMode)} - data={[{ label: 'Выкл.', value: 'off' }, { label: 'Проверка', value: 'observe' }, { label: 'Авто', value: 'apply' }]} + data={[{ label: t('node.off'), value: 'off' }, { label: t('node.verifying'), value: 'observe' }, { label: t('node.auto2'), value: 'apply' }]} disabled={!firewall || busyAction === 'firewall'} - aria-label="Политика управления UFW" + aria-label={t('node.ufwManagementPolicy')} />
{partialErrors.firewall &&

UFW: {partialErrors.firewall}

}
- + {partialErrors.audit ?
{partialErrors.audit}
: audit.length ? ( -
+
{[...audit].sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)).map((entry) => (
{auditCopy(entry)} - - {entry.actor_type === 'agent' ? 'Agent' : 'Оператор'} - Успешно + + {entry.actor_type === 'agent' ? 'Agent' : t('app.operator')} + {t('node.succeeded')}
))}
- ) :
Действий пока нет.
} + ) :
{t('node.noActionsYet')}
} - +
-
HAProxy
{shortHAProxy(packageVersion)}
+
HAProxy
{shortHAProxy(packageVersion)}
Node Agent
{agentVersion || '—'}
-
mTLS соединение
{mtlsReady ? 'На связи' : 'Нет свежего сигнала'}
-
Связь с Panel
Исходящий mTLS · :{agentPort}
-
Последняя авторизация
{credentialLastUsed ? timeAgo(credentialLastUsed) : '—'}
-
Политика обновления
Ed25519 · автооткат
+
{t('node.mtlsConnection')}
{mtlsReady ? t('node.connected') : t('node.noFreshHeartbeat')}
+
{t('node.panelConnection')}
{t('node.outboundMtls')}{agentPort}
+
{t('node.lastAuthorization')}
{credentialLastUsed ? timeAgo(credentialLastUsed) : '—'}
+
{t('node.updatePolicy')}
{t('node.ed25519AutoRollback')}
- !rollbackBusy && setRollbackRelease(null)} title="Откатить Node Agent?" classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}> + !rollbackBusy && setRollbackRelease(null)} title={t('node.rollBackNodeAgent')} classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}>
-

Будет назначен подписанный релиз {rollbackRelease ? releaseChoiceLabel(rollbackRelease) : '—'} для {platformLabel(platform)}. Updater проверит Ed25519, SHA-256 и вернёт текущий бинарник при ошибке запуска.

+

{t('node.theSignedRelease')} {rollbackRelease ? releaseChoiceLabel(rollbackRelease) : '—'} {t('node.for')} {platformLabel(platform)}{t('node.willBeScheduledTheUpdater')}

{rollbackError &&
{rollbackError}
} -
+
diff --git a/frontend/src/features/node-detail/NodeRoutesTable.tsx b/frontend/src/features/node-detail/NodeRoutesTable.tsx index 012256a..2be9f5a 100644 --- a/frontend/src/features/node-detail/NodeRoutesTable.tsx +++ b/frontend/src/features/node-detail/NodeRoutesTable.tsx @@ -19,6 +19,7 @@ import { api } from '../../lib/api'; import { isInteractiveRowTarget } from '../../lib/interaction'; import { RouteCopyDialog, RouteSaveTemplateDialog } from '../routes/RouteLibraryDialog'; +import { t } from '../../i18n'; type RouteFilter = 'all' | 'enabled' | 'draft' | 'failed'; @@ -36,21 +37,21 @@ interface NodeRoutesTableProps { } const deploymentCopy: Record = { - active: 'Активен', pending: 'Применяется', failed: 'Ошибка', draft: 'Черновик', disabled: 'Выключен', deleting: 'Удаляется', + active: t('node.active'), pending: t('node.applying'), failed: t('node.error'), draft: t('node.draft'), disabled: t('node.disabled'), deleting: t('node.deleting'), }; function routeMatch(route: RouteRecord) { if (route.match_mode === 'sni' || (!route.fallback && route.snis.length)) return `SNI ${route.snis.join(', ')}`; - if (route.fallback) return 'Фолбэк'; + if (route.fallback) return t('node.fallback'); // legacy: any_tcp / destination_ip treated as fallback - if (route.match_mode === 'any_tcp' || route.match_mode === 'destination_ip') return 'Фолбэк'; - return route.snis.length ? `SNI ${route.snis.join(', ')}` : 'Фолбэк'; + if (route.match_mode === 'any_tcp' || route.match_mode === 'destination_ip') return t('node.fallback'); + return route.snis.length ? `SNI ${route.snis.join(', ')}` : t('node.fallback'); } function routeTarget(route: RouteRecord) { if (route.target_type === 'unix') return route.unix_socket_path || 'Unix socket'; const target = `${route.target_host || '—'}:${route.target_port || '—'}`; - return route.dns_pool ? `DNS-пул · ${target}` : target; + return route.dns_pool ? t('node.dnsPoolTarget', { target }) : target; } function routeRuntime(route: RouteRecord, traffic: NodeTraffic | null, metrics?: HeartbeatMetrics, reports?: Map) { @@ -71,12 +72,12 @@ function backendHealth(backend?: HAProxyProxyStats, servers?: Record { if (!isInteractiveRowTarget(event.target, event.currentTarget)) onEdit(route); }} onKeyDown={(event) => { if (event.key === 'Enter' && event.target === event.currentTarget) onEdit(route); }} onDragOver={(event) => onDragOver(event, route.id)} onDrop={(event) => onDrop(event, route.id)} >
- onToggle(route, event.currentTarget.checked)} aria-label={`${route.enabled ? 'Выключить' : 'Включить'} маршрут ${name}`} /> + onToggle(route, event.currentTarget.checked)} aria-label={t('node.p0RouteName', { p0: route.enabled ? t('misc.disable') : t('misc.enable'), name })} />
- + onDragStart(event, route.id)} onDragEnd={onDragEnd} >
- { if (previousID) onMove(route.id, previousID); }}> - { if (nextID) onMove(route.id, nextID); }}> + { if (previousID) onMove(route.id, previousID); }}> + { if (nextID) onMove(route.id, nextID); }}>
+ - Состояние - {([['all', 'Все маршруты'], ['enabled', 'Только включённые'], ['draft', 'Только черновики'], ['failed', `С ошибкой (${counts.failed})`]] as const).map(([value, label]) => ( + {t('node.status')} + {([['all', t('node.allRoutes')], ['enabled', t('node.enabledOnly')], ['draft', t('node.draftsOnly')], ['failed', t('node.failedFailed', { failed: counts.failed })]] as const).map(([value, label]) => ( setFilter(value)}>{label} ))} - setRelaxedRows((value) => !value)} aria-label="Переключить плотность строк" aria-pressed={relaxedRows}> + setRelaxedRows((value) => !value)} aria-label={t('nodes.toggleRowDensity')} aria-pressed={relaxedRows}>
{error &&
{error}
} -
+
- {['Статус', 'Маршрут', 'Слушает', 'Правило соответствия', 'Назначение', 'Соединения', 'TCP-сессии', 'RX / TX', 'Трафик', 'Бэкенды', 'Действия'].map((label) => {label})} + {[t('nodes.status'), t('nodes.route'), t('node.listensOn'), t('node.matchRule'), t('node.destination'), t('nodes.connections'), t('node.tcpSessions'), 'RX / TX', t('app.traffic'), t('node.backends'), t('nodes.actions')].map((label) => {label})}
{pageGroups.map(({ key, routes: groupRoutes, hasFallback, hasSNI, lastOnPage }, groupIndex) => (
@@ -509,7 +510,7 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID, {/* F6: no-fallback hint */} {hasSNI && !hasFallback && lastOnPage && (
- несовпавшие соединения сбрасываются + {t('node.unmatchedConnectionsAreDropped')}
)}
@@ -518,33 +519,33 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID,
{paged && (
- Показано {pageStart + 1}–{Math.min(displayRoutes.length, pageEnd)} из {displayRoutes.length} маршрутов + {t('node.showingRoutes', { from: pageStart + 1, to: Math.min(displayRoutes.length, pageEnd), count: displayRoutes.length })} - {ROUTES_PAGE_SIZE} / стр. + {t('app.perPage', { size: ROUTES_PAGE_SIZE })}
)} {!visible.length && ( } onClick={() => navigate(`/nodes/${nodeID}/routes/new${demoQuery}`)}>Добавить маршрут : undefined} + title={routes.length ? t('nodes.nothingFound') : t('nodes.noRoutesYet')} + description={routes.length ? t('nodes.changeTheSearchOrStatus') : t('node.createAListenerTargetA')} + action={!routes.length ? : undefined} /> )} setCopySource(null)} nodeID={nodeID} source={copySource} /> setTemplateSource(null)} source={templateSource} /> - !deleting && setDeleteRoute(null)} title="Удалить маршрут?" classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}> + !deleting && setDeleteRoute(null)} title={t('node.deleteRoute')} classNames={{ content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }}>
-

Маршрут {deleteRoute ? routeName(deleteRoute) : ''} будет сначала безопасно исключён из HAProxy, затем удалён после подтверждения Agent.

+

{t('nodes.route')} {deleteRoute ? routeName(deleteRoute) : ''} {t('node.willFirstBeSafelyRemoved')}

{deleteError &&
{deleteError}
} -
+
@@ -555,10 +556,10 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID,
{statsRoute.listener_ip || '*'}:{statsRoute.listener_port}{routeMatch(statsRoute)}→ {routeTarget(statsRoute)}
-
Соединения{formatNumber(backend?.connections_current)}{formatNumber(backend?.session_rate)} новых/с
-
TCP-сессии{formatNumber(backend?.tcp_sessions_current ?? backend?.sessions_current)}{formatNumber(backend?.sessions_total)} всего
-
Трафик за месяц{formatBytes(report?.used_bytes)}RX {formatBytes(report?.bytes_in)} · TX {formatBytes(report?.bytes_out)}
-
Backend health{health.label === '—' ? 'Нет данных' : health.label}{health.detail}
+
{t('nodes.connections')}{formatNumber(backend?.connections_current)}{t('node.newPerSecond', { rate: formatNumber(backend?.session_rate) })}
+
{t('node.tcpSessions')}{formatNumber(backend?.tcp_sessions_current ?? backend?.sessions_current)}{t('node.totalCount', { total: formatNumber(backend?.sessions_total) })}
+
{t('nodes.monthlyTraffic')}{formatBytes(report?.used_bytes)}RX {formatBytes(report?.bytes_in)} · TX {formatBytes(report?.bytes_out)}
+
Backend health{health.label === '—' ? t('node.noData') : health.label}{health.detail}
{statsError &&
{statsError}
}
@@ -566,16 +567,16 @@ export function NodeRoutesTable({ nodeID, routes, traffic, metrics, busyRouteID, samples={statsHistory} range={statsRange} onRangeChange={changeStatsRange} - emptyTitle={statsLoading ? 'Загружаем историю…' : statsError ? 'История недоступна' : 'Нет точек за выбранный период'} - emptyDescription={statsLoading ? 'Новая выборка заменит предыдущий период.' : statsError ? 'Повторите запрос или выберите другой период.' : 'HAProxy не передал точки для этого маршрута и диапазона.'} - emptyFooterLabel={statsLoading ? 'Запрос выполняется' : statsError ? 'Запрос завершился ошибкой' : 'Точек нет'} + emptyTitle={statsLoading ? t('node.loadingHistory') : statsError ? t('node.historyUnavailable') : t('node.noSamplesForTheSelected')} + emptyDescription={statsLoading ? t('node.theNewSampleSetWill') : statsError ? t('node.retryTheRequestOrPick') : t('node.haproxyReportedNoSamplesFor')} + emptyFooterLabel={statsLoading ? t('node.requestInProgress') : statsError ? t('node.requestFailed') : t('node.noSamples')} status={statsLoading - ? { tone: 'stale', label: 'Загрузка' } + ? { tone: 'stale', label: t('node.downloading') } : statsError - ? { tone: 'offline', label: 'Ошибка загрузки' } + ? { tone: 'offline', label: t('node.loadError') } : undefined} /> - {statsLoading &&
Загружаем историю…
} + {statsLoading &&
{t('node.loadingHistory')}
}
); diff --git a/frontend/src/features/node-detail/nodeSettings.ts b/frontend/src/features/node-detail/nodeSettings.ts index 60f8040..19efbbc 100644 --- a/frontend/src/features/node-detail/nodeSettings.ts +++ b/frontend/src/features/node-detail/nodeSettings.ts @@ -1,7 +1,8 @@ // Node-level settings shared by the node settings dialog and its tests. -// Keep this module free of runtime imports: tests load it with node's -// built-in type stripping. +// Only import modules that node --experimental-strip-types can load (tests +// resolve extensionless imports via tests/setup.ts). import type { NodeRecord } from '../../lib/contracts'; +import { t, locale } from '../../i18n'; /** * Effective «Логи соединений HAProxy» setting. The Panel reports @@ -66,13 +67,13 @@ export function validateHAProxyTuning(form: HAProxyTuningForm): Partial { const value = form[key].trim(); if (!value) return; - if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > max) errors[key] = `Целое число 1–${max.toLocaleString('ru-RU')}`; + if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > max) errors[key] = t('node.integer1P0', { p0: max.toLocaleString(locale()) }); }; integer('maxconn', 10_000_000); integer('nbthread', 256); for (const key of ['timeoutConnect', 'timeoutClient', 'timeoutServer'] as const) { const value = form[key].trim(); - if (value && !TIMEOUT_PATTERN.test(value)) errors[key] = 'Формат: 5s, 15m'; + if (value && !TIMEOUT_PATTERN.test(value)) errors[key] = t('node.format5s15m'); } return errors; } diff --git a/frontend/src/features/node-detail/useNodeDetail.ts b/frontend/src/features/node-detail/useNodeDetail.ts index 711b02a..f1d5cab 100644 --- a/frontend/src/features/node-detail/useNodeDetail.ts +++ b/frontend/src/features/node-detail/useNodeDetail.ts @@ -14,6 +14,7 @@ import type { TrafficHistory, } from '../../lib/contracts'; import type { TrafficRange } from '../nodes/useNodesOverview'; +import { t } from '../../i18n'; export interface NodeDetailData { bundle: NodeBundle; @@ -28,7 +29,7 @@ const explicitDemo = new URLSearchParams(window.location.search).get('demo') === || import.meta.env.VITE_NODEFLOW_DEMO === 'true'; function errorMessage(reason: unknown): string { - return reason instanceof Error ? reason.message : 'Данные недоступны'; + return reason instanceof Error ? reason.message : t('node.dataUnavailable'); } function settledValue(result: PromiseSettledResult, fallback: T, key: keyof NodeDetailData['partialErrors'], errors: NodeDetailData['partialErrors']): T { diff --git a/frontend/src/features/nodes/AddNodeDialog.tsx b/frontend/src/features/nodes/AddNodeDialog.tsx index c43000e..7767f72 100644 --- a/frontend/src/features/nodes/AddNodeDialog.tsx +++ b/frontend/src/features/nodes/AddNodeDialog.tsx @@ -9,6 +9,7 @@ import { api } from '../../lib/api'; import type { AgentRelease, BootstrapAuthMode, BootstrapJobResponse, BootstrapNodeRequest, BootstrapSudoMode, HostKeyResult, } from '../../lib/contracts'; +import { locale } from '../../i18n'; export interface ReinstallNodeTarget { id: string; @@ -54,7 +55,7 @@ const bootstrapStatusLabels: Record = { function formatJournalTime(value: string) { const date = new Date(value); - return Number.isNaN(date.getTime()) ? '—' : date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } export function AddNodeDialog({ opened, onClose, onInstalled, reinstallTarget, demo = false }: AddNodeDialogProps) { diff --git a/frontend/src/features/nodes/KpiStack.tsx b/frontend/src/features/nodes/KpiStack.tsx index 960f08f..4f44ad4 100644 --- a/frontend/src/features/nodes/KpiStack.tsx +++ b/frontend/src/features/nodes/KpiStack.tsx @@ -1,6 +1,7 @@ import { IconArrowDown, IconArrowUp, IconInfoCircle } from '@tabler/icons-react'; import { useId } from 'react'; import { formatBitrate, formatBytes } from '../../lib/format'; +import { t, locale } from '../../i18n'; interface KpiStackProps { rx: number | null; @@ -45,31 +46,31 @@ export function KpiStack({ rx, tx, rateComplete, aggregateScope, monthUsed, mont return (
-
Общая скорость {!rateComplete && Неполные данные}
+
{t('nodes.totalThroughput')} {!rateComplete && {t('nodes.incompleteData')}}
-
{formatBitrate(rx)}RX суммарно
-
{formatBitrate(tx)}TX суммарно
+
{formatBitrate(rx)}{t('nodes.rxTotal')}
+
{formatBitrate(tx)}{t('nodes.txTotal')}
-
Трафик за месяц
-
{formatBytes(monthUsed)}{monthLimit > 0 ? `из ${formatBytes(monthLimit)}` : aggregateScope ? 'без общего лимита' : 'без лимита'}
+
{t('nodes.monthlyTraffic')}
+
{formatBytes(monthUsed)}{monthLimit > 0 ? t('nodes.ofFormatbytes', { formatBytes: formatBytes(monthLimit) }) : aggregateScope ? t('nodes.noSharedLimit') : t('nodes.noLimit')}
0 ? '' : ' is-unlimited'}`} role="img" - aria-label={monthLimit > 0 ? `Использовано ${Math.round(quota)} процентов квоты` : 'Трафик без лимита'} + aria-label={monthLimit > 0 ? t('nodes.roundPercentOfQuotaUsed', { round: Math.round(quota) }) : t('nodes.trafficWithoutLimit')} > 0 ? quota / 100 : 1 } as React.CSSProperties} />
- {monthLimit > 0 ? `${Math.round(quota)}% использовано` : aggregateScope ? 'Общий лимит не задан' : 'Квота не задана'} + {monthLimit > 0 ? t('nodes.roundUsed', { round: Math.round(quota) }) : aggregateScope ? t('nodes.noSharedLimitSet') : t('nodes.noQuotaSet')}
-
Соединения
+
{t('nodes.connections')}
- {connections === null ? '—' : connections.toLocaleString('ru-RU')} - {connections === null ? 'нет полной текущей точки' : 'активных · текущий срез'} + {connections === null ? '—' : connections.toLocaleString(locale())} + {connections === null ? t('nodes.noCompleteCurrentSample') : t('nodes.activeCurrentSnapshot')}
@@ -77,9 +78,9 @@ export function KpiStack({ rx, tx, rateComplete, aggregateScope, monthUsed, mont
{health.percent === null ? '—' : `${health.percent}%`}
- {health.up} Работает - {health.degraded} Деградирует - {health.down} Недоступно + {health.up} {t('nodes.online3')} + {health.degraded} {t('nodes.degraded2')} + {health.down} {t('nodes.unavailable')}
diff --git a/frontend/src/features/nodes/NodesTable.tsx b/frontend/src/features/nodes/NodesTable.tsx index 579412d..fba402a 100644 --- a/frontend/src/features/nodes/NodesTable.tsx +++ b/frontend/src/features/nodes/NodesTable.tsx @@ -7,6 +7,7 @@ import type { DashboardNode } from '../../lib/contracts'; import { formatBitrate, formatBytes, formatNumber, shortHAProxy, timeAgo } from '../../lib/format'; import { demoSuffix } from '../../lib/navigation'; import { dashboardState, memoryUsage } from './model'; +import { t } from '../../i18n'; interface NodesTableProps { nodes: DashboardNode[]; @@ -20,7 +21,7 @@ interface NodesTableProps { onReorder: (sourceID: string, targetID: string) => Promise; } -const stateLabel = { online: 'Работает', degraded: 'Деградирует', offline: 'Недоступна' } as const; +const stateLabel = { online: t('nodes.online3'), degraded: t('nodes.degraded2'), offline: t('nodes.unreachable2') } as const; function numericMetadata(item: DashboardNode, key: string): number { const metadata = item.node.metadata; @@ -82,7 +83,7 @@ const NodeRow = memo(function NodeRow({ item, previousID, nextID, reorderEnabled className={`nf-node-row is-${state}${dragging ? ' is-dragging' : ''}${dropTarget ? ' is-drop-target' : ''}${ordering ? ' is-ordering' : ''}`} role="row" tabIndex={0} - aria-label={`Открыть ноду ${item.node.name}`} + aria-label={t('nodes.openNodeName', { name: item.node.name })} onClick={openFromRow} onKeyDown={openFromKeyboard} onDragOver={(event) => onDragOver(event, item.node.id)} @@ -91,7 +92,7 @@ const NodeRow = memo(function NodeRow({ item, previousID, nextID, reorderEnabled
{stateLabel[state]}
- + onDragStart(event, item.node.id)} onDragEnd={onDragEnd} onClick={stop} @@ -108,14 +109,14 @@ const NodeRow = memo(function NodeRow({ item, previousID, nextID, reorderEnabled
- { stop(event); if (previousID) onMove(item.node.id, previousID); }}> - { stop(event); if (nextID) onMove(item.node.id, nextID); }}> + { stop(event); if (previousID) onMove(item.node.id, previousID); }}> + { stop(event); if (nextID) onMove(item.node.id, nextID); }}>
- {item.node.name}{item.node.address}ответ {timeAgo(item.node.last_seen_at)} + {item.node.name}{item.node.address}{t('nodes.seen')} {timeAgo(item.node.last_seen_at)}
-
{formatNumber(item.routes_total)}{item.routes_enabled} включено
-
{formatNumber(runtime?.connections_current)}{runtime?.connection_rate == null ? 'нет rate' : `${formatNumber(runtime.connection_rate)} новых/с`}
+
{formatNumber(item.routes_total)}{t('nodes.routesEnabled', { count: item.routes_enabled })}
+
{formatNumber(runtime?.connections_current)}{runtime?.connection_rate == null ? t('nodes.noRate') : t('nodes.connectionRateNewS', { connection_rate: formatNumber(runtime.connection_rate) })}
formatNumber(value, 2)).join(' · ')} · RAM ${formatBytes(memory.used)} / ${formatBytes(memory.total)}`} openDelay={350}>
CPU{Number.isFinite(cpu) ? `${formatNumber(cpu)}%` : '—'}
@@ -124,28 +125,28 @@ const NodeRow = memo(function NodeRow({ item, previousID, nextID, reorderEnabled
↓ {formatBitrate(rx)}↑ {formatBitrate(tx)}
-
{formatBytes(used)}{limit > 0 ? `/ ${formatBytes(limit)}` : 'без лимита'}
+
{formatBytes(used)}{limit > 0 ? `/ ${formatBytes(limit)}` : t('nodes.noLimit')}
0 ? '' : ' is-unlimited'}`} role="img" - aria-label={limit > 0 ? `Использовано ${Math.round(quota)} процентов квоты` : 'Трафик без лимита'} + aria-label={limit > 0 ? t('nodes.roundPercentOfQuotaUsed', { round: Math.round(quota) }) : t('nodes.trafficWithoutLimit')} > 0 ? quota / 100 : 1 } as CSSProperties} />
- {limit > 0 ? `${Math.round(quota)}%` : item.traffic_month || 'нет данных'} + {limit > 0 ? `${Math.round(quota)}%` : item.traffic_month || t('nodes.noData')}
HAProxy {shortHAProxy(metrics?.haproxy_version)}Agent {heartbeat?.agent_version ?? '—'}
- + - } onClick={open}>Открыть ноду - } onClick={() => navigate(newRouteURL)}>Добавить маршрут - } onClick={() => navigate(`${nodeURL}#operations`)}>Управление нодой + } onClick={open}>{t('nodes.openNode')} + } onClick={() => navigate(newRouteURL)}>{t('nodes.addRoute')} + } onClick={() => navigate(`${nodeURL}#operations`)}>{t('nodes.nodeManagement')}
@@ -197,9 +198,9 @@ export function NodesTable({ nodes, page, pageSize, total, onPageChange, reorder void latest.current.onReorder(sourceID, targetID); }, []); return ( -
+
- СтатусНодаМаршрутыСоединенияCPU / RAMRX / TXТрафик за месяцHAProxy / Agent + {t('nodes.status')}{t('nodes.node')}{t('nodes.routes')}{t('nodes.connections')}CPU / RAMRX / TX{t('nodes.monthlyTraffic')}HAProxy / Agent
{nodes.map((item, index) => ( @@ -222,9 +223,9 @@ export function NodesTable({ nodes, page, pageSize, total, onPageChange, reorder ))}
- Показано {total ? (page - 1) * pageSize + 1 : 0}–{Math.min(total, page * pageSize)} из {total} нод + {t('nodes.showingNodes', { from: total ? (page - 1) * pageSize + 1 : 0, to: Math.min(total, page * pageSize), count: total })} - {pageSize} / стр. + {t('app.perPage', { size: pageSize })}
); diff --git a/frontend/src/features/nodes/RoutesRanking.tsx b/frontend/src/features/nodes/RoutesRanking.tsx index dfb0754..006801e 100644 --- a/frontend/src/features/nodes/RoutesRanking.tsx +++ b/frontend/src/features/nodes/RoutesRanking.tsx @@ -3,11 +3,12 @@ import { Link, useLocation } from 'react-router-dom'; import type { DashboardRoute } from '../../lib/contracts'; import { formatBitrate, formatBytes } from '../../lib/format'; import { demoSuffix } from '../../lib/navigation'; +import { t } from '../../i18n'; function routeLabel(route: DashboardRoute): string { if (route.name) return route.name; - if (route.fallback) return `Весь трафик · ${route.listener_ip || '*'}:${route.listener_port}`; - return route.snis[0] ?? `Маршрут · ${route.listener_ip || '*'}:${route.listener_port}`; + if (route.fallback) return t('nodes.allTrafficP0ListenerPort', { p0: route.listener_ip || '*', listener_port: route.listener_port }); + return route.snis[0] ?? t('nodes.routeP0ListenerPort', { p0: route.listener_ip || '*', listener_port: route.listener_port }); } export function RoutesRanking({ routes }: { routes: DashboardRoute[] }) { @@ -16,10 +17,10 @@ export function RoutesRanking({ routes }: { routes: DashboardRoute[] }) { return (
-

Топ маршрутов

+

{t('nodes.topRoutes')}

RX + TX
- +
    {visible.length ? visible.map((route, index) => (
  1. @@ -29,12 +30,12 @@ export function RoutesRanking({ routes }: { routes: DashboardRoute[] }) {
{route.node_name} - {formatBitrate(route.bits_per_second)} + {formatBitrate(route.bits_per_second)} {Math.round(route.share_percent)}% - )) :
  • Маршрутов пока нет
  • } + )) :
  • {t('nodes.noRoutesYet')}
  • } - Посмотреть все маршруты + {t('nodes.viewAllRoutes')}
    ); } diff --git a/frontend/src/features/nodes/TrafficChart.tsx b/frontend/src/features/nodes/TrafficChart.tsx index c8fa925..00ef06d 100644 --- a/frontend/src/features/nodes/TrafficChart.tsx +++ b/frontend/src/features/nodes/TrafficChart.tsx @@ -10,6 +10,7 @@ import { nodeFlowChartTheme } from '../../lib/appearance'; import type { TrafficHistorySample } from '../../lib/contracts'; import { formatBitrate } from '../../lib/format'; import type { TrafficRange } from './useNodesOverview'; +import { t, locale } from '../../i18n'; interface TrafficChartProps { samples: TrafficHistorySample[]; @@ -27,12 +28,12 @@ interface TrafficChartProps { } const ranges = [ - { label: '1м', value: '1m' }, - { label: '5м', value: '5m' }, - { label: '1ч', value: '1h' }, - { label: '24ч', value: '24h' }, - { label: '7д', value: '7d' }, - { label: '30д', value: '30d' }, + { label: t('nodes.n1m'), value: '1m' }, + { label: t('nodes.n5m'), value: '5m' }, + { label: t('nodes.n1h'), value: '1h' }, + { label: t('nodes.n24h'), value: '24h' }, + { label: t('nodes.n7d'), value: '7d' }, + { label: t('nodes.n30d'), value: '30d' }, ]; type TooltipParam = { axisValueLabel?: string; marker?: string; seriesName?: string; value?: [number, number | null] }; @@ -40,18 +41,18 @@ type TooltipParam = { axisValueLabel?: string; marker?: string; seriesName?: str function formatAxisTick(value: number | string, range: TrafficRange) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; - if (range === '1m') return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(date); - if (range === '5m' || range === '1h' || range === '24h') return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(date); - return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(date); + if (range === '1m') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(date); + if (range === '5m' || range === '1h' || range === '24h') return new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit' }).format(date); + return new Intl.DateTimeFormat(locale(), { day: '2-digit', month: '2-digit' }).format(date); } echarts.use([LineChart, GridComponent, TooltipComponent, AriaComponent, CanvasRenderer]); export function TrafficChart({ samples, range, onRangeChange, currentRx, currentTx, status, - emptyTitle = 'История трафика собирается', - emptyDescription = 'Первая точка появится после сигнала Node Agent.', - emptyFooterLabel = 'Ожидаем первую точку', + emptyTitle = t('nodes.collectingTrafficHistory'), + emptyDescription = t('nodes.theFirstSampleWillAppear'), + emptyFooterLabel = t('nodes.waitingForTheFirstSample'), }: TrafficChartProps) { const reducedMotion = useReducedMotion(); const compactChart = useMediaQuery('(max-width: 479px)') ?? false; @@ -153,22 +154,22 @@ export function TrafficChart({ }), [samples, range, reducedMotion, compactChart, timeExtent, chartTheme]); const updatedAt = latest?.timestamp ? new Date(latest.timestamp) : null; const updatedLabel = updatedAt && !Number.isNaN(updatedAt.getTime()) - ? `Обновлено: ${new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(updatedAt)}` + ? t('nodes.updatedP0', { p0: new Intl.DateTimeFormat(locale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(updatedAt) }) : emptyFooterLabel; const explicitCurrent = currentRx !== undefined || currentTx !== undefined; const actualRx = currentRx === undefined ? latest?.rx_bps : currentRx; const actualTx = currentTx === undefined ? latest?.tx_bps : currentTx; - const valueScope = explicitCurrent ? 'Сейчас' : 'Последняя точка'; - const chartLabel = `Трафик за период ${ranges.find((item) => item.value === range)?.label ?? range}. ${valueScope} RX ${formatBitrate(actualRx)}, TX ${formatBitrate(actualTx)}.`; + const valueScope = explicitCurrent ? t('nodes.now') : t('nodes.latestSample'); + const chartLabel = t('nodes.trafficForP0ValuescopeRx', { p0: ranges.find((item) => item.value === range)?.label ?? range, valueScope, formatBitrate: formatBitrate(actualRx), p3: formatBitrate(actualTx) }); const currentStatus = status ?? { tone: latest ? 'stale' as const : 'offline' as const, - label: latest ? 'Исторические данные' : 'Нет свежих данных', + label: latest ? t('nodes.historicalData') : t('nodes.noFreshData'), }; return (
    -
    +
    RX {formatBitrate(actualRx)} TX {formatBitrate(actualTx)}
    @@ -178,7 +179,7 @@ export function TrafficChart({ onChange={(value) => onRangeChange(value as TrafficRange)} data={ranges} size="sm" - aria-label="Период графика" + aria-label={t('nodes.chartPeriod')} />
    {latest ? ( diff --git a/frontend/src/features/nodes/model.ts b/frontend/src/features/nodes/model.ts index 47a6365..7eea4c6 100644 --- a/frontend/src/features/nodes/model.ts +++ b/frontend/src/features/nodes/model.ts @@ -1,5 +1,6 @@ import type { DashboardNode, HeartbeatMetrics, NodeBundle, RouteRecord, TrafficHistorySample } from '../../lib/contracts'; import type { TrafficRange } from './useNodesOverview'; +import { t } from '../../i18n'; export type OperationalState = 'online' | 'degraded' | 'offline'; @@ -43,6 +44,6 @@ export function filterSamples(samples: TrafficHistorySample[], range: TrafficRan export function routeName(route: RouteRecord): string { return route.name || (route.fallback - ? `Весь трафик · ${route.listener_ip || '*'}:${route.listener_port}` - : route.snis[0] ?? route.hostname ?? 'Без имени'); + ? t('nodes.allTrafficP0ListenerPort', { p0: route.listener_ip || '*', listener_port: route.listener_port }) + : route.snis[0] ?? route.hostname ?? t('nodes.unnamed')); } diff --git a/frontend/src/features/routes/RouteLibraryDialog.tsx b/frontend/src/features/routes/RouteLibraryDialog.tsx index 47163d6..a1efbe8 100644 --- a/frontend/src/features/routes/RouteLibraryDialog.tsx +++ b/frontend/src/features/routes/RouteLibraryDialog.tsx @@ -5,11 +5,12 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import type { NodeRecord, RouteRecord } from '../../lib/contracts'; import { copyRoutes, deleteTemplate, listTemplates, saveRouteTemplate, templateNodes, type CopyResult, type RouteTemplate } from './templates'; +import { apiErrorText, t } from '../../i18n'; // Same Modal chrome as every other panel dialog (node settings, delete route). const dialogClassNames = { content: 'nf-dialog', header: 'nf-dialog__header', body: 'nf-dialog__body' }; -const COPY_NOTE = 'Копии создаются выключенными; счётчики и история не переносятся.'; -const LISTENER_HINT = 'На другой ноде проверьте listener IP и Unix-сокеты перед включением.'; +const COPY_NOTE = t('templates.copiesAreCreatedDisabledCounters'); +const LISTENER_HINT = t('templates.onAnotherNodeCheckThe'); function errorText(e: unknown, fallback: string) { return e instanceof Error ? e.message : fallback; @@ -30,17 +31,17 @@ function CopyTargets({ nodes, nodeID, targets, setTargets, name, setName, namePl const nodeName = (id: string) => nodes.find((n) => n.id === id)?.name || id; return <> ({ value: n.id, label: n.id === nodeID ? `${n.name} (эта нода)` : n.name }))} + label={t('templates.targetNodes')} placeholder={targets.length ? '' : t('templates.selectNodes')} searchable maxValues={100} + data={nodes.map((n) => ({ value: n.id, label: n.id === nodeID ? t('templates.nameThisNode', { name: n.name }) : n.name }))} value={targets} onChange={setTargets} disabled={busy} description={otherNode ? LISTENER_HINT : undefined} /> - setName(e.currentTarget.value)} maxLength={80} disabled={busy} /> - {results.length > 0 &&
      + setName(e.currentTarget.value)} maxLength={80} disabled={busy} /> + {results.length > 0 &&
        {results.map((r) =>
      • {nodeName(r.node_id)} - {r.error || 'Черновик создан'} - {r.route && } + {r.error ? apiErrorText(r.error_code, r.error) : t('templates.draftCreated')} + {r.route && }
      • )}
      } ; @@ -63,7 +64,7 @@ export function RouteCopyDialog({ opened, onClose, nodeID, source }: { if (!opened) return; let active = true; setError(''); setResults([]); setTargets([]); setName(''); - templateNodes().then((n) => { if (active) setNodes(n); }).catch((e) => { if (active) setError(errorText(e, 'Список нод недоступен')); }); + templateNodes().then((n) => { if (active) setNodes(n); }).catch((e) => { if (active) setError(errorText(e, t('templates.nodeListUnavailable'))); }); return () => { active = false; }; }, [opened, source?.id]); const copy = async () => { @@ -76,10 +77,10 @@ export function RouteCopyDialog({ opened, onClose, nodeID, source }: { // Keep failed targets selected, so a retry cannot duplicate successful copies. setTargets(copied.filter((r) => r.error).map((r) => r.node_id)); await cache.invalidateQueries({ queryKey: ['node-detail'] }); - } catch (e) { setError(errorText(e, 'Копии не созданы')); } finally { setBusy(false); } + } catch (e) { setError(errorText(e, t('templates.copiesNotCreated'))); } finally { setBusy(false); } }; const done = results.length > 0 && targets.length === 0; - return {} : onClose} title="Копировать на ноды" size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}> + return {} : onClose} title={t('node.copyToNodes')} size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}> {COPY_NOTE} {error &&
      {error}
      } - - {!done && } + + {!done && }
      ; } /** «Сохранить как шаблон»: name and an optional short description. */ -export function RouteTemplateSaveDialog({ opened, onClose, title = 'Сохранить как шаблон', initialName, initialDescription = '', onSave }: { +export function RouteTemplateSaveDialog({ opened, onClose, title = t('node.saveAsTemplate'), initialName, initialDescription = '', onSave }: { opened: boolean; onClose: () => void; title?: string; initialName: string; initialDescription?: string; onSave: (name: string, description: string) => Promise; }) { @@ -111,17 +112,17 @@ export function RouteTemplateSaveDialog({ opened, onClose, title = 'Сохран }, [opened]); // eslint-disable-line react-hooks/exhaustive-deps const save = async () => { setBusy(true); setError(''); - try { await onSave(name.trim(), description.trim()); } catch (e) { setError(errorText(e, 'Шаблон не сохранён')); } finally { setBusy(false); } + try { await onSave(name.trim(), description.trim()); } catch (e) { setError(errorText(e, t('templates.templateNotSaved'))); } finally { setBusy(false); } }; return {} : onClose} title={title} size="md" closeOnClickOutside={!busy} closeOnEscape={!busy} classNames={dialogClassNames}> - Изменение шаблона не меняет созданные из него маршруты. - setName(e.currentTarget.value)} maxLength={80} disabled={busy} data-autofocus required /> -