mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
Merge branch 'dev-2.9.1' into dev-26.10.0
# Conflicts: # package-lock.json # package.json # plugins/ai/locales/translated/af_ZA.json # plugins/ai/locales/translated/ar_SA.json # plugins/ai/locales/translated/bg_BG.json # plugins/ai/locales/translated/bn_BD.json # plugins/ai/locales/translated/ca_ES.json # plugins/ai/locales/translated/cs_CZ.json # plugins/ai/locales/translated/da_DK.json # plugins/ai/locales/translated/de_DE.json # plugins/ai/locales/translated/el_GR.json # plugins/ai/locales/translated/es_ES.json # plugins/ai/locales/translated/fi_FI.json # plugins/ai/locales/translated/fr_FR.json # plugins/ai/locales/translated/he_IL.json # plugins/ai/locales/translated/hi_IN.json # plugins/ai/locales/translated/hu_HU.json # plugins/ai/locales/translated/id_ID.json # plugins/ai/locales/translated/it_IT.json # plugins/ai/locales/translated/ja_JP.json # plugins/ai/locales/translated/ko_KR.json # plugins/ai/locales/translated/nl_NL.json # plugins/ai/locales/translated/no_NO.json # plugins/ai/locales/translated/pl_PL.json # plugins/ai/locales/translated/pt_BR.json # plugins/ai/locales/translated/pt_PT.json # plugins/ai/locales/translated/ro_RO.json # plugins/ai/locales/translated/ru_RU.json # plugins/ai/locales/translated/sr_SP.json # plugins/ai/locales/translated/sv_SE.json # plugins/ai/locales/translated/th_TH.json # plugins/ai/locales/translated/tr_TR.json # plugins/ai/locales/translated/uk_UA.json # plugins/ai/locales/translated/vi_VN.json # plugins/ai/locales/translated/zh_TW.json
This commit is contained in:
commit
6b91c3a460
996 files changed
+56245
-47648
No files matched your search
@@ -46,7 +46,7 @@ export function registerHostStatusRoutes(
|
||||
* /host/status:
|
||||
* get:
|
||||
* summary: Get host statuses
|
||||
* description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call.
|
||||
* description: Returns the online or offline status of every host the user can see. Starts status checks for the user's own hosts on first call and waits briefly for their first results.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
* The host list's status dot.
|
||||
*
|
||||
* Every host with status checks on gets a TCP connection to its port on a
|
||||
* timer. A port that answers is "online", one that does not is "offline".
|
||||
* "reachable" is the warning in between: the port answers but the last login
|
||||
* failed (a session, a ctx.ssh connection or a plugin's
|
||||
* ctx.hosts.status.reportLogin said so). It clears on the next working login,
|
||||
* when the host is edited, or when a changed host key is accepted. Probing
|
||||
* never logs in to the host itself: on RADIUS or Duo backed devices a login
|
||||
* fires a real 2FA push, every interval. A host behind jump hosts is probed
|
||||
* through a chain, which does log in to the hops, so hosts that share a hop
|
||||
* list share one chain (jump-probe-pool.ts).
|
||||
* timer. A port that answers is "online", one that does not (twice in a row)
|
||||
* is "offline". An open session or a working login also counts as online.
|
||||
* Probing never logs in to the host itself: on RADIUS or Duo backed devices a
|
||||
* login fires a real 2FA push, every interval. A host behind jump hosts is
|
||||
* probed through a chain, which does log in to the hops, so hosts that share
|
||||
* a hop list share one chain (jump-probe-pool.ts).
|
||||
*
|
||||
* Polling is demand driven. A user asking for statuses starts polling their
|
||||
* own hosts; nothing is probed for a user who never opened the app.
|
||||
* own hosts; nothing is probed for a user who never opened the app. The first
|
||||
* request waits briefly for those first checks so it is not empty.
|
||||
*/
|
||||
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
@@ -24,24 +22,21 @@ import {
|
||||
getCurrentSettingValue,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
|
||||
import {
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
type HostStatus,
|
||||
} from "./host-status.js";
|
||||
import type { HostStatus } from "./host-status.js";
|
||||
import { ConcurrentLimiter } from "./limiter.js";
|
||||
import { tcpPing } from "./tcp-ping.js";
|
||||
import { JumpProbePool } from "./jump-probe-pool.js";
|
||||
|
||||
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
|
||||
export const DEFAULT_STATUS_INTERVAL = 60;
|
||||
export const DEFAULT_STATUS_INTERVAL = 30;
|
||||
/** How long the first request for a user waits for the first checks. */
|
||||
const FIRST_CHECK_WAIT_MS = 6_000;
|
||||
/** A status younger than this is fresh enough for check(). */
|
||||
const FRESH_MS = 30_000;
|
||||
|
||||
export interface HostStatusEntry {
|
||||
status: HostStatus;
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
export interface HostStatusPayload {
|
||||
@@ -51,7 +46,6 @@ export interface HostStatusPayload {
|
||||
previous: HostStatus | null;
|
||||
/** Anything but offline, the shape automations' trigger already reads. */
|
||||
online: boolean;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
export interface StatusTarget {
|
||||
@@ -84,6 +78,8 @@ export interface HostStatusDeps {
|
||||
hasActiveSession?: (hostId: number) => boolean;
|
||||
globalInterval: () => number;
|
||||
emit: (payload: HostStatusPayload) => void;
|
||||
/** Pause before a failed ping is tried again. */
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
|
||||
@@ -158,7 +154,6 @@ export class HostStatusService {
|
||||
private readonly store = new Map<number, HostStatusEntry>();
|
||||
private readonly owners = new Map<number, string>();
|
||||
private readonly inFlight = new Map<number, Promise<void>>();
|
||||
private readonly loginFailed = new Set<number>();
|
||||
private readonly startedUsers = new Set<string>();
|
||||
private readonly portResolvers = new Map<string, Set<PortResolver>>();
|
||||
private readonly limiter = new ConcurrentLimiter(20);
|
||||
@@ -175,19 +170,12 @@ export class HostStatusService {
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostUpdated, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
// An edit may have fixed the login, so the next attempt decides.
|
||||
this.loginFailed.delete(hostId);
|
||||
void this.reload(hostId);
|
||||
if (hostId) void this.reload(hostId);
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostDeleted, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) this.forget(hostId);
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostKeyUpdated, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) this.clearHostKeyReason(hostId);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -212,11 +200,13 @@ export class HostStatusService {
|
||||
userId: string,
|
||||
requestedHostIds: Set<number> | null,
|
||||
): Promise<Map<number, HostStatusEntry>> {
|
||||
const started: Promise<void>[] = [];
|
||||
if (requestedHostIds !== null) {
|
||||
await this.reconcile(userId, requestedHostIds);
|
||||
await this.reconcile(userId, requestedHostIds, started);
|
||||
} else if (!this.startedUsers.has(userId)) {
|
||||
await this.startUser(userId);
|
||||
await this.startUser(userId, started);
|
||||
}
|
||||
if (started.length > 0) await this.waitFor(started);
|
||||
return this.store;
|
||||
}
|
||||
|
||||
@@ -249,20 +239,12 @@ export class HostStatusService {
|
||||
return this.store.get(hostId) ?? null;
|
||||
}
|
||||
|
||||
reportLogin(
|
||||
hostId: number,
|
||||
outcome: { ok: boolean; hostKeyChanged?: boolean },
|
||||
): void {
|
||||
if (outcome.ok) {
|
||||
this.loginFailed.delete(hostId);
|
||||
this.set(hostId, { status: statusAfterAuthentication(true) });
|
||||
return;
|
||||
}
|
||||
this.loginFailed.add(hostId);
|
||||
this.set(hostId, {
|
||||
status: statusAfterAuthentication(false, this.store.get(hostId)?.status),
|
||||
...(outcome.hostKeyChanged ? { reason: "host_key_changed" } : {}),
|
||||
});
|
||||
/**
|
||||
* A working login proves the host is up. A failed one says nothing about
|
||||
* reachability (a wrong password, another user's login), so it is ignored.
|
||||
*/
|
||||
reportLogin(hostId: number, outcome: { ok: boolean }): void {
|
||||
if (outcome.ok) this.set(hostId, { status: "online" });
|
||||
}
|
||||
|
||||
registerPort(connectionType: string, resolve: PortResolver): () => void {
|
||||
@@ -278,13 +260,33 @@ export class HostStatusService {
|
||||
};
|
||||
}
|
||||
|
||||
private async startUser(userId: string): Promise<void> {
|
||||
private async waitFor(started: Promise<void>[]): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
Promise.allSettled(started),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
private track(started: Promise<void>[], target: StatusTarget): void {
|
||||
const probe = this.startPolling(target);
|
||||
if (probe) started.push(probe);
|
||||
}
|
||||
|
||||
private async startUser(
|
||||
userId: string,
|
||||
started: Promise<void>[],
|
||||
): Promise<void> {
|
||||
this.startedUsers.add(userId);
|
||||
const targets = await this.deps.loadTargets({ userId });
|
||||
for (const target of targets) {
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
if (!this.polled.has(target.id)) this.track(started, target);
|
||||
}
|
||||
await this.startShared(userId, null);
|
||||
await this.startShared(userId, null, started);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,6 +296,7 @@ export class HostStatusService {
|
||||
private async startShared(
|
||||
userId: string,
|
||||
allowed: Set<number> | null,
|
||||
started: Promise<void>[],
|
||||
): Promise<void> {
|
||||
if (!this.deps.loadSharedHostIds) return;
|
||||
try {
|
||||
@@ -304,7 +307,7 @@ export class HostStatusService {
|
||||
if (shared.length === 0) return;
|
||||
const targets = await this.deps.loadTargets({ hostIds: shared });
|
||||
for (const target of targets) {
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
if (!this.polled.has(target.id)) this.track(started, target);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn("Could not start status checks for shared hosts", {
|
||||
@@ -315,7 +318,11 @@ export class HostStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(userId: string, allowed: Set<number>): Promise<void> {
|
||||
private async reconcile(
|
||||
userId: string,
|
||||
allowed: Set<number>,
|
||||
started: Promise<void>[],
|
||||
): Promise<void> {
|
||||
const targets = await this.deps.loadTargets({ userId });
|
||||
for (const target of targets) {
|
||||
if (!allowed.has(target.id)) {
|
||||
@@ -323,9 +330,9 @@ export class HostStatusService {
|
||||
this.store.delete(target.id);
|
||||
continue;
|
||||
}
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
if (!this.polled.has(target.id)) this.track(started, target);
|
||||
}
|
||||
await this.startShared(userId, allowed);
|
||||
await this.startShared(userId, allowed, started);
|
||||
}
|
||||
|
||||
private async reload(hostId: number): Promise<void> {
|
||||
@@ -351,16 +358,6 @@ export class HostStatusService {
|
||||
this.stopPolling(hostId);
|
||||
this.store.delete(hostId);
|
||||
this.owners.delete(hostId);
|
||||
this.loginFailed.delete(hostId);
|
||||
}
|
||||
|
||||
private clearHostKeyReason(hostId: number): void {
|
||||
const current = this.store.get(hostId);
|
||||
if (current?.reason !== "host_key_changed") return;
|
||||
this.loginFailed.delete(hostId);
|
||||
this.set(hostId, {
|
||||
status: current.status === "offline" ? "offline" : "online",
|
||||
});
|
||||
}
|
||||
|
||||
private intervalMs(target: StatusTarget): number {
|
||||
@@ -373,21 +370,26 @@ export class HostStatusService {
|
||||
);
|
||||
}
|
||||
|
||||
private startPolling(target: StatusTarget, probeNow = true): void {
|
||||
/** Returns the first probe when one was started. */
|
||||
private startPolling(
|
||||
target: StatusTarget,
|
||||
probeNow = true,
|
||||
): Promise<void> | undefined {
|
||||
this.stopPolling(target.id);
|
||||
this.owners.set(target.id, target.userId);
|
||||
if (!target.statusCheckEnabled) {
|
||||
this.store.delete(target.id);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
const polled: Polled = { target };
|
||||
this.polled.set(target.id, polled);
|
||||
if (probeNow) void this.probe(target);
|
||||
const first = probeNow ? this.probe(target) : undefined;
|
||||
polled.timer = setInterval(() => {
|
||||
const latest = this.polled.get(target.id);
|
||||
if (latest) void this.probe(latest.target);
|
||||
}, this.intervalMs(target));
|
||||
polled.timer.unref?.();
|
||||
return first;
|
||||
}
|
||||
|
||||
private stopPolling(hostId: number): void {
|
||||
@@ -427,33 +429,28 @@ export class HostStatusService {
|
||||
return target.port;
|
||||
}
|
||||
|
||||
private async pingOnce(target: StatusTarget): Promise<boolean> {
|
||||
try {
|
||||
if (this.deps.hasActiveSession?.(target.id)) return true;
|
||||
const port = await this.portFor(target);
|
||||
return target.jumpHosts.length > 0
|
||||
? await this.deps.pingThroughJumpHosts(target, port)
|
||||
: await this.deps.ping(target.ip, port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async probeNow(target: StatusTarget): Promise<void> {
|
||||
this.owners.set(target.id, target.userId);
|
||||
let reachable = false;
|
||||
try {
|
||||
if (this.deps.hasActiveSession?.(target.id)) {
|
||||
reachable = true;
|
||||
} else {
|
||||
const port = await this.portFor(target);
|
||||
reachable =
|
||||
target.jumpHosts.length > 0
|
||||
? await this.deps.pingThroughJumpHosts(target, port)
|
||||
: await this.deps.ping(target.ip, port);
|
||||
}
|
||||
} catch {
|
||||
reachable = false;
|
||||
let reachable = await this.pingOnce(target);
|
||||
if (!reachable) {
|
||||
// One lost packet should not flip the dot.
|
||||
const delay = this.deps.retryDelayMs ?? 1000;
|
||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
reachable = await this.pingOnce(target);
|
||||
}
|
||||
|
||||
const current = this.store.get(target.id);
|
||||
this.set(target.id, {
|
||||
status: statusAfterReachabilityCheck(
|
||||
reachable,
|
||||
this.loginFailed.has(target.id),
|
||||
),
|
||||
...(reachable && current?.reason === "host_key_changed"
|
||||
? { reason: "host_key_changed" as const }
|
||||
: {}),
|
||||
});
|
||||
this.set(target.id, { status: reachable ? "online" : "offline" });
|
||||
}
|
||||
|
||||
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
|
||||
@@ -486,8 +483,7 @@ export class HostStatusService {
|
||||
ownerUserId,
|
||||
status: entry.status,
|
||||
previous,
|
||||
online: entry.status !== "offline",
|
||||
...(entry.reason ? { reason: entry.reason } : {}),
|
||||
online: entry.status === "online",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type HostStatus = "online" | "reachable" | "offline";
|
||||
export type HostStatus = "online" | "offline";
|
||||
|
||||
export function isHostKeyVerificationError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -7,19 +7,3 @@ export function isHostKeyVerificationError(error: unknown): boolean {
|
||||
error.message.includes("Host key changed"))
|
||||
);
|
||||
}
|
||||
|
||||
export function statusAfterReachabilityCheck(
|
||||
reachable: boolean,
|
||||
loginFailed: boolean,
|
||||
): HostStatus {
|
||||
if (!reachable) return "offline";
|
||||
return loginFailed ? "reachable" : "online";
|
||||
}
|
||||
|
||||
export function statusAfterAuthentication(
|
||||
authenticated: boolean,
|
||||
current?: HostStatus,
|
||||
): HostStatus {
|
||||
if (authenticated) return "online";
|
||||
return current === "offline" ? "offline" : "reachable";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { tcpPingThroughJumpHost } from "./tcp-ping.js";
|
||||
import { tcpPingThroughJumpHostResult } from "./tcp-ping.js";
|
||||
|
||||
type OpenChain = (
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
@@ -44,7 +44,19 @@ export class JumpProbePool {
|
||||
return false;
|
||||
}
|
||||
this.touch(key, entry, client);
|
||||
return tcpPingThroughJumpHost(client, host, port, timeoutMs, true);
|
||||
const result = await tcpPingThroughJumpHostResult(
|
||||
client,
|
||||
host,
|
||||
port,
|
||||
timeoutMs,
|
||||
true,
|
||||
);
|
||||
if (result === "timeout") {
|
||||
// The hop never answered, so the next ping opens a fresh chain.
|
||||
this.drop(key, entry);
|
||||
client.end();
|
||||
}
|
||||
return result === "ok";
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
|
||||
@@ -64,18 +64,23 @@ export function tcpPing(
|
||||
});
|
||||
}
|
||||
|
||||
/** The same check from the far end of a jump host chain. Ends the chain. */
|
||||
export function tcpPingThroughJumpHost(
|
||||
/**
|
||||
* "refused" means the last hop answered that the target is down. "timeout"
|
||||
* means the hop never answered, so the chain itself may be dead.
|
||||
*/
|
||||
export type JumpPingResult = "ok" | "refused" | "timeout";
|
||||
|
||||
export function tcpPingThroughJumpHostResult(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
keepOpen = false,
|
||||
): Promise<boolean> {
|
||||
): Promise<JumpPingResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
const finish = (result: JumpPingResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
@@ -83,11 +88,30 @@ export function tcpPingThroughJumpHost(
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
const timeout = setTimeout(() => finish("timeout"), timeoutMs);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
|
||||
stream?.destroy();
|
||||
finish(!error && !!stream);
|
||||
finish(!error && !!stream ? "ok" : "refused");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The same check from the far end of a jump host chain. Ends the chain. */
|
||||
export async function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
keepOpen = false,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await tcpPingThroughJumpHostResult(
|
||||
jumpClient,
|
||||
host,
|
||||
port,
|
||||
timeoutMs,
|
||||
keepOpen,
|
||||
)) === "ok"
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
} from "../hosts/connect/auth-provider-registry.js";
|
||||
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import { isHostKeyVerificationError } from "../hosts/status/host-status.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
registerLoginMethod,
|
||||
@@ -124,9 +123,6 @@ function withOverrides(
|
||||
return merged as unknown as SshConnectHost;
|
||||
}
|
||||
|
||||
const LOGIN_FAILED =
|
||||
/all configured authentication methods failed|permission denied|authentication failed/i;
|
||||
|
||||
export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
@@ -223,23 +219,13 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
return { ...(host as unknown as SshConnectHost), userId };
|
||||
};
|
||||
|
||||
/**
|
||||
* A login to a saved host core resolved feeds its status dot. Only a clear
|
||||
* auth or host key failure counts: a timeout says nothing about the login.
|
||||
*/
|
||||
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
|
||||
/** A working login to a saved host core resolved marks it online. */
|
||||
const reportLogin = (host: number | PluginSshHost) => {
|
||||
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) return;
|
||||
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
|
||||
if (error !== undefined) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const hostKeyChanged = isHostKeyVerificationError(error);
|
||||
if (!hostKeyChanged && !LOGIN_FAILED.test(message)) return;
|
||||
outcome = { ok: false, hostKeyChanged };
|
||||
}
|
||||
void import("../hosts/status/host-status-service.js")
|
||||
.then(({ hostStatusService }) =>
|
||||
hostStatusService.reportLogin(hostId, outcome),
|
||||
hostStatusService.reportLogin(hostId, { ok: true }),
|
||||
)
|
||||
.catch(() => {});
|
||||
};
|
||||
@@ -299,7 +285,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reportLogin(host, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +156,7 @@ async function writeAudit(
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
// Attribution comes from the runtime, never from the plugin.
|
||||
userId: getActor() ?? "system",
|
||||
userId: getActor() ?? null,
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: `plugin_${options.action}`,
|
||||
resourceType: "plugin",
|
||||
@@ -965,12 +965,12 @@ export function createPluginContext(
|
||||
record: async (entry) => {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
const actor = getActor() ?? "system";
|
||||
const actor = getActor();
|
||||
const meta = requestMeta(entry.request);
|
||||
await logAudit({
|
||||
...meta,
|
||||
userId: actor,
|
||||
username: actor,
|
||||
userId: actor ?? null,
|
||||
username: actor ?? "system",
|
||||
action: entry.action,
|
||||
resourceType: entry.resourceType ?? "plugin",
|
||||
resourceId: entry.resourceId ?? pluginId,
|
||||
|
||||
@@ -351,7 +351,7 @@ async function writePublicRouteAudit(
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
userId: "system",
|
||||
userId: null,
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: "plugin_http_public_routes",
|
||||
resourceType: "plugin",
|
||||
|
||||
@@ -111,7 +111,7 @@ async function auditRefusal(
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
// Attribution comes from the runtime, never from the plugin.
|
||||
userId: getActor() ?? "system",
|
||||
userId: getActor() ?? null,
|
||||
username: `plugin:${pluginId}`,
|
||||
action: `plugin_${action}`,
|
||||
resourceType: "plugin",
|
||||
|
||||
@@ -382,7 +382,7 @@ function auditPublicSocket(pluginId: string, path: string): void {
|
||||
void import("../utils/audit-logger.js")
|
||||
.then(({ logAudit }) =>
|
||||
logAudit({
|
||||
userId: "system",
|
||||
userId: null,
|
||||
username: `plugin:${pluginId}`,
|
||||
action: "plugin_ws_public_route",
|
||||
resourceType: "plugin",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "./host-plugin-settings.js";
|
||||
import {
|
||||
exportProtocolLogins,
|
||||
exportSharedProtocolLogins,
|
||||
importProtocolLogins,
|
||||
} from "./host-protocol-auth.js";
|
||||
import {
|
||||
@@ -477,7 +478,7 @@ async function writeAccountProfile(userId: string, wire: SyncRow) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Host columns a shared copy never carries. */
|
||||
/** Host columns a shared copy never carries as the owner has them. */
|
||||
const SHARED_HOST_DROP = [
|
||||
"id",
|
||||
"userId",
|
||||
@@ -546,6 +547,11 @@ async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
||||
: value;
|
||||
}
|
||||
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
||||
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
||||
userId,
|
||||
permissionLevel,
|
||||
});
|
||||
wire.protocolAuth = await exportSharedProtocolLogins(host, userId);
|
||||
wire.syncId = host.syncId;
|
||||
wire.shared = {
|
||||
hostId,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { databaseLogger } from "../utils/logger.js";
|
||||
import type { DefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
hostSettingsPlugins,
|
||||
withHostPluginSettings,
|
||||
type HostPluginSettings,
|
||||
} from "../database/routes/host-plugin-settings.js";
|
||||
|
||||
@@ -23,19 +24,45 @@ export interface PluginHostSettingsSync {
|
||||
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What a host carries to the other side of a sync pair. */
|
||||
/** Who a shared host's copy is for, and at what share level. */
|
||||
export interface SharedHostViewer {
|
||||
userId: string;
|
||||
permissionLevel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host carries to the other side of a sync pair. With a viewer, only
|
||||
* what that user sees of a host shared with them, as the host routes show it.
|
||||
*/
|
||||
export async function exportHostPluginSettings(
|
||||
hostId: number,
|
||||
viewer?: SharedHostViewer,
|
||||
): Promise<HostPluginSettings> {
|
||||
const visible = viewer
|
||||
? (((
|
||||
await withHostPluginSettings(
|
||||
{
|
||||
id: hostId,
|
||||
isShared: true,
|
||||
permissionLevel: viewer.permissionLevel,
|
||||
},
|
||||
viewer.userId,
|
||||
)
|
||||
).pluginSettings as HostPluginSettings | undefined) ?? {})
|
||||
: null;
|
||||
const result: HostPluginSettings = {};
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const fields = declaredFields(manifest, "host").filter(
|
||||
(field) => field.type !== "secret",
|
||||
(field) =>
|
||||
field.type !== "secret" &&
|
||||
(!visible || field.key in (visible[manifest.id] ?? {})),
|
||||
);
|
||||
if (fields.length === 0) continue;
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
const values = visible
|
||||
? visible[manifest.id]
|
||||
: await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
|
||||
import type { SyncRow } from "@termix/plugin-sdk/backend";
|
||||
import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
|
||||
import { createCurrentHostProtocolAuthRepository } from "../database/repositories/factory.js";
|
||||
import {
|
||||
fromPortableLogin,
|
||||
listProtocolLogins,
|
||||
replaceProtocolLogins,
|
||||
resolveRecipientProtocolLogin,
|
||||
toPortableLogins,
|
||||
} from "../hosts/protocol-auth/protocol-auth.js";
|
||||
import { findHostProtocol } from "../hosts/protocol-auth/registry.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
|
||||
type ResolveSyncId = (entityType: string, id: number) => Promise<string | null>;
|
||||
@@ -38,6 +41,45 @@ export async function exportProtocolLogins(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The logins a user a host is shared with may use, for their desktop's copy:
|
||||
* their own override or the owner's shared snapshot, never the owner's own
|
||||
* secret. Each goes as a direct login since its credential is not theirs.
|
||||
*/
|
||||
export async function exportSharedProtocolLogins(
|
||||
host: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Promise<SyncRow> {
|
||||
const out: SyncRow = {};
|
||||
const hostId = Number(host.id);
|
||||
const rows =
|
||||
await createCurrentHostProtocolAuthRepository().listRowsForHost(hostId);
|
||||
for (const row of rows) {
|
||||
const declared = findHostProtocol(row.protocol);
|
||||
if (!declared) continue;
|
||||
try {
|
||||
const login = await resolveRecipientProtocolLogin(host, userId, declared);
|
||||
out[row.protocol] = {
|
||||
authType: login.authType === "none" ? "none" : "direct",
|
||||
username: login.username || null,
|
||||
password: login.password || null,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(login.fields).filter(([, value]) => value),
|
||||
),
|
||||
credentialSyncId: null,
|
||||
};
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not include a shared host's protocol login", {
|
||||
operation: "sync_shared_host_protocol_auth",
|
||||
hostId,
|
||||
protocol: row.protocol,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes what a synced host carried. A row from a peer that sends no
|
||||
* protocolAuth leaves the stored logins alone.
|
||||
|
||||
@@ -77,6 +77,34 @@ describe("AuditLogRepository", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores entries with no acting user, and refuses ids that are not users", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
userId: null,
|
||||
username: "plugin:example",
|
||||
action: "plugin_http_public_routes",
|
||||
resourceType: "plugin",
|
||||
success: true,
|
||||
});
|
||||
await expect(
|
||||
repo.create({
|
||||
userId: "system",
|
||||
username: "system",
|
||||
action: "cleanup",
|
||||
resourceType: "plugin",
|
||||
success: true,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const page = await repo.listPage({ filters: {}, limit: 10, offset: 0 });
|
||||
expect(page.logs).toHaveLength(1);
|
||||
expect(page.logs[0]).toMatchObject({
|
||||
userId: null,
|
||||
username: "plugin:example",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
|
||||
@@ -88,7 +88,7 @@ beforeEach(() => {
|
||||
state.statuses = new Map([
|
||||
[1, { status: "online", lastChecked: "t" }],
|
||||
[2, { status: "offline", lastChecked: "t" }],
|
||||
[3, { status: "reachable", lastChecked: "t" }],
|
||||
[3, { status: "offline", lastChecked: "t" }],
|
||||
]);
|
||||
service.statusesFor.mockImplementation(async () => state.statuses);
|
||||
service.get.mockImplementation(
|
||||
|
||||
@@ -55,6 +55,7 @@ function setup(
|
||||
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
|
||||
globalInterval: () => 60,
|
||||
emit: (payload) => emitted.push(payload),
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
|
||||
}
|
||||
@@ -192,86 +193,44 @@ describe("HostStatusService", () => {
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
|
||||
});
|
||||
|
||||
it("warns about a failed login until a login works again", async () => {
|
||||
it("ignores a failed login and counts a working one as online", async () => {
|
||||
const { service, emitted } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
const before = emitted.length;
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
expect(emitted.at(-1)).toMatchObject({
|
||||
status: "reachable",
|
||||
previous: "online",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
|
||||
service.reportLogin(1, { ok: true });
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
expect(emitted).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("marks a changed host key and clears it once the new key is accepted", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
it("marks a host online when a terminal session opens", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false, hostKeyChanged: true });
|
||||
expect(service.get(1)).toMatchObject({
|
||||
status: "reachable",
|
||||
reason: "host_key_changed",
|
||||
});
|
||||
|
||||
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
|
||||
expect(service.get(1)?.reason).toBeUndefined();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("clears a failed login when the host is edited", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
|
||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId: 1 });
|
||||
await flush();
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("clears a failed login when a terminal session opens", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
|
||||
const release = hostSessionStatus.register(1);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
release();
|
||||
});
|
||||
|
||||
it("tries a failed ping again before reporting offline", async () => {
|
||||
const { service, ping } = setup([target(1)]);
|
||||
active = service;
|
||||
ping.mockResolvedValueOnce(false);
|
||||
await service.statusesFor("owner", null);
|
||||
expect(ping).toHaveBeenCalledTimes(2);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("shows an offline host as offline whatever the login said", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
it("answers the first request with the first checks done", async () => {
|
||||
const { service } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
service.reportLogin(1, { ok: true });
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
const statuses = await service.statusesFor("owner", null);
|
||||
expect(statuses.get(1)?.status).toBe("online");
|
||||
expect(statuses.get(2)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("only checks the requested hosts on the desktop app", async () => {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "../../../hosts/status/host-status.js";
|
||||
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
|
||||
|
||||
describe("isHostKeyVerificationError", () => {
|
||||
it.each([
|
||||
@@ -19,24 +15,3 @@ describe("isHostKeyVerificationError", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host availability status", () => {
|
||||
it("calls a host whose port answers online", () => {
|
||||
expect(statusAfterReachabilityCheck(true, false)).toBe("online");
|
||||
expect(statusAfterReachabilityCheck(false, false)).toBe("offline");
|
||||
});
|
||||
|
||||
it("warns about a reachable host whose last login failed", () => {
|
||||
expect(statusAfterReachabilityCheck(true, true)).toBe("reachable");
|
||||
expect(statusAfterReachabilityCheck(false, true)).toBe("offline");
|
||||
});
|
||||
|
||||
it("marks successful authentication online", () => {
|
||||
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
|
||||
});
|
||||
|
||||
it("downgrades failed authentication without hiding reachability", () => {
|
||||
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
|
||||
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,29 @@ describe("JumpProbePool", () => {
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("drops a chain whose hop stops answering", async () => {
|
||||
vi.useFakeTimers();
|
||||
const stale = Object.assign(new EventEmitter(), {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
});
|
||||
const fresh = fakeClient();
|
||||
const open = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(fresh);
|
||||
const pool = new JumpProbePool(open);
|
||||
|
||||
const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(await first).toBe(false);
|
||||
expect(stale.end).toHaveBeenCalled();
|
||||
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("retries a chain that failed to open", async () => {
|
||||
const open = vi
|
||||
.fn()
|
||||
|
||||
@@ -73,7 +73,8 @@ describe("ctx.audit.record", () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({ action: "cleanup", success: false });
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
userId: "system",
|
||||
userId: null,
|
||||
username: "system",
|
||||
resourceType: "plugin",
|
||||
resourceId: "audit-fixture",
|
||||
resourceName: "Audit Fixture",
|
||||
|
||||
@@ -129,7 +129,7 @@ vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
get: (hostId: number) =>
|
||||
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
|
||||
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
|
||||
check: async (hostId: number) => ({
|
||||
status: "offline",
|
||||
lastChecked: String(hostId),
|
||||
@@ -496,7 +496,7 @@ describe("ctx.hosts", () => {
|
||||
}
|
||||
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
|
||||
expect(record.pluginSettings).toEqual({ mine: "own-4" });
|
||||
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
|
||||
expect(record.status).toEqual({ status: "online", lastChecked: "t" });
|
||||
expect(record.enableSsh).toBe(true);
|
||||
});
|
||||
|
||||
@@ -620,7 +620,7 @@ describe("ctx.hosts.status", () => {
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.status.get(4)).resolves.toEqual({
|
||||
status: "reachable",
|
||||
status: "online",
|
||||
lastChecked: "t",
|
||||
});
|
||||
await expect(hosts.status.get(5)).resolves.toBeNull();
|
||||
@@ -656,11 +656,9 @@ describe("ctx.hosts.status", () => {
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
|
||||
hosts.status.reportLogin(4, { ok: true });
|
||||
await settle();
|
||||
expect(h.statusReports).toEqual([
|
||||
{ hostId: 4, ok: false, hostKeyChanged: true },
|
||||
]);
|
||||
expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]);
|
||||
});
|
||||
|
||||
it("removes a registered port when the plugin is disposed", async () => {
|
||||
|
||||
@@ -220,24 +220,19 @@ describe("ctx.ssh", () => {
|
||||
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]);
|
||||
});
|
||||
|
||||
it("reports an auth or host key failure, but not a timeout", async () => {
|
||||
it("never reports a failed login to the status", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
h.connectError = new Error("Timed out while waiting for handshake");
|
||||
await expect(ssh.connect(7)).rejects.toThrow();
|
||||
h.connectError = new Error("All configured authentication methods failed");
|
||||
await expect(ssh.connect(7)).rejects.toThrow();
|
||||
h.connectError = new Error("Host key changed");
|
||||
await expect(ssh.connect(8)).rejects.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.logins).toEqual([
|
||||
{ hostId: 7, outcome: { ok: false, hostKeyChanged: false } },
|
||||
{ hostId: 8, outcome: { ok: false, hostKeyChanged: true } },
|
||||
]);
|
||||
expect(h.logins).toEqual([]);
|
||||
});
|
||||
|
||||
it("never believes a host object's own userId", async () => {
|
||||
|
||||
@@ -11,10 +11,19 @@ const h = vi.hoisted(() => ({
|
||||
writes: [] as Array<[string, number, string, unknown]>,
|
||||
hooks: new Map<string, unknown>(),
|
||||
manifests: [] as PluginManifest[],
|
||||
visible: null as Record<string, Record<string, unknown>> | null,
|
||||
viewerCalls: [] as Array<[Record<string, unknown>, string | undefined]>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => h.manifests,
|
||||
withHostPluginSettings: async (
|
||||
host: Record<string, unknown>,
|
||||
viewerId?: string,
|
||||
) => {
|
||||
h.viewerCalls.push([host, viewerId]);
|
||||
return h.visible ? { ...host, pluginSettings: h.visible } : host;
|
||||
},
|
||||
}));
|
||||
vi.mock("../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => h.hooks.get(key),
|
||||
@@ -65,11 +74,27 @@ const VAULT = {
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
const TUNNELS = {
|
||||
id: "tunnels",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "enableTunnel", type: "boolean", labelKey: "k" },
|
||||
{ key: "tunnelConnections", type: "custom", labelKey: "k" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
beforeEach(() => {
|
||||
h.stored.clear();
|
||||
h.writes.length = 0;
|
||||
h.hooks.clear();
|
||||
h.manifests = [VAULT];
|
||||
h.visible = null;
|
||||
h.viewerCalls.length = 0;
|
||||
h.hooks.set("vault.hostSettingsSync", {
|
||||
exportValue: (key: string, value: unknown) =>
|
||||
key === "profileId" && value === 4 ? "profile-sync-4" : value,
|
||||
@@ -87,6 +112,25 @@ describe("host plugin settings over sync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exports only what a shared host's viewer sees", async () => {
|
||||
h.manifests = [VAULT, TUNNELS];
|
||||
h.visible = {
|
||||
vault: { token: { set: true } },
|
||||
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
|
||||
};
|
||||
expect(
|
||||
await exportHostPluginSettings(1, {
|
||||
userId: "viewer",
|
||||
permissionLevel: "connect",
|
||||
}),
|
||||
).toEqual({
|
||||
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
|
||||
});
|
||||
expect(h.viewerCalls).toEqual([
|
||||
[{ id: 1, isShared: true, permissionLevel: "connect" }, "viewer"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports them back to local ids", async () => {
|
||||
await importHostPluginSettings(9, {
|
||||
vault: { profileId: "profile-sync-4", token: "ignored" },
|
||||
|
||||
@@ -8,6 +8,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const state = vi.hoisted(() => ({
|
||||
logins: [] as Array<Record<string, unknown>>,
|
||||
replaced: null as null | { ownerId: string; hostId: number; logins: unknown },
|
||||
rows: [] as Array<{ protocol: string }>,
|
||||
recipient: new Map<string, Record<string, unknown> | Error>(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
@@ -20,6 +22,15 @@ vi.mock(
|
||||
return {
|
||||
...actual,
|
||||
listProtocolLogins: async () => state.logins,
|
||||
resolveRecipientProtocolLogin: async (
|
||||
_host: unknown,
|
||||
_userId: string,
|
||||
declared: { id: string },
|
||||
) => {
|
||||
const login = state.recipient.get(declared.id);
|
||||
if (login instanceof Error) throw login;
|
||||
return login;
|
||||
},
|
||||
replaceProtocolLogins: async (
|
||||
ownerId: string,
|
||||
hostId: number,
|
||||
@@ -30,6 +41,11 @@ vi.mock(
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostProtocolAuthRepository: () => ({
|
||||
listRowsForHost: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => null,
|
||||
getSqlite: () => null,
|
||||
@@ -37,12 +53,15 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
|
||||
import {
|
||||
exportProtocolLogins,
|
||||
exportSharedProtocolLogins,
|
||||
importProtocolLogins,
|
||||
} from "../../sync/host-protocol-auth.js";
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.replaced = null;
|
||||
state.rows = [];
|
||||
state.recipient.clear();
|
||||
state.logins = [
|
||||
{
|
||||
protocol: "spice",
|
||||
@@ -129,3 +148,52 @@ describe("host protocol logins over sync", () => {
|
||||
expect(state.replaced).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared host protocol logins over sync", () => {
|
||||
it("sends what the recipient may use as direct logins", async () => {
|
||||
state.rows = [{ protocol: "spice" }, { protocol: "unknown" }];
|
||||
state.recipient.set("spice", {
|
||||
authType: "credential",
|
||||
username: "alice",
|
||||
password: "pw",
|
||||
fields: { display: "2", ticket: "" },
|
||||
});
|
||||
expect(
|
||||
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
|
||||
).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
username: "alice",
|
||||
password: "pw",
|
||||
fields: { display: "2" },
|
||||
credentialSyncId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a none login and skips one that fails", async () => {
|
||||
setHostProtocolSource(() => [
|
||||
{ id: "spice", credentialFields: [], pluginId: "p", pluginName: "P" },
|
||||
{ id: "vnc", credentialFields: [], pluginId: "p", pluginName: "P" },
|
||||
]);
|
||||
state.rows = [{ protocol: "spice" }, { protocol: "vnc" }];
|
||||
state.recipient.set("spice", {
|
||||
authType: "none",
|
||||
username: "",
|
||||
password: "",
|
||||
fields: {},
|
||||
});
|
||||
state.recipient.set("vnc", new Error("locked"));
|
||||
expect(
|
||||
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
|
||||
).toEqual({
|
||||
spice: {
|
||||
authType: "none",
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
credentialSyncId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,9 @@ export async function getAuditUsername(userId: string): Promise<string> {
|
||||
}
|
||||
|
||||
export interface AuditLogParams {
|
||||
userId: string;
|
||||
// Null when no user acted (a plugin starting up, background work): the
|
||||
// column references users.id, so anything else would be refused.
|
||||
userId: string | null;
|
||||
username: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
|
||||
@@ -25,7 +25,7 @@ export type Host = {
|
||||
*/
|
||||
childHosts?: Host[];
|
||||
online: boolean;
|
||||
status?: "online" | "reachable" | "offline" | "unknown";
|
||||
status?: "online" | "offline" | "unknown";
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
lastAccess: string;
|
||||
|
||||
@@ -61,6 +61,7 @@ function isTransientStatusError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Throws when the statuses could not be read, so callers keep what they had. */
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
@@ -71,31 +72,24 @@ export async function getAllServerStatuses(): Promise<
|
||||
let remoteHostsBySyncId: Map<string, number> | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
const hosts = response.data || [];
|
||||
localHostIds = hosts
|
||||
// A host-list failure throws here: sending no ids would stop every check.
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
const hosts = response.data || [];
|
||||
localHostIds = hosts
|
||||
.filter((host) => (host.connectionOrigin ?? defaultOrigin) === "local")
|
||||
.map((host) => host.id);
|
||||
remoteHostsBySyncId = new Map(
|
||||
hosts
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
(host) =>
|
||||
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
|
||||
!!host.syncId,
|
||||
)
|
||||
.map((host) => host.id);
|
||||
remoteHostsBySyncId = new Map(
|
||||
hosts
|
||||
.filter(
|
||||
(host) =>
|
||||
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
|
||||
!!host.syncId,
|
||||
)
|
||||
.map((host) => [host.syncId as string, host.id]),
|
||||
);
|
||||
} catch {
|
||||
// A host-list failure must not start local probes for hosts whose
|
||||
// configured origin may be remote.
|
||||
localHostIds = [];
|
||||
}
|
||||
.map((host) => [host.syncId as string, host.id]),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
@@ -128,10 +122,7 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
if (lastError) handleApiError(lastError, "fetch server statuses");
|
||||
|
||||
if (remoteHostsBySyncId?.size && (await getLinkedSession())) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import { handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
||||
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import {
|
||||
getCachedSSHHosts,
|
||||
invalidateHostsAndStatusCaches,
|
||||
@@ -10,42 +10,16 @@ import {
|
||||
// SSH HOST MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export type GetSSHHostsOptions = {
|
||||
/** When false, skip the status service call (host config only). Default true. */
|
||||
includeStatus?: boolean;
|
||||
};
|
||||
|
||||
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
|
||||
}
|
||||
|
||||
export async function getSSHHosts(
|
||||
options: GetSSHHostsOptions = {},
|
||||
): Promise<SSHHostWithStatus[]> {
|
||||
const includeStatus = options.includeStatus !== false;
|
||||
|
||||
/** Host config only. Live status comes from ServerStatusContext. */
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
|
||||
|
||||
if (!includeStatus) {
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
status: "unknown",
|
||||
}));
|
||||
}
|
||||
|
||||
let statuses: Record<number, ServerStatus> = {};
|
||||
try {
|
||||
statuses = (await getAllServerStatuses()) || {};
|
||||
} catch {
|
||||
// Status fetch failure should not prevent host list from loading
|
||||
}
|
||||
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
status: statuses[host.id]?.status || "unknown",
|
||||
}));
|
||||
return hosts.map((host) => ({ ...host, status: "unknown" }));
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch SSH hosts");
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import { useServerStatusMeta } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
useServerStatus,
|
||||
useServerStatusMeta,
|
||||
} from "@/lib/ServerStatusContext";
|
||||
import { withLiveHostStatus } from "@/sidebar/live-host-status";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
|
||||
@@ -429,7 +433,7 @@ export function HostStatusCard({
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{online}/{hosts.length} {t("hosts.status.available")}
|
||||
{online}/{hosts.length} {t("hosts.status.online")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
@@ -439,12 +443,7 @@ export function HostStatusCard({
|
||||
</div>
|
||||
)}
|
||||
{hosts.map((host, i) => {
|
||||
const availability =
|
||||
host.status && host.status !== "unknown"
|
||||
? host.status
|
||||
: host.online
|
||||
? "online"
|
||||
: "offline";
|
||||
const availability = host.status ?? "offline";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -492,13 +491,11 @@ export function HostStatusCard({
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
|
||||
>
|
||||
{statusLoading
|
||||
{statusLoading || availability === "unknown"
|
||||
? t("dashboardTab.checking")
|
||||
: availability === "online"
|
||||
? t("hosts.status.available")
|
||||
: availability === "reachable"
|
||||
? t("hosts.status.reachable")
|
||||
: t("hosts.status.offline")}
|
||||
? t("hosts.status.online")
|
||||
: t("hosts.status.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -582,7 +579,7 @@ function RecentActivityCard({
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.online ?? false, statusScheme, "dot", statusLoading)}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.status ?? false, statusScheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
@@ -1102,7 +1099,14 @@ export function DashboardTab({
|
||||
}
|
||||
}, [mainWidthPct]);
|
||||
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [configHosts, setHosts] = useState<Host[]>([]);
|
||||
const { statuses, getStatus } = useServerStatus();
|
||||
const hosts = useMemo(
|
||||
() => withLiveHostStatus(configHosts, getStatus),
|
||||
// statuses changes identity whenever a host's status changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[configHosts, statuses, getStatus],
|
||||
);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [uptimeFormatted, setUptimeFormatted] = useState("");
|
||||
const [versionText, setVersionText] = useState("");
|
||||
|
||||
@@ -48,14 +48,13 @@ export function useStatusColorScheme(): StatusColorScheme {
|
||||
|
||||
/** Returns Tailwind class names for a status dot/stripe. */
|
||||
export function getStatusClasses(
|
||||
status: boolean | "online" | "reachable" | "offline" | "degraded",
|
||||
status: boolean | "online" | "offline" | "unknown",
|
||||
scheme: StatusColorScheme,
|
||||
variant: "dot" | "stripe" | "badge",
|
||||
loading = false,
|
||||
): string {
|
||||
const online = status === true || status === "online";
|
||||
const reachable = status === "reachable";
|
||||
if (loading) {
|
||||
if (loading || status === "unknown") {
|
||||
if (scheme === "status") {
|
||||
if (variant === "dot") return "bg-yellow-400 animate-pulse";
|
||||
if (variant === "stripe") return "bg-yellow-400/40 animate-pulse";
|
||||
@@ -65,11 +64,6 @@ export function getStatusClasses(
|
||||
if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse";
|
||||
return "border-border/50 text-muted-foreground/50 bg-muted/20 animate-pulse";
|
||||
}
|
||||
if (reachable) {
|
||||
if (variant === "dot") return "bg-amber-400";
|
||||
if (variant === "stripe") return "bg-amber-400/50";
|
||||
return "border-amber-400/40 text-amber-400 bg-amber-400/10";
|
||||
}
|
||||
if (scheme === "status") {
|
||||
if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
|
||||
if (variant === "stripe")
|
||||
|
||||
@@ -9,7 +9,8 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAllServerStatuses, getSSHHosts } from "@/main-axios";
|
||||
import { getAllServerStatuses } from "@/main-axios";
|
||||
import { invalidateServerStatusCache } from "./hosts-request-cache";
|
||||
import {
|
||||
ServerStatusStore,
|
||||
type ServerStatusEntry,
|
||||
@@ -19,17 +20,26 @@ import { runAdaptivePolling } from "./adaptive-polling";
|
||||
|
||||
interface ServerStatusContextType {
|
||||
statuses: Map<number, ServerStatusEntry>;
|
||||
isLoading: boolean;
|
||||
initialLoadComplete: boolean;
|
||||
refreshStatuses: () => Promise<void>;
|
||||
/** "unknown" until the host has been checked. */
|
||||
getStatus: (hostId: number) => StatusValue;
|
||||
}
|
||||
|
||||
/** Stable for the provider lifetime — fine-grained hooks only need this. */
|
||||
/** Stable for the provider lifetime. Fine-grained hooks only need this. */
|
||||
const StatusStoreContext = createContext<ServerStatusStore | null>(null);
|
||||
const ServerStatusContext = createContext<ServerStatusContextType | null>(null);
|
||||
|
||||
const POLL_INTERVAL = 30000;
|
||||
/** The mounted provider's store, for code outside React (plugin host lists). */
|
||||
let activeStore: ServerStatusStore | null = null;
|
||||
|
||||
/** A host's live status outside React. "unknown" with no provider mounted. */
|
||||
export function getLiveHostStatus(hostId: number): StatusValue {
|
||||
return activeStore?.getStatus(hostId) ?? "unknown";
|
||||
}
|
||||
|
||||
/** The backend keeps statuses in memory, so reading them often is cheap. */
|
||||
const POLL_INTERVAL = 15_000;
|
||||
|
||||
export function ServerStatusProvider({
|
||||
children,
|
||||
@@ -47,121 +57,48 @@ export function ServerStatusProvider({
|
||||
// Bumps only full-context consumers (dashboard, folder counts, etc.).
|
||||
const [version, setVersion] = useState(0);
|
||||
const mountedRef = useRef(true);
|
||||
const refreshInFlightRef = useRef<Promise<boolean | void> | null>(null);
|
||||
const refreshInFlightRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => store.subscribeAll(() => setVersion((v) => v + 1)), [store]);
|
||||
|
||||
useEffect(() => {
|
||||
return store.subscribeAll(() => {
|
||||
setVersion((v) => v + 1);
|
||||
});
|
||||
activeStore = store;
|
||||
return () => {
|
||||
if (activeStore === store) activeStore = null;
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
useEffect(() => {
|
||||
return store.subscribeMeta(() => {
|
||||
setVersion((v) => v + 1);
|
||||
});
|
||||
}, [store]);
|
||||
|
||||
const fetchEnabledHosts = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
store.setEnabledHostIds(new Set());
|
||||
return new Set<number>();
|
||||
}
|
||||
|
||||
try {
|
||||
const hosts = await getSSHHosts({ includeStatus: false });
|
||||
const enabled = new Set<number>();
|
||||
|
||||
hosts.forEach((host) => {
|
||||
if (host.statusCheckEnabled !== false) enabled.add(host.id);
|
||||
});
|
||||
|
||||
store.setEnabledHostIds(enabled);
|
||||
return enabled;
|
||||
} catch {
|
||||
return store.getEnabledHostIds();
|
||||
}
|
||||
}, [isAuthenticated, store]);
|
||||
|
||||
const refreshStatusesImpl = useCallback(
|
||||
async (rethrow = false) => {
|
||||
if (!mountedRef.current || !isAuthenticated) return;
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.visibilityState === "hidden"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (refreshInFlightRef.current) {
|
||||
return refreshInFlightRef.current;
|
||||
}
|
||||
|
||||
const showLoading = !store.getInitialLoadComplete();
|
||||
if (showLoading) store.setLoading(true);
|
||||
|
||||
const run = (async () => {
|
||||
try {
|
||||
const data = await getAllServerStatuses();
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const newStatuses = new Map<number, ServerStatusEntry>();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
Object.entries(data).forEach(([idStr, statusData]) => {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (!isNaN(id)) {
|
||||
const status =
|
||||
statusData?.status === "online" ||
|
||||
statusData?.status === "reachable"
|
||||
? statusData.status
|
||||
: "offline";
|
||||
newStatuses.set(id, {
|
||||
status,
|
||||
lastChecked: statusData?.lastChecked || now,
|
||||
...(statusData?.reason === "host_key_changed"
|
||||
? { reason: statusData.reason }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const previousStatuses = store.getStatuses();
|
||||
const changed =
|
||||
previousStatuses.size !== newStatuses.size ||
|
||||
[...newStatuses].some(
|
||||
([id, entry]) =>
|
||||
previousStatuses.get(id)?.status !== entry.status ||
|
||||
previousStatuses.get(id)?.reason !== entry.reason,
|
||||
);
|
||||
store.applyStatuses(newStatuses);
|
||||
return changed;
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
store.markDegraded(store.getEnabledHostIds());
|
||||
}
|
||||
if (rethrow) throw error;
|
||||
return false;
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (showLoading) store.setLoading(false);
|
||||
store.setInitialLoadComplete(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
refreshInFlightRef.current = run.finally(() => {
|
||||
refreshInFlightRef.current = null;
|
||||
});
|
||||
return refreshInFlightRef.current;
|
||||
},
|
||||
[isAuthenticated, store],
|
||||
);
|
||||
|
||||
const refreshStatuses = useCallback(async () => {
|
||||
await refreshStatusesImpl();
|
||||
}, [refreshStatusesImpl]);
|
||||
if (!mountedRef.current || !isAuthenticated) return;
|
||||
if (refreshInFlightRef.current) return refreshInFlightRef.current;
|
||||
|
||||
const run = (async () => {
|
||||
try {
|
||||
const data = await getAllServerStatuses();
|
||||
if (!mountedRef.current || !data || typeof data !== "object") return;
|
||||
const next = new Map<number, ServerStatusEntry>();
|
||||
const now = new Date().toISOString();
|
||||
for (const [idStr, entry] of Object.entries(data)) {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (isNaN(id)) continue;
|
||||
next.set(id, {
|
||||
status: entry?.status === "online" ? "online" : "offline",
|
||||
lastChecked: entry?.lastChecked || now,
|
||||
});
|
||||
}
|
||||
store.applyStatuses(next);
|
||||
} catch {
|
||||
// Keep the last known statuses rather than flipping every host.
|
||||
} finally {
|
||||
if (mountedRef.current) store.setInitialLoadComplete(true);
|
||||
}
|
||||
})();
|
||||
|
||||
refreshInFlightRef.current = run.finally(() => {
|
||||
refreshInFlightRef.current = null;
|
||||
});
|
||||
return refreshInFlightRef.current;
|
||||
}, [isAuthenticated, store]);
|
||||
|
||||
const getStatus = useCallback(
|
||||
(hostId: number): StatusValue => store.getStatus(hostId),
|
||||
@@ -170,35 +107,31 @@ export function ServerStatusProvider({
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
if (!isAuthenticated) {
|
||||
store.clear();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}
|
||||
|
||||
let stopPolling: (() => void) | null = null;
|
||||
|
||||
const init = async () => {
|
||||
await fetchEnabledHosts();
|
||||
if (!mountedRef.current) return;
|
||||
stopPolling = runAdaptivePolling(
|
||||
() => refreshStatusesImpl(true),
|
||||
{
|
||||
minIntervalMs: POLL_INTERVAL,
|
||||
maxIntervalMs: 120_000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ enabled: () => isAuthenticated },
|
||||
);
|
||||
};
|
||||
|
||||
void init();
|
||||
const stopPolling = runAdaptivePolling(refreshStatuses, {
|
||||
minIntervalMs: POLL_INTERVAL,
|
||||
maxIntervalMs: POLL_INTERVAL,
|
||||
});
|
||||
const onFocus = () => void refreshStatuses();
|
||||
window.addEventListener("focus", onFocus);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopPolling?.();
|
||||
stopPolling();
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [fetchEnabledHosts, isAuthenticated, refreshStatusesImpl]);
|
||||
}, [isAuthenticated, refreshStatuses, store]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHostsChanged = async () => {
|
||||
await fetchEnabledHosts();
|
||||
await refreshStatuses();
|
||||
const handleHostsChanged = () => {
|
||||
invalidateServerStatusCache();
|
||||
void refreshStatuses();
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
@@ -208,17 +141,16 @@ export function ServerStatusProvider({
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
window.removeEventListener("hosts:refresh", handleHostsChanged);
|
||||
};
|
||||
}, [fetchEnabledHosts, refreshStatuses]);
|
||||
}, [refreshStatuses]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
statuses: store.getStatuses(),
|
||||
isLoading: store.getIsLoading(),
|
||||
initialLoadComplete: store.getInitialLoadComplete(),
|
||||
refreshStatuses,
|
||||
getStatus,
|
||||
}),
|
||||
// version refreshes statuses/isLoading/initialLoadComplete snapshots
|
||||
// version refreshes the statuses/initialLoadComplete snapshots
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[version, refreshStatuses, getStatus],
|
||||
);
|
||||
@@ -252,85 +184,49 @@ export function useServerStatus() {
|
||||
|
||||
/**
|
||||
* Subscribe to a single host's status. Only re-renders when that host's
|
||||
* status value changes (or its enabled flag flips). Does not re-render when
|
||||
* other hosts update.
|
||||
* status changes. Null when status checks are off for the host.
|
||||
*/
|
||||
export function useHostStatus(
|
||||
hostId: number,
|
||||
statusCheckEnabled: boolean = true,
|
||||
): StatusValue | null {
|
||||
const store = useStatusStore();
|
||||
|
||||
const snapshot = useSyncExternalStore(
|
||||
const status = useSyncExternalStore(
|
||||
(onChange) => store.subscribeHost(hostId, onChange),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getStatus(hostId),
|
||||
() => store.getStatus(hostId),
|
||||
);
|
||||
|
||||
if (!statusCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.split(":", 1)[0] as StatusValue;
|
||||
}
|
||||
|
||||
export function useHostStatusReason(
|
||||
hostId: number,
|
||||
statusCheckEnabled: boolean = true,
|
||||
): ServerStatusEntry["reason"] | null {
|
||||
const store = useStatusStore();
|
||||
const snapshot = useSyncExternalStore(
|
||||
(onChange) => store.subscribeHost(hostId, onChange),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
);
|
||||
if (!statusCheckEnabled) return null;
|
||||
return snapshot.endsWith(":host_key_changed")
|
||||
? "host_key_changed"
|
||||
: undefined;
|
||||
return statusCheckEnabled ? status : null;
|
||||
}
|
||||
|
||||
/** Meta flags without depending on the full status map. */
|
||||
export function useServerStatusMeta(): {
|
||||
initialLoadComplete: boolean;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
export function useServerStatusMeta(): { initialLoadComplete: boolean } {
|
||||
const store = useStatusStore();
|
||||
|
||||
useSyncExternalStore(
|
||||
(onChange) => store.subscribeMeta(onChange),
|
||||
() => store.getMetaSnapshot(),
|
||||
() => store.getMetaSnapshot(),
|
||||
);
|
||||
|
||||
return {
|
||||
initialLoadComplete: store.getInitialLoadComplete(),
|
||||
isLoading: store.getIsLoading(),
|
||||
};
|
||||
return { initialLoadComplete: store.getInitialLoadComplete() };
|
||||
}
|
||||
|
||||
const noStatus = () => "";
|
||||
const noStatus = (): StatusValue => "unknown";
|
||||
|
||||
/**
|
||||
* One host's status for plugin views, which can render outside the app shell
|
||||
* (a standalone window). Null there, and while the host has not been checked.
|
||||
*/
|
||||
export function useOptionalHostStatusEntry(hostId: number | undefined): {
|
||||
status: StatusValue;
|
||||
reason?: "host_key_changed";
|
||||
} | null {
|
||||
export function useOptionalHostStatusEntry(
|
||||
hostId: number | undefined,
|
||||
): { status: "online" | "offline" } | null {
|
||||
const store = useContext(StatusStoreContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
const status = useSyncExternalStore(
|
||||
(onChange) =>
|
||||
store && hostId !== undefined
|
||||
? store.subscribeHost(hostId, onChange)
|
||||
: () => {},
|
||||
() => (store && hostId !== undefined ? store.getHostSnapshot(hostId) : ""),
|
||||
() => (store && hostId !== undefined ? store.getStatus(hostId) : "unknown"),
|
||||
noStatus,
|
||||
);
|
||||
if (!snapshot) return null;
|
||||
const [status] = snapshot.split(":", 1);
|
||||
if (!status) return null;
|
||||
return snapshot.endsWith(":host_key_changed")
|
||||
? { status: status as StatusValue, reason: "host_key_changed" }
|
||||
: { status: status as StatusValue };
|
||||
return status === "unknown" ? null : { status };
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
export type StatusValue = "online" | "reachable" | "offline" | "degraded";
|
||||
export type StatusValue = "online" | "offline" | "unknown";
|
||||
|
||||
export interface ServerStatusEntry {
|
||||
status: StatusValue;
|
||||
status: "online" | "offline";
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -14,44 +13,26 @@ type Listener = () => void;
|
||||
*/
|
||||
export class ServerStatusStore {
|
||||
private statuses = new Map<number, ServerStatusEntry>();
|
||||
private enabledHostIds = new Set<number>();
|
||||
private initialLoadComplete = false;
|
||||
private isLoading = false;
|
||||
private readonly hostListeners = new Map<number, Set<Listener>>();
|
||||
private readonly allListeners = new Set<Listener>();
|
||||
private readonly metaListeners = new Set<Listener>();
|
||||
|
||||
/** "unknown" until the host has been checked. */
|
||||
getStatus(hostId: number): StatusValue {
|
||||
if (!this.enabledHostIds.has(hostId)) {
|
||||
return "offline";
|
||||
}
|
||||
return this.statuses.get(hostId)?.status || "degraded";
|
||||
return this.statuses.get(hostId)?.status ?? "unknown";
|
||||
}
|
||||
|
||||
getStatuses(): Map<number, ServerStatusEntry> {
|
||||
return this.statuses;
|
||||
}
|
||||
|
||||
getEnabledHostIds(): Set<number> {
|
||||
return this.enabledHostIds;
|
||||
}
|
||||
|
||||
getInitialLoadComplete(): boolean {
|
||||
return this.initialLoadComplete;
|
||||
}
|
||||
|
||||
getIsLoading(): boolean {
|
||||
return this.isLoading;
|
||||
}
|
||||
|
||||
/** Snapshot string for a host — used as useSyncExternalStore getSnapshot. */
|
||||
getHostSnapshot(hostId: number): string {
|
||||
const status = this.getStatus(hostId);
|
||||
return `${status}:${this.statuses.get(hostId)?.reason ?? ""}`;
|
||||
}
|
||||
|
||||
getMetaSnapshot(): string {
|
||||
return `${this.initialLoadComplete ? 1 : 0}:${this.isLoading ? 1 : 0}:${this.enabledHostIds.size}`;
|
||||
return this.initialLoadComplete ? "1" : "0";
|
||||
}
|
||||
|
||||
subscribeHost(hostId: number, listener: Listener): () => void {
|
||||
@@ -83,28 +64,6 @@ export class ServerStatusStore {
|
||||
};
|
||||
}
|
||||
|
||||
setEnabledHostIds(next: Set<number>): void {
|
||||
if (setsEqual(this.enabledHostIds, next)) return;
|
||||
const prev = this.enabledHostIds;
|
||||
this.enabledHostIds = next;
|
||||
this.emitMeta();
|
||||
// Hosts that entered/left the enabled set need a status re-read.
|
||||
for (const id of next) {
|
||||
if (!prev.has(id)) this.emitHost(id);
|
||||
}
|
||||
for (const id of prev) {
|
||||
if (!next.has(id)) this.emitHost(id);
|
||||
}
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
if (this.isLoading === loading) return;
|
||||
this.isLoading = loading;
|
||||
this.emitMeta();
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
setInitialLoadComplete(complete: boolean): void {
|
||||
if (this.initialLoadComplete === complete) return;
|
||||
this.initialLoadComplete = complete;
|
||||
@@ -120,50 +79,24 @@ export class ServerStatusStore {
|
||||
const changedIds: number[] = [];
|
||||
|
||||
for (const [id, entry] of next) {
|
||||
const prev = this.statuses.get(id);
|
||||
if (
|
||||
!prev ||
|
||||
prev.status !== entry.status ||
|
||||
prev.reason !== entry.reason
|
||||
) {
|
||||
changedIds.push(id);
|
||||
}
|
||||
if (this.statuses.get(id)?.status !== entry.status) changedIds.push(id);
|
||||
}
|
||||
for (const id of this.statuses.keys()) {
|
||||
if (!next.has(id)) {
|
||||
changedIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedIds.length === 0) {
|
||||
// Still refresh lastChecked silently without notifying.
|
||||
this.statuses = next;
|
||||
return;
|
||||
if (!next.has(id)) changedIds.push(id);
|
||||
}
|
||||
|
||||
this.statuses = next;
|
||||
if (changedIds.length === 0) return;
|
||||
for (const id of changedIds) {
|
||||
this.emitHost(id);
|
||||
}
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
markDegraded(enabledIds: Iterable<number>): void {
|
||||
const next = new Map(this.statuses);
|
||||
let changed = false;
|
||||
const now = new Date().toISOString();
|
||||
for (const id of enabledIds) {
|
||||
const existing = next.get(id);
|
||||
if (existing?.status === "degraded") continue;
|
||||
changed = true;
|
||||
next.set(id, {
|
||||
status: "degraded",
|
||||
lastChecked: existing?.lastChecked || now,
|
||||
});
|
||||
}
|
||||
if (changed) {
|
||||
this.applyStatuses(next);
|
||||
}
|
||||
/** Forgets everything, on sign out. */
|
||||
clear(): void {
|
||||
this.applyStatuses(new Map());
|
||||
this.setInitialLoadComplete(false);
|
||||
}
|
||||
|
||||
private emitHost(hostId: number): void {
|
||||
@@ -180,11 +113,3 @@ export class ServerStatusStore {
|
||||
for (const listener of this.metaListeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function setsEqual(a: Set<number>, b: Set<number>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const id of a) {
|
||||
if (!b.has(id)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -122,7 +122,6 @@
|
||||
"minimize": "Minimize",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Sign in failed",
|
||||
"unknown": "Unknown",
|
||||
"continue": "Continue",
|
||||
"maintenance": "Maintenance",
|
||||
@@ -358,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Host Metrics",
|
||||
"status": {
|
||||
"available": "Available",
|
||||
"reachable": "Available, but login failed",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitoring disabled",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Opdatering beskikbaar. Maak die vrystellingsnotas op GitHub oop."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Maak toe",
|
||||
"minimize": "Minimaliseer",
|
||||
"online": "Aanlyn",
|
||||
"offline": "Vanlyn",
|
||||
"signInFailed": "Aanmelding het misluk",
|
||||
"unknown": "Onbekend",
|
||||
"continue": "Gaan voort",
|
||||
"maintenance": "Onderhoud",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Verwyder",
|
||||
"add": "Voeg by",
|
||||
"update": "Opdatering",
|
||||
"noResults": "Geen resultate gevind nie"
|
||||
"noResults": "Geen resultate gevind nie",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Stap {{current}} van {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tonnel",
|
||||
"serverStats": "Gasheermetrieke",
|
||||
"status": {
|
||||
"available": "Beskikbaar",
|
||||
"reachable": "Beskikbaar, maar aanmelding het misluk",
|
||||
"online": "Online",
|
||||
"offline": "Vanlyn",
|
||||
"monitoringDisabled": "Monitering gedeaktiveer",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Lêer \"{{oldName}}\" is suksesvol hernoem na \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Hierdie gasheer (direkte tonnel)",
|
||||
"statusChecksLabel": "Statuskontroles",
|
||||
"enableStatusChecks": "Aktiveer statuskontroles",
|
||||
"enableStatusChecksDesc": "Ping hierdie gasheer gereeld om beskikbaarheid te verifieer",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Gebruik globale interval",
|
||||
"useGlobalIntervalDesc": "Oorskryf met die bedienerwye statuskontrole-interval",
|
||||
"checkIntervalS": "Kontrole-interval (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Jou rekening gebruik 'n tweede faktor wat tans nie beskikbaar is nie. Kontak 'n administrateur om dit terug te stel.",
|
||||
"secondFactorNoUI": "Hierdie tweede faktor kan nie op hierdie skerm gewys word nie. Kontak 'n administrateur.",
|
||||
"serverAuthentication": "Bediener-aanmelding",
|
||||
"minChars": "min. {{min}} karakters"
|
||||
"minChars": "min. {{min}} karakters",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifieer SSH-gasheersleutel",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Taal",
|
||||
"keyPassword": "sleutelwagwoord",
|
||||
"pastePrivateKey": "Plak jou privaat sleutel hier...",
|
||||
"enterPassword": "Voer jou wagwoord in"
|
||||
"enterPassword": "Voer jou wagwoord in",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Uitgereik deur {{issuer}}",
|
||||
"sslNames": "Omslae {{names}}",
|
||||
"sslManualTitle": "Laai 'n sertifikaat op",
|
||||
"updateExternalAutoProvisionFailed": "Kon nie die outomatiese skep-instelling opdateer nie"
|
||||
"updateExternalAutoProvisionFailed": "Kon nie die outomatiese skep-instelling opdateer nie",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "يتوفر تحديث. اطلع على ملاحظات الإصدار على GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "إغلاق",
|
||||
"minimize": "تصغير",
|
||||
"online": "متصل",
|
||||
"offline": "غير متصل",
|
||||
"signInFailed": "فشل تسجيل الدخول",
|
||||
"unknown": "غير معروف",
|
||||
"continue": "متابعة",
|
||||
"maintenance": "صيانة",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "إزالة",
|
||||
"add": "إضافة",
|
||||
"update": "تحديث",
|
||||
"noResults": "لم يتم العثور على نتائج"
|
||||
"noResults": "لم يتم العثور على نتائج",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "الخطوة {{current}} من {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "النفق",
|
||||
"serverStats": "مقاييس المضيف",
|
||||
"status": {
|
||||
"available": "متاح",
|
||||
"reachable": "متاح، لكن تسجيل الدخول فشل",
|
||||
"online": "Online",
|
||||
"offline": "غير متصل بالإنترنت",
|
||||
"monitoringDisabled": "تم تعطيل المراقبة",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "حالة"
|
||||
},
|
||||
"folderRenamed": "تمت إعادة تسمية المجلد \"{{oldName}}\" إلى \"{{newName}}\" بنجاح",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "هذا المضيف (نفق مباشر)",
|
||||
"statusChecksLabel": "فحوصات الحالة",
|
||||
"enableStatusChecks": "تمكين فحوصات الحالة",
|
||||
"enableStatusChecksDesc": "إرسال ping دوريًا لهذا المضيف للتحقق من توفره",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "استخدام الفاصل الزمني العام",
|
||||
"useGlobalIntervalDesc": "تجاوز باستخدام فترة فحص الحالة على مستوى الخادم",
|
||||
"checkIntervalS": "فترة الفحص (ثانية)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "يستخدم حسابك عامل مصادقة ثانٍ غير متوفر حاليًا. تواصل مع أحد المسؤولين لإعادة تعيينه.",
|
||||
"secondFactorNoUI": "لا يمكن عرض هذا العامل الثاني على هذه الشاشة. تواصل مع أحد المسؤولين.",
|
||||
"serverAuthentication": "تسجيل الدخول إلى الخادم",
|
||||
"minChars": "الحد الأدنى {{min}} حرفًا"
|
||||
"minChars": "الحد الأدنى {{min}} حرفًا",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "تحقق من مفتاح مضيف SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "اللغة",
|
||||
"keyPassword": "كلمة مرور المفتاح",
|
||||
"pastePrivateKey": "الصق مفتاحك الخاص هنا...",
|
||||
"enterPassword": "أدخل كلمة المرور"
|
||||
"enterPassword": "أدخل كلمة المرور",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "لوحة التحكم",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "صادر عن {{issuer}}",
|
||||
"sslNames": "يغطي {{names}}",
|
||||
"sslManualTitle": "قم بتحميل شهادة",
|
||||
"updateExternalAutoProvisionFailed": "فشل تحديث إعداد الإنشاء التلقائي"
|
||||
"updateExternalAutoProvisionFailed": "فشل تحديث إعداد الإنشاء التلقائي",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Налична е актуализация. Отворете бележките за изданието в GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Затвори",
|
||||
"minimize": "Минимизиране",
|
||||
"online": "Онлайн",
|
||||
"offline": "Офлайн",
|
||||
"signInFailed": "Влизането не бе успешно",
|
||||
"unknown": "Неизвестен",
|
||||
"continue": "Продължи",
|
||||
"maintenance": "Поддръжка",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Премахване",
|
||||
"add": "Добавяне",
|
||||
"update": "Актуализация",
|
||||
"noResults": "Няма намерени резултати"
|
||||
"noResults": "Няма намерени резултати",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Стъпка {{current}} от {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Тунел",
|
||||
"serverStats": "Метрики на хоста",
|
||||
"status": {
|
||||
"available": "Налично",
|
||||
"reachable": "Достъпен, но влизането е неуспешно",
|
||||
"online": "Online",
|
||||
"offline": "Офлайн",
|
||||
"monitoringDisabled": "Мониторингът е деактивиран",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Статус"
|
||||
},
|
||||
"folderRenamed": "Папка „{{oldName}}“ е преименувана успешно на „{{newName}}“",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Този хост (директен тунел)",
|
||||
"statusChecksLabel": "Проверки на състоянието",
|
||||
"enableStatusChecks": "Активиране на проверки на състоянието",
|
||||
"enableStatusChecksDesc": "Периодично проверявайте наличността на този хост чрез ping.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Използване на глобален интервал",
|
||||
"useGlobalIntervalDesc": "Замяна с интервала за проверка на състоянието на целия сървър",
|
||||
"checkIntervalS": "Интервал(и) на проверка",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Вашият акаунт използва втори фактор, който не е наличен в момента. Свържете се с администратор, за да го нулира.",
|
||||
"secondFactorNoUI": "Този втори фактор не може да бъде показан на този екран. Свържете се с администратор.",
|
||||
"serverAuthentication": "Влизане в сървъра",
|
||||
"minChars": "мин. {{min}} символа"
|
||||
"minChars": "мин. {{min}} символа",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Проверете SSH хост ключа",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Език",
|
||||
"keyPassword": "ключова парола",
|
||||
"pastePrivateKey": "Поставете личния си ключ тук...",
|
||||
"enterPassword": "Въведете паролата си"
|
||||
"enterPassword": "Въведете паролата си",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Табло за управление",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Издадено от {{issuer}}",
|
||||
"sslNames": "Обхваща {{names}}",
|
||||
"sslManualTitle": "Качване на сертификат",
|
||||
"updateExternalAutoProvisionFailed": "Актуализирането на настройката за автоматично създаване не бе успешно"
|
||||
"updateExternalAutoProvisionFailed": "Актуализирането на настройката за автоматично създаване не бе успешно",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Actualització disponible. Obriu les notes de la versió a GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Tanca",
|
||||
"minimize": "Minimitzar",
|
||||
"online": "En línia",
|
||||
"offline": "Fora de línia",
|
||||
"signInFailed": "No s'ha pogut iniciar la sessió",
|
||||
"unknown": "Desconegut",
|
||||
"continue": "Continua",
|
||||
"maintenance": "Manteniment",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Elimina",
|
||||
"add": "Afegeix",
|
||||
"update": "Actualització",
|
||||
"noResults": "No s'han trobat resultats"
|
||||
"noResults": "No s'han trobat resultats",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Pas {{current}} de {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Túnel",
|
||||
"serverStats": "Mètriques de l'amfitrió",
|
||||
"status": {
|
||||
"available": "Disponible",
|
||||
"reachable": "Disponible, però l'inici de sessió ha fallat",
|
||||
"online": "Online",
|
||||
"offline": "Fora de línia",
|
||||
"monitoringDisabled": "Monitorització desactivada",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Estat"
|
||||
},
|
||||
"folderRenamed": "La carpeta \"{{oldName}}\" s'ha canviat de nom a \"{{newName}}\" correctament",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Aquest host (túnel directe)",
|
||||
"statusChecksLabel": "Comprovacions d'estat",
|
||||
"enableStatusChecks": "Activa les comprovacions d'estat",
|
||||
"enableStatusChecksDesc": "Fes ping periòdicament a aquest host per verificar la disponibilitat",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Utilitza l'interval global",
|
||||
"useGlobalIntervalDesc": "Substitueix amb l'interval de comprovació d'estat de tot el servidor",
|
||||
"checkIntervalS": "Interval (s) de comprovació",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "El teu compte utilitza un segon factor que no està disponible ara mateix. Contacta amb un administrador per restablir-lo.",
|
||||
"secondFactorNoUI": "Aquest segon factor no es pot mostrar en aquesta pantalla. Contacta amb un administrador.",
|
||||
"serverAuthentication": "Inici de sessió al servidor",
|
||||
"minChars": "mínim {{min}} caràcters"
|
||||
"minChars": "mínim {{min}} caràcters",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifica la clau d'amfitrió SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Llengua",
|
||||
"keyPassword": "contrasenya clau",
|
||||
"pastePrivateKey": "Enganxa la teva clau privada aquí...",
|
||||
"enterPassword": "Introdueix la teva contrasenya"
|
||||
"enterPassword": "Introdueix la teva contrasenya",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tauler de control",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Publicat per {{issuer}}",
|
||||
"sslNames": "Cobertes {{names}}",
|
||||
"sslManualTitle": "Puja un certificat",
|
||||
"updateExternalAutoProvisionFailed": "No s'ha pogut actualitzar la configuració de creació automàtica"
|
||||
"updateExternalAutoProvisionFailed": "No s'ha pogut actualitzar la configuració de creació automàtica",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Aktualizace je k dispozici. Otevřít poznámky k verzi na GitHubu."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Blízko",
|
||||
"minimize": "Minimalizovat",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Přihlášení se nezdařilo",
|
||||
"unknown": "Neznámý",
|
||||
"continue": "Pokračovat",
|
||||
"maintenance": "Údržba",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Odstranit",
|
||||
"add": "Přidat",
|
||||
"update": "Aktualizovat",
|
||||
"noResults": "Nenalezeny žádné výsledky"
|
||||
"noResults": "Nenalezeny žádné výsledky",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Krok {{current}} z {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunel",
|
||||
"serverStats": "Metriky hostitele",
|
||||
"status": {
|
||||
"available": "K dispozici",
|
||||
"reachable": "Dostupný, ale přihlášení selhalo",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitorování zakázáno",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Postavení"
|
||||
},
|
||||
"folderRenamed": "Složka „{{oldName}}“ byla úspěšně přejmenována na „{{newName}}“",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Tento hostitel (přímý tunel)",
|
||||
"statusChecksLabel": "Kontroly stavu",
|
||||
"enableStatusChecks": "Povolit kontroly stavu",
|
||||
"enableStatusChecksDesc": "Pravidelně pingovat tento hostitel pro ověření dostupnosti",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Použít globální interval",
|
||||
"useGlobalIntervalDesc": "Přepsat intervalem kontroly stavu na úrovni celého serveru",
|
||||
"checkIntervalS": "Kontrolní interval(y)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Váš účet používá druhý faktor, který momentálně není k dispozici. Pro jeho resetování kontaktujte administrátora.",
|
||||
"secondFactorNoUI": "Tento druhý faktor nelze na této obrazovce zobrazit. Kontaktujte administrátora.",
|
||||
"serverAuthentication": "Přihlášení k serveru",
|
||||
"minChars": "min. {{min}} znaků"
|
||||
"minChars": "min. {{min}} znaků",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Ověření klíče hostitele SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Jazyk",
|
||||
"keyPassword": "klíčové heslo",
|
||||
"pastePrivateKey": "Vložte sem svůj soukromý klíč...",
|
||||
"enterPassword": "Zadejte své heslo"
|
||||
"enterPassword": "Zadejte své heslo",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Vydáno uživatelem {{issuer}}",
|
||||
"sslNames": "Kryty {{names}}",
|
||||
"sslManualTitle": "Nahrát certifikát",
|
||||
"updateExternalAutoProvisionFailed": "Nepodařilo se aktualizovat nastavení automatického vytváření"
|
||||
"updateExternalAutoProvisionFailed": "Nepodařilo se aktualizovat nastavení automatického vytváření",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Opdatering tilgængelig. Åbn udgivelsesnoterne på GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Tæt",
|
||||
"minimize": "Minimer",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Log ind mislykkedes",
|
||||
"unknown": "Ukendt",
|
||||
"continue": "Fortsætte",
|
||||
"maintenance": "Opretholdelse",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Fjerne",
|
||||
"add": "Tilføje",
|
||||
"update": "Opdatering",
|
||||
"noResults": "Ingen resultater fundet"
|
||||
"noResults": "Ingen resultater fundet",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Trin {{current}} af {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Værtsmålinger",
|
||||
"status": {
|
||||
"available": "Tilgængelig",
|
||||
"reachable": "Tilgængelig, men login mislykkedes",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Overvågning deaktiveret",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Mappen \"{{oldName}}\" er omdøbt til \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Denne vært (direkte tunnel)",
|
||||
"statusChecksLabel": "Statuskontroller",
|
||||
"enableStatusChecks": "Aktivér statuskontroller",
|
||||
"enableStatusChecksDesc": "Ping denne vært regelmæssigt for at bekræfte tilgængelighed",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Brug globalt interval",
|
||||
"useGlobalIntervalDesc": "Tilsidesæt med det serveromfattende statuskontrolinterval",
|
||||
"checkIntervalS": "Kontrolinterval (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Din konto bruger en anden faktor, der ikke er tilgængelig lige nu. Kontakt en administrator for at nulstille den.",
|
||||
"secondFactorNoUI": "Denne anden faktor kan ikke vises på denne skærm. Kontakt en administrator.",
|
||||
"serverAuthentication": "Serverlogin",
|
||||
"minChars": "min. {{min}} tegn"
|
||||
"minChars": "min. {{min}} tegn",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Bekræft SSH-værtsnøgle",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Sprog",
|
||||
"keyPassword": "nøgleadgangskode",
|
||||
"pastePrivateKey": "Indsæt din private nøgle her...",
|
||||
"enterPassword": "Indtast din adgangskode"
|
||||
"enterPassword": "Indtast din adgangskode",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Udstedt af {{issuer}}",
|
||||
"sslNames": "Dækker {{names}}",
|
||||
"sslManualTitle": "Upload et certifikat",
|
||||
"updateExternalAutoProvisionFailed": "Kunne ikke opdatere indstillingen for automatisk oprettelse"
|
||||
"updateExternalAutoProvisionFailed": "Kunne ikke opdatere indstillingen for automatisk oprettelse",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Update verfügbar. Die Versionshinweise finden Sie auf GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Schließen",
|
||||
"minimize": "Minimieren",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Anmeldung fehlgeschlagen",
|
||||
"unknown": "Unbekannt",
|
||||
"continue": "Weiter",
|
||||
"maintenance": "Wartung",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Entfernen",
|
||||
"add": "Hinzufügen",
|
||||
"update": "Aktualisieren",
|
||||
"noResults": "Keine Ergebnisse gefunden"
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Schritt {{current}} von {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Host-Metriken",
|
||||
"status": {
|
||||
"available": "Verfügbar",
|
||||
"reachable": "Erreichbar, aber Anmeldung fehlgeschlagen",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Überwachung deaktiviert",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Ordner \"{{oldName}}\" erfolgreich in \"{{newName}}\" umbenannt",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Dieser Host (direkter Tunnel)",
|
||||
"statusChecksLabel": "Statusprüfungen",
|
||||
"enableStatusChecks": "Statusprüfungen aktivieren",
|
||||
"enableStatusChecksDesc": "Diesen Host regelmäßig anpingen, um die Verfügbarkeit zu überprüfen",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Globales Intervall verwenden",
|
||||
"useGlobalIntervalDesc": "Mit dem serverweiten Statusprüfungsintervall überschreiben",
|
||||
"checkIntervalS": "Prüfintervall (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Ihr Konto verwendet einen zweiten Faktor, der momentan nicht verfügbar ist. Wenden Sie sich an einen Administrator, um ihn zurückzusetzen.",
|
||||
"secondFactorNoUI": "Dieser zweite Faktor kann auf diesem Bildschirm nicht angezeigt werden. Wenden Sie sich an einen Administrator.",
|
||||
"serverAuthentication": "Serveranmeldung",
|
||||
"minChars": "min. {{min}} Zeichen"
|
||||
"minChars": "min. {{min}} Zeichen",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSH-Host-Key überprüfen",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Sprache",
|
||||
"keyPassword": "Schlüsselpasswort",
|
||||
"pastePrivateKey": "Privaten Schlüssel hier einfügen...",
|
||||
"enterPassword": "Passwort eingeben"
|
||||
"enterPassword": "Passwort eingeben",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Herausgegeben von {{issuer}}",
|
||||
"sslNames": "Cover {{names}}",
|
||||
"sslManualTitle": "Laden Sie ein Zertifikat hoch",
|
||||
"updateExternalAutoProvisionFailed": "Die Aktualisierung der Einstellung für die automatische Erstellung ist fehlgeschlagen."
|
||||
"updateExternalAutoProvisionFailed": "Die Aktualisierung der Einstellung für die automatische Erstellung ist fehlgeschlagen.",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Διαθέσιμη ενημέρωση. Ανοίξτε τις σημειώσεις έκδοσης στο GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Κοντά",
|
||||
"minimize": "Ελαττώνω",
|
||||
"online": "Διαδικτυακά",
|
||||
"offline": "Εκτός σύνδεσης",
|
||||
"signInFailed": "Η σύνδεση απέτυχε",
|
||||
"unknown": "Αγνωστος",
|
||||
"continue": "Συνεχίζω",
|
||||
"maintenance": "Συντήρηση",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Αφαιρώ",
|
||||
"add": "Προσθέτω",
|
||||
"update": "Εκσυγχρονίζω",
|
||||
"noResults": "Δεν βρέθηκαν αποτελέσματα"
|
||||
"noResults": "Δεν βρέθηκαν αποτελέσματα",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Βήμα {{current}} από {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Σήραγγα",
|
||||
"serverStats": "Μετρήσεις κεντρικού υπολογιστή",
|
||||
"status": {
|
||||
"available": "Διαθέσιμος",
|
||||
"reachable": "Διαθέσιμος, αλλά η σύνδεση απέτυχε",
|
||||
"online": "Online",
|
||||
"offline": "Εκτός σύνδεσης",
|
||||
"monitoringDisabled": "Η παρακολούθηση απενεργοποιήθηκε",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Κατάσταση"
|
||||
},
|
||||
"folderRenamed": "Ο φάκελος \"{{oldName}}\" μετονομάστηκε με επιτυχία σε \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Αυτός ο κεντρικός υπολογιστής (άμεση σήραγγα)",
|
||||
"statusChecksLabel": "Έλεγχοι κατάστασης",
|
||||
"enableStatusChecks": "Ενεργοποίηση ελέγχων κατάστασης",
|
||||
"enableStatusChecksDesc": "Περιοδική ping σε αυτόν τον κεντρικό υπολογιστή για επαλήθευση διαθεσιμότητας",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Χρήση καθολικού διαστήματος",
|
||||
"useGlobalIntervalDesc": "Παράκαμψη με το διάστημα ελέγχου κατάστασης σε ολόκληρο τον διακομιστή",
|
||||
"checkIntervalS": "Διάστημα ελέγχου (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Ο λογαριασμός σας χρησιμοποιεί έναν δεύτερο παράγοντα που δεν είναι διαθέσιμος αυτήν τη στιγμή. Επικοινωνήστε με έναν διαχειριστή για να τον επαναφέρετε.",
|
||||
"secondFactorNoUI": "Αυτός ο δεύτερος παράγοντας δεν μπορεί να εμφανιστεί σε αυτήν την οθόνη. Επικοινωνήστε με έναν διαχειριστή.",
|
||||
"serverAuthentication": "Σύνδεση διακομιστή",
|
||||
"minChars": "ελάχ. {{min}} χαρακτήρες"
|
||||
"minChars": "ελάχ. {{min}} χαρακτήρες",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Επαλήθευση κλειδιού κεντρικού υπολογιστή SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Γλώσσα",
|
||||
"keyPassword": "κωδικός πρόσβασης κλειδιού",
|
||||
"pastePrivateKey": "Επικολλήστε το ιδιωτικό σας κλειδί εδώ...",
|
||||
"enterPassword": "Εισαγάγετε τον κωδικό πρόσβασής σας"
|
||||
"enterPassword": "Εισαγάγετε τον κωδικό πρόσβασής σας",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ταμπλό",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Εκδόθηκε από {{issuer}}",
|
||||
"sslNames": "Καλύμματα {{names}}",
|
||||
"sslManualTitle": "Μεταφόρτωση πιστοποιητικού",
|
||||
"updateExternalAutoProvisionFailed": "Η ενημέρωση της ρύθμισης αυτόματης δημιουργίας απέτυχε"
|
||||
"updateExternalAutoProvisionFailed": "Η ενημέρωση της ρύθμισης αυτόματης δημιουργίας απέτυχε",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Actualización disponible. Consulta las notas de la versión en GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Cerrar",
|
||||
"minimize": "Minimizar",
|
||||
"online": "En línea",
|
||||
"offline": "Sin conexión",
|
||||
"signInFailed": "Error al iniciar sesión",
|
||||
"unknown": "Desconocido",
|
||||
"continue": "Continuar",
|
||||
"maintenance": "Mantenimiento",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Quitar",
|
||||
"add": "Añadir",
|
||||
"update": "Actualizar",
|
||||
"noResults": "No se encontraron resultados"
|
||||
"noResults": "No se encontraron resultados",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Paso {{current}} de {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Túnel",
|
||||
"serverStats": "Métricas del host",
|
||||
"status": {
|
||||
"available": "Disponible",
|
||||
"reachable": "Disponible, pero el inicio de sesión falló",
|
||||
"online": "Online",
|
||||
"offline": "Desconectado",
|
||||
"monitoringDisabled": "Monitoreo desactivado",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Estado"
|
||||
},
|
||||
"folderRenamed": "Carpeta \"{{oldName}}\" renombrada a \"{{newName}}\" correctamente",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Este host (túnel directo)",
|
||||
"statusChecksLabel": "Comprobaciones de estado",
|
||||
"enableStatusChecks": "Habilitar comprobaciones de estado",
|
||||
"enableStatusChecksDesc": "Hacer ping periódicamente a este host para verificar su disponibilidad.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Usar intervalo global",
|
||||
"useGlobalIntervalDesc": "Sobrescribir con el intervalo de comprobación de estado global del servidor.",
|
||||
"checkIntervalS": "Intervalo de comprobación (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Tu cuenta utiliza un segundo factor de autenticación que no está disponible en este momento. Ponte en contacto con un administrador para restablecerlo.",
|
||||
"secondFactorNoUI": "Este segundo factor no se puede mostrar en esta pantalla. Contacta con un administrador.",
|
||||
"serverAuthentication": "Inicio de sesión en el servidor",
|
||||
"minChars": "min. {{min}} caracteres"
|
||||
"minChars": "min. {{min}} caracteres",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verificar clave de host SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Idioma",
|
||||
"keyPassword": "contraseña de la clave",
|
||||
"pastePrivateKey": "Pegue su clave privada aquí...",
|
||||
"enterPassword": "Introduce tu contraseña"
|
||||
"enterPassword": "Introduce tu contraseña",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de control",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Emitido por {{issuer}}",
|
||||
"sslNames": "Portadas {{names}}",
|
||||
"sslManualTitle": "Subir un certificado",
|
||||
"updateExternalAutoProvisionFailed": "No se pudo actualizar la configuración de creación automática."
|
||||
"updateExternalAutoProvisionFailed": "No se pudo actualizar la configuración de creación automática.",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Päivitys saatavilla. Avaa julkaisutiedot GitHubissa."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Lähellä",
|
||||
"minimize": "Minimoida",
|
||||
"online": "Verkossa",
|
||||
"offline": "Offline-tilassa",
|
||||
"signInFailed": "Kirjautuminen epäonnistui",
|
||||
"unknown": "Tuntematon",
|
||||
"continue": "Jatkaa",
|
||||
"maintenance": "Huolto",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Poistaa",
|
||||
"add": "Lisätä",
|
||||
"update": "Päivittää",
|
||||
"noResults": "Ei tuloksia"
|
||||
"noResults": "Ei tuloksia",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Vaihe {{current}} / {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunneli",
|
||||
"serverStats": "Isännän mittarit",
|
||||
"status": {
|
||||
"available": "Saatavilla",
|
||||
"reachable": "Saatavilla, mutta kirjautuminen epäonnistui",
|
||||
"online": "Online",
|
||||
"offline": "Offline-tilassa",
|
||||
"monitoringDisabled": "Valvonta poistettu käytöstä",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Kansio \"{{oldName}}\" nimettiin uudelleen onnistuneesti muotoon \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Tämä isäntä (suora tunneli)",
|
||||
"statusChecksLabel": "Tilatarkastukset",
|
||||
"enableStatusChecks": "Ota käyttöön tilan tarkistukset",
|
||||
"enableStatusChecksDesc": "Lähetä tälle isännälle säännöllisesti ping-viesti saatavuuden varmistamiseksi.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Käytä globaalia väliä",
|
||||
"useGlobalIntervalDesc": "Ohita palvelimen laajuisella tilan tarkistusvälillä",
|
||||
"checkIntervalS": "Tarkistusväli (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Tilisi käyttää toista vahvistustekijää, joka ei ole tällä hetkellä käytettävissä. Ota yhteyttä ylläpitäjään sen palauttamiseksi.",
|
||||
"secondFactorNoUI": "Tätä toista tekijää ei voida näyttää tällä näytöllä. Ota yhteyttä ylläpitäjään.",
|
||||
"serverAuthentication": "Palvelimelle kirjautuminen",
|
||||
"minChars": "vähintään {{min}} merkkiä"
|
||||
"minChars": "vähintään {{min}} merkkiä",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Vahvista SSH-isäntäavain",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Kieli",
|
||||
"keyPassword": "avainsalasana",
|
||||
"pastePrivateKey": "Liitä yksityinen avaimesi tähän...",
|
||||
"enterPassword": "Anna salasanasi"
|
||||
"enterPassword": "Anna salasanasi",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Kojelauta",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Myöntäjä {{issuer}}",
|
||||
"sslNames": "Kannet {{names}}",
|
||||
"sslManualTitle": "Lähetä sertifikaatti",
|
||||
"updateExternalAutoProvisionFailed": "Automaattisen luomisen asetuksen päivittäminen epäonnistui"
|
||||
"updateExternalAutoProvisionFailed": "Automaattisen luomisen asetuksen päivittäminen epäonnistui",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Mise à jour disponible. Consultez les notes de version sur GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Fermer",
|
||||
"minimize": "Réduire",
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"signInFailed": "Échec de la connexion",
|
||||
"unknown": "Inconnu",
|
||||
"continue": "Continuer",
|
||||
"maintenance": "Maintenance",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Retirer",
|
||||
"add": "Ajouter",
|
||||
"update": "Mettre à jour",
|
||||
"noResults": "Aucun résultat trouvé"
|
||||
"noResults": "Aucun résultat trouvé",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Étape {{current}} de {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Métriques de l'hôte",
|
||||
"status": {
|
||||
"available": "Disponible",
|
||||
"reachable": "Disponible, mais la connexion a échoué",
|
||||
"online": "Online",
|
||||
"offline": "Hors ligne",
|
||||
"monitoringDisabled": "Surveillance désactivée",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Statut"
|
||||
},
|
||||
"folderRenamed": "Dossier « {{oldName}} » renommé en « {{newName}} » avec succès",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Cet hôte (tunnel direct)",
|
||||
"statusChecksLabel": "Vérifications d'état",
|
||||
"enableStatusChecks": "Activer les vérifications d'état",
|
||||
"enableStatusChecksDesc": "Envoyer périodiquement un ping à cet hôte pour vérifier sa disponibilité",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Utiliser l'intervalle global",
|
||||
"useGlobalIntervalDesc": "Remplacer par l'intervalle de vérification d'état défini à l'échelle du serveur",
|
||||
"checkIntervalS": "Intervalle de vérification (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Votre compte utilise une authentification à deux facteurs actuellement indisponible. Veuillez contacter un administrateur pour la réinitialiser.",
|
||||
"secondFactorNoUI": "Ce deuxième facteur ne peut pas être affiché sur cet écran. Veuillez contacter un administrateur.",
|
||||
"serverAuthentication": "Connexion au serveur",
|
||||
"minChars": "min. {{min}} caractères"
|
||||
"minChars": "min. {{min}} caractères",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Vérifier la clé d'hôte SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Langue",
|
||||
"keyPassword": "mot de passe de la clé",
|
||||
"pastePrivateKey": "Collez votre clé privée ici...",
|
||||
"enterPassword": "Saisissez votre mot de passe"
|
||||
"enterPassword": "Saisissez votre mot de passe",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Émis par {{issuer}}",
|
||||
"sslNames": "Couvertures {{names}}",
|
||||
"sslManualTitle": "Téléverser un certificat",
|
||||
"updateExternalAutoProvisionFailed": "Échec de la mise à jour du paramètre de création automatique"
|
||||
"updateExternalAutoProvisionFailed": "Échec de la mise à jour du paramètre de création automatique",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "עדכון זמין. פתח את הערות הגרסה ב-GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "לִסְגוֹר",
|
||||
"minimize": "לְצַמְצֵם",
|
||||
"online": "באינטרנט",
|
||||
"offline": "לא מקוון",
|
||||
"signInFailed": "הכניסה נכשלה",
|
||||
"unknown": "לֹא יְדוּעַ",
|
||||
"continue": "לְהַמשִׁיך",
|
||||
"maintenance": "תַחזוּקָה",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "לְהַסִיר",
|
||||
"add": "לְהוֹסִיף",
|
||||
"update": "לְעַדְכֵּן",
|
||||
"noResults": "לא נמצאו תוצאות"
|
||||
"noResults": "לא נמצאו תוצאות",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "שלב {{current}} של {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "מִנהָרָה",
|
||||
"serverStats": "מדדי מארח",
|
||||
"status": {
|
||||
"available": "זָמִין",
|
||||
"reachable": "זמין, אך ההתחברות נכשלה",
|
||||
"online": "Online",
|
||||
"offline": "לא מקוון",
|
||||
"monitoringDisabled": "ניטור מושבת",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "סטָטוּס"
|
||||
},
|
||||
"folderRenamed": "תיקיית \"{{oldName}}\" שמה שונה בהצלחה ל- \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "מארח זה (מנהרה ישירה)",
|
||||
"statusChecksLabel": "בדיקות סטטוס",
|
||||
"enableStatusChecks": "הפעל בדיקות סטטוס",
|
||||
"enableStatusChecksDesc": "שלח פינג למארח הזה מעת לעת כדי לוודא זמינות",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "השתמש במרווח גלובלי",
|
||||
"useGlobalIntervalDesc": "עקיפה עם מרווח בדיקת המצב הכולל של השרת",
|
||||
"checkIntervalS": "מרווח זמן בדיקה (ים)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "החשבון שלך משתמש בגורם שני שאינו זמין כרגע. צור קשר עם מנהל כדי לאפס אותו.",
|
||||
"secondFactorNoUI": "לא ניתן להציג את הגורם השני במסך זה. צור קשר עם מנהל.",
|
||||
"serverAuthentication": "כניסה לשרת",
|
||||
"minChars": "מינימום {{min}} תווים"
|
||||
"minChars": "מינימום {{min}} תווים",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "אימות מפתח מארח SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "שָׂפָה",
|
||||
"keyPassword": "סיסמת מפתח",
|
||||
"pastePrivateKey": "הדבק את המפתח הפרטי שלך כאן...",
|
||||
"enterPassword": "הזן את הסיסמה שלך"
|
||||
"enterPassword": "הזן את הסיסמה שלך",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "לוּחַ מַחווָנִים",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "הונפק על ידי {{issuer}}",
|
||||
"sslNames": "כריכות {{names}}",
|
||||
"sslManualTitle": "העלה תעודה",
|
||||
"updateExternalAutoProvisionFailed": "נכשל עדכון הגדרת היצירה האוטומטית"
|
||||
"updateExternalAutoProvisionFailed": "נכשל עדכון הגדרת היצירה האוטומטית",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Frissítés elérhető. Nyissa meg a kiadási megjegyzéseket a GitHubon."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Közeli",
|
||||
"minimize": "Minimalizálás",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Bejelentkezés sikertelen",
|
||||
"unknown": "Ismeretlen",
|
||||
"continue": "Folytatás",
|
||||
"maintenance": "Karbantartás",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Eltávolítás",
|
||||
"add": "Hozzáadás",
|
||||
"update": "Frissítés",
|
||||
"noResults": "Nincs találat"
|
||||
"noResults": "Nincs találat",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "{{total}} {{current}} lépése",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Alagút",
|
||||
"serverStats": "Gazdagép-metrikák",
|
||||
"status": {
|
||||
"available": "Elérhető",
|
||||
"reachable": "Elérhető, de a bejelentkezés sikertelen",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitorozás letiltva",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Állapot"
|
||||
},
|
||||
"folderRenamed": "A(z) „{{oldName}}” mappa sikeresen átnevezve erre: „{{newName}}”",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Ez a gazdagép (közvetlen alagút)",
|
||||
"statusChecksLabel": "Állapotellenőrzések",
|
||||
"enableStatusChecks": "Állapotellenőrzések engedélyezése",
|
||||
"enableStatusChecksDesc": "Rendszeresen pingelje ezt a gazdagépet az elérhetőség ellenőrzéséhez",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Globális intervallum használata",
|
||||
"useGlobalIntervalDesc": "Felülbírálás a szerver szintű állapotellenőrzési intervallummal",
|
||||
"checkIntervalS": "Ellenőrzési intervallum (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "A fiókod egy második faktort használ, ami jelenleg nem érhető el. Lépj kapcsolatba egy adminisztrátorral a visszaállításához.",
|
||||
"secondFactorNoUI": "Ez a második tényező nem jeleníthető meg ezen a képernyőn. Lépjen kapcsolatba egy adminisztrátorral.",
|
||||
"serverAuthentication": "Szerver bejelentkezés",
|
||||
"minChars": "min. {{min}} karakter"
|
||||
"minChars": "min. {{min}} karakter",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSH host kulcs ellenőrzése",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Nyelv",
|
||||
"keyPassword": "kulcs jelszó",
|
||||
"pastePrivateKey": "Illeszd be ide a privát kulcsodat...",
|
||||
"enterPassword": "Írja be a jelszavát"
|
||||
"enterPassword": "Írja be a jelszavát",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Műszerfal",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Kibocsátó: {{issuer}}",
|
||||
"sslNames": "Borítók {{names}}",
|
||||
"sslManualTitle": "Tanúsítvány feltöltése",
|
||||
"updateExternalAutoProvisionFailed": "Nem sikerült frissíteni az automatikus létrehozási beállítást"
|
||||
"updateExternalAutoProvisionFailed": "Nem sikerült frissíteni az automatikus létrehozási beállítást",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Aggiornamento disponibile. Consulta le note di rilascio su GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Chiudi",
|
||||
"minimize": "Riduci a icona",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Accesso non riuscito",
|
||||
"unknown": "Sconosciuto",
|
||||
"continue": "Continua",
|
||||
"maintenance": "Manutenzione",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Rimuovi",
|
||||
"add": "Aggiungi",
|
||||
"update": "Aggiorna",
|
||||
"noResults": "Nessun risultato trovato"
|
||||
"noResults": "Nessun risultato trovato",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Passo {{current}} di {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Metriche host",
|
||||
"status": {
|
||||
"available": "Disponibile",
|
||||
"reachable": "Disponibile, ma l'accesso non è riuscito",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitoraggio disabilitato",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Stato"
|
||||
},
|
||||
"folderRenamed": "Cartella \"{{oldName}}\" rinominata in \"{{newName}}\" con successo",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Questo host (tunnel diretto)",
|
||||
"statusChecksLabel": "Controlli di stato",
|
||||
"enableStatusChecks": "Abilita controlli di stato",
|
||||
"enableStatusChecksDesc": "Esegui periodicamente il ping di questo host per verificarne la disponibilità",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Usa intervallo globale",
|
||||
"useGlobalIntervalDesc": "Sovrascrivi con l'intervallo di controllo dello stato del server",
|
||||
"checkIntervalS": "Intervallo di controllo (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Il tuo account utilizza un secondo fattore di autenticazione al momento non disponibile. Contatta un amministratore per reimpostarlo.",
|
||||
"secondFactorNoUI": "Questo secondo fattore non può essere visualizzato in questa schermata. Contatta un amministratore.",
|
||||
"serverAuthentication": "Accesso al server",
|
||||
"minChars": "min. {{min}} caratteri"
|
||||
"minChars": "min. {{min}} caratteri",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifica chiave host SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Lingua",
|
||||
"keyPassword": "password chiave",
|
||||
"pastePrivateKey": "Incolla qui la tua chiave privata...",
|
||||
"enterPassword": "Inserisci la password"
|
||||
"enterPassword": "Inserisci la password",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Emesso da {{issuer}}",
|
||||
"sslNames": "Copertine {{names}}",
|
||||
"sslManualTitle": "Carica un certificato",
|
||||
"updateExternalAutoProvisionFailed": "Impossibile aggiornare l'impostazione di creazione automatica"
|
||||
"updateExternalAutoProvisionFailed": "Impossibile aggiornare l'impostazione di creazione automatica",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "アップデートが利用可能です。GitHubのリリースノートをご覧ください。"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "閉じる",
|
||||
"minimize": "最小化",
|
||||
"online": "オンライン",
|
||||
"offline": "オフライン",
|
||||
"signInFailed": "サインインに失敗しました",
|
||||
"unknown": "不明",
|
||||
"continue": "続行",
|
||||
"maintenance": "メンテナンス",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "削除",
|
||||
"add": "追加",
|
||||
"update": "更新",
|
||||
"noResults": "検索結果が見つかりませんでした"
|
||||
"noResults": "検索結果が見つかりませんでした",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "ステップ {{current}} / {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "トンネル",
|
||||
"serverStats": "ホストメトリクス",
|
||||
"status": {
|
||||
"available": "利用可能",
|
||||
"reachable": "利用可能ですが、ログインに失敗しました",
|
||||
"online": "Online",
|
||||
"offline": "オフライン",
|
||||
"monitoringDisabled": "監視が無効になっています",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "状態"
|
||||
},
|
||||
"folderRenamed": "フォルダ「{{oldName}}」を「{{newName}}」に正常にリネームしました",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "このホスト(直接トンネル)",
|
||||
"statusChecksLabel": "ステータスチェック",
|
||||
"enableStatusChecks": "ステータスチェックを有効にする",
|
||||
"enableStatusChecksDesc": "定期的にこのホストに ping を送信して可用性を確認します",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "グローバル間隔を使用する",
|
||||
"useGlobalIntervalDesc": "サーバー全体のステータスチェック間隔で上書きします",
|
||||
"checkIntervalS": "チェック間隔 (秒)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "お客様のアカウントには、現在ご利用いただけない二段階認証が設定されています。リセットするには、管理者にお問い合わせください。",
|
||||
"secondFactorNoUI": "この2つ目の要素は、この画面には表示できません。管理者にお問い合わせください。",
|
||||
"serverAuthentication": "サーバーへのサインイン",
|
||||
"minChars": "最小 {{min}} 文字"
|
||||
"minChars": "最小 {{min}} 文字",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSHホストキーを確認",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "言語",
|
||||
"keyPassword": "キーパスワード",
|
||||
"pastePrivateKey": "秘密鍵をここに貼り付け...",
|
||||
"enterPassword": "パスワードを入力"
|
||||
"enterPassword": "パスワードを入力",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "ダッシュボード",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "{{issuer}} によって発行されました",
|
||||
"sslNames": "カバー {{names}}",
|
||||
"sslManualTitle": "証明書をアップロードしてください",
|
||||
"updateExternalAutoProvisionFailed": "自動作成設定の更新に失敗しました"
|
||||
"updateExternalAutoProvisionFailed": "自動作成設定の更新に失敗しました",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "업데이트가 있습니다. GitHub에서 릴리스 노트를 확인하세요."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "닫기",
|
||||
"minimize": "최소화",
|
||||
"online": "온라인",
|
||||
"offline": "오프라인",
|
||||
"signInFailed": "로그인 실패",
|
||||
"unknown": "알 수 없음",
|
||||
"continue": "계속",
|
||||
"maintenance": "유지 관리",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "제거",
|
||||
"add": "추가",
|
||||
"update": "업데이트",
|
||||
"noResults": "검색 결과가 없습니다."
|
||||
"noResults": "검색 결과가 없습니다.",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "{{current}} 단계 {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "터널",
|
||||
"serverStats": "호스트 메트릭",
|
||||
"status": {
|
||||
"available": "사용 가능",
|
||||
"reachable": "사용 가능하지만 로그인에 실패했습니다",
|
||||
"online": "Online",
|
||||
"offline": "오프라인",
|
||||
"monitoringDisabled": "모니터링이 비활성화되었습니다.",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "상태"
|
||||
},
|
||||
"folderRenamed": "폴더 \"{{oldName}}\"이(가) \"{{newName}}\"(으)로 성공적으로 이름이 변경되었습니다.",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "이 호스트 (직접 터널)",
|
||||
"statusChecksLabel": "상태 확인",
|
||||
"enableStatusChecks": "상태 확인 활성화",
|
||||
"enableStatusChecksDesc": "주기적으로 이 호스트에 ping을 보내 가용성을 확인합니다.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "전역 간격 사용",
|
||||
"useGlobalIntervalDesc": "서버 전체 상태 확인 간격으로 재정의합니다.",
|
||||
"checkIntervalS": "확인 간격(초)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "현재 사용 중인 2단계 인증 방식이 비활성화되어 있습니다. 관리자에게 문의하여 재설정하세요.",
|
||||
"secondFactorNoUI": "두 번째 요소는 이 화면에 표시할 수 없습니다. 관리자에게 문의하십시오.",
|
||||
"serverAuthentication": "서버 로그인",
|
||||
"minChars": "최소 {{min}} 문자"
|
||||
"minChars": "최소 {{min}} 문자",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSH 호스트 키 확인",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "언어",
|
||||
"keyPassword": "키 비밀번호",
|
||||
"pastePrivateKey": "여기에 개인 키를 붙여넣으세요...",
|
||||
"enterPassword": "비밀번호를 입력하세요"
|
||||
"enterPassword": "비밀번호를 입력하세요",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "대시보드",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "{{issuer}}에서 발행함",
|
||||
"sslNames": "{{names}}을 덮습니다.",
|
||||
"sslManualTitle": "인증서를 업로드하세요",
|
||||
"updateExternalAutoProvisionFailed": "자동 생성 설정을 업데이트하는 데 실패했습니다."
|
||||
"updateExternalAutoProvisionFailed": "자동 생성 설정을 업데이트하는 데 실패했습니다.",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Update beschikbaar. Bekijk de release-opmerkingen op GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Dichtbij",
|
||||
"minimize": "Minimaliseren",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Aanmelden mislukt",
|
||||
"unknown": "Onbekend",
|
||||
"continue": "Doorgaan",
|
||||
"maintenance": "Onderhoud",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Verwijderen",
|
||||
"add": "Toevoegen",
|
||||
"update": "Update",
|
||||
"noResults": "Geen resultaten gevonden"
|
||||
"noResults": "Geen resultaten gevonden",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Stap {{current}} van {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Hoststatistieken",
|
||||
"status": {
|
||||
"available": "Beschikbaar",
|
||||
"reachable": "Beschikbaar, maar inloggen mislukt",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitoring uitgeschakeld",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Map \"{{oldName}}\" is succesvol hernoemd naar \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Deze host (directe tunnel)",
|
||||
"statusChecksLabel": "Statuscontroles",
|
||||
"enableStatusChecks": "Statuscontroles inschakelen",
|
||||
"enableStatusChecksDesc": "Controleer periodiek de beschikbaarheid van deze host.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Gebruik globaal interval",
|
||||
"useGlobalIntervalDesc": "Overschrijf dit met het serverbrede statuscontrole-interval.",
|
||||
"checkIntervalS": "Controle-interval (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Uw account maakt gebruik van een tweede authenticatiemethode die momenteel niet beschikbaar is. Neem contact op met een beheerder om deze opnieuw in te stellen.",
|
||||
"secondFactorNoUI": "Deze tweede factor kan niet op dit scherm worden weergegeven. Neem contact op met een beheerder.",
|
||||
"serverAuthentication": "Server aanmelden",
|
||||
"minChars": "min. {{min}} tekens"
|
||||
"minChars": "min. {{min}} tekens",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifieer de SSH-hostsleutel",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Taal",
|
||||
"keyPassword": "sleutel wachtwoord",
|
||||
"pastePrivateKey": "Plak hier uw privésleutel...",
|
||||
"enterPassword": "Voer uw wachtwoord in"
|
||||
"enterPassword": "Voer uw wachtwoord in",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Uitgegeven door {{issuer}}",
|
||||
"sslNames": "Omslagen {{names}}",
|
||||
"sslManualTitle": "Een certificaat uploaden",
|
||||
"updateExternalAutoProvisionFailed": "Het bijwerken van de instelling voor automatisch aanmaken is mislukt."
|
||||
"updateExternalAutoProvisionFailed": "Het bijwerken van de instelling voor automatisch aanmaken is mislukt.",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Actualizare disponibilă. Deschideți notele de lansare pe GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Aproape",
|
||||
"minimize": "Minimizare",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Conectarea a eșuat",
|
||||
"unknown": "Necunoscut",
|
||||
"continue": "Continua",
|
||||
"maintenance": "Întreţinere",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Elimina",
|
||||
"add": "Adăuga",
|
||||
"update": "Actualizare",
|
||||
"noResults": "Nu s-au găsit rezultate"
|
||||
"noResults": "Nu s-au găsit rezultate",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Pasul {{current}} din {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunel",
|
||||
"serverStats": "Metrici ale gazdei",
|
||||
"status": {
|
||||
"available": "Disponibil",
|
||||
"reachable": "Disponibil, dar autentificarea a eșuat",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitorizarea este dezactivată",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Stare"
|
||||
},
|
||||
"folderRenamed": "Dosarul „{{oldName}}” a fost redenumit în „{{newName}}” a fost reușit.",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Această gazdă (tunel direct)",
|
||||
"statusChecksLabel": "Verificări de stare",
|
||||
"enableStatusChecks": "Activați verificările de stare",
|
||||
"enableStatusChecksDesc": "Ping periodic această gazdă pentru a verifica disponibilitatea",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Utilizați intervalul global",
|
||||
"useGlobalIntervalDesc": "Suprascriere cu intervalul de verificare a stării la nivel de server",
|
||||
"checkIntervalS": "Interval (e) de verificare",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Contul tău folosește un al doilea factor care nu este disponibil momentan. Contactează un administrator pentru a-l reseta.",
|
||||
"secondFactorNoUI": "Acest al doilea factor nu poate fi afișat pe acest ecran. Contactați un administrator.",
|
||||
"serverAuthentication": "Conectare la server",
|
||||
"minChars": "min. {{min}} caractere"
|
||||
"minChars": "min. {{min}} caractere",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verificați cheia gazdă SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Limbă",
|
||||
"keyPassword": "parolă cheie",
|
||||
"pastePrivateKey": "Lipește cheia privată aici...",
|
||||
"enterPassword": "Introduceți parola"
|
||||
"enterPassword": "Introduceți parola",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Bord",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Emis de {{issuer}}",
|
||||
"sslNames": "Acoperă {{names}}",
|
||||
"sslManualTitle": "Încărcați un certificat",
|
||||
"updateExternalAutoProvisionFailed": "Nu s-a putut actualiza setarea de creare automată"
|
||||
"updateExternalAutoProvisionFailed": "Nu s-a putut actualiza setarea de creare automată",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -63,13 +63,12 @@ import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
|
||||
export type ServerStatus = {
|
||||
status: "online" | "reachable" | "offline";
|
||||
status: "online" | "offline";
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
};
|
||||
|
||||
export type SSHHostWithStatus = SSHHost & {
|
||||
status: "online" | "reachable" | "offline" | "unknown";
|
||||
status: "online" | "offline" | "unknown";
|
||||
};
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@@ -46,7 +46,10 @@ import { invokeAction } from "@/shell/action-registry";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { usePluginComponent } from "./component-registry";
|
||||
import { useOptionalHostStatusEntry } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
getLiveHostStatus,
|
||||
useOptionalHostStatusEntry,
|
||||
} from "@/lib/ServerStatusContext";
|
||||
|
||||
/**
|
||||
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
|
||||
@@ -370,12 +373,7 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
usePluginComponent: (id) => usePluginComponent(id),
|
||||
|
||||
useHostStatus: (hostId) => {
|
||||
const entry = useOptionalHostStatusEntry(hostId);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
status: entry.status === "degraded" ? "unknown" : entry.status,
|
||||
...(entry.reason ? { reason: entry.reason } : {}),
|
||||
};
|
||||
return useOptionalHostStatusEntry(hostId);
|
||||
},
|
||||
|
||||
useHostActions: () => useHostActions() as unknown as HostActionContribution[],
|
||||
@@ -429,9 +427,13 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
void setCookie(name, value, 365);
|
||||
},
|
||||
listHosts: async (pluginId) =>
|
||||
(await getSSHHosts({ includeStatus: false })).map((host) =>
|
||||
toPluginHostRecord(host, pluginId ?? null),
|
||||
),
|
||||
(await getSSHHosts()).map((host) => {
|
||||
const status = getLiveHostStatus(host.id);
|
||||
return toPluginHostRecord(
|
||||
{ ...host, status, online: status === "online" },
|
||||
pluginId ?? null,
|
||||
);
|
||||
}),
|
||||
notifyHostsChanged: () => {
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./palette-registry";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { shell } from "@/plugin-host/shell-bridge";
|
||||
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
@@ -501,7 +502,7 @@ export function CommandPalette({
|
||||
<Server
|
||||
className={cn(
|
||||
"size-4",
|
||||
host.online
|
||||
getLiveHostStatus(Number(host.id)) === "online"
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
@@ -517,7 +518,8 @@ export function CommandPalette({
|
||||
{t("hosts.sharing.sharedBadge")}
|
||||
</span>
|
||||
)}
|
||||
{host.online && (
|
||||
{getLiveHostStatus(Number(host.id)) ===
|
||||
"online" && (
|
||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function AdminSettingsPanel({
|
||||
const [passwordLoginForced, setPasswordLoginForced] = useState(false);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
|
||||
const [sessionTimeout, setSessionTimeout] = useState("24");
|
||||
const [statusInterval, setStatusInterval] = useState("60");
|
||||
const [statusInterval, setStatusInterval] = useState("30");
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { HostData } from "@/types/index";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
|
||||
import { withLiveStatus } from "@/sidebar/live-host-status";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
|
||||
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||
import type {
|
||||
@@ -137,9 +139,7 @@ function hostPassesFilters(
|
||||
if (filters.status.length > 0) {
|
||||
const ok =
|
||||
(filters.status.includes("online") && host.online) ||
|
||||
(filters.status.includes("offline") &&
|
||||
host.status !== "reachable" &&
|
||||
!host.online) ||
|
||||
(filters.status.includes("offline") && host.status === "offline") ||
|
||||
(filters.status.includes("pinned") && !!host.pin);
|
||||
if (!ok) return false;
|
||||
}
|
||||
@@ -219,6 +219,13 @@ export function HostsPanel({
|
||||
active?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { statuses, getStatus } = useServerStatus();
|
||||
const liveHostTree = useMemo(
|
||||
() => (hostTree ? withLiveStatus(hostTree, getStatus) : undefined),
|
||||
// statuses changes identity whenever a host's status changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[hostTree, statuses, getStatus],
|
||||
);
|
||||
const hostSwitchPlugins = usePluginHostSections().filter(
|
||||
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
|
||||
);
|
||||
@@ -1028,10 +1035,10 @@ export function HostsPanel({
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<SidebarTree
|
||||
children={
|
||||
hostTree
|
||||
liveHostTree
|
||||
? groupHosts(
|
||||
applyFilters(
|
||||
sortHostTree(hostTree, sortKey, pinnedFirst),
|
||||
sortHostTree(liveHostTree, sortKey, pinnedFirst),
|
||||
filterState,
|
||||
hostSwitches,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
import type { StatusValue } from "@/lib/server-status-store";
|
||||
import { isFolder } from "./tree/visible-rows";
|
||||
|
||||
/**
|
||||
* Copies each host's live status onto the tree so sorting, filtering and
|
||||
* grouping by status read the same value as the row's dot. A host with
|
||||
* status checks off gets no status.
|
||||
*/
|
||||
export function withLiveStatus(
|
||||
folder: HostFolder,
|
||||
getStatus: (hostId: number) => StatusValue,
|
||||
): HostFolder {
|
||||
return {
|
||||
...folder,
|
||||
children: folder.children.map((child) => {
|
||||
if (isFolder(child)) return withLiveStatus(child, getStatus);
|
||||
const host = child as Host;
|
||||
const status =
|
||||
host.statusCheckEnabled === false
|
||||
? undefined
|
||||
: getStatus(Number(host.id));
|
||||
if (host.status === status) return host;
|
||||
return { ...host, status, online: status === "online" };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The flat-list version of withLiveStatus. */
|
||||
export function withLiveHostStatus(
|
||||
hosts: Host[],
|
||||
getStatus: (hostId: number) => StatusValue,
|
||||
): Host[] {
|
||||
return withLiveStatus({ name: "", children: hosts }, getStatus)
|
||||
.children as Host[];
|
||||
}
|
||||
@@ -15,23 +15,12 @@ import { HostItem, statusCheckEnabled } from "../HostItem/HostItem";
|
||||
import { isFolder, folderHasMatch, collectAllHosts } from "../visible-rows";
|
||||
import { FolderActions } from "./FolderActions";
|
||||
|
||||
export function folderHostCount(folder: HostFolder): {
|
||||
total: number;
|
||||
online: number;
|
||||
} {
|
||||
let total = 0,
|
||||
online = 0;
|
||||
export function folderHostCount(folder: HostFolder): number {
|
||||
let total = 0;
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
const c = folderHostCount(child);
|
||||
total += c.total;
|
||||
online += c.online;
|
||||
} else {
|
||||
total++;
|
||||
if (child.online) online++;
|
||||
}
|
||||
total += isFolder(child) ? folderHostCount(child) : 1;
|
||||
}
|
||||
return { total, online };
|
||||
return total;
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
@@ -140,13 +129,11 @@ export function FolderItem({
|
||||
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { getStatus, initialLoadComplete } = useServerStatus();
|
||||
const { total } = folderHostCount(folder);
|
||||
const online = initialLoadComplete
|
||||
? collectAllHosts(folder.children).filter(
|
||||
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
|
||||
).length
|
||||
: folderHostCount(folder).online;
|
||||
const { getStatus } = useServerStatus();
|
||||
const total = folderHostCount(folder);
|
||||
const online = collectAllHosts(folder.children).filter(
|
||||
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
|
||||
).length;
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
||||
|
||||
|
||||
@@ -59,11 +59,7 @@ import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import {
|
||||
useHostStatus,
|
||||
useHostStatusReason,
|
||||
useServerStatusMeta,
|
||||
} from "@/lib/ServerStatusContext";
|
||||
import { useHostStatus } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -101,16 +97,16 @@ export function statusCheckEnabled(host: Host): boolean {
|
||||
|
||||
export function buildStatusTooltip(
|
||||
host: Host,
|
||||
status: "online" | "reachable" | "offline",
|
||||
status: "online" | "offline" | "unknown",
|
||||
t: (key: string) => string = (k) => k,
|
||||
): string {
|
||||
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
|
||||
const statusLabel =
|
||||
status === "online"
|
||||
? t("hosts.status.available")
|
||||
: status === "reachable"
|
||||
? t("hosts.status.reachable")
|
||||
: t("hosts.status.offline");
|
||||
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
|
||||
? t("hosts.status.online")
|
||||
: status === "offline"
|
||||
? t("hosts.status.offline")
|
||||
: t("hosts.status.checking");
|
||||
const protocols: string[] = [];
|
||||
if (host.enableSsh) protocols.push("SSH");
|
||||
for (const protocol of enabledHostProtocols(host)) {
|
||||
@@ -283,22 +279,11 @@ export function HostItem({
|
||||
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
|
||||
const splitTargets = useSplitTargets();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const { initialLoadComplete } = useServerStatusMeta();
|
||||
const statusCheckOn = statusCheckEnabled(host);
|
||||
const statusLoading = !initialLoadComplete && statusCheckOn;
|
||||
// Per-host subscription — status polls only re-render rows that flipped.
|
||||
const liveStatus = useHostStatus(Number(host.id), statusCheckOn);
|
||||
const statusReason = useHostStatusReason(Number(host.id), statusCheckOn);
|
||||
// Per-host subscription, so a poll only re-renders rows that flipped.
|
||||
const availability =
|
||||
liveStatus === "online" ||
|
||||
liveStatus === "reachable" ||
|
||||
liveStatus === "offline"
|
||||
? liveStatus
|
||||
: host.status === "reachable"
|
||||
? "reachable"
|
||||
: host.online
|
||||
? "online"
|
||||
: "offline";
|
||||
useHostStatus(Number(host.id), statusCheckOn) ?? "offline";
|
||||
const statusLoading = availability === "unknown";
|
||||
const isOnline = availability === "online";
|
||||
const previousAvailability = useRef(availability);
|
||||
const [statusLocking, setStatusLocking] = useState(false);
|
||||
@@ -1136,9 +1121,7 @@ export function HostItem({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{statusReason === "host_key_changed"
|
||||
? `${t("hostKey.keyChangedWarning")}: ${t("hostKey.keyChangedDescription")}`
|
||||
: buildStatusTooltip(host, availability, t)}
|
||||
{buildStatusTooltip(host, availability, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -502,7 +502,7 @@ export function SidebarTree({
|
||||
|
||||
function handleDeleteFolder(folder: HostFolder) {
|
||||
const folderPath = folder.path ?? folder.name;
|
||||
const { total } = folderHostCount(folder);
|
||||
const total = folderHostCount(folder);
|
||||
setConfirmDialog({
|
||||
message: t("hosts.deleteFolderConfirm", {
|
||||
name: folder.name,
|
||||
|
||||
@@ -49,7 +49,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// The server keys statuses by sync id; its own ids mean nothing here.
|
||||
remoteCoreApiMock.get.mockResolvedValue({
|
||||
data: { "sync-2": { status: "reachable" }, "sync-9": { status: "x" } },
|
||||
data: { "sync-2": { status: "offline" }, "sync-9": { status: "x" } },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("status check origin routing", () => {
|
||||
|
||||
await expect(getAllServerStatuses()).resolves.toEqual({
|
||||
1: { status: "online" },
|
||||
2: { status: "reachable" },
|
||||
2: { status: "offline" },
|
||||
});
|
||||
|
||||
expect(sshHostApiMock.get).toHaveBeenCalledWith("/status", {
|
||||
@@ -91,6 +91,19 @@ describe("status check origin routing", () => {
|
||||
__silentRetry: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the tick when the host list cannot be read", async () => {
|
||||
sshHostApiMock.get.mockImplementation(async (path: string) => {
|
||||
if (path === "/db/host") throw new Error("down");
|
||||
return { data: {} };
|
||||
});
|
||||
|
||||
await expect(getAllServerStatuses()).rejects.toThrow("down");
|
||||
expect(sshHostApiMock.get).not.toHaveBeenCalledWith(
|
||||
"/status",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status check routes", () => {
|
||||
|
||||
@@ -66,8 +66,8 @@ describe("getStatusClasses", () => {
|
||||
expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand");
|
||||
});
|
||||
|
||||
it("draws reachable as a warning in both schemes", () => {
|
||||
expect(getStatusClasses("reachable", "accent", "dot")).toContain("amber");
|
||||
expect(getStatusClasses("reachable", "status", "dot")).toContain("amber");
|
||||
it("draws an unchecked host as loading", () => {
|
||||
expect(getStatusClasses("unknown", "accent", "dot")).toContain("pulse");
|
||||
expect(getStatusClasses("unknown", "status", "dot")).toContain("pulse");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { ServerStatusStore } from "../../lib/server-status-store";
|
||||
describe("ServerStatusStore", () => {
|
||||
it("notifies only listeners for hosts whose status value changed", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.setEnabledHostIds(new Set([1, 2, 3]));
|
||||
|
||||
const on1 = vi.fn();
|
||||
const on2 = vi.fn();
|
||||
@@ -43,7 +42,6 @@ describe("ServerStatusStore", () => {
|
||||
|
||||
it("does not notify when the status map is unchanged", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.setEnabledHostIds(new Set([1]));
|
||||
store.applyStatuses(
|
||||
new Map([[1, { status: "online", lastChecked: "t1" }]]),
|
||||
);
|
||||
@@ -57,47 +55,23 @@ describe("ServerStatusStore", () => {
|
||||
expect(store.getStatus(1)).toBe("online");
|
||||
});
|
||||
|
||||
it("returns offline for disabled hosts", () => {
|
||||
it("calls a host it has no result for unknown, never offline", () => {
|
||||
const store = new ServerStatusStore();
|
||||
expect(store.getStatus(5)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("forgets everything on clear", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.applyStatuses(new Map([[5, { status: "online", lastChecked: "t" }]]));
|
||||
expect(store.getStatus(5)).toBe("offline");
|
||||
store.setEnabledHostIds(new Set([5]));
|
||||
expect(store.getStatus(5)).toBe("online");
|
||||
});
|
||||
|
||||
it("preserves reachable as distinct from authenticated online", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.setEnabledHostIds(new Set([7]));
|
||||
store.applyStatuses(
|
||||
new Map([[7, { status: "reachable", lastChecked: "t" }]]),
|
||||
);
|
||||
expect(store.getStatus(7)).toBe("reachable");
|
||||
});
|
||||
|
||||
it("notifies when a status reason changes without a status change", () => {
|
||||
const store = new ServerStatusStore();
|
||||
store.setEnabledHostIds(new Set([7]));
|
||||
store.applyStatuses(
|
||||
new Map([[7, { status: "reachable", lastChecked: "t1" }]]),
|
||||
);
|
||||
store.setInitialLoadComplete(true);
|
||||
const listener = vi.fn();
|
||||
store.subscribeHost(7, listener);
|
||||
store.subscribeHost(5, listener);
|
||||
|
||||
store.applyStatuses(
|
||||
new Map([
|
||||
[
|
||||
7,
|
||||
{
|
||||
status: "reachable",
|
||||
reason: "host_key_changed",
|
||||
lastChecked: "t2",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
store.clear();
|
||||
|
||||
expect(store.getStatus(5)).toBe("unknown");
|
||||
expect(store.getInitialLoadComplete()).toBe(false);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(store.getHostSnapshot(7)).toBe("reachable:host_key_changed");
|
||||
});
|
||||
|
||||
it("emits meta listeners when initial load completes", () => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
import { withLiveHostStatus, withLiveStatus } from "@/sidebar/live-host-status";
|
||||
|
||||
const host = (id: number, extra: Partial<Host> = {}) =>
|
||||
({ id: String(id), name: `h${id}`, online: false, ...extra }) as Host;
|
||||
|
||||
const statuses: Record<number, "online" | "offline"> = {
|
||||
1: "online",
|
||||
2: "offline",
|
||||
};
|
||||
const getStatus = (id: number) => statuses[id] ?? "unknown";
|
||||
|
||||
describe("withLiveStatus", () => {
|
||||
it("copies live statuses into nested folders", () => {
|
||||
const tree: HostFolder = {
|
||||
name: "root",
|
||||
children: [host(1), { name: "f", children: [host(2), host(3)] }],
|
||||
};
|
||||
const live = withLiveStatus(tree, getStatus);
|
||||
const [first, folder] = live.children as [Host, HostFolder];
|
||||
const [second, third] = folder.children as Host[];
|
||||
expect(first).toMatchObject({ status: "online", online: true });
|
||||
expect(second).toMatchObject({ status: "offline", online: false });
|
||||
expect(third).toMatchObject({ status: "unknown", online: false });
|
||||
});
|
||||
|
||||
it("gives a host with status checks off no status", () => {
|
||||
const [live] = withLiveHostStatus(
|
||||
[host(1, { statusCheckEnabled: false })],
|
||||
getStatus,
|
||||
);
|
||||
expect(live.status).toBeUndefined();
|
||||
expect(live.online).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the same object when nothing changed", () => {
|
||||
const same = host(1, { status: "online", online: true });
|
||||
expect(withLiveHostStatus([same], getStatus)[0]).toBe(same);
|
||||
});
|
||||
});
|
||||
@@ -27,12 +27,11 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
vi.mock("@/lib/ServerStatusContext", () => ({
|
||||
useHostStatus: () => null,
|
||||
useHostStatusReason: () => null,
|
||||
useServerStatus: () => ({
|
||||
getStatus: () => "online",
|
||||
initialLoadComplete: true,
|
||||
}),
|
||||
useServerStatusMeta: () => ({ initialLoadComplete: true, isLoading: false }),
|
||||
useServerStatusMeta: () => ({ initialLoadComplete: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-status-color-scheme", () => ({
|
||||
|
||||
@@ -27,24 +27,24 @@ function makeHost(overrides: Partial<Host> = {}): Host {
|
||||
// not the key paths the translator falls back to when a resource is missing.
|
||||
const t = (key: string): string =>
|
||||
({
|
||||
"hosts.status.available": "Available",
|
||||
"hosts.status.reachable": "Reachable, not authenticated",
|
||||
"hosts.status.online": "Online",
|
||||
"hosts.status.checking": "Checking",
|
||||
"hosts.status.offline": "Offline",
|
||||
"hosts.status.monitoringDisabled": "Monitoring disabled",
|
||||
})[key] ?? key;
|
||||
|
||||
describe("buildStatusTooltip", () => {
|
||||
it("returns the translated 'Available' label for online status", () => {
|
||||
it("returns the translated 'Online' label for online status", () => {
|
||||
const host = makeHost();
|
||||
const tooltip = buildStatusTooltip(host, "online", t);
|
||||
expect(tooltip).toContain("Available");
|
||||
expect(tooltip).toContain("Online");
|
||||
expect(tooltip).not.toContain("hosts.status.");
|
||||
});
|
||||
|
||||
it("returns the translated 'Reachable, not authenticated' label for reachable status", () => {
|
||||
it("returns 'Checking' before the host has been checked", () => {
|
||||
const host = makeHost();
|
||||
const tooltip = buildStatusTooltip(host, "reachable", t);
|
||||
expect(tooltip).toContain("Reachable, not authenticated");
|
||||
const tooltip = buildStatusTooltip(host, "unknown", t);
|
||||
expect(tooltip).toContain("Checking");
|
||||
expect(tooltip).not.toContain("hosts.status.");
|
||||
});
|
||||
|
||||
@@ -78,13 +78,13 @@ describe("buildStatusTooltip", () => {
|
||||
dispose();
|
||||
expect(tooltip).toContain("SSH");
|
||||
expect(tooltip).toContain("Demo Desktop");
|
||||
expect(tooltip).toContain("Available");
|
||||
expect(tooltip).toContain("Online");
|
||||
});
|
||||
|
||||
it("returns just the status label when no protocols are enabled", () => {
|
||||
const host = makeHost({ enableSsh: false });
|
||||
const tooltip = buildStatusTooltip(host, "online", t);
|
||||
expect(tooltip).toBe("Available");
|
||||
expect(tooltip).toBe("Online");
|
||||
});
|
||||
|
||||
it("does not render key paths when a translator is supplied", () => {
|
||||
|
||||
Reference in new issue
Block a user