Files
Easytier_lkddi/easytier-web/frontend-lib/src/components/RemoteManagement.vue

595 lines
20 KiB
Vue

<script setup lang="ts">
import { Button, ConfirmPopup, Divider, IftaLabel, Menu, Select, useConfirm, useToast } from 'primevue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import * as Api from '../modules/api';
import { RemoteClient } from '../modules/api';
import * as Utils from '../modules/utils';
import * as NetworkTypes from '../types/network';
const { t } = useI18n()
const props = defineProps<{
api: RemoteClient;
newConfigGenerator?: () => NetworkTypes.NetworkConfig;
}>();
const instanceId = defineModel('instanceId', {
type: String as () => string | undefined,
required: false,
})
const emits = defineEmits(['update']);
const toast = useToast();
const configFile = ref();
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
const isEditing = ref(false);
// const showCreateNetworkDialog = ref(false);
const showConfigEditDialog = ref(false);
const isCreatingNetwork = ref(false); // Flag to indicate if we're in network creation mode
const editingNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefined);
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
const instanceIdList = computed(() => {
let insts = new Set<string>();
let t = listInstanceIdResponse.value;
if (t) {
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
}
let options = Array.from(insts).map((instance: string) => {
return { uuid: instance };
});
return options;
});
const selectedInstanceId = computed({
get() {
return instanceIdList.value.find((instance) => instance.uuid === instanceId.value);
},
set(value: any) {
console.log("set instanceId", value);
instanceId.value = value ? value.uuid : undefined;
}
});
watch(selectedInstanceId, async (newVal, oldVal) => {
if (newVal?.uuid !== oldVal?.uuid && networkIsDisabled.value) {
await loadCurrentNetworkConfig();
}
});
const needShowNetworkStatus = computed(() => {
if (!selectedInstanceId.value) {
// nothing selected
return false;
}
if (networkIsDisabled.value) {
// network is disabled
return false;
}
return true;
})
const networkIsDisabled = computed(() => {
if (!selectedInstanceId.value) {
return false;
}
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
});
watch(networkIsDisabled, async (newVal, oldVal) => {
if (newVal !== oldVal && newVal === true) {
await loadCurrentNetworkConfig();
}
});
const loadCurrentNetworkConfig = async () => {
currentNetworkConfig.value = undefined;
if (!selectedInstanceId.value) {
return;
}
let ret = await props.api.get_network_config(selectedInstanceId.value.uuid);
currentNetworkConfig.value = ret;
}
const updateNetworkState = async (disabled: boolean) => {
if (!selectedInstanceId.value) {
return;
}
if (disabled || !currentNetworkConfig.value) {
await props.api.update_network_instance_state(selectedInstanceId.value.uuid, disabled);
} else if (currentNetworkConfig.value) {
await props.api.delete_network(currentNetworkConfig.value.instance_id);
await props.api.run_network(currentNetworkConfig.value);
}
await loadNetworkInstanceIds();
}
const confirm = useConfirm();
const confirmDeleteNetwork = (event: any) => {
confirm.require({
target: event.currentTarget,
message: 'Do you want to delete this network?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
try {
await props.api.delete_network(instanceId.value!);
} catch (e) {
console.error(e);
}
emits('update');
},
reject: () => {
return;
}
});
};
const saveAndRunNewNetwork = async () => {
try {
if (isEditing.value) {
await props.api.delete_network(instanceId.value!);
}
let ret = await props.api.run_network(editingNetworkConfig.value);
console.debug("saveAndRunNewNetwork", ret);
selectedInstanceId.value = { uuid: editingNetworkConfig.value.instance_id };
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
emits('update');
// showCreateNetworkDialog.value = false;
isCreatingNetwork.value = false; // Exit creation mode after successful network creation
}
const newNetwork = () => {
editingNetworkConfig.value = props.newConfigGenerator?.() ?? NetworkTypes.DEFAULT_NETWORK_CONFIG();
isEditing.value = false;
// showCreateNetworkDialog.value = true; // Old dialog approach
isCreatingNetwork.value = true; // Switch to creation mode instead
}
const cancelNetworkCreation = () => {
isCreatingNetwork.value = false;
}
const editNetwork = async () => {
if (!instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
isEditing.value = true;
try {
let ret = await props.api.get_network_config(instanceId.value!);
console.debug("editNetwork", ret);
editingNetworkConfig.value = ret;
// showCreateNetworkDialog.value = true; // Old dialog approach
isCreatingNetwork.value = true; // Switch to creation mode instead
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to edit network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
}
const loadNetworkInstanceIds = async () => {
listInstanceIdResponse.value = await props.api.list_network_instance_ids();
console.debug("loadNetworkInstanceIds", listInstanceIdResponse.value);
}
const loadCurrentNetworkInfo = async () => {
if (!instanceId.value) {
return;
}
let ret = await props.api.get_network_info(instanceId.value);
let network_info = ret[instanceId.value];
curNetworkInfo.value = {
instance_id: instanceId.value,
running: network_info.running,
error_msg: network_info.error_msg,
detail: network_info,
} as NetworkTypes.NetworkInstance;
}
const exportConfig = async () => {
if (!instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
try {
let networkConfig = await props.api.get_network_config(instanceId.value!);
delete networkConfig.instance_id;
let { toml_config: tomlConfig, error } = await props.api.generate_config(networkConfig);
if (error) {
throw { response: { data: error } };
}
exportTomlFile(tomlConfig ?? '', instanceId.value + '.toml');
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to export network config, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
}
const importConfig = () => {
configFile.value.click();
}
const handleFileUpload = (event: Event) => {
const files = (event.target as HTMLInputElement).files;
const file = files ? files[0] : null;
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
let tomlConfig = e.target?.result?.toString();
if (!tomlConfig) return;
const resp = await props.api.parse_config(tomlConfig);
if (resp.error) {
throw resp.error;
}
const config = resp.config;
if (!config) return;
config.instance_id = editingNetworkConfig.value?.instance_id ?? config?.instance_id;
Object.assign(editingNetworkConfig.value, resp.config);
toast.add({ severity: 'success', summary: 'Import Success', detail: "Config file import success", life: 2000 });
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Config file parse error: ' + error, life: 2000 });
}
configFile.value.value = null;
}
reader.readAsText(file);
}
const exportTomlFile = (context: string, name: string) => {
let url = window.URL.createObjectURL(new Blob([context], { type: 'application/toml' }));
let link = document.createElement('a');
link.style.display = 'none';
link.href = url;
link.setAttribute('download', name);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
const generateConfig = async (config: NetworkTypes.NetworkConfig): Promise<string> => {
let { toml_config: tomlConfig, error } = await props.api.generate_config(config);
if (error) {
throw error;
}
return tomlConfig ?? '';
}
const saveConfig = async (tomlConfig: string): Promise<void> => {
let resp = await props.api.parse_config(tomlConfig);
if (resp.error) {
throw resp.error;
};
const config = resp.config;
if (!config) {
throw new Error("Parsed config is empty");
}
config.instance_id = currentNetworkConfig.value?.instance_id ?? config?.instance_id;
if (networkIsDisabled.value) {
currentNetworkConfig.value = config;
} else {
editingNetworkConfig.value = config;
}
}
// 响应式屏幕宽度
const screenWidth = ref(window.innerWidth);
const updateScreenWidth = () => {
screenWidth.value = window.innerWidth;
};
// 菜单引用和菜单项
const menuRef = ref();
const actionMenu = ref([
{
label: t('web.device_management.edit_network'),
icon: 'pi pi-pencil',
command: () => editNetwork()
},
{
label: t('web.device_management.export_config'),
icon: 'pi pi-download',
command: () => exportConfig()
},
{
label: t('web.device_management.delete_network'),
icon: 'pi pi-trash',
class: 'p-error',
command: () => confirmDeleteNetwork(new Event('click'))
}
]);
let periodFunc = new Utils.PeriodicTask(async () => {
try {
await Promise.all([loadNetworkInstanceIds(), loadCurrentNetworkInfo()]);
} catch (e) {
console.debug(e);
}
}, 1000);
onMounted(async () => {
periodFunc.start();
// 添加屏幕尺寸监听
window.addEventListener('resize', updateScreenWidth);
});
onUnmounted(() => {
periodFunc.stop();
// 移除屏幕尺寸监听
window.removeEventListener('resize', updateScreenWidth);
});
</script>
<template>
<div class="device-management">
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
<ConfirmPopup></ConfirmPopup>
<!-- 网络选择和操作按钮始终在同一行 -->
<div class="network-header bg-surface-50 p-3 rounded-lg shadow-sm mb-1">
<div class="flex flex-row justify-between items-center gap-2" style="align-items: center;">
<!-- 网络选择 -->
<div class="flex-1 min-w-0">
<IftaLabel class="w-full">
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid" class="w-full"
inputId="dd-inst-id" :placeholder="t('web.device_management.select_network')"
:pt="{ root: { class: 'network-select-container' } }" />
<label class="network-label mr-2 font-medium" for="dd-inst-id">{{
t('web.device_management.network') }}</label>
</IftaLabel>
</div>
<!-- 简化的按钮区域 - 无论屏幕大小都显示 -->
<div class="flex gap-2 shrink-0 button-container items-center">
<!-- Create/Cancel button based on state -->
<Button v-if="!isCreatingNetwork" @click="newNetwork" icon="pi pi-plus"
:label="screenWidth > 640 ? t('web.device_management.create_new') : undefined"
:class="['create-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
:tooltip="screenWidth <= 640 ? t('web.device_management.create_network') : undefined"
tooltipOptions="{ position: 'bottom' }" severity="primary" />
<Button v-else @click="cancelNetworkCreation" icon="pi pi-times"
:label="screenWidth > 640 ? t('web.device_management.cancel_creation') : undefined"
:class="['cancel-button', screenWidth <= 640 ? 'p-button-icon-only' : '']"
:style="screenWidth <= 640 ? 'width: 3rem !important; height: 3rem !important; font-size: 1.2rem' : ''"
:tooltip="screenWidth <= 640 ? t('web.device_management.cancel_creation') : undefined"
tooltipOptions="{ position: 'bottom' }" severity="secondary" />
<!-- More actions menu -->
<Menu ref="menuRef" :model="actionMenu" :popup="true" />
<Button v-if="!isCreatingNetwork && selectedInstanceId" icon="pi pi-ellipsis-v"
class="p-button-rounded flex items-center justify-center" severity="help"
style="width: 3rem !important; height: 3rem !important; font-size: 1.2rem"
@click="menuRef.toggle($event)" :aria-label="t('web.device_management.more_actions')"
:tooltip="t('web.device_management.more_actions')" tooltipOptions="{ position: 'bottom' }" />
</div>
</div>
</div>
<!-- Main Content Area -->
<div class="network-content bg-surface-0 p-4 rounded-lg shadow-sm">
<!-- Network Creation Form -->
<div v-if="isCreatingNetwork" class="network-creation-container">
<div class="network-creation-header flex items-center gap-2 mb-3">
<i class="pi pi-plus-circle text-primary text-xl"></i>
<h2 class="text-xl font-medium">{{ isEditing ? t('web.device_management.edit_network') :
t('web.device_management.create_network') }}</h2>
</div>
<div class="w-full flex gap-2 flex-wrap justify-start mb-3">
<Button @click="showConfigEditDialog = true" icon="pi pi-file-edit"
:label="t('web.device_management.edit_as_file')" iconPos="left" severity="secondary" />
<Button @click="importConfig" icon="pi pi-upload" :label="t('web.device_management.import_config')"
iconPos="left" severity="help" />
</div>
<Divider />
<Config :cur-network="editingNetworkConfig" @run-network="saveAndRunNewNetwork"></Config>
</div>
<!-- Network Status (for running networks) -->
<div v-else-if="needShowNetworkStatus" class="network-status-container">
<div class="network-status-header flex items-center gap-2 mb-3">
<i class="pi pi-chart-line text-primary text-xl"></i>
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
</div>
<Status v-bind:cur-network-inst="curNetworkInfo" class="mb-4"></Status>
<div class="text-center mt-4">
<Button @click="updateNetworkState(true)" :label="t('web.device_management.disable_network')"
severity="warning" icon="pi pi-power-off" iconPos="left" />
</div>
</div>
<!-- Network Configuration (for disabled networks) -->
<div v-else-if="networkIsDisabled" class="network-config-container">
<div class="network-config-header flex items-center gap-2 mb-3">
<i class="pi pi-cog text-secondary text-xl"></i>
<h2 class="text-xl font-medium">{{ t('web.device_management.network_configuration') }}</h2>
</div>
<div v-if="currentNetworkConfig" class="mb-4">
<Config :cur-network="currentNetworkConfig" @run-network="updateNetworkState(false)" />
</div>
<div v-else class="network-loading-placeholder text-center py-8">
<i class="pi pi-spin pi-spinner text-3xl text-primary mb-3"></i>
<div class="text-xl text-secondary">{{ t('web.device_management.loading_network_configuration') }}
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state flex flex-col items-center py-12">
<i class="pi pi-sitemap text-5xl text-secondary mb-4 opacity-50"></i>
<div class="text-xl text-center font-medium mb-3">{{ t('web.device_management.no_network_selected') }}
</div>
<p class="text-secondary text-center mb-6 max-w-md">
{{ t('web.device_management.select_existing_network_or_create_new') }}
</p>
<Button @click="newNetwork" :label="t('web.device_management.create_network')" icon="pi pi-plus"
iconPos="left" />
</div>
</div>
<!-- Keep only the config edit dialogs -->
<!-- <ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
:cur-network="currentNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" /> -->
<ConfigEditDialog v-model:visible="showConfigEditDialog" :cur-network="editingNetworkConfig"
:generate-config="generateConfig" :save-config="saveConfig" />
</div>
</template>
<style scoped>
.device-management {
height: 100%;
display: flex;
flex-direction: column;
}
.network-content {
flex: 1;
overflow-y: auto;
}
/* 按钮样式 */
.button-container {
gap: 0.5rem;
}
.create-button {
font-weight: 600;
min-width: 3rem;
}
/* 菜单样式定制 */
:deep(.p-menu) {
min-width: 12rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
padding: 0.25rem;
}
:deep(.p-menu .p-menuitem) {
border-radius: 0.25rem;
}
:deep(.p-menu .p-menuitem-link) {
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
:deep(.p-menu .p-menuitem-icon) {
margin-right: 0.75rem;
}
:deep(.p-menu .p-menuitem.p-error .p-menuitem-text,
.p-menu .p-menuitem.p-error .p-menuitem-icon) {
color: var(--red-500);
}
:deep(.p-menu .p-menuitem:hover.p-error .p-menuitem-link) {
background-color: var(--red-50);
}
/* 按钮图标样式 */
:deep(.p-button-icon-only) {
width: 2.5rem !important;
padding: 0.5rem !important;
}
:deep(.p-button-icon-only .p-button-icon) {
font-size: 1rem;
}
/* 网络选择相关样式 */
.network-label {
white-space: nowrap;
}
:deep(.network-select-container) {
max-width: 100%;
}
/* Dark mode adaptations */
:deep(.bg-surface-50) {
background-color: var(--surface-50, #f8fafc);
}
:deep(.bg-surface-0) {
background-color: var(--surface-card, #ffffff);
}
:deep(.text-primary) {
color: var(--primary-color, #3b82f6);
}
:deep(.text-secondary) {
color: var(--text-color-secondary, #64748b);
}
@media (prefers-color-scheme: dark) {
:deep(.bg-surface-50) {
background-color: var(--surface-ground, #0f172a);
}
:deep(.bg-surface-0) {
background-color: var(--surface-card, #1e293b);
}
}
/* Responsive design for mobile devices */
@media (max-width: 768px) {
.network-header {
padding: 0.75rem;
}
.network-content {
padding: 0.75rem;
}
/* 在小屏幕上缩短网络标签文本 */
.network-label {
font-size: 0.9rem;
}
}
</style>