mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d333c37277 | ||
|
|
2430de9ef0 |
No files matched your search
@@ -42,6 +42,7 @@ import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
|
||||
import { upsertKnownHost } from '../../domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
|
||||
import { useVaultHostDeletion } from '../state/useVaultHostDeletion';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
@@ -1199,11 +1200,16 @@ export function AppSideEffects() {
|
||||
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
|
||||
}, [hosts]);
|
||||
|
||||
const handleConfirmDeleteHost = useCallback(() => {
|
||||
const deleteVaultHosts = useVaultHostDeletion(commitVaultGroupMutation);
|
||||
const handleConfirmDeleteHost = useCallback(async () => {
|
||||
if (!deleteHostConfirm) return;
|
||||
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
|
||||
setDeleteHostConfirm(null);
|
||||
}, [deleteHostConfirm, hosts, updateHosts]);
|
||||
try {
|
||||
await deleteVaultHosts([deleteHostConfirm.hostId]);
|
||||
setDeleteHostConfirm(null);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}, [deleteHostConfirm, deleteVaultHosts, t]);
|
||||
|
||||
const handleCancelDeleteHost = useCallback(() => {
|
||||
setDeleteHostConfirm(null);
|
||||
|
||||
@@ -483,3 +483,73 @@ for (const phase of ["snapshot", "commit"] as const) {
|
||||
assert.equal(commits, phase === "snapshot" ? 0 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const mixedSelection of [false, true]) {
|
||||
test(`group deletion prunes host and inherited chains, mixed selection: ${mixedSelection}`, async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const storedHosts = [
|
||||
{ id: "jump", group: "A", label: "jump", hostname: "j.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const },
|
||||
{ id: "survivor", group: "B", label: "survivor", hostname: "s.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const, hostChain: { hostIds: ["jump"] } },
|
||||
{ id: "extra", group: "B", label: "extra", hostname: "e.example.com", username: "root", tags: [], os: "linux" as const },
|
||||
{ id: "kept", group: "B", label: "kept", hostname: "k.example.com", username: "root", tags: [], os: "linux" as const },
|
||||
];
|
||||
const configs: GroupConfig[] = [{ path: "B", hostChain: { hostIds: ["jump", "extra", "kept"] } }];
|
||||
let deleteGroups: ReturnType<typeof useVaultGroupDeletion> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let committedHosts: Array<{ id: string; hostChain?: { hostIds: string[] } }> = [];
|
||||
let committedConfigs: GroupConfig[] = [];
|
||||
|
||||
const Probe = () => {
|
||||
deleteGroups = useVaultGroupDeletion({
|
||||
customGroups: ["A", "B"],
|
||||
hosts: storedHosts,
|
||||
groupConfigs: configs,
|
||||
managedSources: [],
|
||||
onReadPersistedHosts: async () => storedHosts,
|
||||
onReadPersistedManagedSources: () => [],
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
const result = mutate({
|
||||
groups: ["A", "B"],
|
||||
configs,
|
||||
hosts: storedHosts,
|
||||
managedSources: [],
|
||||
snippets: [],
|
||||
});
|
||||
if (result.ok) {
|
||||
committedHosts = result.state.hosts;
|
||||
committedConfigs = result.state.configs;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
await deleteGroups?.(["A"], true, new Set(mixedSelection ? ["extra"] : []));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.deepEqual(committedHosts.map(({ id, hostChain }) => ({ id, hostChain })), [
|
||||
{ id: "survivor", hostChain: { hostIds: [] } },
|
||||
...(!mixedSelection ? [{ id: "extra", hostChain: undefined }] : []),
|
||||
{ id: "kept", hostChain: undefined },
|
||||
]);
|
||||
assert.deepEqual(committedConfigs[0].hostChain,
|
||||
{ hostIds: mixedSelection ? ["kept"] : ["extra", "kept"] });
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useRef } from "react";
|
||||
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
|
||||
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
|
||||
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
|
||||
import { pruneVaultJumpHostReferences } from "../../domain/vaultJumpGraph";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
@@ -155,14 +156,30 @@ export function useVaultGroupDeletion({
|
||||
)) {
|
||||
throw RETRY_VAULT_GROUP_DELETION;
|
||||
}
|
||||
const nextTransactionHosts = currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
);
|
||||
// Hosts removed by this transaction must stop being referenced
|
||||
// from other hosts' jump chains (and group config chains), or
|
||||
// those targets keep failing with "A configured jump host is
|
||||
// missing".
|
||||
const retainedHostIds = new Set(
|
||||
nextTransactionHosts.map((host) => host.id),
|
||||
);
|
||||
const removedHostIds = current.hosts
|
||||
.filter((host) => !retainedHostIds.has(host.id))
|
||||
.map((host) => host.id);
|
||||
const prunedReferences = pruneVaultJumpHostReferences({
|
||||
hosts: nextTransactionHosts,
|
||||
groupConfigs: currentDeletion.groupConfigs,
|
||||
removedHostIds,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: currentDeletion.customGroups,
|
||||
hosts: currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
),
|
||||
configs: currentDeletion.groupConfigs,
|
||||
hosts: prunedReferences.hosts,
|
||||
configs: prunedReferences.groupConfigs,
|
||||
managedSources: current.managedSources.filter(
|
||||
(source) => !currentDeletion.sourcesToRemove.some(
|
||||
(removed) => removed.id === source.id,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import type { Host } from '../../domain/models';
|
||||
import type { VaultGroupMutationState } from '../../domain/vaultGroupMutation';
|
||||
import { useVaultHostDeletion } from './useVaultHostDeletion';
|
||||
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
|
||||
const host = (id: string, hostIds?: string[]): Host => ({
|
||||
id, label: id, hostname: `${id}.test`, username: 'root', tags: [], os: 'linux',
|
||||
...(hostIds ? { hostChain: { hostIds } } : {}),
|
||||
});
|
||||
|
||||
async function withDeletion(
|
||||
commit: Parameters<typeof useVaultHostDeletion>[0],
|
||||
run: (remove: ReturnType<typeof useVaultHostDeletion>) => Promise<void>,
|
||||
) {
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let remove!: ReturnType<typeof useVaultHostDeletion>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
remove = useVaultHostDeletion(commit);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await run(remove);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
}
|
||||
|
||||
for (const removed of [['jump'], ['jump', 'extra']]) {
|
||||
test(`host deletion cleans latest host and group references together: ${removed.join(',')}`, async () => {
|
||||
const latest: VaultGroupMutationState = {
|
||||
hosts: [host('jump'), host('extra'), host('kept'), host('target', ['jump', 'kept'])],
|
||||
configs: [{ path: 'prod', username: 'latest-user', hostChain: { hostIds: ['jump'] } }],
|
||||
groups: ['prod'], managedSources: [], snippets: [],
|
||||
};
|
||||
let committed: VaultGroupMutationState | undefined;
|
||||
await withDeletion(async (mutate) => {
|
||||
const result = mutate(latest);
|
||||
if (result.ok) committed = result.state;
|
||||
return result;
|
||||
}, async (remove) => { await act(async () => { await remove(removed); }); });
|
||||
assert.ok(committed);
|
||||
assert.deepEqual(committed.hosts.map((item) => item.id),
|
||||
removed.length === 1 ? ['extra', 'kept', 'target'] : ['kept', 'target']);
|
||||
assert.deepEqual(committed.hosts.at(-1)?.hostChain, { hostIds: ['kept'] });
|
||||
assert.deepEqual(committed.configs[0], {
|
||||
path: 'prod', username: 'latest-user', hostChain: { hostIds: [] },
|
||||
});
|
||||
assert.equal(committed.snippets, latest.snippets);
|
||||
assert.deepEqual(latest.hosts.at(-1)?.hostChain, { hostIds: ['jump', 'kept'] });
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed group-config write rolls host deletion back and reports failure', async () => {
|
||||
const original = {
|
||||
[STORAGE_KEY_HOSTS]: [host('jump'), host('target', ['jump'])],
|
||||
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', hostChain: { hostIds: ['jump'] } }],
|
||||
};
|
||||
const values = new Map(Object.entries(original).map(([key, value]) => [key, JSON.stringify(value)]));
|
||||
const storage = {
|
||||
read: <T,>(key: string): T | null => JSON.parse(values.get(key) ?? 'null'),
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
write: <T,>(key: string, value: T) => {
|
||||
if (key === STORAGE_KEY_GROUP_CONFIGS) return false;
|
||||
values.set(key, JSON.stringify(value));
|
||||
return true;
|
||||
},
|
||||
writeString: (key: string, value: string) => { values.set(key, value); return true; },
|
||||
remove: (key: string) => { values.delete(key); },
|
||||
};
|
||||
const identity = async <T,>(value: T) => value;
|
||||
await withDeletion((mutate) => commitVaultGroupMutationPersistence({
|
||||
storage, mutate, prepareState: (value) => value,
|
||||
decryptHosts: identity, decryptConfigs: identity,
|
||||
encryptHosts: identity, encryptConfigs: identity, isCurrent: () => true,
|
||||
}), async (remove) => {
|
||||
await assert.rejects(remove(['jump']), /rejected importer transaction/);
|
||||
});
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('host deletion reports a rejected or superseded transaction', async () => {
|
||||
for (const failure of [{ ok: false, error: 'unavailable' }, { ok: false, superseded: true }] as const) {
|
||||
await withDeletion(async () => failure, async (remove) => {
|
||||
await assert.rejects(remove(['jump']), /unavailable|superseded/);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { VaultGroupMutationResult, VaultGroupMutationState } from '../../domain/vaultGroupMutation';
|
||||
import { pruneVaultJumpHostReferences } from '../../domain/vaultJumpGraph';
|
||||
|
||||
export function useVaultHostDeletion(
|
||||
commit: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>,
|
||||
) {
|
||||
return useCallback(async (hostIds: Iterable<string>) => {
|
||||
const removedHostIds = new Set(hostIds);
|
||||
if (removedHostIds.size === 0) return;
|
||||
const result = await commit((current) => {
|
||||
const pruned = pruneVaultJumpHostReferences({
|
||||
hosts: current.hosts.filter((host) => !removedHostIds.has(host.id)),
|
||||
groupConfigs: current.configs,
|
||||
removedHostIds,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
state: { ...current, hosts: pruned.hosts, configs: pruned.groupConfigs },
|
||||
};
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error('error' in result ? result.error : 'Host deletion was superseded. Try again.');
|
||||
}
|
||||
}, [commit]);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import { useStoredString } from "../application/state/useStoredString";
|
||||
import type { VaultLockHandle } from "../application/state/vaultManagedImportLock";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
|
||||
import { useVaultHostDeletion } from "../application/state/useVaultHostDeletion";
|
||||
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
@@ -980,15 +981,19 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsMultiSelectMode(false);
|
||||
}, []);
|
||||
|
||||
const deleteSelectedHosts = useCallback(() => {
|
||||
const deleteVaultHosts = useVaultHostDeletion(onCommitVaultGroupMutation);
|
||||
const deleteSelectedHosts = useCallback(async () => {
|
||||
if (selectedHostIds.size === 0) return;
|
||||
const updatedHosts = hosts.filter((h) => !selectedHostIds.has(h.id));
|
||||
onUpdateHosts(updatedHosts);
|
||||
clearHostSelection();
|
||||
toast.success(
|
||||
t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }),
|
||||
);
|
||||
}, [selectedHostIds, hosts, onUpdateHosts, clearHostSelection, t]);
|
||||
try {
|
||||
await deleteVaultHosts(selectedHostIds);
|
||||
clearHostSelection();
|
||||
toast.success(
|
||||
t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("common.error"));
|
||||
}
|
||||
}, [selectedHostIds, deleteVaultHosts, clearHostSelection, t]);
|
||||
|
||||
const connectSelectedHosts = useCallback(() => {
|
||||
if (selectedHostIds.size === 0) return;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { GroupConfig, Host } from "./models.ts";
|
||||
import { pruneVaultJumpHostReferences } from "./vaultJumpGraph.ts";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig.ts";
|
||||
|
||||
const host = (id: string, hostIds?: string[]): Host => ({
|
||||
id,
|
||||
label: id,
|
||||
hostname: `${id}.example.com`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
group: "",
|
||||
...(hostIds ? { hostChain: { hostIds } } : {}),
|
||||
});
|
||||
|
||||
test("pruning removes deleted hosts from other hosts' jump chains", () => {
|
||||
const hosts = [
|
||||
host("target", ["jump-a", "jump-b"]),
|
||||
host("other"),
|
||||
];
|
||||
const result = pruneVaultJumpHostReferences({
|
||||
hosts,
|
||||
groupConfigs: [],
|
||||
removedHostIds: ["jump-a"],
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.hosts[0].hostChain, { hostIds: ["jump-b"] });
|
||||
assert.equal(result.hosts[1], hosts[1]);
|
||||
});
|
||||
|
||||
test("pruning clears an empty chain and prunes group config chains", () => {
|
||||
const groupConfigs: GroupConfig[] = [
|
||||
{ path: "A", hostChain: { hostIds: ["gone"] } },
|
||||
{ path: "B" },
|
||||
];
|
||||
const result = pruneVaultJumpHostReferences({
|
||||
hosts: [host("target", ["gone"])],
|
||||
groupConfigs,
|
||||
removedHostIds: ["gone"],
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.hosts[0].hostChain, { hostIds: [] });
|
||||
assert.deepEqual(result.groupConfigs[0].hostChain, { hostIds: [] });
|
||||
assert.equal(result.groupConfigs[1], groupConfigs[1]);
|
||||
});
|
||||
|
||||
test("pruning keeps collections untouched when no chains reference removed hosts", () => {
|
||||
const hosts = [host("target", ["kept"]), host("chain-free")];
|
||||
const groupConfigs: GroupConfig[] = [{ path: "B" }];
|
||||
const result = pruneVaultJumpHostReferences({
|
||||
hosts,
|
||||
groupConfigs,
|
||||
removedHostIds: ["gone"],
|
||||
});
|
||||
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.hosts, hosts);
|
||||
assert.equal(result.groupConfigs, groupConfigs);
|
||||
});
|
||||
|
||||
test("removing a final explicit hop does not activate another inherited chain", () => {
|
||||
const result = pruneVaultJumpHostReferences({
|
||||
hosts: [{ ...host("target", ["gone"]), group: "Parent" },
|
||||
{ ...host("inherited"), group: "Parent/Child" }],
|
||||
groupConfigs: [
|
||||
{ path: "Parent", hostChain: { hostIds: ["kept"] } },
|
||||
{ path: "Parent/Child", hostChain: { hostIds: ["gone"] } },
|
||||
],
|
||||
removedHostIds: ["gone"],
|
||||
});
|
||||
for (const target of result.hosts) {
|
||||
const effective = applyGroupDefaults(target,
|
||||
resolveGroupDefaults(target.group!, result.groupConfigs));
|
||||
assert.deepEqual(effective.hostChain, { hostIds: [] });
|
||||
}
|
||||
assert.deepEqual(result.groupConfigs[0].hostChain, { hostIds: ["kept"] });
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GroupConfig, Host } from './models';
|
||||
import type { GroupConfig, Host, HostChainConfig } from './models';
|
||||
|
||||
export type VaultJumpGraphIssue = {
|
||||
key: string;
|
||||
@@ -10,6 +10,56 @@ export type VaultJumpGraphIssue = {
|
||||
|
||||
const identity = (host: Host): Host => host;
|
||||
|
||||
/**
|
||||
* Drop host-chain entries that point at hosts the user removed. Without this,
|
||||
* a deleted jump host stays referenced by its targets and connecting them
|
||||
* keeps failing with "A configured jump host is missing".
|
||||
*/
|
||||
export function pruneVaultJumpHostReferences({
|
||||
hosts,
|
||||
groupConfigs,
|
||||
removedHostIds,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
removedHostIds: Iterable<string>;
|
||||
}): {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
changed: boolean;
|
||||
} {
|
||||
const removed = removedHostIds instanceof Set
|
||||
? removedHostIds
|
||||
: new Set(removedHostIds);
|
||||
if (removed.size === 0) return { hosts, groupConfigs, changed: false };
|
||||
|
||||
const buildWithoutRemovedRefs = <Item extends { hostChain?: HostChainConfig }>(
|
||||
items: Item[],
|
||||
): Item[] => {
|
||||
let next: Item[] | undefined;
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
const hostIds = item.hostChain?.hostIds;
|
||||
if (!hostIds?.length) continue;
|
||||
const nextHostIds = hostIds.filter((hostId) => !removed.has(hostId));
|
||||
if (nextHostIds.length === hostIds.length) continue;
|
||||
if (!next) next = items.slice();
|
||||
next[index] = {
|
||||
...item,
|
||||
// Keep an explicit empty chain so a host/child group does not start
|
||||
// using a different inherited jump chain after its last hop is removed.
|
||||
hostChain: { hostIds: nextHostIds },
|
||||
};
|
||||
}
|
||||
return next ?? items;
|
||||
};
|
||||
|
||||
const nextHosts = buildWithoutRemovedRefs(hosts);
|
||||
const nextGroupConfigs = buildWithoutRemovedRefs(groupConfigs);
|
||||
const changed = nextHosts !== hosts || nextGroupConfigs !== groupConfigs;
|
||||
return { hosts: nextHosts, groupConfigs: nextGroupConfigs, changed };
|
||||
}
|
||||
|
||||
export function findVaultGroupConfigJumpReference(
|
||||
configs: GroupConfig[],
|
||||
hostId: string,
|
||||
|
||||
Reference in new issue
Block a user