Files
EasyTier/easytier-web/frontend/src/components/NetworkDetail.vue
T

1661 lines
88 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { v4 as uuidv4 } from 'uuid';
import { Button, Column, ConfirmDialog, DataTable, Dialog, Drawer, InputSwitch, InputText, Message, ProgressSpinner, ScrollPanel, Select, SelectButton, Skeleton, Tab, TabList, TabPanel, TabPanels, Tabs, Tag, useConfirm, useToast } from 'primevue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Config, NetworkTypes, UrlListInput, Utils } from 'easytier-frontend-lib';
import AclPolicyTab from './AclPolicyTab.vue';
import ApiClient, { type CentralNetworkDetail, type CentralNetworkMember, type NetworkCredential, type NodeAclRuleStat, type NodePeerInfo, type NodeRouteInfo, type TemporaryPeer } from '../modules/api';
import { normalizeVirtualSubnet } from '../modules/virtual-subnet';
const { t } = useI18n()
const route = useRoute();
const router = useRouter();
const toast = useToast();
const confirm = useConfirm();
const props = defineProps({
api: ApiClient,
});
const api = props.api;
const networkId = computed(() => route.params.networkId as string);
const network = ref<CentralNetworkDetail | undefined>(undefined);
const members = ref<CentralNetworkMember[] | undefined>(undefined);
const temporaryPeers = ref<TemporaryPeer[]>([]);
const loadError = ref<string | undefined>(undefined);
const activeTab = ref('members');
const loadAll = async () => {
try {
network.value = await api?.get_network(networkId.value);
loadError.value = undefined;
} catch (e: any) {
loadError.value = e?.response?.data?.message ?? String(e);
return;
}
try {
const view = await api?.list_network_members(networkId.value);
members.value = view?.members;
temporaryPeers.value = view?.temporary_peers ?? [];
} catch (e) {
loadError.value = String(e);
console.error(e);
}
if (network.value?.secure_mode && credentials.value !== undefined) {
loadCredentials();
}
};
const periodFunc = new Utils.PeriodicTask(async () => {
await loadAll();
}, 2000);
const loadGatewayInfo = async () => {
if (gatewayInfo.value !== undefined) {
return;
}
try {
gatewayInfo.value = await api?.get_gateway_info();
} catch (e) {
gatewayInfo.value = { enabled: false };
}
};
onMounted(async () => {
// Load both in parallel; the settings form must not read a Gateway
// network before the gateway info is available.
await Promise.all([loadAll(), loadGatewayInfo()]);
periodFunc.start();
});
onUnmounted(() => {
periodFunc.stop();
window.clearInterval(nodeDetailTimer);
configRequest++;
});
// --- members tab ---
// 成员列表的表格/卡片视图切换,选择持久化;移动端始终使用紧凑列表
const viewMode = ref<'table' | 'card'>(localStorage.getItem('networkMembers.viewMode') === 'card' ? 'card' : 'table');
const viewModeOptions = [
{ value: 'table', icon: 'pi pi-table' },
{ value: 'card', icon: 'pi pi-th-large' },
];
const viewModeLabel = (value: string) => t(value === 'card' ? 'web.console.view_card' : 'web.console.view_table');
watch(viewMode, (mode) => localStorage.setItem('networkMembers.viewMode', mode));
const addMemberVisible = ref(false);
const adding = ref(false);
const candidateDevices = ref<Utils.DeviceInfo[]>([]);
const selectedDevices = ref<Utils.DeviceInfo[]>([]);
const memberDeviceIds = computed(() => new Set((members.value ?? []).map((member) => member.device_id)));
const openAddMember = async () => {
try {
const machines = await api?.list_machines() ?? [];
candidateDevices.value = machines
.map((machine: any) => Utils.buildDeviceInfo(machine))
.filter((device: Utils.DeviceInfo) => !!device.machine_id && !memberDeviceIds.value.has(device.machine_id));
} catch (e) {
console.error(e);
candidateDevices.value = [];
}
selectedDevices.value = [];
addTemporary.value = false;
addTtlHours.value = 7 * 24;
addMemberVisible.value = true;
};
const addTemporary = ref(false);
const addTtlHours = ref(7 * 24);
const addTtlOptions = [
{ label: t('web.network_detail.ttl_24h'), value: 24 },
{ label: t('web.network_detail.ttl_7d'), value: 7 * 24 },
{ label: t('web.network_detail.ttl_30d'), value: 30 * 24 },
];
const addMembers = async () => {
const deviceIds = selectedDevices.value.map((device) => device.machine_id);
if (deviceIds.length === 0) {
return;
}
adding.value = true;
try {
await api?.add_network_members(
networkId.value,
deviceIds,
addTemporary.value,
addTemporary.value ? addTtlHours.value * 3600 : undefined,
);
addMemberVisible.value = false;
await loadAll();
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.add_member'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
} finally {
adding.value = false;
}
};
const removeMember = (member: CentralNetworkMember) => {
confirm.require({
header: t('web.network_detail.remove_member'),
message: t('web.network_detail.remove_member_confirm', { device: member.hostname ?? member.device_id }),
acceptLabel: t('web.common.confirm'),
rejectLabel: t('web.common.cancel'),
accept: async () => {
try {
await api?.remove_network_member(networkId.value, member.device_id);
await loadAll();
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.remove_member'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
}
},
});
};
const editMemberVisible = ref(false);
const editingMember = ref<CentralNetworkMember | undefined>(undefined);
const editMemberForm = ref({ hostname_override: '', virtual_ipv4: '' });
const editProxyCidrs = ref<string[]>([]);
const proxyCidrInput = ref('');
const savingMember = ref(false);
// Normalize user input into a network CIDR: a bare host address expands to
// its /24 network, and an explicit prefix gets its host bits cleared.
const toNetworkCidr = (input: string): string | null => {
const trimmed = input.trim();
if (!trimmed) return null;
if (trimmed.includes('/')) {
const [addr, prefixStr] = trimmed.split('/');
const prefix = Number(prefixStr);
const ip = addr.split('.').map(Number);
if (ip.length !== 4 || ip.some(o => Number.isNaN(o) || o < 0 || o > 255)
|| !Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
return null;
}
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
const ipNum = ((ip[0] << 24) | (ip[1] << 16) | (ip[2] << 8) | ip[3]) >>> 0;
const network = (ipNum & mask) >>> 0;
return [24, 16, 8, 0].map(shift => (network >> shift) & 0xff).join('.') + '/' + prefix;
}
const ip = trimmed.split('.').map(Number);
if (ip.length !== 4 || ip.some(o => Number.isInteger(o) && o >= 0 && o <= 255 ? false : true)) {
return null;
}
return `${ip[0]}.${ip[1]}.${ip[2]}.0/24`;
};
// The Enter used to confirm an IME composition must not add the entry.
const onProxyCidrEnter = (event: KeyboardEvent) => {
if (event.isComposing || event.keyCode === 229) {
return;
}
addProxyCidr();
};
const addProxyCidr = () => {
const converted = toNetworkCidr(proxyCidrInput.value);
if (converted === null) {
toast.add({ severity: 'warn', summary: t('proxy_cidrs'), detail: t('web.network_detail.proxy_cidr_invalid'), life: 3000 });
return;
}
if (!editProxyCidrs.value.includes(converted)) {
editProxyCidrs.value.push(converted);
}
proxyCidrInput.value = '';
};
const openEditMember = (member: CentralNetworkMember) => {
editingMember.value = member;
editTab.value = 'basic';
editMemberForm.value = {
hostname_override: member.hostname_override ?? '',
virtual_ipv4: member.virtual_ipv4 ?? '',
};
editProxyCidrs.value = [...(member.proxy_cidrs ?? [])];
proxyCidrInput.value = '';
candidateCidrs.value = undefined;
editMemberVisible.value = true;
loadCandidateCidrs(member);
loadMemberConfigForm(member);
};
// Suggest subnets detected on the device: each interface IPv4 expands to
// its /24 network; loopback, link-local and the member's own virtual
// subnet are filtered out.
const candidateCidrs = ref<string[] | undefined>(undefined);
const loadCandidateCidrs = async (member: CentralNetworkMember) => {
if (!member.online) {
candidateCidrs.value = [];
return;
}
try {
const addrs = await api?.get_node_interface_ips(member.device_id, networkId.value);
const virtualPrefix = (member.runtime_virtual_ipv4 ?? member.virtual_ipv4 ?? member.allocated_ipv4 ?? '')
.split('/')[0].split('.').slice(0, 3).join('.');
const cidrs = new Set<string>();
for (const addr of addrs ?? []) {
const ip = [24, 16, 8, 0].map(shift => (addr >> shift) & 0xff).join('.');
const first = Number(ip.split('.')[0]);
if (first === 127 || first === 169 || first === 0 || first === 224) continue;
const network = ip.split('.').slice(0, 3).join('.') + '.0/24';
if (virtualPrefix && network.startsWith(virtualPrefix + '.')) continue;
cidrs.add(network);
}
candidateCidrs.value = [...cidrs];
} catch (e) {
console.error(e);
candidateCidrs.value = [];
}
};
const toggleCandidateCidr = (cidr: string) => {
const index = editProxyCidrs.value.indexOf(cidr);
if (index >= 0) {
editProxyCidrs.value.splice(index, 1);
} else {
editProxyCidrs.value.push(cidr);
}
};
const saveMember = async () => {
if (!editingMember.value) {
return;
}
savingMember.value = true;
try {
await api?.update_network_member(networkId.value, editingMember.value.device_id, {
hostname_override: editMemberForm.value.hostname_override.trim(),
virtual_ipv4: editMemberForm.value.virtual_ipv4.trim(),
proxy_cidrs: [...editProxyCidrs.value],
});
editMemberVisible.value = false;
await loadAll();
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.edit_member'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
} finally {
savingMember.value = false;
}
};
// --- settings tab ---
const settingsForm = ref({
display_name: '',
network_name: '',
virtual_cidr: '',
secure_mode: false,
regenerate_secret: false,
});
// Initial nodes in the same model as the GUI config form; an empty list
// means Standalone.
const initialNodes = ref<string[]>([]);
const settingsLoaded = ref(false);
const savingSettings = ref(false);
const gatewayInfo = ref<{ enabled: boolean; peer_url?: string } | undefined>(undefined);
const protos = NetworkTypes.PEER_PROTOCOLS;
const gatewayFollowed = () =>
gatewayInfo.value?.enabled === true
&& initialNodes.value.length === 1
&& initialNodes.value[0] === gatewayInfo.value?.peer_url;
const loadSettingsForm = () => {
if (!network.value) {
return;
}
settingsForm.value = {
display_name: network.value.display_name,
network_name: network.value.network_name,
virtual_cidr: network.value.virtual_cidr ?? '',
secure_mode: network.value.secure_mode ?? false,
regenerate_secret: false,
};
// Present every networking method as an initial-node list: the gateway
// URL for gateway networks, the stored peers otherwise.
switch (network.value.networking_method) {
case 'Gateway':
initialNodes.value =
gatewayInfo.value?.enabled && gatewayInfo.value?.peer_url
? [gatewayInfo.value.peer_url]
: [];
break;
case 'Manual':
initialNodes.value = [...(network.value.peer_urls ?? [])];
break;
case 'PublicServer':
initialNodes.value = network.value.public_server_url
? [network.value.public_server_url]
: [];
break;
default:
initialNodes.value = [];
break;
}
settingsLoaded.value = true;
};
const saveSettings = async () => {
savingSettings.value = true;
try {
// PublicServer retains its discovery mode while it has a single URL.
// Keeping exactly the gateway URL as the only initial node preserves
// the Gateway mode (the compiled peer list follows the gateway URL);
// anything else compiles to Manual peers, or Standalone when empty.
const method = network.value?.networking_method === 'PublicServer' && initialNodes.value.length === 1 ? 'PublicServer'
: gatewayFollowed() ? 'Gateway'
: initialNodes.value.length > 0 ? 'Manual' : 'Standalone';
const settings = {
display_name: settingsForm.value.display_name,
network_name: null,
networking_method: method,
public_server_url: method === 'PublicServer' ? initialNodes.value[0] : null,
peer_urls: method === 'Manual' ? initialNodes.value : [],
virtual_cidr: normalizeVirtualSubnet(settingsForm.value.virtual_cidr) || null,
secure_mode: settingsForm.value.secure_mode,
};
await api?.update_network(networkId.value, settings, settingsForm.value.regenerate_secret ? uuidv4() : undefined);
settingsForm.value.regenerate_secret = false;
await loadAll();
toast.add({ severity: 'success', summary: t('web.network_detail.settings'), detail: t('web.device_management.config_saved'), life: 2000 });
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.settings'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
} finally {
savingSettings.value = false;
}
};
// --- member advanced config (advanced tab of the edit dialog) ---
const editTab = ref<'basic' | 'advanced'>('basic');
const configSaving = ref(false);
const configLoaded = ref(false);
const configMember = ref<CentralNetworkMember | null>(null);
const configForm = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const configBaseline = ref<{ network_name: string; network_secret: string }>({ network_name: '', network_secret: '' });
let configRequest = 0;
watch(editMemberVisible, (visible) => {
if (!visible) configRequest++;
}, { flush: 'sync' });
const loadMemberConfigForm = async (member: CentralNetworkMember) => {
const request = ++configRequest;
configMember.value = member;
configLoaded.value = false;
try {
const raw = await api?.get_member_config(networkId.value, member.device_id);
if (request !== configRequest) return;
configForm.value = NetworkTypes.normalizeNetworkConfig(raw);
configBaseline.value = {
network_name: String(configForm.value.network_name ?? ''),
network_secret: String(configForm.value.network_secret ?? ''),
};
configLoaded.value = true;
} catch (e: any) {
if (request !== configRequest) return;
toast.add({ severity: 'error', summary: t('web.network_detail.advanced_config'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
}
};
const saveMemberConfig = async () => {
if (!configMember.value) return;
const form = configForm.value;
if (String(form.network_name ?? '') !== configBaseline.value.network_name
|| String(form.network_secret ?? '') !== configBaseline.value.network_secret) {
toast.add({ severity: 'warn', summary: t('web.network_detail.advanced_config'),
detail: t('web.network_detail.identity_ignored'), life: 4000 });
}
configSaving.value = true;
try {
await api?.set_member_config(networkId.value, configMember.value.device_id, NetworkTypes.toBackendNetworkConfig(form));
editMemberVisible.value = false;
await loadAll();
toast.add({ severity: 'success', summary: t('web.network_detail.advanced_config'), detail: t('web.device_management.config_saved'), life: 2000 });
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.advanced_config'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
} finally {
configSaving.value = false;
}
};
const resetMemberConfig = async () => {
if (!configMember.value) return;
configSaving.value = true;
try {
await api?.clear_member_config(networkId.value, configMember.value.device_id);
editMemberVisible.value = false;
await loadAll();
toast.add({ severity: 'success', summary: t('web.network_detail.advanced_config'), detail: t('web.network_detail.config_reset'), life: 2000 });
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.advanced_config'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
} finally {
configSaving.value = false;
}
};
// --- node detail dialog (runtime view of one member in this network) ---
const nodeDetailVisible = ref(false);
const nodeLoggerLevel = ref<number | undefined>(undefined);
const nodeLoggerSaving = ref(false);
const nodeTomlConfig = ref<string | undefined>(undefined);
const nodeConfigVisible = ref(false);
const loggerLevelOptions = computed(() => [
{ label: t('web.network_detail.log_disabled'), value: 0 },
{ label: t('web.network_detail.log_error'), value: 1 },
{ label: t('web.network_detail.log_warning'), value: 2 },
{ label: t('web.network_detail.log_info'), value: 3 },
{ label: t('web.network_detail.log_debug'), value: 4 },
{ label: t('web.network_detail.log_trace'), value: 5 },
]);
const loadNodeGeneral = async () => {
if (!nodeDetailMember.value) return;
try {
nodeLoggerLevel.value = await api?.get_node_logger_level(nodeDetailMember.value.device_id);
} catch (e) {
console.error(e);
}
};
const applyNodeLoggerLevel = async (level: number) => {
if (!nodeDetailMember.value) return;
nodeLoggerSaving.value = true;
try {
await api?.set_node_logger_level(nodeDetailMember.value.device_id, level);
nodeLoggerLevel.value = level;
toast.add({ severity: 'success', summary: t('web.network_detail.log_level'), detail: t('web.device_management.config_saved'), life: 2000 });
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.log_level'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
} finally {
nodeLoggerSaving.value = false;
}
};
const exportNodeConfig = async () => {
if (!nodeDetailMember.value) return;
nodeTomlConfig.value = undefined;
nodeConfigVisible.value = true;
try {
nodeTomlConfig.value = await api?.get_node_toml_config(nodeDetailMember.value.device_id, networkId.value);
} catch (e: any) {
nodeTomlConfig.value = '';
toast.add({ severity: 'error', summary: t('web.network_detail.export_config'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
}
};
const copyNodeConfig = async () => {
if (!nodeTomlConfig.value) return;
try {
await navigator.clipboard.writeText(nodeTomlConfig.value);
toast.add({ severity: 'success', summary: t('web.network_detail.export_config'), detail: t('copy_config'), life: 2000 });
} catch {
toast.add({ severity: 'error', summary: t('web.network_detail.export_config'), detail: 'clipboard unavailable', life: 3000 });
}
};
const downloadNodeConfig = () => {
if (!nodeTomlConfig.value || !nodeDetailMember.value) return;
const blob = new Blob([nodeTomlConfig.value], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${nodeDetailMember.value.hostname ?? nodeDetailMember.value.device_id}.toml`;
a.click();
URL.revokeObjectURL(url);
};
const nodeDetailMember = ref<CentralNetworkMember | null>(null);
const nodeRoutes = ref<NodeRouteInfo[] | undefined>(undefined);
const nodePeers = ref<NodePeerInfo[] | undefined>(undefined);
const nodeAclStats = ref<NodeAclRuleStat[] | undefined>(undefined);
const nodeDetailError = ref<string | undefined>(undefined);
const ipv4ToString = (addr?: { address?: { addr?: number } } | null) => {
const value = addr?.address?.addr;
return value != null
? [24, 16, 8, 0].map(shift => (value >> shift) & 0xff).join('.')
: '';
};
const loadNodeDetail = async () => {
if (!nodeDetailMember.value) return;
const machine_id = nodeDetailMember.value.device_id;
const inst_id = networkId.value;
nodeDetailError.value = undefined;
const results = await Promise.allSettled([
api?.get_node_routes(machine_id, inst_id),
api?.get_node_peers(machine_id, inst_id),
api?.get_node_acl_stats(machine_id, inst_id),
]);
if (results[0].status === 'fulfilled') nodeRoutes.value = results[0].value ?? [];
if (results[1].status === 'fulfilled') nodePeers.value = results[1].value ?? [];
if (results[2].status === 'fulfilled') nodeAclStats.value = results[2].value ?? [];
nodeDetailError.value = results.find(r => r.status === 'rejected')
? String((results.find(r => r.status === 'rejected') as PromiseRejectedResult)?.reason?.response?.data?.message
?? (results.find(r => r.status === 'rejected') as PromiseRejectedResult)?.reason)
: undefined;
};
let nodeDetailTimer: number | undefined;
const openNodeDetail = (member: CentralNetworkMember) => {
nodeDetailMember.value = member;
nodeRoutes.value = undefined;
nodePeers.value = undefined;
nodeAclStats.value = undefined;
nodeDetailVisible.value = true;
nodeLoggerLevel.value = undefined;
loadNodeDetail();
loadNodeGeneral();
window.clearInterval(nodeDetailTimer);
nodeDetailTimer = window.setInterval(loadNodeDetail, 5_000);
};
const closeNodeDetail = () => {
window.clearInterval(nodeDetailTimer);
nodeDetailTimer = undefined;
nodeDetailVisible.value = false;
};
const routeCostLabel = (cost: number) => cost === 1 ? 'p2p' : `relay(${cost})`;
const latencyLabel = (latencyMs: number | undefined) =>
latencyMs == null ? '—' : `${Math.round(latencyMs)} ms`;
// One row per known peer: route facts (hostname / IP / hop cost) merged
// with its direct connections' protocols and best latency. Peers seen only
// in the route table (no direct conn) still show up with relay costs.
const nodePeerRows = computed(() => {
const connsByPeer = new Map<number, NodePeerInfo>();
(nodePeers.value ?? []).forEach(peer => connsByPeer.set(peer.peer_id, peer));
const rows = new Map<number, { key: string; peer_id: number; hostname: string; ipv4: string; cost: number; protocols: string[]; latencyMs?: number }>();
(nodeRoutes.value ?? []).forEach(route => {
const addr = route.ipv4_addr;
const ip = ipv4ToString(addr);
rows.set(route.peer_id, {
key: String(route.peer_id),
peer_id: route.peer_id,
hostname: route.hostname,
ipv4: ip ? `${ip}/${addr?.network_length ?? 24}` : '',
cost: route.cost,
protocols: [],
latencyMs: route.path_latency,
});
});
connsByPeer.forEach((peer, peer_id) => {
const conns = peer.conns ?? [];
const protocols = [...new Set(conns.map(conn => conn.tunnel?.tunnel_type).filter((v): v is string => !!v))];
const bestLatency = conns
.map(conn => conn.stats?.latency_us ? conn.stats.latency_us / 1000 : undefined)
.filter((v): v is number => v != null)
.sort((a, b) => a - b)[0];
const row = rows.get(peer_id) ?? {
key: String(peer_id),
peer_id,
hostname: '',
ipv4: '',
cost: 1,
protocols: [],
latencyMs: undefined,
};
row.protocols = protocols;
if (bestLatency != null && (row.latencyMs == null || bestLatency < row.latencyMs)) {
row.latencyMs = bestLatency;
}
rows.set(peer_id, row);
});
return [...rows.values()].sort((a, b) => a.hostname.localeCompare(b.hostname));
});
// flatten peer connections for the connection table
const nodeConnRows = computed(() => {
const hostnameByPeer = new Map<number, string>();
(nodeRoutes.value ?? []).forEach(route => hostnameByPeer.set(route.peer_id, route.hostname));
const rows: Array<{ conn: NodePeerInfo['conns'][number], hostname: string }> = [];
(nodePeers.value ?? []).forEach(peer => {
(peer.conns ?? []).forEach(conn => {
rows.push({ conn, hostname: hostnameByPeer.get(peer.peer_id) ?? `#${peer.peer_id}` });
});
});
return rows;
});
// --- credentials tab ---
const credentials = ref<NetworkCredential[] | undefined>(undefined);
const loadCredentials = async () => {
if (!network.value?.secure_mode) return;
try {
credentials.value = await api?.list_credentials(networkId.value);
} catch (e) {
console.error(e);
}
};
const highlightedCredential = ref<string | null>(null);
const locateCredential = async (credentialId: string) => {
highlightedCredential.value = credentialId;
activeTab.value = 'credentials';
await switchTabWithCredentials('credentials');
await nextTick();
document
.querySelector('.credential-row-highlight')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const switchTabWithCredentials = async (tab: string) => {
if (tab === 'credentials' && credentials.value === undefined) {
await loadCredentials();
}
};
const ttlOptions = [
{ label: t('web.network_detail.ttl_1h'), value: 1 },
{ label: t('web.network_detail.ttl_24h'), value: 24 },
{ label: t('web.network_detail.ttl_7d'), value: 168 },
{ label: t('web.network_detail.ttl_30d'), value: 720 },
];
const generateVisible = ref(false);
const generating = ref(false);
const generateForm = ref({ ttl_hours: 24, reusable: true });
const generatedCredential = ref<NetworkCredential | null>(null);
const openGenerateCredential = () => {
generateForm.value = { ttl_hours: 24, reusable: true };
generatedCredential.value = null;
generateVisible.value = true;
};
const generateCredential = async () => {
generating.value = true;
try {
const created = await api?.generate_credential(
networkId.value,
generateForm.value.ttl_hours * 3600,
generateForm.value.reusable,
);
if (created) {
generatedCredential.value = created;
}
await loadCredentials();
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.generate_credential'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
} finally {
generating.value = false;
}
};
const revokeCredential = (credential: NetworkCredential) => {
confirm.require({
header: t('web.network_detail.revoke_credential'),
message: t('web.network_detail.revoke_credential_confirm', { id: credential.credential_id }),
acceptLabel: t('web.common.confirm'),
rejectLabel: t('web.common.cancel'),
accept: async () => {
try {
await api?.revoke_credential(networkId.value, credential.credential_id);
await loadCredentials();
revealedSecrets.value.delete(credential.credential_id);
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.revoke_credential'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
}
},
});
};
// --- credential secret reveal & join command viewer ---
const revealedSecrets = ref(new Set<string>());
const toggleSecretRevealed = (credential: NetworkCredential) => {
const next = new Set(revealedSecrets.value);
if (next.has(credential.credential_id)) {
next.delete(credential.credential_id);
} else {
next.add(credential.credential_id);
}
revealedSecrets.value = next;
};
const joinCommandCredential = ref<NetworkCredential | null>(null);
const showJoinCommand = (credential: NetworkCredential) => {
joinCommandCredential.value = credential;
};
const credentialJoinPeer = () => {
if (!network.value) return '';
if (network.value.networking_method === 'PublicServer') return network.value.public_server_url ?? '';
return (network.value.networking_method === 'Gateway' && gatewayInfo.value?.peer_url)
? gatewayInfo.value.peer_url
: (network.value.peer_urls ?? [])[0] ?? '';
};
const credentialJoinCommand = (secret: string) => {
if (!network.value) return '';
const peer = credentialJoinPeer();
return [
'easytier-core',
`--network-name ${network.value.network_name}`,
'--secure-mode',
`--credential ${secret}`,
peer ? `-p ${peer}` : '',
].filter(Boolean).join(' ');
};
// Config-file form of the same join: the credential secret doubles as the
// node's x25519 private key; the matching public key is derived on load.
const credentialJoinToml = (secret: string) => {
if (!network.value) return '';
const peer = credentialJoinPeer();
return [
'[network_identity]',
`network_name = "${network.value.network_name}"`,
'',
'[[peer]]',
`uri = "${peer}"`,
'',
'[secure_mode]',
'enabled = true',
`local_private_key = "${secret}"`,
].join('\n');
};
type JoinFormat = 'cli' | 'toml';
const joinFormat = ref<JoinFormat>('cli');
const joinFormatOptions = [
{ label: t('web.network_detail.credential_join_cli'), value: 'cli' },
{ label: t('web.network_detail.credential_join_toml'), value: 'toml' },
];
const joinText = (secret: string) =>
joinFormat.value === 'cli' ? credentialJoinCommand(secret) : credentialJoinToml(secret);
const copyText = async (text: string, what: string) => {
let copied = false;
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
copied = true;
} catch {
// Fall through to the legacy path below.
}
}
if (!copied) {
// Plain-HTTP deployments have no async clipboard API; the legacy
// synchronous copy still works inside the click gesture.
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
copied = document.execCommand('copy');
} catch {
copied = false;
}
document.body.removeChild(textarea);
}
if (copied) {
toast.add({ severity: 'success', summary: what, detail: t('copy_config'), life: 2000 });
} else {
toast.add({ severity: 'error', summary: what, detail: t('web.network_detail.copy_failed'), life: 4000 });
}
};
// --- danger zone ---
const deleteNetwork = () => {
confirm.require({
header: t('web.network_detail.delete'),
message: t('web.network_detail.delete_confirm', { name: network.value?.display_name ?? '' }),
acceptLabel: t('web.common.confirm'),
rejectLabel: t('web.common.cancel'),
accept: async () => {
try {
await api?.delete_network(networkId.value);
router.push({ name: 'networkList' });
} catch (e: any) {
toast.add({ severity: 'error', summary: t('web.network_detail.delete'), detail: e?.response?.data?.message ?? String(e), life: 5000 });
}
},
});
};
const switchTab = async (tab: string) => {
activeTab.value = tab;
await switchTabWithCredentials(tab);
if (tab === 'settings' && !settingsLoaded.value) {
// Guard the Gateway conversion against the gateway info not having
// arrived yet; otherwise a Gateway network would load as an empty
// list and saving would silently downgrade it to Standalone.
await loadGatewayInfo();
loadSettingsForm();
}
};
</script>
<template>
<div class="console-page">
<ConfirmDialog />
<header class="page-heading">
<div class="flex items-center gap-3 min-w-0">
<Button icon="pi pi-arrow-left" text rounded severity="secondary"
:aria-label="t('web.console.back_networks')" @click="router.push({ name: 'networkList' })" />
<div class="min-w-0">
<h1>{{ network?.display_name ?? networkId }}</h1>
</div>
</div>
</header>
<div v-if="loadError !== undefined">
<Message severity="warn" :closable="false">{{ t('web.console.load_failed') }} <Button :label="t('web.console.retry')" text size="small" @click="loadAll" /></Message>
</div>
<div v-if="network === undefined && !loadError" class="console-panel loading-rows">
<Skeleton v-for="i in 4" :key="i" height="2rem" />
</div>
<Tabs v-if="network !== undefined" :value="activeTab" @update:value="switchTab(String($event))" lazy class="console-panel network-tabs">
<TabList>
<Tab value="members">{{ t('web.network_detail.tab_members') }}</Tab>
<Tab value="acl">{{ t('web.acl.tab') }}</Tab>
<Tab v-if="network.secure_mode" value="credentials">{{ t('web.network_detail.tab_credentials') }}</Tab>
<Tab value="settings">{{ t('web.network_detail.tab_settings') }}</Tab>
</TabList>
<TabPanels>
<!-- members -->
<TabPanel value="members" class="flex flex-col gap-3">
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="text-sm muted flex-1 min-w-48">{{ t('web.network_detail.members_hint') }}</div>
<div class="flex items-center gap-3 shrink-0">
<SelectButton v-model="viewMode" :options="viewModeOptions" optionValue="value"
:allow-empty="false" size="small" class="view-toggle">
<template #option="{ option }">
<i :class="option.icon" v-tooltip.top="viewModeLabel(option.value)"
:aria-label="viewModeLabel(option.value)"></i>
</template>
</SelectButton>
<Button :label="t('web.network_detail.add_member')" icon="pi pi-plus" size="small" class="shrink-0" @click="openAddMember" />
</div>
</div>
<div v-if="members === undefined" class="w-full flex justify-center py-4"><ProgressSpinner /></div>
<div v-else-if="members.length === 0" class="text-center p-6 text-500">{{ t('web.console.no_results') }}</div>
<template v-else>
<DataTable v-if="viewMode === 'table'" :value="members" dataKey="device_id" size="small" class="console-table desktop-list" scrollable>
<Column :header="t('web.device.hostname')">
<template #body="{ data }">
<div class="entity-link">
<span class="status-dot" :class="data.online ? 'online' : 'offline'"
v-tooltip.top="data.online ? t('web.device.online') : t('web.device.offline')"></span>
<div class="min-w-0">
<div class="preview-name">{{ data.hostname_override || data.alias || data.hostname || data.device_id }}</div>
<div v-if="data.hostname_override" class="preview-secondary">{{ data.hostname_override }}</div>
<div v-else-if="data.alias && data.hostname" class="preview-secondary">{{ data.hostname }}</div>
</div>
</div>
</template>
</Column>
<Column :header="t('web.network_detail.static_ip')">
<template #body="{ data }">
<span v-if="data.runtime_virtual_ipv4" class="mono-value">{{ data.runtime_virtual_ipv4 }}</span>
<span v-else-if="data.virtual_ipv4 || data.allocated_ipv4" class="mono-value muted">{{ data.virtual_ipv4 ?? data.allocated_ipv4 }}/{{ network?.virtual_cidr?.split('/')[1] ?? 24 }}</span>
<span v-else class="muted">{{ t('virtual_ipv4_dhcp') }}</span>
</template>
</Column>
<Column :header="t('web.device.version')" style="width: 6rem">
<template #body="{ data }"><span class="muted">v{{ data.version?.split('-')[0] ?? '?' }}</span></template>
</Column>
<Column :header="t('web.device.status')" style="width: 8rem">
<template #body="{ data }">
<Tag v-if="data.temporary" severity="warn" :value="t('web.network_detail.temporary_tag')" />
<Tag v-else-if="data.running === true" severity="success" :value="t('web.network_detail.running')" />
<Tag v-else-if="data.running === false" severity="secondary" :value="t('web.network_detail.stopped')" />
<span v-else class="muted">—</span>
</template>
</Column>
<Column :header="t('web.network_detail.member_error')">
<template #body="{ data }">
<span v-if="data.error_msg" class="text-red-500 truncate" :title="data.error_msg">{{ data.error_msg }}</span>
<span v-else class="muted">—</span>
</template>
</Column>
<Column :header="t('web.console.manage')" style="width: 8rem">
<template #body="{ data }">
<div class="flex items-center gap-1">
<i v-if="data.has_override" class="pi pi-sliders-h override-marker"
v-tooltip.top="t('web.network_detail.has_override')"></i>
<Button icon="pi pi-chart-line" text rounded severity="secondary"
:disabled="!data.online" :aria-label="t('web.network_detail.node_detail')"
v-tooltip.top="data.online ? t('web.network_detail.node_detail') : t('web.network_detail.node_detail_offline')"
@click="openNodeDetail(data)" />
<Button icon="pi pi-pencil" text rounded severity="secondary" :aria-label="t('web.network_detail.edit_member')"
v-tooltip.top="t('web.network_detail.edit_member')"
@click="openEditMember(data)" />
<Button icon="pi pi-trash" text rounded severity="danger" :aria-label="t('web.network_detail.remove_member')"
@click="removeMember(data)" />
</div>
</template>
</Column>
</DataTable>
<div class="mobile-list" :class="{ 'member-card-grid': viewMode === 'card' }">
<div v-for="member in members" :key="member.device_id" class="member-card"
:class="{ offline: !member.online }">
<div class="member-title-row">
<span class="status-dot" :class="member.online ? 'online' : 'offline'"
v-tooltip.top="member.online ? t('web.device.online') : t('web.device.offline')"></span>
<div class="font-semibold truncate" :title="(member.hostname_override || member.alias || member.hostname) ?? member.device_id">
{{ (member.hostname_override || member.alias || member.hostname) ?? member.device_id }}
</div>
</div>
<div class="text-sm flex flex-col gap-0.5 min-w-0 mt-1.5">
<div class="flex items-center gap-1.5 flex-wrap">
<Tag v-if="member.temporary" :value="t('web.network_detail.temporary_tag')" severity="secondary" />
<span v-if="member.temporary && member.credential_expiry_unix"
class="text-xs muted">{{ t('web.network_detail.expires_at') }}
{{ new Date(member.credential_expiry_unix * 1000).toLocaleDateString() }}</span>
<Tag v-if="member.running === true" :value="t('web.network_detail.running')" severity="success" />
<Tag v-else-if="member.running === false" :value="t('web.network_detail.stopped')" severity="warn" />
<span v-if="member.runtime_virtual_ipv4" class="font-mono">{{ member.runtime_virtual_ipv4 }}</span>
<span v-else-if="member.virtual_ipv4 || member.allocated_ipv4" class="font-mono muted">{{ member.virtual_ipv4 ?? member.allocated_ipv4 }}/{{ network?.virtual_cidr?.split('/')[1] ?? 24 }}</span>
<span v-else class="text-500">{{ t('virtual_ipv4_dhcp') }}</span>
<span v-if="member.version" class="text-xs muted">v{{ member.version.split('-')[0] }}</span>
</div>
<span v-if="member.hostname_override" class="text-xs muted truncate"
:title="t('web.network_detail.hostname_override')">{{ member.hostname_override }}</span>
<span v-if="member.error_msg" class="text-xs text-red-500 break-words" :title="member.error_msg">{{ member.error_msg }}</span>
</div>
<div class="member-actions">
<span v-if="member.has_override" class="pi pi-sliders override-marker"
v-tooltip.top="t('web.network_detail.has_override')"></span>
<Button icon="pi pi-chart-line" text rounded severity="secondary"
:disabled="!member.online" :aria-label="t('web.network_detail.node_detail')"
v-tooltip.top="member.online ? t('web.network_detail.node_detail') : t('web.network_detail.node_detail_offline')"
@click="openNodeDetail(member)" />
<Button icon="pi pi-pencil" text rounded severity="secondary" :aria-label="t('web.network_detail.edit_member')"
v-tooltip.top="t('web.network_detail.edit_member')"
@click="openEditMember(member)" />
<Button icon="pi pi-trash" text rounded severity="danger" :aria-label="t('web.network_detail.remove_member')"
@click="removeMember(member)" />
</div>
</div>
</div>
</template>
<div v-if="temporaryPeers.length > 0" class="flex flex-col gap-2">
<div class="text-sm font-semibold flex items-center gap-2">
<i class="pi pi-clock text-xs"></i>
{{ t('web.network_detail.temporary_devices') }}
<span class="text-xs font-normal muted">{{ t('web.network_detail.temporary_devices_hint') }}</span>
</div>
<DataTable :value="temporaryPeers" size="small" class="console-table desktop-list" scrollable>
<Column :header="t('web.device.hostname')">
<template #body="{ data }">
<div class="entity-link">
<span class="status-dot online" v-tooltip.top="t('web.device.online')"></span>
<span class="preview-name">{{ data.hostname ?? t('web.network_detail.temporary_device') }}</span>
<Tag severity="warn" :value="t('web.network_detail.temporary_tag')" class="shrink-0" />
</div>
</template>
</Column>
<Column :header="t('web.network_detail.static_ip')">
<template #body="{ data }"><span class="mono-value">{{ data.ipv4 ?? '—' }}</span></template>
</Column>
<Column :header="t('web.device.version')" style="width: 6rem">
<template #body="{ data }"><span class="muted">v{{ data.version?.split('-')[0] ?? '?' }}</span></template>
</Column>
<Column :header="t('web.network_detail.temporary_credential')">
<template #body="{ data }">
<span v-if="data.credential_id" class="mono-value muted" :title="data.credential_id">{{ data.credential_id.slice(0, 13) }}…</span>
<span v-else class="muted truncate" :title="t('web.network_detail.temporary_credential_unknown')">{{ t('web.network_detail.temporary_credential_unknown') }}</span>
</template>
</Column>
<Column :header="t('web.network_detail.credential_expiry')" style="width: 8rem">
<template #body="{ data }">
<span v-if="data.credential_expiry_unix" class="muted">{{ new Date(data.credential_expiry_unix * 1000).toLocaleDateString() }}</span>
<span v-else class="muted">—</span>
</template>
</Column>
<Column :header="t('web.console.manage')" style="width: 8rem">
<template #body="{ data }">
<Button v-if="data.credential_id" :label="t('web.network_detail.locate_credential')" text size="small" severity="secondary"
@click="locateCredential(data.credential_id)" />
</template>
</Column>
</DataTable>
<div class="mobile-list">
<div v-for="peer in temporaryPeers" :key="peer.peer_id" class="member-card">
<div class="member-title-row">
<span class="status-dot online"
v-tooltip.top="t('web.device.online')"></span>
<div class="font-semibold truncate" :title="peer.hostname ?? t('web.network_detail.temporary_device')">
{{ peer.hostname ?? t('web.network_detail.temporary_device') }}
</div>
<Tag :value="t('web.network_detail.temporary_tag')" severity="secondary" class="ml-1 shrink-0" />
</div>
<div class="text-sm flex items-center gap-1.5 flex-wrap mt-1.5">
<span v-if="peer.ipv4" class="font-mono">{{ peer.ipv4 }}</span>
<span v-if="peer.version" class="text-xs muted">v{{ peer.version.split('-')[0] }}</span>
<template v-if="peer.credential_id">
<span class="text-xs muted truncate" :title="peer.credential_id">
{{ peer.credential_id.slice(0, 13) }}…
</span>
<span v-if="peer.credential_expiry_unix" class="text-xs muted">
· {{ t('web.network_detail.expires_at') }}
{{ new Date(peer.credential_expiry_unix * 1000).toLocaleDateString() }}
</span>
<Button :label="t('web.network_detail.locate_credential')" text size="small" severity="secondary"
@click="locateCredential(peer.credential_id!)" />
</template>
<span v-else class="text-xs muted truncate" :title="t('web.network_detail.temporary_credential_unknown')">
{{ t('web.network_detail.temporary_credential_unknown') }}
</span>
</div>
</div>
</div>
</div>
</TabPanel>
<!-- credentials -->
<TabPanel value="acl" class="flex flex-col gap-3">
<p class="text-sm muted">{{ t('web.acl.trust_hint') }}</p>
<AclPolicyTab v-if="api" :api="api" :network-id="networkId" :members="members" />
</TabPanel>
<TabPanel value="credentials" class="flex flex-col gap-3">
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="text-sm muted flex-1 min-w-48">{{ t('web.network_detail.credentials_hint') }}</div>
<Button :label="t('web.network_detail.generate_credential')" icon="pi pi-key" size="small" class="shrink-0"
@click="openGenerateCredential" />
</div>
<DataTable :value="credentials ?? []" dataKey="credential_id" size="small" class="console-table" scrollable tableStyle="min-width: 50rem"
:rowClass="(data: any) => data.credential_id === highlightedCredential ? 'credential-row-highlight' : ''">
<Column field="credential_id" :header="t('web.network_detail.credential_id')" />
<Column :header="t('web.network_detail.credential_secret')"><template #body="{ data }">
<div class="flex items-center gap-1.5">
<span v-if="revealedSecrets.has(data.credential_id)" class="font-mono text-xs break-all">{{ data.credential_secret }}</span>
<span v-else class="font-mono text-xs select-none">••••••••••••••••••••••</span>
<Button :icon="revealedSecrets.has(data.credential_id) ? 'pi pi-eye-slash' : 'pi pi-eye'" text rounded severity="secondary"
:aria-label="t('web.network_detail.credential_secret')"
v-tooltip.top="revealedSecrets.has(data.credential_id) ? t('web.network_detail.credential_hide_secret') : t('web.network_detail.credential_show_secret')"
@click="toggleSecretRevealed(data)" />
<Button icon="pi pi-copy" text rounded severity="secondary"
:aria-label="t('web.network_detail.credential_copy_secret')"
v-tooltip.top="t('web.network_detail.credential_copy_secret')"
@click="copyText(data.credential_secret, t('web.network_detail.credential_secret'))" />
</div>
</template></Column>
<Column :header="t('web.network_detail.credential_expiry')"><template #body="{ data }">
{{ new Date(data.expiry_unix * 1000).toLocaleString() }}
</template></Column>
<Column :header="t('web.network_detail.credential_online_devices')"><template #body="{ data }">
<span v-if="!data.online_peers?.length" class="text-500">—</span>
<div v-else class="flex flex-col gap-0.5">
<span v-for="peer in data.online_peers" :key="peer.peer_id"
class="text-sm truncate">
{{ peer.hostname ?? t('web.network_detail.temporary_device') }}<span v-if="peer.ipv4" class="font-mono text-xs muted">&nbsp;{{ peer.ipv4 }}</span>
</span>
</div>
</template></Column>
<Column :header="t('web.network_detail.credential_status')"><template #body="{ data }">
<Tag :value="data.expiry_unix > Date.now() / 1000 ? t('web.network_detail.credential_active') : t('web.network_detail.credential_expired')"
:severity="data.expiry_unix > Date.now() / 1000 ? 'success' : 'danger'" />
</template></Column>
<Column :header="t('web.network_detail.credential_reusable')"><template #body="{ data }">
{{ data.reusable ? t('web.common.confirm') : '—' }}
</template></Column>
<Column :header="t('web.console.manage')"><template #body="{ data }">
<div class="flex items-center gap-1">
<Button :label="t('web.network_detail.credential_show_command')" text size="small" severity="secondary"
@click="showJoinCommand(data)" />
<Button :label="t('web.network_detail.revoke_credential')" text size="small" severity="danger"
@click="revokeCredential(data)" />
</div>
</template></Column>
</DataTable>
</TabPanel>
<!-- settings -->
<TabPanel value="settings">
<div v-if="!settingsLoaded" class="loading-rows"><Skeleton v-for="i in 4" :key="i" height="2rem" /></div>
<div v-else class="settings-form">
<div class="flex flex-col gap-1">
<label for="settings-display-name">{{ t('web.network_list.display_name') }}</label>
<InputText id="settings-display-name" v-model="settingsForm.display_name" />
</div>
<div class="flex flex-col gap-1">
<label for="settings-network-name">{{ t('web.network_detail.mesh_name') }}</label>
<span id="settings-network-name" class="mono-value muted">{{ settingsForm.network_name }}</span>
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center">
<label for="settings-virtual-cidr">{{ t('web.network_list.virtual_cidr') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('web.network_list.virtual_cidr_hint')"></span>
</div>
<InputText id="settings-virtual-cidr" v-model="settingsForm.virtual_cidr" class="font-mono"
@blur="settingsForm.virtual_cidr = normalizeVirtualSubnet(settingsForm.virtual_cidr)"
:placeholder="t('web.network_list.virtual_cidr_placeholder')" />
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center">
<label for="settings-secure-mode">{{ t('web.network_list.secure_mode') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('web.network_list.secure_mode_hint')"></span>
</div>
<InputSwitch inputId="settings-secure-mode" v-model="settingsForm.secure_mode" />
</div>
<div class="flex flex-col gap-1">
<label>{{ t('network_secret') }}</label>
<div class="flex items-center gap-2">
<InputText :model-value="network.network_secret" :aria-label="t('network_secret')" readonly class="flex-1 min-w-0" />
</div>
<div class="flex items-center gap-2 mt-1">
<InputSwitch inputId="regenerate-secret" v-model="settingsForm.regenerate_secret" />
<label for="regenerate-secret" class="text-sm">{{ t('web.network_detail.regenerate_secret') }}</label>
</div>
</div>
<div v-if="gatewayFollowed()" class="flex items-start gap-2 p-3 surface-100 rounded-md">
<i class="pi pi-check-circle text-green-500 mt-0.5"></i>
<div class="text-sm">{{ t('web.network_detail.gateway_followed') }}</div>
</div>
<div class="flex items-center">
<label for="settings-initial-nodes">{{ t('initial_nodes') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('initial_nodes_help')"></span>
</div>
<div class="items-center flex flex-col p-fluid gap-y-2">
<UrlListInput id="settings-initial-nodes" v-model="initialNodes" :protos="protos"
defaultUrl="tcp://:11010" :add-label="t('add_initial_node')"
:placeholder="t('initial_node_placeholder')" />
</div>
<div class="flex gap-2">
<Button :label="t('web.common.save')" icon="pi pi-check" :loading="savingSettings" @click="saveSettings" />
</div>
<div class="mt-6 pt-4 border-t border-red-200 flex flex-col gap-2">
<div class="text-lg font-semibold text-red-500">{{ t('web.network_detail.danger_zone') }}</div>
<div class="text-sm text-500">{{ t('web.network_detail.delete_hint') }}</div>
<div>
<Button :label="t('web.network_detail.delete')" icon="pi pi-trash" severity="danger" outlined
@click="deleteNetwork" />
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- add member dialog -->
<Dialog v-model:visible="addMemberVisible" modal class="console-dialog" :header="t('web.network_detail.add_member')" :style="{ width: '32rem' }">
<div class="flex flex-col gap-2">
<div v-if="candidateDevices.length === 0" class="text-sm text-500">
{{ t('web.network_detail.no_candidate_devices') }}
</div>
</div>
<DataTable v-model:selection="selectedDevices" :value="candidateDevices" dataKey="machine_id" size="small" scrollable
scrollHeight="20rem" selectionMode="multiple">
<Column selectionMode="multiple" style="width: 3rem" />
<Column field="hostname" :header="t('web.device.hostname')" />
<Column field="easytier_version" :header="t('web.device.version')" />
</DataTable>
<div class="flex flex-col gap-2 mt-2">
<div class="flex items-center gap-2">
<InputSwitch inputId="add-temporary" v-model="addTemporary" :disabled="!network?.secure_mode" />
<label for="add-temporary" class="text-sm"
:class="{ 'text-500': !network?.secure_mode }">{{ t('web.network_detail.add_temporary_member') }}</label>
<span class="pi pi-question-circle text-sm" v-tooltip.top="network?.secure_mode
? t('web.network_detail.add_temporary_hint')
: t('web.network_detail.add_temporary_needs_secure')"></span>
</div>
<div v-if="addTemporary" class="flex items-center gap-2">
<label for="add-ttl" class="text-sm">{{ t('web.network_detail.credential_ttl') }}</label>
<SelectButton v-model="addTtlHours" :options="addTtlOptions"
optionLabel="label" optionValue="value" size="small" />
</div>
</div>
<template #footer>
<Button :label="t('web.common.cancel')" icon="pi pi-times" text severity="secondary" @click="addMemberVisible = false" />
<Button :label="t('web.common.confirm')" icon="pi pi-check" :loading="adding" :disabled="selectedDevices.length === 0"
@click="addMembers" />
</template>
</Dialog>
<!-- edit member dialog -->
<Dialog v-model:visible="editMemberVisible" modal class="console-dialog"
:header="`${t('web.network_detail.edit_member')} · ${editingMember?.hostname ?? editingMember?.device_id ?? ''}`"
:style="{ width: 'min(52rem, 96vw)' }" :maximizable="true">
<Tabs :value="editTab" @update:value="(v: any) => editTab = v">
<TabList>
<Tab value="basic">{{ t('web.network_detail.tab_basic_settings') }}</Tab>
<Tab value="advanced">{{ t('web.network_detail.advanced_config') }}</Tab>
</TabList>
<TabPanels>
<TabPanel value="basic">
<div class="flex flex-col gap-3 max-w-30rem">
<div class="flex flex-col gap-1">
<label for="edit-hostname-override">{{ t('web.network_detail.hostname_override') }}</label>
<InputText id="edit-hostname-override" v-model="editMemberForm.hostname_override"
:placeholder="t('web.network_detail.hostname_override_placeholder')" />
</div>
<div class="flex flex-col gap-1">
<label for="edit-virtual-ipv4">{{ t('web.network_detail.static_ip') }}</label>
<InputText id="edit-virtual-ipv4" v-model="editMemberForm.virtual_ipv4" placeholder="10.126.126.10" />
<div class="text-xs text-500">{{ t('web.network_detail.static_ip_hint') }}</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center">
<label>{{ t('proxy_cidrs') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('web.network_detail.proxy_cidr_hint')"></span>
</div>
<!-- 设备检测到的网段:点击即添加/移除 -->
<div v-if="candidateCidrs === undefined" class="text-xs muted">{{ t('web.console.loading') }}</div>
<div v-else-if="candidateCidrs.length > 0" class="flex flex-wrap gap-2">
<Button v-for="cidr in candidateCidrs" :key="cidr"
:label="cidr"
:severity="editProxyCidrs.includes(cidr) ? 'primary' : 'secondary'"
size="small" :outlined="!editProxyCidrs.includes(cidr)"
class="font-mono"
:aria-label="editProxyCidrs.includes(cidr)
? t('web.network_detail.proxy_remove_suggestion', { cidr })
: t('web.network_detail.proxy_add_suggestion', { cidr })"
@click="toggleCandidateCidr(cidr)" />
</div>
<div v-else class="text-xs muted">{{ t('web.network_detail.proxy_no_suggestions') }}</div>
<!-- 已配置(含手动添加的远端网段) -->
<div v-if="editProxyCidrs.length > 0" class="flex flex-wrap gap-2">
<Tag v-for="cidr in editProxyCidrs" :key="cidr" :value="cidr"
class="font-mono" closable @close="editProxyCidrs.splice(editProxyCidrs.indexOf(cidr), 1)" />
</div>
<!-- 手动输入兜底(远端网段/映射网段) -->
<div class="flex items-center gap-2">
<InputText v-model="proxyCidrInput"
:placeholder="t('web.network_detail.proxy_cidr_placeholder')"
class="flex-1 font-mono"
@keydown.enter.prevent="onProxyCidrEnter" />
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="secondary" outlined size="small"
:aria-label="t('web.common.add')" @click="addProxyCidr" />
</div>
</div>
</div>
</TabPanel>
<TabPanel value="advanced">
<div v-if="!configLoaded" class="flex justify-center py-6"><ProgressSpinner /></div>
<Config v-else v-model:cur-network="configForm" :config-invalid="false" :hide-secure-mode="true"
:edit-vpn-portal-clients="true" />
</TabPanel>
</TabPanels>
</Tabs>
<template #footer>
<template v-if="editTab === 'basic'">
<Button :label="t('web.common.cancel')" icon="pi pi-times" text severity="secondary" @click="editMemberVisible = false" />
<Button :label="t('web.common.save')" icon="pi pi-check" :loading="savingMember" @click="saveMember" />
</template>
<template v-else>
<Button v-if="configMember?.has_override" :label="t('web.network_detail.reset_default')" icon="pi pi-replay"
severity="secondary" text :loading="configSaving" @click="resetMemberConfig" />
<Button :label="t('web.common.cancel')" icon="pi pi-times" text severity="secondary" @click="editMemberVisible = false" />
<Button :label="t('web.common.save')" icon="pi pi-check" :loading="configSaving" :disabled="!configLoaded" @click="saveMemberConfig" />
</template>
</template>
</Dialog>
<!-- generate credential dialog -->
<Dialog v-model:visible="generateVisible" modal class="console-dialog"
:header="t('web.network_detail.generate_credential')" :style="{ width: '34rem' }">
<div v-if="!generatedCredential" class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label for="cred-ttl">{{ t('web.network_detail.credential_ttl') }}</label>
<Select id="cred-ttl" v-model="generateForm.ttl_hours" :options="ttlOptions"
optionLabel="label" optionValue="value" />
</div>
<div class="flex items-center gap-2">
<InputSwitch inputId="cred-reusable" v-model="generateForm.reusable" />
<label for="cred-reusable" class="text-sm">{{ t('web.network_detail.credential_reusable') }}</label>
</div>
<div class="text-xs text-500">{{ t('web.network_detail.credential_reusable_hint') }}</div>
</div>
<div v-else class="flex flex-col gap-3">
<div class="text-sm">{{ t('web.network_detail.credential_secret_hint') }}</div>
<div class="flex items-center gap-2">
<InputText :value="generatedCredential.credential_secret" readonly class="flex-1 font-mono text-xs" />
<Button icon="pi pi-copy" text rounded severity="secondary"
:aria-label="t('copy_config')" @click="copyText(generatedCredential.credential_secret, t('web.network_detail.generate_credential'))" />
</div>
<div class="text-sm font-semibold">{{ t('web.network_detail.join_command') }}</div>
<div class="flex items-start gap-2">
<pre class="flex-1 font-mono text-xs whitespace-pre-wrap break-all surface-100 rounded-md p-3 m-0">{{ credentialJoinCommand(generatedCredential.credential_secret) }}</pre>
<Button icon="pi pi-copy" text rounded severity="secondary"
:aria-label="t('copy_config')" @click="copyText(credentialJoinCommand(generatedCredential.credential_secret), t('web.network_detail.join_command'))" />
</div>
</div>
<template #footer>
<Button v-if="!generatedCredential" :label="t('web.common.cancel')" icon="pi pi-times" text severity="secondary" @click="generateVisible = false" />
<Button v-else :label="t('web.common.confirm')" icon="pi pi-check" text severity="secondary" @click="generateVisible = false" />
<Button v-if="!generatedCredential" :label="t('web.network_detail.generate_credential')" icon="pi pi-key" :loading="generating" @click="generateCredential" />
</template>
</Dialog>
<!-- join command viewer -->
<Dialog :visible="joinCommandCredential !== null" modal class="console-dialog"
:header="t('web.network_detail.join_command')" :style="{ width: '42rem' }"
@update:visible="joinCommandCredential = null">
<div v-if="joinCommandCredential" class="flex flex-col gap-3">
<SelectButton v-model="joinFormat" :options="joinFormatOptions"
optionLabel="label" optionValue="value" />
<div class="flex items-start gap-2">
<pre class="flex-1 font-mono text-xs whitespace-pre-wrap break-all surface-100 rounded-md p-3 m-0 select-text">{{ joinText(joinCommandCredential.credential_secret) }}</pre>
<Button icon="pi pi-copy" text rounded severity="secondary"
:aria-label="t('web.network_detail.credential_copy_command')"
v-tooltip.top="t('web.network_detail.credential_copy_command')"
@click="copyText(joinText(joinCommandCredential.credential_secret), t('web.network_detail.join_command'))" />
</div>
</div>
</Dialog>
<!-- node detail drawer -->
<Drawer :visible="nodeDetailVisible" @update:visible="closeNodeDetail" position="right"
class="console-node-drawer" :style="{ width: 'min(48rem, 100vw)' }">
<template #header>
<div class="node-drawer-heading">
<span class="node-drawer-icon pi pi-desktop" aria-hidden="true"></span>
<div class="node-drawer-identity">
<h2 class="node-drawer-title">{{ (nodeDetailMember?.hostname_override || nodeDetailMember?.alias || nodeDetailMember?.hostname) ?? nodeDetailMember?.device_id }}</h2>
<div class="node-drawer-meta">
<span v-if="nodeDetailMember?.runtime_virtual_ipv4 || nodeDetailMember?.virtual_ipv4 || nodeDetailMember?.allocated_ipv4" class="mono-value">{{ nodeDetailMember?.runtime_virtual_ipv4 ?? nodeDetailMember?.virtual_ipv4 ?? nodeDetailMember?.allocated_ipv4 }}</span>
<span v-if="nodeDetailMember?.version">v{{ nodeDetailMember.version.split('-')[0] }}</span>
</div>
</div>
<span class="node-drawer-presence">
<span class="status-dot" :class="nodeDetailMember?.online ? 'online' : 'offline'" aria-hidden="true"></span>
{{ t(nodeDetailMember?.online ? 'web.device.online' : 'web.device.offline') }}
</span>
</div>
</template>
<div class="node-drawer-main">
<Message v-if="nodeDetailError" severity="warn" :closable="false">{{ nodeDetailError }}</Message>
<section class="node-drawer-summary" :aria-label="t('web.network_detail.node_overview')">
<div class="node-drawer-metric node-drawer-metric-state">
<strong>{{ nodeDetailMember?.running === true ? t('web.network_detail.running') : nodeDetailMember?.running === false ? t('web.network_detail.stopped') : '—' }}</strong>
<span class="node-drawer-metric-label">{{ t('web.network_detail.instance_state') }}</span>
</div>
<div class="node-drawer-metric">
<strong>{{ nodePeers === undefined || nodeRoutes === undefined ? '—' : nodePeerRows.length }}</strong>
<span class="node-drawer-metric-label">{{ t('web.network_detail.node_peers') }}</span>
</div>
<div class="node-drawer-metric">
<strong>{{ nodeRoutes?.length ?? '—' }}</strong>
<span class="node-drawer-metric-label">{{ t('web.network_detail.node_routes') }}</span>
</div>
<div class="node-drawer-metric">
<strong>{{ nodePeers === undefined ? '—' : nodeConnRows.length }}</strong>
<span class="node-drawer-metric-label">{{ t('web.network_detail.node_conns') }}</span>
</div>
</section>
<Tabs value="overview" class="node-drawer-tabs">
<TabList>
<Tab value="overview">{{ t('web.network_detail.node_overview') }}</Tab>
<Tab value="general">{{ t('web.network_detail.node_actions') }}</Tab>
</TabList>
<TabPanels>
<TabPanel value="overview" class="node-drawer-panel">
<section v-if="!nodeDetailError || nodePeerRows.length > 0 || (nodePeers !== undefined && nodeRoutes !== undefined)" class="node-drawer-section">
<div v-if="(nodePeers === undefined && nodeRoutes === undefined) || nodePeerRows.length > 0" class="node-drawer-section-heading">
<h3>{{ t('web.network_detail.node_peers') }}</h3>
</div>
<div v-if="nodePeers === undefined && nodeRoutes === undefined" class="node-drawer-loading">
<Skeleton height="2.5rem" />
<Skeleton height="2.5rem" />
</div>
<div v-else-if="nodePeerRows.length === 0" class="node-drawer-empty">
<i class="pi pi-share-alt" aria-hidden="true"></i>
<span><strong>{{ t('web.network_detail.node_no_peers') }}</strong> · {{ t('web.network_detail.node_no_peers_hint') }}</span>
</div>
<template v-else>
<DataTable :value="nodePeerRows" dataKey="key" size="small" class="console-table node-drawer-desktop-peers" scrollable
:paginator="nodePeerRows.length > 15" :rows="15" tableStyle="min-width: 40rem">
<Column field="hostname" :header="t('web.device.hostname')">
<template #body="{ data }">{{ data.hostname || `#${data.peer_id}` }}</template>
</Column>
<Column field="ipv4" :header="t('virtual_ipv4')">
<template #body="{ data }">
<span class="font-mono">{{ data.ipv4 || '—' }}</span>
</template>
</Column>
<Column :header="t('web.network_detail.route_cost')"><template #body="{ data }">
{{ routeCostLabel(data.cost) }}
</template></Column>
<Column :header="t('web.network_detail.conn_type')"><template #body="{ data }">
<span class="font-mono text-xs">{{ data.protocols.join(' / ') || '—' }}</span>
</template></Column>
<Column :header="t('web.network_detail.path_latency')"><template #body="{ data }">
{{ latencyLabel(data.latencyMs) }}
</template></Column>
</DataTable>
<div class="node-drawer-mobile-peers">
<div v-for="peer in nodePeerRows" :key="peer.key" class="node-drawer-mobile-peer">
<div class="node-drawer-mobile-peer-heading">
<strong>{{ peer.hostname || `#${peer.peer_id}` }}</strong>
<span class="mono-value">{{ peer.ipv4 || '—' }}</span>
</div>
<div class="node-drawer-mobile-peer-path">
<span>{{ routeCostLabel(peer.cost) }}</span>
<span>{{ peer.protocols.join(' / ') || '—' }}</span>
<span>{{ latencyLabel(peer.latencyMs) }}</span>
</div>
</div>
</div>
</template>
</section>
<details v-if="(nodeRoutes?.length ?? 0) > 0" class="node-drawer-disclosure">
<summary><span>{{ t('web.network_detail.node_routes') }}</span><span class="node-drawer-disclosure-count">{{ nodeRoutes?.length }}</span><i class="pi pi-chevron-down" aria-hidden="true"></i></summary>
<DataTable :value="nodeRoutes ?? []" size="small" class="console-table" scrollable
:paginator="(nodeRoutes?.length ?? 0) > 15" :rows="15" tableStyle="min-width: 40rem">
<Column field="hostname" :header="t('web.device.hostname')" />
<Column :header="t('virtual_ipv4')"><template #body="{ data }">
<span class="font-mono">{{ ipv4ToString(data.ipv4_addr) }}/{{ data.ipv4_addr?.network_length ?? 24 }}</span>
</template></Column>
<Column :header="t('web.network_detail.route_cost')"><template #body="{ data }">
{{ routeCostLabel(data.cost) }}
</template></Column>
<Column :header="t('web.network_detail.path_latency')"><template #body="{ data }">
{{ latencyLabel(data.path_latency) }}
</template></Column>
<Column field="proxy_cidrs" :header="t('proxy_cidrs')"><template #body="{ data }">
<span class="font-mono text-xs">{{ (data.proxy_cidrs ?? []).join(', ') || '—' }}</span>
</template></Column>
<Column field="version" :header="t('web.device.version')"><template #body="{ data }">
<span class="text-xs muted">v{{ (data.version || '?').split('-')[0] }}</span>
</template></Column>
</DataTable>
</details>
<details v-if="nodeConnRows.length > 0" class="node-drawer-disclosure">
<summary><span>{{ t('web.network_detail.node_conns') }}</span><span class="node-drawer-disclosure-count">{{ nodeConnRows.length }}</span><i class="pi pi-chevron-down" aria-hidden="true"></i></summary>
<DataTable :value="nodeConnRows" size="small" class="console-table" scrollable
:paginator="nodeConnRows.length > 15" :rows="15" tableStyle="min-width: 40rem">
<Column field="hostname" :header="t('web.device.hostname')" />
<Column :header="t('web.network_detail.conn_type')"><template #body="{ data }">
<span class="font-mono text-xs">{{ data.conn.tunnel?.tunnel_type || '—' }}</span>
</template></Column>
<Column :header="t('web.network_detail.conn_remote')"><template #body="{ data }">
<span class="font-mono text-xs">{{ data.conn.tunnel?.remote_addr?.url ?? '—' }}</span>
</template></Column>
<Column :header="t('web.network_detail.path_latency')"><template #body="{ data }">
{{ data.conn.stats ? latencyLabel(data.conn.stats.latency_us ? data.conn.stats.latency_us / 1000 : undefined) : '—' }}
</template></Column>
<Column :header="t('web.network_detail.loss_rate')"><template #body="{ data }">
{{ ((data.conn.loss_rate ?? 0) * 100).toFixed(1) + '%' }}
</template></Column>
<Column :header="t('web.network_detail.traffic')"><template #body="{ data }">
<span class="font-mono text-xs">↓{{ ((data.conn.stats?.rx_bytes ?? 0) / 1024).toFixed(0) }}K ↑{{ ((data.conn.stats?.tx_bytes ?? 0) / 1024).toFixed(0) }}K</span>
</template></Column>
</DataTable>
</details>
<details v-if="(nodeAclStats ?? []).length > 0" class="node-drawer-disclosure">
<summary><span>{{ t('web.network_detail.acl_stats') }}</span><span class="node-drawer-disclosure-count">{{ nodeAclStats?.length }}</span><i class="pi pi-chevron-down" aria-hidden="true"></i></summary>
<DataTable :value="nodeAclStats!" size="small" class="console-table" scrollable tableStyle="min-width: 32rem">
<Column :header="t('web.network_detail.acl_rule')"><template #body="{ data }">
{{ data.rule?.name || '—' }}
</template></Column>
<Column :header="t('web.network_detail.acl_packets')"><template #body="{ data }">
{{ data.stat?.packet_count ?? 0 }}
</template></Column>
<Column :header="t('web.network_detail.acl_bytes')"><template #body="{ data }">
{{ data.stat?.byte_count ?? 0 }}
</template></Column>
</DataTable>
</details>
</TabPanel>
<TabPanel value="general" class="node-drawer-panel node-drawer-actions">
<div class="node-drawer-action">
<div>
<div class="font-semibold">{{ t('web.network_detail.log_level') }}</div>
<div class="text-xs muted">{{ t('web.network_detail.log_level_hint') }}</div>
</div>
<Select :model-value="nodeLoggerLevel" :options="loggerLevelOptions" optionLabel="label"
optionValue="value" :loading="nodeLoggerLevel === undefined" :disabled="nodeLoggerSaving"
:placeholder="t('web.console.loading')"
class="min-w-40" @update:model-value="applyNodeLoggerLevel" />
</div>
<div class="node-drawer-action">
<div>
<div class="font-semibold">{{ t('web.network_detail.export_config') }}</div>
<div class="text-xs muted">{{ t('web.network_detail.export_config_hint') }}</div>
</div>
<Button :label="t('web.network_detail.export_config')" icon="pi pi-download" severity="secondary" outlined
@click="exportNodeConfig" />
</div>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</Drawer>
<!-- node config export dialog -->
<Dialog v-model:visible="nodeConfigVisible" modal class="console-dialog"
:header="`${t('web.network_detail.export_config')} · ${nodeDetailMember?.hostname ?? ''}`"
:style="{ width: 'min(48rem, 96vw)' }">
<ScrollPanel style="height: 60vh">
<pre v-if="nodeTomlConfig" class="font-mono text-xs whitespace-pre-wrap">{{ nodeTomlConfig }}</pre>
<div v-else-if="nodeTomlConfig === undefined" class="text-center py-6"><ProgressSpinner /></div>
<div v-else class="text-center py-6 text-500">—</div>
</ScrollPanel>
<template #footer>
<Button :label="t('copy_config')" icon="pi pi-copy" text severity="secondary" :disabled="!nodeTomlConfig" @click="copyNodeConfig" />
<Button :label="t('web.network_detail.download')" icon="pi pi-download" text severity="secondary" :disabled="!nodeTomlConfig" @click="downloadNodeConfig" />
<Button :label="t('web.common.cancel')" icon="pi pi-times" text severity="secondary" @click="nodeConfigVisible = false" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.member-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.member-card {
border: 1px solid var(--console-border);
border-radius: 0.75rem;
background: var(--surface-card);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.mobile-list .member-card {
border: 0;
border-bottom: 1px solid var(--console-border);
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 16px 0;
}
.mobile-list .member-card:hover {
transform: none;
box-shadow: none;
}
.mobile-list .member-card:last-child {
border-bottom: 0;
}
/* 桌面卡片视图下恢复卡片的独立外观(移动端 .mobile-list 内仍是扁平列表项) */
.member-card-grid .member-card.member-card {
border: 1px solid var(--console-border);
border-radius: 0.75rem;
background: var(--surface-card);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
padding: 1rem 1.1rem;
}
@media (max-width: 767px) {
.member-card-grid {
display: block;
}
.member-card-grid .member-card.member-card {
border: 0;
border-bottom: 1px solid var(--console-border);
border-radius: 0;
background: transparent;
box-shadow: none;
padding: 16px 0;
}
.member-card-grid .member-card.member-card:hover {
transform: none;
box-shadow: none;
}
.member-card-grid .member-card:last-child {
border-bottom: 0;
}
}
.member-card.offline {
opacity: 0.72;
}
.member-title-row {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.override-marker { color: var(--p-primary-color); font-size: 0.8rem; margin-right: 0.25rem; }
.member-actions {
display: flex;
justify-content: flex-end;
gap: 0.25rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--console-border);
}
:deep(tr.credential-row-highlight) {
background-color: var(--p-highlight-background, rgba(59, 130, 246, 0.12));
outline: 1px solid var(--p-primary-color, #3b82f6);
outline-offset: -1px;
}
</style>