Compare commits

..

No commits in common. "b9a5437909310ee5190f06cee564e674e7a74225" and "d0b0aba30da58c108f689b10ad8794be1d970888" have entirely different histories.

8 changed files with 188 additions and 821 deletions

View file

@ -3,10 +3,7 @@ package main
import ( import (
// Standard // Standard
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"slices"
"strings"
) )
type Config struct { type Config struct {
@ -22,18 +19,13 @@ type Config struct {
LogDir string LogDir string
} }
Devices []Device Device struct {
filename string
}
type Device struct {
Name string
Address string Address string
Port int Port int
Username string Username string
Password string Password string
Timeout int Timeout int
}
} }
func readConfig() (config Config, err error) { func readConfig() (config Config, err error) {
@ -44,64 +36,5 @@ func readConfig() (config Config, err error) {
} }
err = json.Unmarshal(configData, &config) 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 return
} }

36
main.go
View file

@ -9,8 +9,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path" "path"
"slices"
"fmt"
) )
const VERSION = "v1" const VERSION = "v1"
@ -25,7 +23,7 @@ var (
initLogger *slog.Logger initLogger *slog.Logger
logger *slog.Logger logger *slog.Logger
devices map[string]RouterosDevice device RouterosDevice
) )
func init() { // {{{ func init() { // {{{
@ -75,45 +73,13 @@ func initLogging(config Config) *slog.Logger { // {{{
func main() { func main() {
initLogger.Info("application", "version", VERSION) initLogger.Info("application", "version", VERSION)
devices = make(map[string]RouterosDevice)
/*
device.Host = config.Device.Address device.Host = config.Device.Address
device.Port = config.Device.Port device.Port = config.Device.Port
device.Username = config.Device.Username device.Username = config.Device.Username
device.Password = config.Device.Password device.Password = config.Device.Password
device.Timeout = config.Device.Timeout device.Timeout = config.Device.Timeout
device.Init() device.Init()
*/
registerWebserverHandlers() registerWebserverHandlers()
startWebserver() 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
}

View file

@ -58,7 +58,6 @@ func (dev *RouterosDevice) Init() { // {{{
// query sends a RouterOS REST API query and returns the unparsed body. // 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) { // {{{ 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) url := fmt.Sprintf("https://%s:%d/rest%s", dev.Host, dev.Port, path)
logger.Info("URL", "method", method, "url", url) logger.Info("URL", "method", method, "url", url)
@ -151,7 +150,7 @@ func (dev *RouterosDevice) UpdateDNSEntry(record DNSEntry) (entry DNSEntry, err
err = json.Unmarshal(body, &entry) err = json.Unmarshal(body, &entry)
return 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{}) _, err = dev.query("DELETE", "/ip/dns/static/"+id, []byte{})
if err != nil { if err != nil {
rosError := struct{ Detail string }{} rosError := struct{ Detail string }{}
@ -163,7 +162,7 @@ func (dev *RouterosDevice) DeleteDNSEntry(id string) (err error) {// {{{
return return
} }
return return
}// }}} }
/* /*
// FillPeerDetails retrieves RouterOS resource ID, allowed-address and comment from the router // FillPeerDetails retrieves RouterOS resource ID, allowed-address and comment from the router

View file

@ -1,7 +1,4 @@
:root { :root {
--header-background: #e1eabe;
--header-border: 1px solid #869a41;
--line-color: #ccc; --line-color: #ccc;
--line-color-record: #eee; --line-color-record: #eee;
@ -23,7 +20,7 @@
--record-NXDOMAIN: #aa0000; --record-NXDOMAIN: #aa0000;
--record-other: #888; --record-other: #888;
--record-hover: #fafafa; --record-hover: #fffff4;
} }
html { html {
@ -35,35 +32,19 @@ html {
*:after { *:after {
box-sizing: inherit; box-sizing: inherit;
} }
*:focus {
outline: none;
}
[onClick] { [onClick] {
cursor: pointer; cursor: pointer;
} }
h1 {
font-size: 1.5em;
&:first-child {
margin-top: 0px;
}
}
label { label {
user-select: none; user-select: none;
} }
html,
body {
margin: 0px;
padding: 0px;
}
body { body {
font-family: sans-serif; font-family: sans-serif;
font-size: 12pt; font-size: 12pt;
margin-left: 32px;
/* Boxed folders are a settings for the user. */ /* Boxed folders are a settings for the user. */
&.boxed-folders { &.boxed-folders {
@ -107,79 +88,30 @@ select,
button { button {
font-size: 1em; font-size: 1em;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px;
border: 1px solid #444;
} }
#application-header { #create-icon {
display: grid; position: absolute;
grid-template-columns: min-content min-content 1fr min-content; top: 18px;
align-items: center; right: 64px;
justify-items: end;
border-bottom: var(--header-border);
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);
.label {
grid-column: 1 / -1;
font-weight: bold;
}
img {
margin-left: 8px;
}
}
#search {
padding: 16px;
display: grid;
grid-template-columns: min-content min-content;
grid-gap: 0px 8px;
&>* {
display: none;
}
&.show {
border-right: var(--header-border);
&>* {
display: initial;
}
}
.search-label {
grid-column: 1 / -1;
font-weight: bold;
}
}
#create-icon {
width: 24px; width: 24px;
cursor: pointer; cursor: pointer;
margin-right: 16px; }
}
#settings-icon { #settings-icon {
position: absolute;
top: 16px;
right: 16px;
width: 32px; width: 32px;
cursor: pointer; cursor: pointer;
margin-right: 16px; }
}
#search {
margin-bottom: 16px;
} }
#records-tree { #records-tree {
white-space: nowrap; white-space: nowrap;
margin-top: calc(32px - 5px);
/* padding from the topmost folder */
margin-left: 32px;
.folder { .folder {
padding-left: 32px; padding-left: 32px;
@ -188,7 +120,7 @@ button {
padding-left: 0px; padding-left: 0px;
} }
&.no-domain>.label { &.no-domain > .label {
font-style: italic; font-style: italic;
} }
@ -287,10 +219,6 @@ button {
padding-left: 8px; padding-left: 8px;
border-bottom: 1px solid var(--header-line); border-bottom: 1px solid var(--header-line);
&.mouse-over {
background-color: var(--record-hover);
}
img { img {
display: block; display: block;
padding-left: 4px; padding-left: 4px;
@ -354,8 +282,6 @@ button {
border-left: 1px solid var(--header-line); border-left: 1px solid var(--header-line);
align-content: center; align-content: center;
cursor: pointer; cursor: pointer;
display: grid;
justify-items: center;
&.mouse-over { &.mouse-over {
background-color: var(--record-hover); background-color: var(--record-hover);
@ -474,49 +400,3 @@ button {
margin-top: 8px; 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;
}
}
}

View file

@ -25,9 +25,9 @@
borderopacity="1.0" borderopacity="1.0"
inkscape:pageopacity="0.0" inkscape:pageopacity="0.0"
inkscape:pageshadow="2" inkscape:pageshadow="2"
inkscape:zoom="5.6568542" inkscape:zoom="1"
inkscape:cx="-20.948038" inkscape:cx="-71"
inkscape:cy="9.0156115" inkscape:cy="-52"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
showgrid="false" showgrid="false"
@ -62,6 +62,6 @@
<path <path
d="m -80.697917,15.345833 h -1.058333 v 1.058334 h -0.529167 v -1.058334 h -1.058333 v -0.529166 h 1.058333 v -1.058334 h 0.529167 v 1.058334 h 1.058333 M -80.16875,12.7 h -3.704167 c -0.293687,0 -0.529167,0.235479 -0.529167,0.529167 v 3.704166 a 0.52916667,0.52916667 0 0 0 0.529167,0.529167 h 3.704167 a 0.52916667,0.52916667 0 0 0 0.529166,-0.529167 v -3.704166 c 0,-0.293688 -0.238125,-0.529167 -0.529166,-0.529167 z" d="m -80.697917,15.345833 h -1.058333 v 1.058334 h -0.529167 v -1.058334 h -1.058333 v -0.529166 h 1.058333 v -1.058334 h 0.529167 v 1.058334 h 1.058333 M -80.16875,12.7 h -3.704167 c -0.293687,0 -0.529167,0.235479 -0.529167,0.529167 v 3.704166 a 0.52916667,0.52916667 0 0 0 0.529167,0.529167 h 3.704167 a 0.52916667,0.52916667 0 0 0 0.529166,-0.529167 v -3.704166 c 0,-0.293688 -0.238125,-0.529167 -0.529166,-0.529167 z"
id="path1" id="path1"
style="stroke-width:0.264583;fill:#000000" /> style="stroke-width:0.264583;fill:#89a02c" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

@ -1,109 +1,26 @@
import { MessageBus } from '@mbus' import { MessageBus } from '@mbus'
export class Application { export class Application {
constructor() {// {{{ constructor(records) {// {{{
window._mbus = new MessageBus() window._mbus = new MessageBus()
this.settings = new Settings() this.settings = new Settings()
this.devices = new Map() this.records = this.parseRecords(records)
this.currentDevice = null this.topFolder = new Folder(this, null, 'root')
this.recordsTree = null
this.settingsIcon = null this.settingsIcon = null
this.createIcon = null this.createIcon = null
this.elHeader = null
this.deviceSelector = null
this.searchWidget = null
this.resetDeviceState() this.searchFor = ''
this.renderApplication()
this.retrieveDevices()
.then(() => {
this.deviceSelector.setDevices(this.devices)
this.renderApplication()
})
.catch(err => {
console.error(err)
alert(err)
})
_mbus.subscribe('device_selected', event => this.connectDevice(event.detail.devName)) this.renderFolders()
_mbus.subscribe('search', ()=>this.search()) this.render()
}// }}} }// }}}
// 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 == '-') {
this.settings.set('last_device', '')
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.settings.set('last_device', this.currentDevice.name())
this.createFolders()
this.renderDevice()
} catch (err) {
console.error(err)
alert(err)
}
}// }}}
// filteredRecords takes the current search value and only returns records matching it.
filteredRecords() {// {{{ filteredRecords() {// {{{
const searchFor = this.searchWidget.value() if (this.searchFor === '')
if (searchFor === '')
return this.records return this.records
const records = this.records.filter(r => { const records = this.records.filter(r => {
return (r.name().includes(searchFor)) return (r.name().includes(this.searchFor))
}) })
return records return records
}// }}} }// }}}
@ -113,7 +30,7 @@ export class Application {
}// }}} }// }}}
// cleanFolders removes all records from all folders. // cleanFolders removes all records from all folders.
// createFolders can then put moved records in the correct // renderFolders can then put moved records in the correct
// (or newly created) folders again when record names are updated. // (or newly created) folders again when record names are updated.
cleanFolders(folder) {// {{{ cleanFolders(folder) {// {{{
if (folder === undefined) if (folder === undefined)
@ -128,99 +45,7 @@ export class Application {
const i = this.records.findIndex(rec => rec.id() == id) const i = this.records.findIndex(rec => rec.id() == id)
this.records.splice(i, 1) this.records.splice(i, 1)
}// }}} }// }}}
sortFolders(a, b) {// {{{ renderFolders() {// {{{
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()
const lastDevice = this.settings.get('last_device')
if (lastDevice !== '')
this.deviceSelector.selectDevice(lastDevice)
}// }}}
// 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() const records = this.filteredRecords()
records.sort(this.sortRecords) records.sort(this.sortRecords)
@ -260,9 +85,91 @@ export class Application {
currFolder.addRecord(rec) currFolder.addRecord(rec)
} }
}// }}} }// }}}
sortFolders(a, b) {// {{{
const aLabels = a.labels().reverse()
const bLabels = b.labels().reverse()
// removeEmptyFolders finds the leaf folders and recursively removes empty ones. for (let i = 0; i < aLabels.length && i < bLabels.length; i++) {
// These are usually left from moving records away from them. 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 = `
<input type="text" class="search-for">
<button class="search">Search</button>
`
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(folder) {// {{{ removeEmptyFolders(folder) {// {{{
if (folder === undefined) if (folder === undefined)
folder = this.topFolder folder = this.topFolder
@ -282,7 +189,6 @@ export class Application {
folder.div?.remove() folder.div?.remove()
} }
}// }}} }// }}}
handlerKeys(event) {// {{{ handlerKeys(event) {// {{{
let handled = true let handled = true
@ -300,11 +206,7 @@ export class Application {
break break
case 'f': case 'f':
this.searchWidget.searchField.focus() this.searchField.focus()
break
case 'd':
new DeviceDialog(this.devices).render()
break break
default: default:
@ -327,145 +229,15 @@ export class Application {
else else
document.body.classList.remove('boxed-folders') document.body.classList.remove('boxed-folders')
}// }}} }// }}}
// search sets the search filter and re-renders the records tree.
search() {// {{{ search() {// {{{
this.searchFor = this.searchField.value.trim().toLowerCase()
this.recordsTree.remove() this.recordsTree.remove()
this.topFolder = new Folder(this, null, 'root') this.topFolder = new Folder(this, null, 'root')
this.recordsTree = null this.recordsTree = null
this.createFolders() this.cleanFolders()
this.topFolder.openFolder(true) this.renderFolders()
this.renderDevice() this.render()
}// }}}
}
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 = `
<div class="search-label">Search</div>
<input type="text" class="search-for">
<button class="search">Search</button>
`
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() || ''
}// }}}
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()
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 = `
<div class="label">Select device</div>
<select></select>
<img src="/images/${_VERSION}/icon_device_edit.svg">
`
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
}// }}}
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 })
}// }}}
selectDevice(devname) {// {{{
this.elDeviceSelect.value = devname
this.notifyDeviceSelect()
}// }}} }// }}}
} }
@ -562,9 +334,11 @@ class Folder {
this.div.querySelector('.label').addEventListener('click', event => this.toggleFolder(event)) this.div.querySelector('.label').addEventListener('click', event => this.toggleFolder(event))
this.div.querySelector('.label .create').addEventListener('click', event => this.createRecord(event)) this.div.querySelector('.label .create').addEventListener('click', event => this.createRecord(event))
if (this.name() == '_no.domain') if (this.name() == '_no.domain') {
console.log('wut')
this.div.classList.add('no-domain') this.div.classList.add('no-domain')
} }
}
// Subfolders are refreshed. // Subfolders are refreshed.
@ -774,7 +548,6 @@ class Record {
return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divTTL, this.divActions] return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divTTL, this.divActions]
}// }}} }// }}}
mouseEnter() {// {{{ mouseEnter() {// {{{
this.imgIcon.classList.add('mouse-over')
this.divFQDN.classList.add('mouse-over') this.divFQDN.classList.add('mouse-over')
this.divType.classList.add('mouse-over') this.divType.classList.add('mouse-over')
this.divValue.classList.add('mouse-over') this.divValue.classList.add('mouse-over')
@ -782,7 +555,6 @@ class Record {
this.divActions.classList.add('mouse-over') this.divActions.classList.add('mouse-over')
}// }}} }// }}}
mouseLeave() {// {{{ mouseLeave() {// {{{
this.imgIcon.classList.remove('mouse-over')
this.divFQDN.classList.remove('mouse-over') this.divFQDN.classList.remove('mouse-over')
this.divType.classList.remove('mouse-over') this.divType.classList.remove('mouse-over')
this.divValue.classList.remove('mouse-over') this.divValue.classList.remove('mouse-over')
@ -793,7 +565,7 @@ class Record {
save() {// {{{ save() {// {{{
const created = (this.id() == '') const created = (this.id() == '')
fetch(`/device/${_app.currentDevice.name()}/record`, { fetch('/record/save', {
method: 'POST', method: 'POST',
body: JSON.stringify(this.data), body: JSON.stringify(this.data),
}) })
@ -812,10 +584,10 @@ class Record {
} }
_app.cleanFolders() _app.cleanFolders()
_app.createFolders() _app.renderFolders()
_app.renderDevice() _app.render()
// createFolders is setting the folder the record resides in. // renderFolders is setting the folder the record resides in.
// It can now be expanded to the parent folder. // It can now be expanded to the parent folder.
if (created) { if (created) {
this.openParentFolders() this.openParentFolders()
@ -831,7 +603,7 @@ class Record {
if (!confirm(`Are you sure you want to delete ${this.name()}?`)) if (!confirm(`Are you sure you want to delete ${this.name()}?`))
return return
fetch(`/device/${_app.currentDevice.name()}/record/${this.id()}`, { method: 'DELETE' }) fetch(`/record/delete/${this.id()}`)
.then(data => data.json()) .then(data => data.json())
.then(json => { .then(json => {
if (!json.OK) { if (!json.OK) {
@ -840,8 +612,8 @@ class Record {
} }
_app.deleteRecord(this.id()) _app.deleteRecord(this.id())
_app.cleanFolders() _app.cleanFolders()
_app.createFolders() _app.renderFolders()
_app.renderDevice() _app.render()
}) })
}// }}} }// }}}
@ -933,7 +705,6 @@ class Settings {
this.settings = new Map([ this.settings = new Map([
['boxed_folders', false], ['boxed_folders', false],
['toplevel_open', false], ['toplevel_open', false],
['last_device', ''],
]) ])
// Read any configured settings from local storage, but keeping default value // Read any configured settings from local storage, but keeping default value
@ -997,117 +768,3 @@ class SettingsDialog {
this.dlg.close() 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 = `
<div class="devices">
<div class="header">Devices</div>
</div>
<div class="fields">
<div>Name</div>
<input type="text" class="name">
<div>Address</div>
<input type="text" class="address">
<div>Port</div>
<input type="number" class="port">
<div>Username</div>
<input type="text" class="username">
<div>Password</div>
<input type="text" class="password">
<div class="actions">
<button class="delete">Delete</button>
<button class="update">Update</button>
</div>
</div>
`
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)
}
}// }}}
}

View file

@ -1,6 +1,9 @@
{{ define "page" }} {{ define "page" }}
<script type="module"> <script type="module">
import { Application } from "/js/{{ .Data.VERSION }}/dns.mjs" import { Application } from "/js/{{ .Data.VERSION }}/dns.mjs"
window._app = new Application() window._app = new Application({{ .Data.DNSRecords }})
</script> </script>
<h1>{{ .Data.Identity }}</h1>
{{ end }} {{ end }}

View file

@ -24,7 +24,7 @@ var (
viewFS embed.FS viewFS embed.FS
) )
func registerWebserverHandlers() { // {{{ func registerWebserverHandlers() {
var err error var err error
htmlEngine, err = HTMLTemplate.NewEngine(viewFS, staticFS, flagDev) htmlEngine, err = HTMLTemplate.NewEngine(viewFS, staticFS, flagDev)
if err != nil { if err != nil {
@ -33,32 +33,17 @@ func registerWebserverHandlers() { // {{{
} }
http.HandleFunc("/", rootHandler) http.HandleFunc("/", rootHandler)
http.HandleFunc("GET /devices", actionDevices) http.HandleFunc("/record/save", actionRecordSave)
http.HandleFunc("POST /device", actionDeviceUpdate) http.HandleFunc("/record/delete/{id}", actionRecordDelete)
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() {
} // }}}
func startWebserver() { // {{{
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port) listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
logger.Info("webserver", "listen", listen) logger.Info("webserver", "listen", listen)
http.ListenAndServe(listen, nil) http.ListenAndServe(listen, nil)
} // }}} }
func httpError(w http.ResponseWriter, err error) { // {{{ func rootHandler(w http.ResponseWriter, r *http.Request) {
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 == "/" { if r.URL.Path == "/" {
page := HTMLTemplate.SimplePage{} page := HTMLTemplate.SimplePage{}
page.Layout = "main" page.Layout = "main"
@ -67,6 +52,21 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
var err error var err error
data := make(map[string]any) 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 data["VERSION"] = VERSION
page.Data = data page.Data = data
@ -79,91 +79,25 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
} }
htmlEngine.StaticResource(w, r) htmlEngine.StaticResource(w, r)
} // }}} }
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(struct { func httpError(w http.ResponseWriter, err error) {
resp := struct {
OK bool OK bool
Devices []Device Error string
}{ }{
true, false,
devs, err.Error(),
}) }
j, _ := json.Marshal(resp)
w.Write(j) 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")
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) body, _ := io.ReadAll(r.Body)
var record DNSRecord var record DNSRecord
err = json.Unmarshal(body, &record) err := json.Unmarshal(body, &record)
if err != nil { if err != nil {
httpError(w, err) httpError(w, err)
return return
@ -183,22 +117,17 @@ func actionRecordSave(w http.ResponseWriter, r *http.Request) { // {{{
Record DNSRecord Record DNSRecord
}{true, record}) }{true, record})
w.Write(j) 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") id := r.PathValue("id")
if id == "" { if id == "" {
httpError(w, errors.New("No ID provided")) httpError(w, errors.New("No ID provided"))
return return
} }
err = device.DeleteDNSEntry(id) err := device.DeleteDNSEntry(id)
if err != nil { if err != nil {
httpError(w, err) httpError(w, err)
logger.Error("webserver", "op", "record_delete", "error", err) logger.Error("webserver", "op", "record_delete", "error", err)
@ -209,4 +138,4 @@ func actionRecordDelete(w http.ResponseWriter, r *http.Request) { // {{{
OK bool OK bool
}{true}) }{true})
w.Write(j) w.Write(j)
} // }}} }