Merge pull request #35 from Sudo-Ivan/interface-import-export

Interface Import/Export
This commit is contained in:
Liam Cottle
2025-01-01 17:35:15 +13:00
committed by GitHub
3 changed files with 421 additions and 1 deletions

View File

@@ -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")

View 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>

View File

@@ -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() {