From 7feeacea4265cdedb76196d778a01e8928c44ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 26 Feb 2026 15:21:24 +0100 Subject: [PATCH] Added basic multidevice support --- config.go | 17 +- main.go | 36 ++- static/css/index.css | 86 +++++-- static/images/icon_create.svg | 8 +- static/js/dns.mjs | 412 +++++++++++++++++++++++++--------- views/pages/index.gotmpl | 5 +- webserver.go | 127 +++++++---- 7 files changed, 505 insertions(+), 186 deletions(-) diff --git a/config.go b/config.go index 34df751..1293c5f 100644 --- a/config.go +++ b/config.go @@ -19,13 +19,16 @@ type Config struct { LogDir string } - Device struct { - Address string - Port int - Username string - Password string - Timeout int - } + Devices []Device +} + +type Device struct { + Name string + Address string + Port int + Username string + Password string + Timeout int } func readConfig() (config Config, err error) { diff --git a/main.go b/main.go index 2f7fbd1..6be7b38 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,8 @@ import ( "log/slog" "os" "path" + "slices" + "fmt" ) const VERSION = "v1" @@ -23,7 +25,7 @@ var ( initLogger *slog.Logger logger *slog.Logger - device RouterosDevice + devices map[string]RouterosDevice ) func init() { // {{{ @@ -73,13 +75,45 @@ func initLogging(config Config) *slog.Logger { // {{{ func main() { initLogger.Info("application", "version", VERSION) + devices = make(map[string]RouterosDevice) + /* device.Host = config.Device.Address device.Port = config.Device.Port device.Username = config.Device.Username device.Password = config.Device.Password device.Timeout = config.Device.Timeout device.Init() + */ registerWebserverHandlers() startWebserver() } + +func routerosDevice(name string) (dev RouterosDevice, err error) { + var found bool + if dev, found = devices[name]; found { + logger.Debug("routeros", "op", "connection", "name", name, "cached", true) + return + } + + i := slices.IndexFunc(config.Devices, func(d Device) bool { + return d.Name == name + }) + if i == -1 { + err = fmt.Errorf("Unknown device '%s'", name) + logger.Error("routeros", "op", "connection", "error", err) + return + } + + logger.Debug("routeros", "name", name, "cached", false) + confDev := config.Devices[i] + dev.Host = confDev.Address + dev.Port = confDev.Port + dev.Username = confDev.Username + dev.Password = confDev.Password + dev.Timeout = confDev.Timeout + dev.Init() + devices[name] = dev + + return +} diff --git a/static/css/index.css b/static/css/index.css index ba6c2a1..f28e824 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -1,4 +1,6 @@ :root { + --header-background: #acbb78; + --line-color: #ccc; --line-color-record: #eee; @@ -37,14 +39,27 @@ html { cursor: pointer; } +h1 { + font-size: 1.5em; + + &:first-child { + margin-top: 0px; + } +} + label { user-select: none; } +html, +body { + margin: 0px; + padding: 0px; +} + body { font-family: sans-serif; font-size: 12pt; - margin-left: 32px; /* Boxed folders are a settings for the user. */ &.boxed-folders { @@ -90,28 +105,63 @@ button { padding: 4px 8px; } -#create-icon { - position: absolute; - top: 18px; - right: 64px; - width: 24px; - cursor: pointer; -} +#application-header { + display: grid; + grid-template-columns: min-content 1fr min-content min-content; + align-items: center; + border-bottom: 1px solid #a4bc52; + background-color: var(--header-background); -#settings-icon { - position: absolute; - top: 16px; - right: 16px; - width: 32px; - cursor: pointer; -} -#search { - margin-bottom: 16px; + .device-select { + padding: 16px 16px 16px 32px; + + .device-name { + font-weight: bold; + } + } + + #search { + padding: 16px; + display: grid; + grid-template-columns: min-content min-content; + grid-gap: 0px 8px; + + &>* { + display: none; + } + + &.show { + &>* { + display: initial; + } + } + + .search-label { + grid-column: 1 / -1; + font-weight: bold; + } + } + + #create-icon { + width: 24px; + cursor: pointer; + margin-right: 16px; + } + + #settings-icon { + width: 32px; + cursor: pointer; + margin-right: 16px; + } + } #records-tree { white-space: nowrap; + margin-top: calc(32px - 5px); + /* padding from the topmost folder */ + margin-left: 32px; .folder { padding-left: 32px; @@ -120,7 +170,7 @@ button { padding-left: 0px; } - &.no-domain > .label { + &.no-domain>.label { font-style: italic; } diff --git a/static/images/icon_create.svg b/static/images/icon_create.svg index cc1dbd4..1fb9dea 100644 --- a/static/images/icon_create.svg +++ b/static/images/icon_create.svg @@ -25,9 +25,9 @@ borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" - inkscape:zoom="1" - inkscape:cx="-71" - inkscape:cy="-52" + inkscape:zoom="1.1646825" + inkscape:cx="-71.693356" + inkscape:cy="-55.809199" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" @@ -62,6 +62,6 @@ + style="stroke-width:0.264583;fill:#000000" /> diff --git a/static/js/dns.mjs b/static/js/dns.mjs index 8f9195d..08d9302 100644 --- a/static/js/dns.mjs +++ b/static/js/dns.mjs @@ -1,26 +1,105 @@ import { MessageBus } from '@mbus' export class Application { - constructor(records) {// {{{ + constructor() {// {{{ window._mbus = new MessageBus() this.settings = new Settings() - this.records = this.parseRecords(records) - this.topFolder = new Folder(this, null, 'root') - this.recordsTree = null + this.devices = new Map() + this.currentDevice = null + this.settingsIcon = null this.createIcon = null + this.elHeader = null + this.deviceSelector = null + this.searchWidget = null - this.searchFor = '' + this.resetDeviceState() + this.renderApplication() + this.retrieveDevices() + .then(() => { + this.deviceSelector.setDevices(this.devices) + this.renderApplication() + }) + .catch(err => { + console.error(err) + alert(err) + }) - this.renderFolders() - this.render() + _mbus.subscribe('device_selected', event => this.connectDevice(event.detail.devName)) + _mbus.subscribe('search', ()=>this.search()) }// }}} + + // resetDeviceState removes the device state such as the records, records tree and search field. + resetDeviceState() {// {{{ + if (this.recordsTree) { + this.recordsTree.remove() + this.recordsTree = null + return + } + + this.topFolder = new Folder(this, null, 'root') + this.records = null + this.recordsTree = null + + if (this.searchWidget) + this.searchWidget.reset() + }// }}} + + // retrieveDevices fetches the preconfigured devices from server and populates this.devices. + retrieveDevices() {// {{{ + return new Promise((resolve, reject) => { + fetch('/devices') + .then(data => data.json()) + .then(json => { + if (!json.OK) { + reject(json.Error) + return + } + + this.devices = new Map() + for (const devData of json.Devices) { + const dev = new Device(devData) + this.devices.set(dev.name(), dev) + } + + resolve() + }) + }) + }// }}} + + // connectDevice resets the device state, retrieves the records and renders everything necessary. + async connectDevice(devName) {// {{{ + this.resetDeviceState() + if (devName == '-') { + return + } + + try { + const dev = this.devices.get(devName) + if (dev === undefined) { + alert(`Unknown device '${devName}'`) + return + } + this.currentDevice = dev + + const records = await this.currentDevice.retrieveRecords() + this.records = this.parseRecords(records) + this.createFolders() + this.renderDevice() + } catch (err) { + console.error(err) + alert(err) + } + }// }}} + + // filteredRecords takes the current search value and only returns records matching it. filteredRecords() {// {{{ - if (this.searchFor === '') + const searchFor = this.searchWidget.value() + if (searchFor === '') return this.records const records = this.records.filter(r => { - return (r.name().includes(this.searchFor)) + return (r.name().includes(searchFor)) }) return records }// }}} @@ -30,7 +109,7 @@ export class Application { }// }}} // cleanFolders removes all records from all folders. - // renderFolders can then put moved records in the correct + // createFolders can then put moved records in the correct // (or newly created) folders again when record names are updated. cleanFolders(folder) {// {{{ if (folder === undefined) @@ -45,7 +124,96 @@ export class Application { const i = this.records.findIndex(rec => rec.id() == id) this.records.splice(i, 1) }// }}} - renderFolders() {// {{{ + sortFolders(a, b) {// {{{ + const aLabels = a.labels().reverse() + const bLabels = b.labels().reverse() + + for (let i = 0; i < aLabels.length && i < bLabels.length; i++) { + if (aLabels[i] < bLabels[i]) + return -1 + if (aLabels[i] > bLabels[i]) + return 1 + } + + if (a.length < b.length) return 1 + if (a.length > b.length) return -1 + + return 0 + }// }}} + sortRecords(a, b) {// {{{ + const aLabels = a.labels().reverse() + const bLabels = b.labels().reverse() + + for (let i = 0; i < aLabels.length && i < bLabels.length; i++) { + if (aLabels[i] < bLabels[i]) + return -1 + if (aLabels[i] > bLabels[i]) + return 1 + } + + return 0 + }// }}} + + // renderApplication creates and updates application-level elements (not device level elements). + renderApplication() {// {{{ + if (this.elHeader === null) { + this.elHeader = document.createElement('div') + this.elHeader.id = 'application-header' + + this.deviceSelector = new DeviceSelectWidget(this.devices) + this.searchWidget = new SearchWidget() + + this.createIcon = document.createElement('img') + this.createIcon.id = 'create-icon' + this.createIcon.src = `/images/${_VERSION}/icon_create.svg` + this.createIcon.addEventListener('click', () => new RecordDialog(new Record()).show()) + + this.settingsIcon = document.createElement('img') + this.settingsIcon.id = 'settings-icon' + this.settingsIcon.src = `/images/${_VERSION}/icon_settings.svg` + this.settingsIcon.addEventListener('click', () => new SettingsDialog(this).show()) + + this.elHeader.appendChild(this.deviceSelector.render()) + this.elHeader.appendChild(this.searchWidget.render()) + this.elHeader.appendChild(this.createIcon) + this.elHeader.appendChild(this.settingsIcon) + + document.getElementById('app').appendChild(this.elHeader) + document.body.addEventListener('keydown', event => this.handlerKeys(event)) + return + } + + this.deviceSelector.render() + }// }}} + + // renderDevice creates the device specific elements and also updates them. + renderDevice() {// {{{ + // The recordstree is deleted when making a search and is therefore + // not created along with icons and search above. + if (this.recordsTree === null) { + this.recordsTree = document.createElement('div') + this.recordsTree.id = 'records-tree' + document.getElementById('app').appendChild(this.recordsTree) + } + + // Top root folder doesn't have to be shown. + const folders = Array.from(this.topFolder.subfolders.values()) + folders.sort(this.sortFolders) + + for (const folder of folders) + this.recordsTree.append(folder.render()) + + this.removeEmptyFolders() + + // Subscribe to settings update since the elements they will change + // exists now. + _mbus.subscribe('settings_updated', event => this.handlerSettingsUpdated(event.detail)) + this.setBoxedFolders(this.settings.get('boxed_folders')) + }// }}} + + // createFolders goes through the filtered records and create new folders based on the record labels. + // It also populates the child records. + createFolders() {// {{{ const records = this.filteredRecords() records.sort(this.sortRecords) @@ -85,91 +253,9 @@ export class Application { currFolder.addRecord(rec) } }// }}} - sortFolders(a, b) {// {{{ - const aLabels = a.labels().reverse() - const bLabels = b.labels().reverse() - for (let i = 0; i < aLabels.length && i < bLabels.length; i++) { - if (aLabels[i] < bLabels[i]) - return -1 - if (aLabels[i] > bLabels[i]) - return 1 - } - - if (a.length < b.length) return 1 - if (a.length > b.length) return -1 - - return 0 - }// }}} - sortRecords(a, b) {// {{{ - const aLabels = a.labels().reverse() - const bLabels = b.labels().reverse() - - for (let i = 0; i < aLabels.length && i < bLabels.length; i++) { - if (aLabels[i] < bLabels[i]) - return -1 - if (aLabels[i] > bLabels[i]) - return 1 - } - - return 0 - }// }}} - render() {// {{{ - if (this.createIcon === null) { - this.createIcon = document.createElement('img') - this.createIcon.id = 'create-icon' - this.createIcon.src = `/images/${_VERSION}/icon_create.svg` - this.createIcon.addEventListener('click', () => new RecordDialog(new Record()).show()) - - this.settingsIcon = document.createElement('img') - this.settingsIcon.id = 'settings-icon' - this.settingsIcon.src = `/images/${_VERSION}/icon_settings.svg` - this.settingsIcon.addEventListener('click', () => new SettingsDialog(this).show()) - - const searchEl = document.createElement('div') - searchEl.id = 'search' - searchEl.innerHTML = ` - - - ` - this.searchField = searchEl.querySelector('.search-for') - this.searchField.addEventListener('keydown', event => { - if (event.key == 'Enter') - this.search() - }) - const search = searchEl.querySelector('button.search') - search.addEventListener('click', () => this.search()) - - document.body.appendChild(this.settingsIcon) - document.body.appendChild(this.createIcon) - document.body.appendChild(searchEl) - - document.body.addEventListener('keydown', event => this.handlerKeys(event)) - } - - // The recordstree is deleted when making a search and is therefore - // not created along with icons and search above. - if (this.recordsTree === null) { - this.recordsTree = document.createElement('div') - this.recordsTree.id = 'records-tree' - - document.body.appendChild(this.recordsTree) - } - - // Top root folder doesn't have to be shown. - const folders = Array.from(this.topFolder.subfolders.values()) - folders.sort(this.sortFolders) - - for (const folder of folders) - this.recordsTree.append(folder.render()) - - this.removeEmptyFolders() - - // Subscribe to settings update since the elements they will change - // exists now. - _mbus.subscribe('settings_updated', event => this.handlerSettingsUpdated(event.detail)) - this.setBoxedFolders(this.settings.get('boxed_folders')) - }// }}} + // removeEmptyFolders finds the leaf folders and recursively removes empty ones. + // These are usually left from moving records away from them. removeEmptyFolders(folder) {// {{{ if (folder === undefined) folder = this.topFolder @@ -189,6 +275,7 @@ export class Application { folder.div?.remove() } }// }}} + handlerKeys(event) {// {{{ let handled = true @@ -229,15 +316,124 @@ export class Application { else document.body.classList.remove('boxed-folders') }// }}} + + + // search sets the search filter and re-renders the records tree. search() {// {{{ - this.searchFor = this.searchField.value.trim().toLowerCase() this.recordsTree.remove() this.topFolder = new Folder(this, null, 'root') this.recordsTree = null - this.cleanFolders() - this.renderFolders() - this.render() + this.createFolders() + this.renderDevice() + }// }}} +} + +class SearchWidget { + constructor() {// {{{ + _mbus.subscribe('device_selected', event => { + if (event.detail.devName == '-') + this.searchEl?.classList.remove('show') + else + this.searchEl?.classList.add('show') + }) + }// }}} + render() {// {{{ + if (this.searchEl) + return + + this.searchEl = document.createElement('div') + this.searchEl.id = 'search' + this.searchEl.innerHTML = ` +
Search
+ + + ` + this.searchField = this.searchEl.querySelector('.search-for') + this.searchField.addEventListener('keydown', event => { + if (event.key == 'Enter') + this.search() + }) + + const searchButton = this.searchEl.querySelector('button.search') + searchButton.addEventListener('click', () => this.search()) + + return this.searchEl + }// }}} + search() {// {{{ + _mbus.dispatch('search', { search: this.searchField.value.trim().toLowerCase() }) + }// }}} + value() {// {{{ + return this.searchField.value.trim().toLowerCase() + }// }}} + reset() {// {{{ + this.searchField.value = '' + }// }}} +} + +class Device { + constructor(data) {// {{{ + this.data = data + }// }}} + name() {// {{{ + return this.data.Name?.toLowerCase() || '' + }// }}} + async retrieveRecords() {// {{{ + const data = await fetch(`/device/${this.name()}/dns_records`) + const json = await data.json() + if (!json.OK) + throw new Error(json.Error) + return json.Records + }// }}} +} + +class DeviceSelectWidget { + constructor(devices) {// {{{ + this.devices = devices + + this.div = null + this.elDeviceSelect = null + this.currentlySelected = null + }// }}} + setDevices(devices) {// {{{ + this.devices = devices + }// }}} + render() {// {{{ + if (this.div === null) { + this.div = document.createElement('div') + this.div.classList.add('device-select') + this.div.innerHTML = ` +
Select device
+ + ` + + this.elDeviceSelect = this.div.querySelector('select') + this.elDeviceSelect.addEventListener('change', () => this.notifyDeviceSelect()) + } + this.restockDeviceSelect() + return this.div + }// }}} + restockDeviceSelect() {// {{{ + this.elDeviceSelect.replaceChildren() + const emptyOption = document.createElement('option') + emptyOption.innerText = "-" + emptyOption.dataset.devicename = "-" + this.elDeviceSelect.appendChild(emptyOption) + + this.devices.forEach(dev => { + const option = document.createElement('option') + option.innerText = dev.name() + option.dataset.devicename = dev.name() + this.elDeviceSelect.appendChild(option) + }) + + if (this.currentlySelected !== null) + this.elDeviceSelect.value = this.currentlySelected + }// }}} + notifyDeviceSelect() {// {{{ + const devName = this.elDeviceSelect.value + this.currentlySelected = devName + _mbus.dispatch('device_selected', { devName }) }// }}} } @@ -334,10 +530,8 @@ class Folder { this.div.querySelector('.label').addEventListener('click', event => this.toggleFolder(event)) this.div.querySelector('.label .create').addEventListener('click', event => this.createRecord(event)) - if (this.name() == '_no.domain') { - console.log('wut') + if (this.name() == '_no.domain') this.div.classList.add('no-domain') - } } @@ -565,7 +759,7 @@ class Record { save() {// {{{ const created = (this.id() == '') - fetch('/record/save', { + fetch(`/device/${_app.currentDevice.name()}/record`, { method: 'POST', body: JSON.stringify(this.data), }) @@ -584,10 +778,10 @@ class Record { } _app.cleanFolders() - _app.renderFolders() - _app.render() + _app.createFolders() + _app.renderDevice() - // renderFolders is setting the folder the record resides in. + // createFolders is setting the folder the record resides in. // It can now be expanded to the parent folder. if (created) { this.openParentFolders() @@ -603,7 +797,7 @@ class Record { if (!confirm(`Are you sure you want to delete ${this.name()}?`)) return - fetch(`/record/delete/${this.id()}`) + fetch(`/device/${_app.currentDevice.name()}/record/${this.id()}`, { method: 'DELETE' }) .then(data => data.json()) .then(json => { if (!json.OK) { @@ -612,8 +806,8 @@ class Record { } _app.deleteRecord(this.id()) _app.cleanFolders() - _app.renderFolders() - _app.render() + _app.createFolders() + _app.renderDevice() }) }// }}} diff --git a/views/pages/index.gotmpl b/views/pages/index.gotmpl index d045df4..f774e4f 100644 --- a/views/pages/index.gotmpl +++ b/views/pages/index.gotmpl @@ -1,9 +1,6 @@ {{ define "page" }} - -

{{ .Data.Identity }}

- {{ end }} diff --git a/webserver.go b/webserver.go index fc916ec..0869f7c 100644 --- a/webserver.go +++ b/webserver.go @@ -24,7 +24,7 @@ var ( viewFS embed.FS ) -func registerWebserverHandlers() { +func registerWebserverHandlers() { // {{{ var err error htmlEngine, err = HTMLTemplate.NewEngine(viewFS, staticFS, flagDev) if err != nil { @@ -33,17 +33,31 @@ func registerWebserverHandlers() { } http.HandleFunc("/", rootHandler) - http.HandleFunc("/record/save", actionRecordSave) - http.HandleFunc("/record/delete/{id}", actionRecordDelete) -} - -func startWebserver() { + http.HandleFunc("/devices", actionDevices) + http.HandleFunc("GET /device/{dev}/dns_records", actionDNSRecords) + http.HandleFunc("POST /device/{dev}/record", actionRecordSave) + http.HandleFunc("DELETE /device/{dev}/record/{id}", actionRecordDelete) +} // }}} +func startWebserver() { // {{{ listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port) logger.Info("webserver", "listen", listen) http.ListenAndServe(listen, nil) -} +} // }}} -func rootHandler(w http.ResponseWriter, r *http.Request) { +func httpError(w http.ResponseWriter, err error) { // {{{ + resp := struct { + OK bool + Error string + }{ + false, + err.Error(), + } + + j, _ := json.Marshal(resp) + w.Write(j) +} // }}} + +func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{ if r.URL.Path == "/" { page := HTMLTemplate.SimplePage{} page.Layout = "main" @@ -52,21 +66,6 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { var err error data := make(map[string]any) - data["Identity"], err = device.GetIdentity() - if err != nil { - w.Write([]byte(err.Error())) - return - } - - var entries []DNSRecord - entries, err = device.StaticDNSEntries() - if err != nil { - w.Write([]byte(err.Error())) - return - } - - data["DNSRecords"] = entries - data["VERSION"] = VERSION page.Data = data @@ -79,25 +78,62 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { } htmlEngine.StaticResource(w, r) -} - -func httpError(w http.ResponseWriter, err error) { - resp := struct { - OK bool - Error string - }{ - false, - err.Error(), +} // }}} +func actionDevices(w http.ResponseWriter, r *http.Request) { // {{{ + var devs []Device + for _, dev := range config.Devices { + dev.Password = "" + devs = append(devs, dev) } - j, _ := json.Marshal(resp) - w.Write(j) -} + j, _ := json.Marshal(struct { + OK bool + Devices []Device + }{ + true, + devs, + }) + + w.Write(j) +} // }}} + +func actionDNSRecords(w http.ResponseWriter, r *http.Request) { // {{{ + devname := r.PathValue("dev") + + device, err := routerosDevice(devname) + if err != nil { + httpError(w, err) + return + } + + var records []DNSRecord + records, err = device.StaticDNSEntries() + if err != nil { + httpError(w, err) + return + } + + j, _ := json.Marshal(struct { + OK bool + Records []DNSRecord + }{ + true, + records, + }) + w.Write(j) + +} // }}} +func actionRecordSave(w http.ResponseWriter, r *http.Request) { // {{{ + devName := r.PathValue("dev") + device, err := routerosDevice(devName) + if err != nil { + httpError(w, err) + return + } -func actionRecordSave(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var record DNSRecord - err := json.Unmarshal(body, &record) + err = json.Unmarshal(body, &record) if err != nil { httpError(w, err) return @@ -117,17 +153,22 @@ func actionRecordSave(w http.ResponseWriter, r *http.Request) { Record DNSRecord }{true, record}) w.Write(j) -} +} // }}} +func actionRecordDelete(w http.ResponseWriter, r *http.Request) { // {{{ + devName := r.PathValue("dev") + device, err := routerosDevice(devName) + if err != nil { + httpError(w, err) + return + } -func actionRecordDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if id == "" { httpError(w, errors.New("No ID provided")) return } - err := device.DeleteDNSEntry(id) + err = device.DeleteDNSEntry(id) if err != nil { httpError(w, err) logger.Error("webserver", "op", "record_delete", "error", err) @@ -135,7 +176,7 @@ func actionRecordDelete(w http.ResponseWriter, r *http.Request) { } j, _ := json.Marshal(struct { - OK bool + OK bool }{true}) w.Write(j) -} +} // }}}