Merge pull request #35 from Sudo-Ivan/interface-import-export
Interface Import/Export
This commit is contained in:
167
meshchat.py
167
meshchat.py
@@ -587,6 +587,173 @@ class ReticulumMeshChat:
|
||||
return web.json_response({
|
||||
"message": "Interface has been added",
|
||||
})
|
||||
|
||||
# export interfaces
|
||||
@routes.get("/api/v1/reticulum/interfaces/export")
|
||||
async def export_interfaces(request):
|
||||
try:
|
||||
output = []
|
||||
for name, interface in self.reticulum.config["interfaces"].items():
|
||||
output.append(f"[[{name}]]")
|
||||
for key, value in interface.items():
|
||||
output.append(f" {key} = {value}")
|
||||
output.append("")
|
||||
|
||||
return web.Response(
|
||||
text="\n".join(output),
|
||||
content_type="text/plain",
|
||||
headers={
|
||||
"Content-Disposition": "attachment; filename=reticulum_interfaces"
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Export error: {str(e)}")
|
||||
return web.json_response({
|
||||
"message": f"Failed to export interfaces: {str(e)}"
|
||||
}, status=500)
|
||||
|
||||
# preview importable interfaces
|
||||
@routes.post("/api/v1/reticulum/interfaces/preview")
|
||||
async def preview_interfaces(request):
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
|
||||
field = await reader.next()
|
||||
if field.name == 'config':
|
||||
config_text = ''
|
||||
while True:
|
||||
chunk = await field.read_chunk()
|
||||
if not chunk:
|
||||
break
|
||||
config_text += chunk.decode('utf-8')
|
||||
|
||||
interfaces = []
|
||||
current_interface = None
|
||||
|
||||
for line in config_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
if line.startswith("[[") and line.endswith("]]"):
|
||||
if current_interface:
|
||||
interfaces.append(current_interface)
|
||||
name = line[2:-2]
|
||||
current_interface = {
|
||||
"name": name,
|
||||
"type": None
|
||||
}
|
||||
elif current_interface is not None and "=" in line:
|
||||
key, value = [x.strip() for x in line.split("=", 1)]
|
||||
if key == "type":
|
||||
current_interface["type"] = value
|
||||
|
||||
if current_interface:
|
||||
interfaces.append(current_interface)
|
||||
|
||||
return web.json_response({
|
||||
"interfaces": interfaces
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Preview error: {str(e)}")
|
||||
return web.json_response({
|
||||
"message": f"Failed to parse config file: {str(e)}"
|
||||
}, status=500)
|
||||
# import interfaces from config
|
||||
@routes.post("/api/v1/reticulum/interfaces/import")
|
||||
async def import_interfaces(request):
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
config_text = None
|
||||
selected_interfaces = None
|
||||
|
||||
while True:
|
||||
field = await reader.next()
|
||||
if field is None:
|
||||
break
|
||||
|
||||
if field.name == 'config':
|
||||
config_text = ''
|
||||
while True:
|
||||
chunk = await field.read_chunk()
|
||||
if not chunk:
|
||||
break
|
||||
config_text += chunk.decode('utf-8')
|
||||
elif field.name == 'selected_interfaces':
|
||||
data = await field.read(decode=True)
|
||||
selected_interfaces = json.loads(data)
|
||||
|
||||
print(f"Selected interfaces: {selected_interfaces}")
|
||||
current_interface = None
|
||||
interface_config = {}
|
||||
|
||||
for line in config_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("[[") and line.endswith("]]"):
|
||||
if current_interface and current_interface["name"] in selected_interfaces:
|
||||
name = current_interface["name"]
|
||||
interface_config[name] = {
|
||||
"type": current_interface.get("type"),
|
||||
"interface_enabled": "true",
|
||||
"target_host": current_interface.get("target_host"),
|
||||
"target_port": current_interface.get("target_port"),
|
||||
"listen_ip": current_interface.get("listen_ip"),
|
||||
"listen_port": current_interface.get("listen_port"),
|
||||
"forward_ip": current_interface.get("forward_ip"),
|
||||
"forward_port": current_interface.get("forward_port"),
|
||||
"port": current_interface.get("port"),
|
||||
"frequency": current_interface.get("frequency"),
|
||||
"bandwidth": current_interface.get("bandwidth"),
|
||||
"txpower": current_interface.get("txpower"),
|
||||
"spreadingfactor": current_interface.get("spreadingfactor"),
|
||||
"codingrate": current_interface.get("codingrate")
|
||||
}
|
||||
interface_config[name] = {k: v for k, v in interface_config[name].items() if v is not None}
|
||||
|
||||
name = line[2:-2]
|
||||
current_interface = {"name": name}
|
||||
elif current_interface is not None and "=" in line:
|
||||
key, value = [x.strip() for x in line.split("=", 1)]
|
||||
value = value.strip('"').strip("'")
|
||||
current_interface[key] = value
|
||||
|
||||
if current_interface and current_interface["name"] in selected_interfaces:
|
||||
name = current_interface["name"]
|
||||
interface_config[name] = {
|
||||
"type": current_interface.get("type"),
|
||||
"interface_enabled": "true",
|
||||
"target_host": current_interface.get("target_host"),
|
||||
"target_port": current_interface.get("target_port"),
|
||||
"listen_ip": current_interface.get("listen_ip"),
|
||||
"listen_port": current_interface.get("listen_port"),
|
||||
"forward_ip": current_interface.get("forward_ip"),
|
||||
"forward_port": current_interface.get("forward_port"),
|
||||
"port": current_interface.get("port"),
|
||||
"frequency": current_interface.get("frequency"),
|
||||
"bandwidth": current_interface.get("bandwidth"),
|
||||
"txpower": current_interface.get("txpower"),
|
||||
"spreadingfactor": current_interface.get("spreadingfactor"),
|
||||
"codingrate": current_interface.get("codingrate")
|
||||
}
|
||||
interface_config[name] = {k: v for k, v in interface_config[name].items() if v is not None}
|
||||
|
||||
# update reticulum config with new interfaces
|
||||
self.reticulum.config["interfaces"].update(interface_config)
|
||||
print("Final interfaces config:", self.reticulum.config["interfaces"])
|
||||
self.reticulum.config.write()
|
||||
return web.json_response({"message": "Interfaces imported successfully"})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Import error: {str(e)}")
|
||||
print(f"Config text: {config_text}")
|
||||
return web.json_response({
|
||||
"message": f"Failed to import interfaces: {str(e)}"
|
||||
}, status=500)
|
||||
|
||||
|
||||
# handle websocket clients
|
||||
@routes.get("/ws")
|
||||
|
||||
199
src/frontend/components/interfaces/ImportInterfacesModal.vue
Normal file
199
src/frontend/components/interfaces/ImportInterfacesModal.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div v-if="isShowing" class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center">
|
||||
<div class="flex w-full h-full p-4 overflow-y-auto">
|
||||
<div class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl">
|
||||
|
||||
<!-- title -->
|
||||
<div class="p-4 border-b dark:border-zinc-700">
|
||||
<h3 class="text-lg font-semibold dark:text-white">Import Interfaces</h3>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
|
||||
<!-- file input -->
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-700 dark:text-zinc-200">Select a Configuration File</div>
|
||||
<div>
|
||||
<input ref="import-interfaces-file-input" type="file" @change="onFileSelected" accept="*"
|
||||
class="mt-1 block w-full text-sm text-gray-500 dark:text-zinc-400
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-md file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-gray-500 file:text-white
|
||||
hover:file:bg-gray-400
|
||||
dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- select interfaces -->
|
||||
<div v-if="importableInterfaces.length > 0" class="divide-y dark:divide-zinc-700">
|
||||
<div class="flex p-2">
|
||||
<div class="my-auto mr-auto text-sm font-medium text-gray-700 dark:text-zinc-200">Select Interfaces to Import</div>
|
||||
<div class="my-auto space-x-2">
|
||||
<button @click="selectAllInterfaces" class="text-sm text-blue-500 hover:underline">Select All</button>
|
||||
<button @click="deselectAllInterfaces" class="text-sm text-blue-500 hover:underline">Deselect All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 space-y-2 max-h-72 overflow-y-auto">
|
||||
<div @click="toggleSelectedInterface(iface.name)" v-for="iface in importableInterfaces" :key="iface.name" class="cursor-pointer flex items-center p-2 border rounded dark:border-zinc-700 shadow">
|
||||
<div class="mr-auto text-sm text-gray-700 dark:text-zinc-200">
|
||||
<div class="font-semibold">{{ iface.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ iface.type }}</div>
|
||||
</div>
|
||||
<input @click.stop type="checkbox" v-model="selectedInterfaces" :value="iface.name" class="mx-2 h-4 w-4 text-blue-600 rounded border-gray-300 dark:border-zinc-600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="p-4 border-t dark:border-zinc-700 flex justify-end space-x-2">
|
||||
<button @click="dismiss" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-zinc-800 dark:text-zinc-200 dark:border-zinc-600 dark:hover:bg-zinc-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button @click="importSelectedInterfaces" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600">
|
||||
Import Selected
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
|
||||
export default {
|
||||
name: "ImportInterfacesModal",
|
||||
emits: [
|
||||
"dismissed",
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
isShowing: false,
|
||||
selectedFile: null,
|
||||
importableInterfaces: [],
|
||||
selectedInterfaces: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.isShowing = true;
|
||||
this.selectedFile = null;
|
||||
this.importableInterfaces = [];
|
||||
this.selectedInterfaces = [];
|
||||
},
|
||||
dismiss() {
|
||||
this.isShowing = false;
|
||||
this.$emit("dismissed");
|
||||
},
|
||||
clearSelectedFile() {
|
||||
this.selectedFile = null;
|
||||
this.$refs["import-interfaces-file-input"].value = null;
|
||||
},
|
||||
async onFileSelected(event) {
|
||||
|
||||
// get selected file
|
||||
const file = event.target.files[0];
|
||||
if(!file){
|
||||
return;
|
||||
}
|
||||
|
||||
// update ui
|
||||
this.selectedFile = file;
|
||||
this.importableInterfaces = [];
|
||||
this.selectedInterfaces = [];
|
||||
|
||||
try {
|
||||
|
||||
// fetch preview of interfaces to import
|
||||
const formData = new FormData();
|
||||
formData.append('config', file);
|
||||
const response = await window.axios.post('/api/v1/reticulum/interfaces/preview', formData);
|
||||
|
||||
// ensure there are some interfaces available to import
|
||||
if(!response.data.interfaces || response.data.interfaces.length === 0){
|
||||
this.clearSelectedFile();
|
||||
DialogUtils.alert("No interfaces were found in the selected configuration file");
|
||||
return;
|
||||
}
|
||||
|
||||
// update ui
|
||||
this.importableInterfaces = response.data.interfaces;
|
||||
|
||||
// auto select all interfaces
|
||||
this.selectAllInterfaces();
|
||||
|
||||
} catch(e) {
|
||||
this.clearSelectedFile();
|
||||
DialogUtils.alert("Failed to parse configuration file");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
isInterfaceSelected(name) {
|
||||
return this.selectedInterfaces.includes(name);
|
||||
},
|
||||
selectInterface(name) {
|
||||
if(!this.isInterfaceSelected(name)){
|
||||
this.selectedInterfaces.push(name);
|
||||
}
|
||||
},
|
||||
deselectInterface(name) {
|
||||
this.selectedInterfaces = this.selectedInterfaces.filter((selectedInterfaceName) => {
|
||||
return selectedInterfaceName !== name;
|
||||
});
|
||||
},
|
||||
toggleSelectedInterface(name) {
|
||||
if(this.isInterfaceSelected(name)){
|
||||
this.deselectInterface(name);
|
||||
} else {
|
||||
this.selectInterface(name);
|
||||
}
|
||||
},
|
||||
selectAllInterfaces() {
|
||||
this.selectedInterfaces = this.importableInterfaces.map(i => i.name);
|
||||
},
|
||||
deselectAllInterfaces() {
|
||||
this.selectedInterfaces = [];
|
||||
},
|
||||
async importSelectedInterfaces() {
|
||||
|
||||
// ensure user selected a file to import from
|
||||
if(!this.selectedFile){
|
||||
DialogUtils.alert("Please select a configuration file");
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure user selected some interfaces
|
||||
if(this.selectedInterfaces.length === 0){
|
||||
DialogUtils.alert("Please select at least one interface to import");
|
||||
return;
|
||||
}
|
||||
|
||||
// create form data to send to server
|
||||
const formData = new FormData();
|
||||
formData.append('config', this.selectedFile);
|
||||
formData.append('selected_interfaces', JSON.stringify(this.selectedInterfaces));
|
||||
|
||||
try {
|
||||
|
||||
// import interfaces
|
||||
await window.axios.post('/api/v1/reticulum/interfaces/import', formData);
|
||||
|
||||
// dismiss modal
|
||||
this.dismiss();
|
||||
|
||||
// tell user interfaces were imported
|
||||
DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.");
|
||||
|
||||
} catch(e) {
|
||||
const message = e.response?.data?.message || "Failed to import interfaces";
|
||||
DialogUtils.alert(message);
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -17,7 +17,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex space-x-1">
|
||||
|
||||
<!-- Add Interface button -->
|
||||
<RouterLink :to="{ name: 'interfaces.add' }">
|
||||
<button type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
|
||||
@@ -27,6 +29,27 @@
|
||||
<span>Add Interface</span>
|
||||
</button>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Import button -->
|
||||
<div class="my-auto">
|
||||
<button @click="showImportInterfacesModal" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<span>Import</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Export button -->
|
||||
<div class="my-auto">
|
||||
<button @click="exportInterfaces" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- enabled interfaces -->
|
||||
@@ -49,6 +72,10 @@
|
||||
@delete="deleteInterface(iface._name)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Dialog -->
|
||||
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed"/>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -56,10 +83,12 @@ import DialogUtils from "../../js/DialogUtils";
|
||||
import ElectronUtils from "../../js/ElectronUtils";
|
||||
import Interface from "./Interface.vue";
|
||||
import Utils from "../../js/Utils";
|
||||
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
|
||||
|
||||
export default {
|
||||
name: 'InterfacesPage',
|
||||
components: {
|
||||
ImportInterfacesModal,
|
||||
Interface,
|
||||
},
|
||||
data() {
|
||||
@@ -219,6 +248,31 @@ export default {
|
||||
await this.loadInterfaces();
|
||||
|
||||
},
|
||||
async exportInterfaces() {
|
||||
try {
|
||||
const response = await window.axios.get('/api/v1/reticulum/interfaces/export', {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'reticulum_interfaces');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch(e) {
|
||||
DialogUtils.alert("Failed to export interfaces");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
showImportInterfacesModal() {
|
||||
this.$refs["import-interfaces-modal"].show();
|
||||
},
|
||||
onImportInterfacesModalDismissed() {
|
||||
// reload interfaces as something may have been imported
|
||||
this.loadInterfaces();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
|
||||
Reference in New Issue
Block a user