Added basic multidevice support

This commit is contained in:
Magnus Åhall 2026-02-26 15:21:24 +01:00
parent d0b0aba30d
commit 7feeacea42
7 changed files with 505 additions and 186 deletions

View file

@ -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;
}

View file

@ -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 @@
<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"
id="path1"
style="stroke-width:0.264583;fill:#89a02c" />
style="stroke-width:0.264583;fill:#000000" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

@ -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 = `
<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 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 = `
<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() || ''
}// }}}
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="device-name">Select device</div>
<select></select>
`
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()
})
}// }}}