Add conversion method from TomlConfigLoader to NetworkConfig to enhance configuration experience (#990)

* add method to create NetworkConfig from TomlConfigLoader
* allow web export/import toml config file and gui edit toml config
* Extract the configuration file dialog into a separate component and allow direct editing of the configuration file on the web
This commit is contained in:
Mg Pig
2025-06-15 23:41:42 +08:00
committed by GitHub
parent 40b5fe9a54
commit ed162c2e66
18 changed files with 738 additions and 80 deletions

View File

@@ -2,12 +2,11 @@
import { NetworkTypes } from 'easytier-frontend-lib';
import {computed, ref} from 'vue';
import { Api } from 'easytier-frontend-lib'
import {AutoComplete, Divider} from "primevue";
import {AutoComplete, Divider, Button, Textarea} from "primevue";
import {getInitialApiHost, cleanAndLoadApiHosts, saveApiHost} from "../modules/api-host"
const api = computed<Api.ApiClient>(() => new Api.ApiClient(apiHost.value));
const apiHost = ref<string>(getInitialApiHost())
const apiHostSuggestions = ref<Array<string>>([])
const apiHostSearch = async (event: { query: string }) => {
@@ -22,23 +21,46 @@ const apiHostSearch = async (event: { query: string }) => {
}
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const toml_config = ref<string>("Press 'Run Network' to generate TOML configuration");
const toml_config = ref<string>("");
const errorMessage = ref<string>("");
const generateConfig = (config: NetworkTypes.NetworkConfig) => {
saveApiHost(apiHost.value)
errorMessage.value = "";
api.value?.generate_config({
config: config
}).then((res) => {
if (res.error) {
toml_config.value = res.error;
errorMessage.value = "Generation failed: " + res.error;
} else if (res.toml_config) {
toml_config.value = res.toml_config;
} else {
toml_config.value = "Api server returned an unexpected response";
errorMessage.value = "Api server returned an unexpected response";
}
}).catch(err => {
errorMessage.value = "Generate request failed: " + (err instanceof Error ? err.message : String(err));
});
};
const parseConfig = async () => {
try {
errorMessage.value = "";
const res = await api.value?.parse_config({
toml_config: toml_config.value
});
if (res.error) {
errorMessage.value = "Parse failed: " + res.error;
} else if (res.config) {
newNetworkConfig.value = res.config;
} else {
errorMessage.value = "API returned an unexpected response";
}
} catch (e) {
errorMessage.value = "Parse request failed: " + (e instanceof Error ? e.message : String(e));
}
};
</script>
<template>
@@ -55,8 +77,17 @@ const generateConfig = (config: NetworkTypes.NetworkConfig) => {
</div>
<Config :cur-network="newNetworkConfig" @run-network="generateConfig" />
</div>
<div class="sm:w-full md:w-1/2 p-4 bg-gray-100">
<pre class="whitespace-pre-wrap">{{ toml_config }}</pre>
<div class="sm:w-full md:w-1/2 p-4 flex flex-col h-[calc(100vh-80px)]">
<pre v-if="errorMessage" class="mb-2 p-2 rounded text-sm overflow-auto bg-red-100 text-red-700 max-h-40">{{ errorMessage }}</pre>
<Textarea
v-model="toml_config"
spellcheck="false"
class="w-full flex-grow p-2 bg-gray-100 whitespace-pre-wrap font-mono border-none focus:outline-none resize-none"
placeholder="Press 'Run Network' to generate TOML configuration, or paste your TOML configuration here to parse it"
></Textarea>
<div class="mt-3 flex justify-center">
<Button label="Parse Config" icon="pi pi-arrow-left" icon-pos="left" @click="parseConfig" />
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import {Toolbar, IftaLabel, Select, Button, ConfirmPopup, Dialog, useConfirm, useToast, Divider} from 'primevue';
import { NetworkTypes, Status, Utils, Api, } from 'easytier-frontend-lib';
import { Toolbar, IftaLabel, Select, Button, ConfirmPopup, Dialog, useConfirm, useToast, Divider } from 'primevue';
import { NetworkTypes, Status, Utils, Api, ConfigEditDialog } from 'easytier-frontend-lib';
import { watch, computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -33,6 +33,7 @@ const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
const isEditing = ref(false);
const showCreateNetworkDialog = ref(false);
const showConfigEditDialog = ref(false);
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
@@ -103,7 +104,12 @@ const updateNetworkState = async (disabled: boolean) => {
return;
}
await props.api?.update_device_instance_state(deviceId.value, selectedInstanceId.value.uuid, disabled);
if (disabled || !disabledNetworkConfig.value) {
await props.api?.update_device_instance_state(deviceId.value, selectedInstanceId.value.uuid, disabled);
} else if (disabledNetworkConfig.value) {
await props.api?.delete_network(deviceId.value, disabledNetworkConfig.value.instance_id);
await props.api?.run_network(deviceId.value, disabledNetworkConfig.value);
}
await loadNetworkInstanceIds();
}
@@ -211,62 +217,97 @@ const loadDeviceInfo = async () => {
}
const exportConfig = async () => {
if (!deviceId.value || !instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
if (!deviceId.value || !instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
try {
let ret = await props.api?.get_network_config(deviceId.value, instanceId.value);
delete ret.instance_id;
exportJsonFile(JSON.stringify(ret, null, 2),instanceId.value +'.json');
} 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;
}
try {
let networkConfig = await props.api?.get_network_config(deviceId.value, instanceId.value);
delete networkConfig.instance_id;
let { toml_config: tomlConfig, error } = await props.api?.generate_config({
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();
configFile.value.click();
}
const handleFileUpload = (event: Event) => {
const files = (event.target as HTMLInputElement).files;
const file = files ? files[0] : null;
if (file) {
const files = (event.target as HTMLInputElement).files;
const file = files ? files[0] : null;
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
let str = e.target?.result?.toString();
if(str){
const config = JSON.parse(str);
if(config === null || typeof config !== "object"){
throw new Error();
}
Object.assign(newNetworkConfig.value, config);
toast.add({ severity: 'success', summary: 'Import Success', detail: "Config file import success", life: 2000 });
reader.onload = async (e) => {
try {
let tomlConfig = e.target?.result?.toString();
if (!tomlConfig) return;
const resp = await props.api?.parse_config({ toml_config: tomlConfig });
if (resp.error) {
throw resp.error;
}
const config = resp.config;
if (!config) return;
config.instance_id = newNetworkConfig.value?.instance_id ?? config?.instance_id;
Object.assign(newNetworkConfig.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 });
}
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Config file parse error.', life: 2000 });
}
configFile.value.value = null;
configFile.value.value = null;
}
reader.readAsText(file);
}
}
const exportJsonFile = (context: string, name: string) => {
let url = window.URL.createObjectURL(new Blob([context], { type: 'application/json' }));
let link = document.createElement('a');
link.style.display = 'none';
link.href = url;
link.setAttribute('download', name);
document.body.appendChild(link);
link.click();
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);
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({ toml_config: tomlConfig });
if (resp.error) {
throw resp.error;
};
const config = resp.config;
if (!config) {
throw new Error("Parsed config is empty");
}
config.instance_id = disabledNetworkConfig.value?.instance_id ?? config?.instance_id;
if (networkIsDisabled.value) {
disabledNetworkConfig.value = config;
} else {
newNetworkConfig.value = config;
}
}
let periodFunc = new Utils.PeriodicTask(async () => {
@@ -288,18 +329,23 @@ onUnmounted(() => {
</script>
<template>
<input type="file" @change="handleFileUpload" class="hidden" accept="application/json" ref="configFile"/>
<input type="file" @change="handleFileUpload" class="hidden" accept="application/toml" ref="configFile" />
<ConfirmPopup></ConfirmPopup>
<Dialog v-model:visible="showCreateNetworkDialog" modal :header="!isEditing ? 'Create New Network' : 'Edit Network'"
:style="{ width: '55rem' }">
<Dialog v-if="!networkIsDisabled" v-model:visible="showCreateNetworkDialog" modal
:header="!isEditing ? 'Create New Network' : 'Edit Network'" :style="{ width: '55rem' }">
<div class="flex flex-col">
<div class="w-11/12 self-center ">
<Button @click="importConfig" icon="pi pi-file-import" label="Import" iconPos="right" />
<Divider />
</div>
<div class="w-11/12 self-center space-x-2">
<Button @click="showConfigEditDialog = true" icon="pi pi-pen-to-square" label="Edit File" iconPos="right" />
<Button @click="importConfig" icon="pi pi-file-import" label="Import" iconPos="right" />
</div>
</div>
<Divider />
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
</Dialog>
<ConfigEditDialog v-if="networkIsDisabled" v-model:visible="showCreateNetworkDialog"
:cur-network="disabledNetworkConfig" :generate-config="generateConfig" :save-config="saveConfig" />
<ConfigEditDialog v-else v-model:visible="showConfigEditDialog" :cur-network="newNetworkConfig"
:generate-config="generateConfig" :save-config="saveConfig" />
<Toolbar>
<template #start>
@@ -329,7 +375,7 @@ onUnmounted(() => {
</Status>
<Divider />
<div class="text-center">
<Button @click="updateNetworkState(true)" label="Disable Network" severity="warn" />
<Button @click="updateNetworkState(true)" label="Disable Network" severity="warn" />
</div>
</div>