Add runtime version reporting and display
This commit is contained in:
@@ -213,6 +213,12 @@ cd ~/.vpn-proxy-client
|
|||||||
|
|
||||||
Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`.
|
Если раньше использовался нестандартный порт, укажите его снова через `VPN_PROXY_CLIENT_PORT`.
|
||||||
|
|
||||||
|
### Версии
|
||||||
|
|
||||||
|
Текущая версия всегда показана в правом нижнем углу интерфейса. Connect показывает одну строку `M`, Gateway — строки `C` (client) и `B` (backend). Наведите курсор или переведите клавиатурный фокус на любую цифру, чтобы увидеть смысл `major`, `minor` или `hotfix`; у backend там же указана фактическая версия `sing-box` из dataplane.
|
||||||
|
|
||||||
|
Компонентные версии меняются в `src/shared/versions.js`. У всех компонентов должен совпадать `major`, у Gateway client и backend — `major.minor`; `hotfix` может отличаться. Runtime-значения доступны через `GET /api/version`.
|
||||||
|
|
||||||
## Настройки `.env`
|
## Настройки `.env`
|
||||||
|
|
||||||
Для большинства установок достаточно стандартных значений.
|
Для большинства установок достаточно стандартных значений.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||||
|
import { buildVersionInfo } from './version.js';
|
||||||
|
|
||||||
const socketPath = settings.dataplaneSocket;
|
const socketPath = settings.dataplaneSocket;
|
||||||
const runtime = createSingboxRuntime({
|
const runtime = createSingboxRuntime({
|
||||||
@@ -10,6 +11,7 @@ const runtime = createSingboxRuntime({
|
|||||||
gateway: true,
|
gateway: true,
|
||||||
tproxyChain: settings.tproxyChain,
|
tproxyChain: settings.tproxyChain,
|
||||||
});
|
});
|
||||||
|
const versionInfo = buildVersionInfo('gateway');
|
||||||
let ready = false;
|
let ready = false;
|
||||||
|
|
||||||
function sendJson(res, statusCode, payload) {
|
function sendJson(res, statusCode, payload) {
|
||||||
@@ -22,6 +24,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (req.method === 'GET' && req.url === '/status') {
|
if (req.method === 'GET' && req.url === '/status') {
|
||||||
return sendJson(res, ready ? 200 : 503, {
|
return sendJson(res, ready ? 200 : 503, {
|
||||||
...await runtime.refresh(),
|
...await runtime.refresh(),
|
||||||
|
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
||||||
|
singBoxVersion: versionInfo.runtime.singBox,
|
||||||
ready,
|
ready,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '../shared/contracts/state.js';
|
} from '../shared/contracts/state.js';
|
||||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||||
|
import { buildVersionInfo } from './version.js';
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1_000_000;
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
@@ -63,6 +64,7 @@ if (stateStore.recovery) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
const remoteDataplane = settings.appMode === 'gateway' && Boolean(process.env.DATAPLANE_SOCKET);
|
||||||
|
const versionInfo = buildVersionInfo(settings.appMode);
|
||||||
const singboxRuntime = remoteDataplane
|
const singboxRuntime = remoteDataplane
|
||||||
? createDataplaneClient(settings.dataplaneSocket)
|
? createDataplaneClient(settings.dataplaneSocket)
|
||||||
: createSingboxRuntime({
|
: createSingboxRuntime({
|
||||||
@@ -462,6 +464,16 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, await publicState());
|
return sendJson(res, 200, await publicState());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/api/version') {
|
||||||
|
if (!remoteDataplane) return sendJson(res, 200, versionInfo);
|
||||||
|
const runtime = await singboxRuntime.refresh();
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
...versionInfo,
|
||||||
|
components: { gatewayBackend: runtime.gatewayBackendVersion || null },
|
||||||
|
runtime: { singBox: runtime.singBoxVersion || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
|
if (req.method === 'GET' && req.url === '/api/shared-proxy') {
|
||||||
return sendJson(res, 200, buildSharedProxyInfo({
|
return sendJson(res, 200, buildSharedProxyInfo({
|
||||||
appMode: settings.appMode,
|
appMode: settings.appMode,
|
||||||
|
|||||||
20
src/server/version.js
Normal file
20
src/server/version.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { HARBOR_VERSIONS } from '../shared/versions.js';
|
||||||
|
|
||||||
|
export function detectSingBoxVersion(run = spawnSync) {
|
||||||
|
const result = run('sing-box', ['version'], { encoding: 'utf8', timeout: 1000 });
|
||||||
|
const match = /sing-box version\s+v?([^\s]+)/i.exec(`${result.stdout || ''}\n${result.stderr || ''}`);
|
||||||
|
return match?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVersionInfo(appMode, run = spawnSync) {
|
||||||
|
const client = appMode === 'client';
|
||||||
|
return {
|
||||||
|
apiVersion: 1,
|
||||||
|
location: client ? 'mac' : 'gateway',
|
||||||
|
components: client
|
||||||
|
? { macClient: HARBOR_VERSIONS.macClient }
|
||||||
|
: { gatewayBackend: HARBOR_VERSIONS.gatewayBackend },
|
||||||
|
runtime: { singBox: detectSingBoxVersion(run) },
|
||||||
|
};
|
||||||
|
}
|
||||||
27
src/shared/versions.js
Normal file
27
src/shared/versions.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
|
macClient: '0.1.0',
|
||||||
|
gatewayClient: '0.1.0',
|
||||||
|
gatewayBackend: '0.1.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function parseVersion(value) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
|
||||||
|
return match ? {
|
||||||
|
major: Number(match[1]),
|
||||||
|
minor: Number(match[2]),
|
||||||
|
hotfix: Number(match[3]),
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionCompatibility(versions) {
|
||||||
|
const mac = parseVersion(versions?.macClient);
|
||||||
|
const client = parseVersion(versions?.gatewayClient);
|
||||||
|
const backend = parseVersion(versions?.gatewayBackend);
|
||||||
|
const major = Boolean(mac && client && backend
|
||||||
|
&& mac.major === client.major
|
||||||
|
&& client.major === backend.major);
|
||||||
|
const gateway = Boolean(client && backend
|
||||||
|
&& client.major === backend.major
|
||||||
|
&& client.minor === backend.minor);
|
||||||
|
return { compatible: major && gateway, major, gateway };
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ function App() {
|
|||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
const [operations, setOperations] = useState({});
|
const [operations, setOperations] = useState({});
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [versionInfo, setVersionInfo] = useState(null);
|
||||||
const pollGeneration = useRef(0);
|
const pollGeneration = useRef(0);
|
||||||
const operationRegistry = useRef(null);
|
const operationRegistry = useRef(null);
|
||||||
if (!operationRegistry.current) {
|
if (!operationRegistry.current) {
|
||||||
@@ -56,6 +57,17 @@ function App() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.version().then((info) => {
|
||||||
|
if (!cancelled) setVersionInfo(info);
|
||||||
|
}).catch((requestError) => {
|
||||||
|
console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`);
|
||||||
|
if (!cancelled) setVersionInfo(null);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state?.mode) return;
|
if (!state?.mode) return;
|
||||||
const isGateway = state.mode === 'gateway';
|
const isGateway = state.mode === 'gateway';
|
||||||
@@ -129,6 +141,7 @@ function App() {
|
|||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<ClientOverviewPage
|
<ClientOverviewPage
|
||||||
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
|
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
|
||||||
|
versionInfo={versionInfo}
|
||||||
operations={operations}
|
operations={operations}
|
||||||
error={error}
|
error={error}
|
||||||
subscriptionUrl={subscriptionUrl}
|
subscriptionUrl={subscriptionUrl}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export async function request(url, options = {}, fetchImpl = fetch) {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
state: () => request('/api/state'),
|
state: () => request('/api/state'),
|
||||||
|
version: () => request('/api/version'),
|
||||||
subscription: {
|
subscription: {
|
||||||
validate: (url, signal) => request('/api/subscription/validate', {
|
validate: (url, signal) => request('/api/subscription/validate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
import { formatBytes } from '../utils/format.js';
|
import { formatBytes } from '../utils/format.js';
|
||||||
import { instructionBlocks } from '../instructions.js';
|
import { instructionBlocks } from '../instructions.js';
|
||||||
import { createLatestRequest, operationBlocked } from '../state/operations.js';
|
import { createLatestRequest, operationBlocked } from '../state/operations.js';
|
||||||
|
import {
|
||||||
|
HARBOR_VERSIONS,
|
||||||
|
parseVersion,
|
||||||
|
versionCompatibility,
|
||||||
|
} from '../../shared/versions.js';
|
||||||
|
|
||||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||||
@@ -21,6 +26,96 @@ function CloudTooltip({ children }) {
|
|||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VERSION_PARTS = [
|
||||||
|
['major', 'Major'],
|
||||||
|
['minor', 'Minor'],
|
||||||
|
['hotfix', 'Hotfix'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function VersionBadge({ code, component, componentKey, version, singBox, incompatible = false }) {
|
||||||
|
const parsed = parseVersion(version);
|
||||||
|
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['–', '–', '–'];
|
||||||
|
|
||||||
|
function description(key) {
|
||||||
|
if (key === 'major') {
|
||||||
|
return 'Общий уровень совместимости Harbor. При его изменении обновляются Mac и вся Gateway-инфраструктура.';
|
||||||
|
}
|
||||||
|
if (key === 'minor' && componentKey === 'macClient') {
|
||||||
|
return 'Линия Mac-клиента. Gateway может менять minor без обязательного обновления Mac.';
|
||||||
|
}
|
||||||
|
if (key === 'minor') {
|
||||||
|
return 'Линия Gateway. Gateway client и backend должны совпадать по major.minor.';
|
||||||
|
}
|
||||||
|
return `Совместимое исправление только компонента ${component}; hotfix может обновляться независимо.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`harbor-version${incompatible ? ' is-incompatible' : ''}`}>
|
||||||
|
<span className="harbor-version-code" aria-hidden="true">{code}</span>
|
||||||
|
<span className="harbor-version-number" aria-label={`${component} ${version || 'не определена'}`}>
|
||||||
|
{VERSION_PARTS.map(([key, label], index) => {
|
||||||
|
const tooltipId = `harbor-version-${componentKey}-${key}`;
|
||||||
|
return <React.Fragment key={key}>
|
||||||
|
{index > 0 && <span className="harbor-version-dot" aria-hidden="true">.</span>}
|
||||||
|
<span
|
||||||
|
className="harbor-version-part"
|
||||||
|
tabIndex="0"
|
||||||
|
aria-describedby={tooltipId}
|
||||||
|
>
|
||||||
|
{values[index]}
|
||||||
|
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
|
||||||
|
<strong>{component} · {label} {values[index]}</strong>
|
||||||
|
<span>{description(key)}</span>
|
||||||
|
{singBox && <small>Runtime: sing-box {singBox}</small>}
|
||||||
|
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</React.Fragment>;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VersionDisplay({ isGateway, versionInfo }) {
|
||||||
|
const runtimeSingBox = versionInfo?.runtime?.singBox;
|
||||||
|
if (!isGateway) {
|
||||||
|
return <aside className="harbor-versions" aria-label="Версия Harbor">
|
||||||
|
<VersionBadge
|
||||||
|
code="M"
|
||||||
|
component="Mac client"
|
||||||
|
componentKey="macClient"
|
||||||
|
version={versionInfo?.components?.macClient || HARBOR_VERSIONS.macClient}
|
||||||
|
singBox={runtimeSingBox}
|
||||||
|
/>
|
||||||
|
</aside>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendVersion = versionInfo?.components?.gatewayBackend;
|
||||||
|
const compatibility = backendVersion && versionCompatibility({
|
||||||
|
...HARBOR_VERSIONS,
|
||||||
|
gatewayBackend: backendVersion,
|
||||||
|
});
|
||||||
|
const incompatible = compatibility && !compatibility.compatible;
|
||||||
|
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
|
||||||
|
<VersionBadge
|
||||||
|
code="C"
|
||||||
|
component="Gateway client"
|
||||||
|
componentKey="gatewayClient"
|
||||||
|
version={HARBOR_VERSIONS.gatewayClient}
|
||||||
|
incompatible={incompatible}
|
||||||
|
/>
|
||||||
|
<VersionBadge
|
||||||
|
code="B"
|
||||||
|
component="Gateway backend"
|
||||||
|
componentKey="gatewayBackend"
|
||||||
|
version={backendVersion}
|
||||||
|
singBox={runtimeSingBox}
|
||||||
|
incompatible={incompatible}
|
||||||
|
/>
|
||||||
|
</aside>;
|
||||||
|
}
|
||||||
|
|
||||||
function InlineError({ error, context }) {
|
function InlineError({ error, context }) {
|
||||||
if (!error || error.context !== context) return null;
|
if (!error || error.context !== context) return null;
|
||||||
return (
|
return (
|
||||||
@@ -198,6 +293,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSe
|
|||||||
|
|
||||||
export function ClientOverviewPage({
|
export function ClientOverviewPage({
|
||||||
state,
|
state,
|
||||||
|
versionInfo,
|
||||||
operations = {},
|
operations = {},
|
||||||
error,
|
error,
|
||||||
subscriptionUrl,
|
subscriptionUrl,
|
||||||
@@ -553,6 +649,7 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||||||
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
<HarborBrand
|
<HarborBrand
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
gatewayAvailable={gatewayAvailable}
|
gatewayAvailable={gatewayAvailable}
|
||||||
|
|||||||
@@ -192,6 +192,118 @@ p {
|
|||||||
--client-accent: var(--harbor-gateway);
|
--client-accent: var(--harbor-gateway);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-versions {
|
||||||
|
position: fixed;
|
||||||
|
right: max(14px, env(safe-area-inset-right));
|
||||||
|
bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0.56;
|
||||||
|
transition: color 240ms ease, opacity 240ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version:focus-within,
|
||||||
|
.harbor-version:hover {
|
||||||
|
color: var(--client-text);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version.is-incompatible {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-code {
|
||||||
|
width: 1.3ch;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-size: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-number {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-part {
|
||||||
|
position: relative;
|
||||||
|
min-width: 1ch;
|
||||||
|
padding: 3px 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: help;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-part:hover,
|
||||||
|
.harbor-version-part:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
color: var(--client-accent);
|
||||||
|
text-shadow: 0 0 8px color-mix(in oklch, var(--client-accent) 48%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-part:focus-visible {
|
||||||
|
box-shadow: 0 0 0 1px var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 7px);
|
||||||
|
width: min(250px, calc(100vw - 28px));
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in oklch, var(--client-panel) 84%, transparent);
|
||||||
|
box-shadow: 0 9px 30px oklch(0.08 0.015 145 / 0.16);
|
||||||
|
backdrop-filter: blur(12px) saturate(0.9);
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
filter: blur(2px);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(3px);
|
||||||
|
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-tooltip strong {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-tooltip small {
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-tooltip .is-warning {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-version-part:hover .harbor-version-tooltip,
|
||||||
|
.harbor-version-part:focus-visible .harbor-version-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-delay: 20ms, 20ms, 20ms, 0s;
|
||||||
|
}
|
||||||
|
|
||||||
.harbor-brand {
|
.harbor-brand {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -2178,6 +2290,11 @@ p {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-version,
|
||||||
|
.harbor-version-tooltip {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.client-state-copy h2,
|
.client-state-copy h2,
|
||||||
.client-connection-title > span,
|
.client-connection-title > span,
|
||||||
.client-state-detail > * {
|
.client-state-detail > * {
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
const requests = [];
|
const requests = [];
|
||||||
const send = async (socketPath, pathname, method) => {
|
const send = async (socketPath, pathname, method) => {
|
||||||
requests.push(`${method} ${pathname} ${socketPath}`);
|
requests.push(`${method} ${pathname} ${socketPath}`);
|
||||||
return { running: pathname !== '/stop', startedAt: 'now' };
|
return {
|
||||||
|
running: pathname !== '/stop',
|
||||||
|
startedAt: 'now',
|
||||||
|
gatewayBackendVersion: '0.1.0',
|
||||||
|
singBoxVersion: '1.12.13',
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = createDataplaneClient('/run/dataplane.sock', send);
|
const client = createDataplaneClient('/run/dataplane.sock', send);
|
||||||
assert.equal((await client.refresh()).running, true);
|
const status = await client.refresh();
|
||||||
|
assert.equal(status.running, true);
|
||||||
|
assert.equal(status.gatewayBackendVersion, '0.1.0');
|
||||||
|
assert.equal(status.singBoxVersion, '1.12.13');
|
||||||
await client.apply();
|
await client.apply();
|
||||||
await client.restart();
|
await client.restart();
|
||||||
assert.equal((await client.stop()).running, false);
|
assert.equal((await client.stop()).running, false);
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ test('GET and domain mutations return one state shape with monotonic revisions',
|
|||||||
const singboxPath = path.join(binDir, 'sing-box');
|
const singboxPath = path.join(binDir, 'sing-box');
|
||||||
const workingSingbox = `#!/usr/bin/env node
|
const workingSingbox = `#!/usr/bin/env node
|
||||||
if (process.argv[2] === 'check') process.exit(0);
|
if (process.argv[2] === 'check') process.exit(0);
|
||||||
|
if (process.argv[2] === 'version') {
|
||||||
|
console.log('sing-box version 1.12.13');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
process.on('SIGTERM', () => process.exit(0));
|
process.on('SIGTERM', () => process.exit(0));
|
||||||
setInterval(() => {}, 60_000);
|
setInterval(() => {}, 60_000);
|
||||||
`;
|
`;
|
||||||
@@ -155,6 +159,13 @@ setInterval(() => {}, 60_000);
|
|||||||
});
|
});
|
||||||
|
|
||||||
const initial = await waitForState(port, child, () => stderr);
|
const initial = await waitForState(port, child, () => stderr);
|
||||||
|
const version = await request(port, '/api/version');
|
||||||
|
assert.deepEqual(version, {
|
||||||
|
apiVersion: 1,
|
||||||
|
location: 'mac',
|
||||||
|
components: { macClient: '0.1.0' },
|
||||||
|
runtime: { singBox: '1.12.13' },
|
||||||
|
});
|
||||||
assertStateSnapshot(initial);
|
assertStateSnapshot(initial);
|
||||||
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
||||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||||
|
|||||||
33
test/server/version.test.js
Normal file
33
test/server/version.test.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { buildVersionInfo } from '../../src/server/version.js';
|
||||||
|
import { HARBOR_VERSIONS, versionCompatibility } from '../../src/shared/versions.js';
|
||||||
|
|
||||||
|
test('component versions enforce one Harbor major and one Gateway major.minor', () => {
|
||||||
|
assert.deepEqual(versionCompatibility(HARBOR_VERSIONS), {
|
||||||
|
compatible: true,
|
||||||
|
major: true,
|
||||||
|
gateway: true,
|
||||||
|
});
|
||||||
|
assert.equal(versionCompatibility({
|
||||||
|
macClient: '1.9.4',
|
||||||
|
gatewayClient: '1.3.1',
|
||||||
|
gatewayBackend: '1.3.8',
|
||||||
|
}).compatible, true);
|
||||||
|
assert.equal(versionCompatibility({
|
||||||
|
macClient: '1.0.0',
|
||||||
|
gatewayClient: '1.3.0',
|
||||||
|
gatewayBackend: '1.4.0',
|
||||||
|
}).compatible, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime version info reports the installed sing-box binary', () => {
|
||||||
|
const run = () => ({ stdout: 'sing-box version 1.12.13\n', stderr: '' });
|
||||||
|
assert.deepEqual(buildVersionInfo('gateway', run), {
|
||||||
|
apiVersion: 1,
|
||||||
|
location: 'gateway',
|
||||||
|
components: { gatewayBackend: HARBOR_VERSIONS.gatewayBackend },
|
||||||
|
runtime: { singBox: '1.12.13' },
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user