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 1/3] 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) -} +} // }}} From 5635c2af1ad4e429347ea0ec15088e0bf5ecb1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 26 Feb 2026 15:58:01 +0100 Subject: [PATCH 2/3] Remember last device, open all folders on search --- static/css/index.css | 25 ++++++++++++++++++++----- static/js/dns.mjs | 18 ++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index f28e824..cf514f9 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -1,5 +1,6 @@ :root { --header-background: #acbb78; + --header-border: 1px solid #869a41; --line-color: #ccc; --line-color-record: #eee; @@ -22,7 +23,7 @@ --record-NXDOMAIN: #aa0000; --record-other: #888; - --record-hover: #fffff4; + --record-hover: #fafafa; } html { @@ -34,6 +35,9 @@ html { *:after { box-sizing: inherit; } +*:focus { + outline: none; +} [onClick] { cursor: pointer; @@ -103,18 +107,21 @@ select, button { font-size: 1em; padding: 4px 8px; + border-radius: 4px; + border: 1px solid #444; } #application-header { display: grid; - grid-template-columns: min-content 1fr min-content min-content; + grid-template-columns: min-content min-content 1fr min-content; align-items: center; - border-bottom: 1px solid #a4bc52; + justify-items: end; + border-bottom: var(--header-border); background-color: var(--header-background); - .device-select { - padding: 16px 16px 16px 32px; + padding: 16px; + border-right: var(--header-border); .device-name { font-weight: bold; @@ -127,11 +134,13 @@ button { grid-template-columns: min-content min-content; grid-gap: 0px 8px; + &>* { display: none; } &.show { + border-right: var(--header-border); &>* { display: initial; } @@ -269,6 +278,10 @@ button { padding-left: 8px; border-bottom: 1px solid var(--header-line); + &.mouse-over { + background-color: var(--record-hover); + } + img { display: block; padding-left: 4px; @@ -332,6 +345,8 @@ button { border-left: 1px solid var(--header-line); align-content: center; cursor: pointer; + display: grid; + justify-items: center; &.mouse-over { background-color: var(--record-hover); diff --git a/static/js/dns.mjs b/static/js/dns.mjs index 08d9302..766e39d 100644 --- a/static/js/dns.mjs +++ b/static/js/dns.mjs @@ -71,6 +71,7 @@ export class Application { async connectDevice(devName) {// {{{ this.resetDeviceState() if (devName == '-') { + this.settings.set('last_device', '') return } @@ -84,6 +85,9 @@ export class Application { const records = await this.currentDevice.retrieveRecords() this.records = this.parseRecords(records) + + this.settings.set('last_device', this.currentDevice.name()) + this.createFolders() this.renderDevice() } catch (err) { @@ -184,6 +188,9 @@ export class Application { } this.deviceSelector.render() + const lastDevice = this.settings.get('last_device') + if (lastDevice !== '') + this.deviceSelector.selectDevice(lastDevice) }// }}} // renderDevice creates the device specific elements and also updates them. @@ -293,7 +300,7 @@ export class Application { break case 'f': - this.searchField.focus() + this.searchWidget.searchField.focus() break default: @@ -317,7 +324,6 @@ export class Application { document.body.classList.remove('boxed-folders') }// }}} - // search sets the search filter and re-renders the records tree. search() {// {{{ this.recordsTree.remove() @@ -325,6 +331,7 @@ export class Application { this.recordsTree = null this.createFolders() + this.topFolder.openFolder(true) this.renderDevice() }// }}} } @@ -435,6 +442,10 @@ class DeviceSelectWidget { this.currentlySelected = devName _mbus.dispatch('device_selected', { devName }) }// }}} + selectDevice(devname) {// {{{ + this.elDeviceSelect.value = devname + this.notifyDeviceSelect() + }// }}} } class Folder { @@ -742,6 +753,7 @@ class Record { return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divTTL, this.divActions] }// }}} mouseEnter() {// {{{ + this.imgIcon.classList.add('mouse-over') this.divFQDN.classList.add('mouse-over') this.divType.classList.add('mouse-over') this.divValue.classList.add('mouse-over') @@ -749,6 +761,7 @@ class Record { this.divActions.classList.add('mouse-over') }// }}} mouseLeave() {// {{{ + this.imgIcon.classList.remove('mouse-over') this.divFQDN.classList.remove('mouse-over') this.divType.classList.remove('mouse-over') this.divValue.classList.remove('mouse-over') @@ -899,6 +912,7 @@ class Settings { this.settings = new Map([ ['boxed_folders', false], ['toplevel_open', false], + ['last_device', ''], ]) // Read any configured settings from local storage, but keeping default value From b9a5437909310ee5190f06cee564e674e7a74225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 26 Feb 2026 21:37:26 +0100 Subject: [PATCH 3/3] Device management --- config.go | 64 ++++++++++++++++ routeros_device.go | 5 +- static/css/index.css | 59 ++++++++++++++- static/images/icon_create.svg | 6 +- static/js/dns.mjs | 137 +++++++++++++++++++++++++++++++++- webserver.go | 32 +++++++- 6 files changed, 294 insertions(+), 9 deletions(-) diff --git a/config.go b/config.go index 1293c5f..d98f201 100644 --- a/config.go +++ b/config.go @@ -3,7 +3,10 @@ package main import ( // Standard "encoding/json" + "fmt" "os" + "slices" + "strings" ) type Config struct { @@ -20,6 +23,8 @@ type Config struct { } Devices []Device + + filename string } type Device struct { @@ -39,5 +44,64 @@ func readConfig() (config Config, err error) { } err = json.Unmarshal(configData, &config) + config.filename = flagConfig + return +} + +func (cfg *Config) UpdateDevice(currentName string, deviceToUpdate Device) (dev Device, err error) { + + i := slices.IndexFunc(cfg.Devices, func(d Device) bool { + return strings.TrimSpace(strings.ToLower(d.Name)) == strings.TrimSpace(strings.ToLower(currentName)) + }) + + if i > -1 { + dev = cfg.Devices[i] + } + + if strings.TrimSpace(deviceToUpdate.Name) == "" { + err = fmt.Errorf("Name can't be empty") + return + } + if strings.TrimSpace(deviceToUpdate.Address) == "" { + err = fmt.Errorf("Address can't be empty") + return + } + if strings.TrimSpace(deviceToUpdate.Username) == "" { + err = fmt.Errorf("Username can't be empty") + return + } + if deviceToUpdate.Port < 1 || deviceToUpdate.Port > 65535 { + err = fmt.Errorf("Invalid port") + return + } + + dev.Name = strings.TrimSpace(strings.ToLower(deviceToUpdate.Name)) + dev.Address = strings.TrimSpace(strings.ToLower(deviceToUpdate.Address)) + dev.Port = deviceToUpdate.Port + dev.Username = strings.TrimSpace(deviceToUpdate.Username) + + // TODO - Should be configurable... + if dev.Timeout == 0 { + dev.Timeout = 10 + } + + // Device not found - create it! + if i == -1 { + if strings.TrimSpace(deviceToUpdate.Password) == "" { + err = fmt.Errorf("Password can't be empty") + return + } + dev.Password = strings.TrimSpace(deviceToUpdate.Password) + cfg.Devices = append(cfg.Devices, dev) + } else { + if deviceToUpdate.Password != "" { + dev.Password = strings.TrimSpace(deviceToUpdate.Password) + } + cfg.Devices[i] = dev + } + + j, _ := json.Marshal(cfg) + err = os.WriteFile(cfg.filename, j, 0600) + return } diff --git a/routeros_device.go b/routeros_device.go index 50c19d1..93e5738 100644 --- a/routeros_device.go +++ b/routeros_device.go @@ -58,6 +58,7 @@ func (dev *RouterosDevice) Init() { // {{{ // query sends a RouterOS REST API query and returns the unparsed body. func (dev RouterosDevice) query(method, path string, reqBody []byte) (body []byte, err error) { // {{{ + logger.Debug("FOO", "port", dev.Port) url := fmt.Sprintf("https://%s:%d/rest%s", dev.Host, dev.Port, path) logger.Info("URL", "method", method, "url", url) @@ -150,7 +151,7 @@ func (dev *RouterosDevice) UpdateDNSEntry(record DNSEntry) (entry DNSEntry, err err = json.Unmarshal(body, &entry) return }// }}} -func (dev *RouterosDevice) DeleteDNSEntry(id string) (err error) { +func (dev *RouterosDevice) DeleteDNSEntry(id string) (err error) {// {{{ _, err = dev.query("DELETE", "/ip/dns/static/"+id, []byte{}) if err != nil { rosError := struct{ Detail string }{} @@ -162,7 +163,7 @@ func (dev *RouterosDevice) DeleteDNSEntry(id string) (err error) { return } return -} +}// }}} /* // FillPeerDetails retrieves RouterOS resource ID, allowed-address and comment from the router diff --git a/static/css/index.css b/static/css/index.css index cf514f9..407ae3a 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -1,5 +1,5 @@ :root { - --header-background: #acbb78; + --header-background: #e1eabe; --header-border: 1px solid #869a41; --line-color: #ccc; @@ -120,12 +120,21 @@ button { background-color: var(--header-background); .device-select { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; + padding: 16px; border-right: var(--header-border); - .device-name { + .label { + grid-column: 1 / -1; font-weight: bold; } + + img { + margin-left: 8px; + } } #search { @@ -465,3 +474,49 @@ button { margin-top: 8px; } } + +#device-dialog { + display: grid; + grid-template-columns: min-content min-content; + align-items: top; + grid-gap: 16px; + + .devices { + display: flex; + flex-direction:column; + gap: 8px; + align-items: flex-start; + + .header { + font-weight: bold; + } + + .device { + border: 1px solid #aaa; + background-color: #f0f0f0; + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + user-select: none; + + &.selected { + background-color: var(--header-background); + } + } + } + + .fields { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 8px 16px; + align-items: center; + + border: 1px solid #aaa; + padding: 16px; + + .actions { + grid-column: 1 / -1; + justify-self: right; + } + } +} diff --git a/static/images/icon_create.svg b/static/images/icon_create.svg index 1fb9dea..e91d683 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.1646825" - inkscape:cx="-71.693356" - inkscape:cy="-55.809199" + inkscape:zoom="5.6568542" + inkscape:cx="-20.948038" + inkscape:cy="9.0156115" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" diff --git a/static/js/dns.mjs b/static/js/dns.mjs index 766e39d..707b6ed 100644 --- a/static/js/dns.mjs +++ b/static/js/dns.mjs @@ -303,6 +303,10 @@ export class Application { this.searchWidget.searchField.focus() break + case 'd': + new DeviceDialog(this.devices).render() + break + default: handled = false } @@ -385,6 +389,21 @@ class Device { name() {// {{{ return this.data.Name?.toLowerCase() || '' }// }}} + address() {// {{{ + return this.data.Address || '' + }// }}} + port() {// {{{ + return parseInt(this.data.Port || 0) + }// }}} + username() {// {{{ + return this.data.Username || '' + }// }}} + password() {// {{{ + return this.data.Password || '' + }// }}} + name() {// {{{ + return this.data.Name?.toLowerCase() || '' + }// }}} async retrieveRecords() {// {{{ const data = await fetch(`/device/${this.name()}/dns_records`) const json = await data.json() @@ -410,12 +429,14 @@ class DeviceSelectWidget { this.div = document.createElement('div') this.div.classList.add('device-select') this.div.innerHTML = ` -
Select device
+
Select device
+ ` this.elDeviceSelect = this.div.querySelector('select') this.elDeviceSelect.addEventListener('change', () => this.notifyDeviceSelect()) + this.div.querySelector('img').addEventListener('click', ()=>new DeviceDialog(this.devices).render()) } this.restockDeviceSelect() return this.div @@ -976,3 +997,117 @@ class SettingsDialog { this.dlg.close() }// }}} } + +class DeviceDialog { + constructor(devices) {// {{{ + this.devices = devices + this.device = null + }// }}} + render() {// {{{ + // Only one open at any one time. + if (document.getElementById('device-dialog')) + return + + this.dlg = document.createElement('dialog') + this.dlg.id = 'device-dialog' + this.dlg.innerHTML = ` +
+
Devices
+
+
+
Name
+ + +
Address
+ + +
Port
+ + +
Username
+ + +
Password
+ + +
+ + +
+
+ ` + + this.elDevices = this.dlg.querySelector('.devices') + this.elFields = this.dlg.querySelector('.fields') + this.elName = this.dlg.querySelector('.fields .name') + this.elAddress = this.dlg.querySelector('.fields .address') + this.elPort = this.dlg.querySelector('.fields .port') + this.elUsername = this.dlg.querySelector('.fields .username') + this.elPassword = this.dlg.querySelector('.fields .password') + + const devices = Array.from(this.devices.values()) + devices.sort((a, b)=>{ + if (a.name() < b.name()) return -1 + if (a.name() > b.name()) return 1 + return 0 + }) + devices.forEach(dev=>{ + const devEl = document.createElement('div') + devEl.classList.add('device') + devEl.innerText = dev.name() + devEl.addEventListener('click', ()=>this.editDevice(dev, devEl)) + this.elDevices.appendChild(devEl) + }) + + this.dlg.querySelector('.update').addEventListener('click', ()=>this.updateDevice()) + this.dlg.addEventListener('close', ()=>this.dlg.remove()) + + document.body.appendChild(this.dlg) + this.dlg.showModal() + }// }}} + + editDevice(dev, devEl) {// {{{ + this.device = dev + + this.elDevices.querySelectorAll('.device.selected').forEach(el=>el.classList.remove('selected')) + devEl.classList.add('selected') + + this.elName.value = dev.name() + this.elAddress.value = dev.address() + this.elPort.value = dev.port() + this.elUsername.value = dev.username() + this.elPassword.value = dev.password() + }// }}} + async updateDevice() {// {{{ + const req = { + CurrentName: this.device.name(), + Device: { + Name: this.elName.value, + Address: this.elAddress.value, + Port: parseInt(this.elPort.value), + Username: this.elUsername.value, + Password: this.elPassword.value, + } + } + + try { + const data = await fetch('/device', { + method: 'POST', + body: JSON.stringify(req), + }) + const json = await data.json() + + this.device.data.Name = json.Device.Name + this.device.data.Address = json.Device.Address + this.device.data.Port = json.Device.Port + this.device.data.Username = json.Device.Username + + _mbus.dispatch('device_updated', { device: this.device }) + this.dlg.close() + + } catch(err) { + console.error(err) + alert(err) + } + }// }}} +} diff --git a/webserver.go b/webserver.go index 0869f7c..a87a3d3 100644 --- a/webserver.go +++ b/webserver.go @@ -33,7 +33,8 @@ func registerWebserverHandlers() { // {{{ } http.HandleFunc("/", rootHandler) - http.HandleFunc("/devices", actionDevices) + http.HandleFunc("GET /devices", actionDevices) + http.HandleFunc("POST /device", actionDeviceUpdate) http.HandleFunc("GET /device/{dev}/dns_records", actionDNSRecords) http.HandleFunc("POST /device/{dev}/record", actionRecordSave) http.HandleFunc("DELETE /device/{dev}/record/{id}", actionRecordDelete) @@ -96,6 +97,35 @@ func actionDevices(w http.ResponseWriter, r *http.Request) { // {{{ w.Write(j) } // }}} +func actionDeviceUpdate(w http.ResponseWriter, r *http.Request) { // {{{ + var req struct { + CurrentName string + Device Device + } + + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &req) + if err != nil { + httpError(w, err) + return + } + + device, err := config.UpdateDevice(req.CurrentName, req.Device) + if err != nil { + httpError(w, err) + return + } + device.Password = "" // don't leak unnecessarily + + j, _ := json.Marshal(struct { + OK bool + Device Device + }{ + true, + device, + }) + w.Write(j) +} // }}} func actionDNSRecords(w http.ResponseWriter, r *http.Request) { // {{{ devname := r.PathValue("dev")