1216 lines
34 KiB
JavaScript
1216 lines
34 KiB
JavaScript
import { MessageBus } from '@mbus'
|
|
|
|
export class Application {
|
|
constructor() {// {{{
|
|
window._mbus = new MessageBus()
|
|
this.settings = new Settings()
|
|
this.devices = new Map()
|
|
this.currentDevice = null
|
|
|
|
this.settingsIcon = null
|
|
this.createIcon = null
|
|
this.elHeader = null
|
|
this.deviceSelector = null
|
|
this.searchWidget = null
|
|
|
|
this.resetDeviceState()
|
|
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))
|
|
_mbus.subscribe('device_deleted', event => this.deleteDevice(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 == '-') {
|
|
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)
|
|
}
|
|
}// }}}
|
|
|
|
// deleteDevice removes it from the list and resets app state if it was connected.
|
|
deleteDevice(devName) {// {{{
|
|
this.devices.delete(devName)
|
|
if (this.currentDevice?.name() == devName) {
|
|
this.resetDeviceState()
|
|
this.settings.set('last_device', '')
|
|
}
|
|
}// }}}
|
|
|
|
// filteredRecords takes the current search value and only returns records matching it.
|
|
filteredRecords() {// {{{
|
|
const searchFor = this.searchWidget.value()
|
|
if (searchFor === '')
|
|
return this.records
|
|
|
|
const records = this.records.filter(r => {
|
|
return (r.name().includes(searchFor))
|
|
})
|
|
return records
|
|
}// }}}
|
|
parseRecords(recordsData) {// {{{
|
|
const records = recordsData.map(d => new Record(d))
|
|
return records
|
|
}// }}}
|
|
|
|
// cleanFolders removes all records from all folders.
|
|
// createFolders can then put moved records in the correct
|
|
// (or newly created) folders again when record names are updated.
|
|
cleanFolders(folder) {// {{{
|
|
if (folder === undefined)
|
|
folder = this.topFolder
|
|
|
|
folder.records = []
|
|
folder.subfolders.forEach((folder, _label) => {
|
|
this.cleanFolders(folder)
|
|
})
|
|
}// }}}
|
|
deleteRecord(id) {// {{{
|
|
const i = this.records.findIndex(rec => rec.id() == id)
|
|
this.records.splice(i, 1)
|
|
}// }}}
|
|
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()
|
|
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()
|
|
records.sort(this.sortRecords)
|
|
|
|
for (const rec of records) {
|
|
// It felt wrong when records for the base domain (e.g. google.com) was put in the top level domain (.com).
|
|
// While technically correct, the first label (com) is grouped together with the second label (google).
|
|
// Labels are counted reversely since the top domain is most significant.
|
|
//
|
|
// The `if` here is the exception for records that only has the two first labels. It would otherwise just
|
|
// be an empty array of labels and thus discarded.
|
|
let labels = rec.labels().reverse().slice(0, -1)
|
|
if (rec.labels().length == 1)
|
|
labels = rec.labels()
|
|
|
|
// Start each record from the top and iterate through all its labels
|
|
// except the first one since that would be the actual record.
|
|
let currFolder = this.topFolder
|
|
let accFolderLabels = []
|
|
for (const i in labels) {
|
|
const label = labels[i]
|
|
|
|
// The accumulated name is used to create each folder as the record progresses.
|
|
accFolderLabels.push(label)
|
|
const accFolderName = accFolderLabels.map(v => v).reverse().join('.')
|
|
|
|
// A new folder is created only when it doesn't exist
|
|
// to be able to update them when necessary.
|
|
let folder = currFolder.subfolders.get(label)
|
|
if (folder === undefined) {
|
|
folder = new Folder(this, currFolder, accFolderName)
|
|
currFolder.subfolders.set(label, folder)
|
|
}
|
|
currFolder = folder
|
|
|
|
}
|
|
// Add the record to the innermost folder
|
|
currFolder.addRecord(rec)
|
|
}
|
|
}// }}}
|
|
|
|
// 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
|
|
|
|
folder.subfolders.forEach((folder, _label) => {
|
|
this.removeEmptyFolders(folder)
|
|
})
|
|
|
|
// This is a leaf folder in the tree.
|
|
// It has to be removed from the parent as well, since that could be up for
|
|
// removal as well, all the way up the chain.
|
|
if (folder.subfolders.size === 0 && folder.records.length === 0) {
|
|
if (folder.parentFolder) {
|
|
folder.parentFolder.subfolders.delete(folder.labels()[0])
|
|
}
|
|
|
|
folder.div?.remove()
|
|
}
|
|
}// }}}
|
|
|
|
handlerKeys(event) {// {{{
|
|
let handled = true
|
|
|
|
// Every keyboard shortcut for the application wide handler is using Alt+Shift
|
|
// for consistency and that it works with a lot of browsers.
|
|
if (!event.altKey || !event.shiftKey || event.ctrlKey) {
|
|
return
|
|
}
|
|
|
|
switch (event.key.toLowerCase()) {
|
|
case 'n':
|
|
const existingDialog = document.getElementById('record-dialog')
|
|
if (existingDialog === null)
|
|
new RecordDialog(new Record()).show()
|
|
break
|
|
|
|
case 'f':
|
|
this.searchWidget.searchField.focus()
|
|
break
|
|
|
|
case 'd':
|
|
new DeviceDialog(this.devices).render()
|
|
break
|
|
|
|
default:
|
|
handled = false
|
|
}
|
|
|
|
if (handled) {
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
}
|
|
}// }}}
|
|
handlerSettingsUpdated({ key, value }) {// {{{
|
|
if (key == 'boxed_folders') {
|
|
this.setBoxedFolders(value)
|
|
}
|
|
}// }}}
|
|
setBoxedFolders(state) {// {{{
|
|
if (state)
|
|
document.body.classList.add('boxed-folders')
|
|
else
|
|
document.body.classList.remove('boxed-folders')
|
|
}// }}}
|
|
|
|
// search sets the search filter and re-renders the records tree.
|
|
search() {// {{{
|
|
this.recordsTree.remove()
|
|
this.topFolder = new Folder(this, null, 'root')
|
|
this.recordsTree = null
|
|
|
|
this.createFolders()
|
|
this.topFolder.openFolder(true)
|
|
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() || ''
|
|
}// }}}
|
|
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
|
|
|
|
_mbus.subscribe('device_deleted', event => this.deleteDevice(event.detail.devName))
|
|
_mbus.subscribe('device_updated', event => this.updateDevice(event.detail.device))
|
|
}// }}}
|
|
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)
|
|
|
|
const devNames = Array.from(this.devices.keys()).sort()
|
|
devNames.forEach(devName => {
|
|
const dev = this.devices.get(devName)
|
|
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()
|
|
}// }}}
|
|
deleteDevice(devName) {// {{{
|
|
this.devices.delete(devName)
|
|
this.restockDeviceSelect()
|
|
}// }}}
|
|
updateDevice(dev) {// {{{
|
|
console.log(dev)
|
|
this.devices.set(dev.name(), dev)
|
|
this.restockDeviceSelect()
|
|
}// }}}
|
|
}
|
|
|
|
class Folder {
|
|
constructor(app, parentFolder, name) {// {{{
|
|
this.application = app
|
|
this.parentFolder = parentFolder
|
|
this.folderName = name
|
|
this.subfolders = new Map()
|
|
this.records = []
|
|
|
|
this.div = null
|
|
this.divSubfolders = null
|
|
this.divRecords = null
|
|
|
|
const topLevelOpen = this.application.settings.get('toplevel_open')
|
|
this.open = (topLevelOpen && this.labels().length <= 1)
|
|
}// }}}
|
|
name() {// {{{
|
|
return this.folderName.toLowerCase()
|
|
}// }}}
|
|
labels() {// {{{
|
|
let labels = this.name().split('.')
|
|
|
|
// It is very uncommon to see just the top level domain.
|
|
// We're much more used to google.com than com and then google.
|
|
// First level is therefore the two most significant labels concatenated.
|
|
if (labels.length > 1) {
|
|
labels.reverse()
|
|
labels = [`${labels[1]}.${labels[0]}`].concat(labels.slice(2))
|
|
labels.reverse()
|
|
}
|
|
|
|
return labels
|
|
}// }}}
|
|
addRecord(rec) {// {{{
|
|
this.records.push(rec)
|
|
rec.setFolder(this)
|
|
}// }}}
|
|
openFolder(recursive) {// {{{
|
|
this.open = true
|
|
this.div?.classList.add('open')
|
|
this.div?.classList.remove('closed')
|
|
|
|
if (recursive)
|
|
this.subfolders.forEach(folder => folder.openFolder(recursive))
|
|
}// }}}
|
|
closeFolder(recursive) {// {{{
|
|
this.open = false
|
|
this.div.classList.remove('open')
|
|
this.div.classList.add('closed')
|
|
|
|
if (recursive)
|
|
this.subfolders.forEach(folder => folder.closeFolder(recursive))
|
|
}// }}}
|
|
toggleFolder(event) {// {{{
|
|
event.stopPropagation()
|
|
|
|
if (this.open)
|
|
this.closeFolder(event.shiftKey)
|
|
else
|
|
this.openFolder(event.shiftKey)
|
|
}// }}}
|
|
render() {// {{{
|
|
if (this.div === null) {
|
|
this.div = document.createElement('div')
|
|
this.div.classList.add('folder')
|
|
this.div.classList.add(this.open ? 'open' : 'closed')
|
|
if (this.labels().length == 1)
|
|
this.div.classList.add('top-most')
|
|
|
|
const firstLabel = this.labels()[0]
|
|
const restLabels = this.labels().slice(1).join('.')
|
|
|
|
this.div.innerHTML = `
|
|
<div class="label">
|
|
<img class="closed" src="/images/${_VERSION}/icon_folder.svg">
|
|
<img class="open" src="/images/${_VERSION}/icon_folder_open.svg">
|
|
<span>${firstLabel}</span><span>${restLabels != '' ? '.' + restLabels : ''}</span>
|
|
<img class="create" src="/images/${_VERSION}/icon_create.svg">
|
|
</div>
|
|
<div class="subfolders"></div>
|
|
<div class="records">
|
|
<div class="header">FQDN</div>
|
|
<div class="header">Type</div>
|
|
<div class="header">Value</div>
|
|
<div class="header">TTL</div>
|
|
<div class="header last">Actions</div>
|
|
</div>
|
|
`
|
|
this.divSubfolders = this.div.querySelector('.subfolders')
|
|
this.divRecords = this.div.querySelector('.records')
|
|
|
|
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')
|
|
this.div.classList.add('no-domain')
|
|
}
|
|
|
|
|
|
// Subfolders are refreshed.
|
|
const subfolders = Array.from(this.subfolders.values())
|
|
subfolders.sort(this.application.sortFolders)
|
|
|
|
for (const folder of subfolders)
|
|
this.divSubfolders.append(folder.render())
|
|
|
|
// Records are refreshed.
|
|
if (this.records.length == 0)
|
|
this.divRecords.style.display = 'none'
|
|
else
|
|
this.divRecords.style.display = ''
|
|
|
|
// Remove old ones
|
|
for (const recdiv of this.divRecords.children) {
|
|
if (recdiv?.classList?.contains('fqdn')) {
|
|
const rec = this.records.find(r => r.id() == recdiv.dataset.record_id)
|
|
if (!rec)
|
|
this.divRecords.querySelectorAll(`[data-record_id="${recdiv.dataset.record_id}"]`)
|
|
.forEach(el => el.remove())
|
|
}
|
|
}
|
|
|
|
for (const rec of Array.from(this.records))
|
|
this.divRecords.append(...rec.render())
|
|
|
|
// Open this folder automatically if it is a toplevel folder and the settings change to open.
|
|
_mbus.subscribe('settings_updated', ({ detail: { key, value } }) => {
|
|
if (key !== 'toplevel_open')
|
|
return
|
|
|
|
if (value && this.labels().length <= 1)
|
|
this.openFolder()
|
|
})
|
|
|
|
return this.div
|
|
}// }}}
|
|
createRecord(event) {// {{{
|
|
event.stopPropagation()
|
|
const record = new Record({ Name: this.name() })
|
|
new RecordDialog(record).show()
|
|
}// }}}
|
|
}
|
|
|
|
class Record {
|
|
constructor(data) {// {{{
|
|
this.data = data || {}
|
|
|
|
this.folder = null
|
|
this.imgIcon = null
|
|
this.divFQDN = null
|
|
this.divType = null
|
|
this.divValue = null
|
|
this.divTTL = null
|
|
this.divActions = null
|
|
}// }}}
|
|
|
|
id() {// {{{
|
|
return this.data['.id'] || ''
|
|
}// }}}
|
|
disabled() {// {{{
|
|
return this.data.Disabled === 'true'
|
|
}// }}}
|
|
dynamic() {// {{{
|
|
return this.data.Dynamic === 'true'
|
|
}// }}}
|
|
name() {// {{{
|
|
return this.data.Name?.toLowerCase() || ''
|
|
}// }}}
|
|
ttl() {// {{{
|
|
return this.data.TTL || '30m'
|
|
}// }}}
|
|
type() {// {{{
|
|
return this.data.Type?.toUpperCase() || 'A'
|
|
}// }}}
|
|
value() {// {{{
|
|
return this.data.ParsedValue || ''
|
|
}// }}}
|
|
matchSubdomain() {// {{{
|
|
return this.data.MatchSubdomain === 'true'
|
|
}// }}}
|
|
labels() {// {{{
|
|
let labels = this.name().split('.')
|
|
|
|
if (labels.length === 1) {
|
|
labels = [labels[0], '_no', 'domain']
|
|
}
|
|
|
|
// It is very uncommon to see just the top level domain.
|
|
// We're much more used to google.com than com and then google.
|
|
// First level is therefore the two most significant labels concatenated.
|
|
if (labels.length > 1) {
|
|
labels.reverse()
|
|
labels = [`${labels[1]}.${labels[0]}`].concat(labels.slice(2))
|
|
labels.reverse()
|
|
} else {
|
|
console.log(this, labels)
|
|
}
|
|
|
|
return labels
|
|
}// }}}
|
|
|
|
copy(el, text) {// {{{
|
|
el.classList.add('copy')
|
|
navigator.clipboard.writeText(text)
|
|
setTimeout(() => el.classList.remove('copy'), 200)
|
|
}// }}}
|
|
edit() {// {{{
|
|
new RecordDialog(this).show()
|
|
}// }}}
|
|
set(key, value) {// {{{
|
|
if (key == 'Name') {
|
|
if (value.slice(0, 2) == '*.') {
|
|
this.data['Name'] = value.slice(2)
|
|
this.data['MatchSubdomain'] = 'true'
|
|
} else if (value.slice(-11) == '._no.domain') {
|
|
this.data['Name'] = value.slice(0, -11)
|
|
} else {
|
|
this.data['Name'] = value
|
|
this.data['MatchSubdomain'] = 'false'
|
|
}
|
|
return
|
|
}
|
|
|
|
this.data[key] = value
|
|
}// }}}
|
|
setFolder(folder) {// {{{
|
|
this.folder = folder
|
|
}// }}}
|
|
render() {// {{{
|
|
if (this.divFQDN === null) {
|
|
this.imgIcon = document.createElement('div')
|
|
this.divFQDN = document.createElement('div')
|
|
this.divType = document.createElement('div')
|
|
this.divValue = document.createElement('div')
|
|
this.divTTL = document.createElement('div')
|
|
this.divActions = document.createElement('div')
|
|
|
|
this.imgIcon.innerHTML = `<img src="/images/${_VERSION}/icon_record.svg">`
|
|
this.imgIcon.classList.add("record-icon");
|
|
|
|
[this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divTTL, this.divActions]
|
|
.forEach(div => {
|
|
div.addEventListener('mouseenter', () => this.mouseEnter())
|
|
div.addEventListener('mouseleave', () => this.mouseLeave())
|
|
div.dataset.record_id = this.id()
|
|
})
|
|
this.divType.classList.add(this.type())
|
|
|
|
this.divFQDN.classList.add('fqdn')
|
|
this.divType.classList.add('type')
|
|
this.divValue.classList.add('value')
|
|
this.divTTL.classList.add('ttl')
|
|
this.divActions.classList.add('actions')
|
|
|
|
this.divType.innerHTML = `<div></div>`
|
|
this.divFQDN.innerHTML = `
|
|
<span class="subdomains">*.</span>
|
|
<span class="first-label"></span>
|
|
<span class="rest-label"></span>
|
|
`
|
|
this.divActions.innerHTML = `
|
|
<img class="delete" src="/images/${_VERSION}/icon_delete.svg">
|
|
`
|
|
|
|
this.divFQDN.addEventListener('click', event => {
|
|
if (event.shiftKey)
|
|
this.copy(event.target.closest('.fqdn'), this.name())
|
|
else
|
|
this.edit()
|
|
})
|
|
this.divValue.addEventListener('click', event => {
|
|
if (event.shiftKey)
|
|
this.copy(event.target.closest('.value'), this.value())
|
|
else
|
|
this.edit()
|
|
})
|
|
this.divType.addEventListener('click', () => this.edit())
|
|
this.divTTL.addEventListener('click', () => this.edit())
|
|
this.divActions.querySelector('.delete').addEventListener('click', () => this.delete())
|
|
}
|
|
|
|
// FQDN is updated.
|
|
if (this.matchSubdomain())
|
|
this.divFQDN.classList.add('match-subdomains')
|
|
else
|
|
this.divFQDN.classList.remove('match-subdomains')
|
|
|
|
const fl = this.labels()[0]
|
|
const rl = this.labels().slice(1).join('.')
|
|
const flEl = this.divFQDN.querySelector('.first-label')
|
|
const rlEl = this.divFQDN.querySelector('.rest-label')
|
|
flEl.innerText = fl
|
|
rlEl.innerText = rl != '' ? `.${rl}` : ''
|
|
|
|
if (rl == '_no.domain')
|
|
rlEl.classList.add('no-domain')
|
|
else
|
|
rlEl.classList.remove('no-domain')
|
|
|
|
this.divType.querySelector('div').innerText = this.type()
|
|
this.divValue.innerText = this.value()
|
|
this.divTTL.innerText = this.ttl()
|
|
|
|
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')
|
|
this.divTTL.classList.add('mouse-over')
|
|
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')
|
|
this.divTTL.classList.remove('mouse-over')
|
|
this.divActions.classList.remove('mouse-over')
|
|
}// }}}
|
|
|
|
save() {// {{{
|
|
const created = (this.id() == '')
|
|
|
|
fetch(`/device/${_app.currentDevice.name()}/record`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.data),
|
|
})
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
alert(json.Error)
|
|
return
|
|
}
|
|
|
|
// The data is read from the server/routeros device
|
|
// since it could have manipulated the data.
|
|
this.data = json.Record
|
|
if (created) {
|
|
_app.records.push(this)
|
|
}
|
|
|
|
_app.cleanFolders()
|
|
_app.createFolders()
|
|
_app.renderDevice()
|
|
|
|
// createFolders is setting the folder the record resides in.
|
|
// It can now be expanded to the parent folder.
|
|
if (created) {
|
|
this.openParentFolders()
|
|
this.divFQDN.classList.add('created')
|
|
setTimeout(
|
|
() => this.divFQDN.classList.remove('created'),
|
|
1000,
|
|
)
|
|
}
|
|
})
|
|
}// }}}
|
|
delete() {// {{{
|
|
if (!confirm(`Are you sure you want to delete ${this.name()}?`))
|
|
return
|
|
|
|
fetch(`/device/${_app.currentDevice.name()}/record/${this.id()}`, { method: 'DELETE' })
|
|
.then(data => data.json())
|
|
.then(json => {
|
|
if (!json.OK) {
|
|
alert(json.Error)
|
|
return
|
|
}
|
|
_app.deleteRecord(this.id())
|
|
_app.cleanFolders()
|
|
_app.createFolders()
|
|
_app.renderDevice()
|
|
})
|
|
}// }}}
|
|
|
|
openParentFolders(folder) {// {{{
|
|
if (folder === undefined)
|
|
folder = this.folder
|
|
|
|
folder?.openFolder(false)
|
|
|
|
if (folder?.parentFolder)
|
|
this.openParentFolders(folder.parentFolder)
|
|
}// }}}
|
|
}
|
|
|
|
class RecordDialog {
|
|
constructor(record) {// {{{
|
|
this.record = record
|
|
}// }}}
|
|
|
|
show() {// {{{
|
|
this.dlg = document.createElement('dialog')
|
|
this.dlg.id = "record-dialog"
|
|
this.dlg.innerHTML = `
|
|
<div>Name</div>
|
|
<input type="text" class="name">
|
|
|
|
<div>Type</div>
|
|
<select class="type">
|
|
<option>A</option>
|
|
<option>AAAA</option>
|
|
<option>CNAME</option>
|
|
<option>FWD</option>
|
|
<option>NS</option>
|
|
<option>NXDOMAIN</option>
|
|
<option>TXT</option>
|
|
</select>
|
|
|
|
<div>Value</div>
|
|
<input type="text" class="value">
|
|
|
|
<div>TTL</div>
|
|
<input type="text" class="ttl">
|
|
|
|
<div class="buttons">
|
|
<button class="save">Save</button>
|
|
<button class="close">Close</button>
|
|
</div>
|
|
`
|
|
|
|
if (this.record.matchSubdomain())
|
|
this.dlg.querySelector('.name').value = '*.' + this.record.name()
|
|
else
|
|
this.dlg.querySelector('.name').value = this.record.name()
|
|
|
|
this.dlg.querySelector('.type').value = this.record.type()
|
|
this.dlg.querySelector('.value').value = this.record.value()
|
|
this.dlg.querySelector('.ttl').value = this.record.ttl();
|
|
|
|
['.name', '.type', '.value', '.ttl'].forEach(v =>
|
|
this.dlg.querySelector(v).addEventListener('keydown', event => this.enterKeyHandler(event))
|
|
)
|
|
|
|
this.dlg.querySelector('.save').addEventListener('click', () => this.save())
|
|
this.dlg.querySelector('.close').addEventListener('click', () => this.dlg.close())
|
|
|
|
this.dlg.addEventListener('close', () => this.dlg.remove())
|
|
document.body.appendChild(this.dlg)
|
|
this.dlg.showModal()
|
|
|
|
this.dlg.querySelector('.name').focus()
|
|
}// }}}
|
|
enterKeyHandler(event) {// {{{
|
|
if (event.key == "Enter")
|
|
this.save()
|
|
}// }}}
|
|
save() {// {{{
|
|
this.record.set('Name', this.dlg.querySelector('.name').value)
|
|
this.record.set('Type', this.dlg.querySelector('.type').value)
|
|
this.record.set('ParsedValue', this.dlg.querySelector('.value').value)
|
|
this.record.set('TTL', this.dlg.querySelector('.ttl').value)
|
|
this.record.render()
|
|
this.record.save()
|
|
this.dlg.close()
|
|
}// }}}
|
|
}
|
|
|
|
class Settings {
|
|
constructor() {// {{{
|
|
this.settings = new Map([
|
|
['boxed_folders', false],
|
|
['toplevel_open', false],
|
|
['last_device', ''],
|
|
])
|
|
|
|
// Read any configured settings from local storage, but keeping default value
|
|
// if not set.
|
|
this.settings.forEach((_v, key) => {
|
|
const configuredValue = localStorage.getItem(key)
|
|
if (configuredValue !== null)
|
|
this.settings.set(key, JSON.parse(configuredValue))
|
|
})
|
|
}// }}}
|
|
set(key, value) {// {{{
|
|
this.settings.set(key, value)
|
|
localStorage.setItem(key, JSON.stringify(value))
|
|
_mbus.dispatch('settings_updated', { key, value })
|
|
}// }}}
|
|
get(key) {// {{{
|
|
return this.settings.get(key)
|
|
}// }}}
|
|
}
|
|
|
|
class SettingsDialog {
|
|
constructor(app) {// {{{
|
|
this.application = app
|
|
|
|
this.dlg = null
|
|
this.elBoxedFolders = null
|
|
this.elToplevelOpen = null
|
|
}// }}}
|
|
show() {// {{{
|
|
this.dlg = document.createElement('dialog')
|
|
this.dlg.id = 'settings-dialog'
|
|
|
|
this.dlg.innerHTML = `
|
|
<input type="checkbox" id="boxed-folders"> <label for="boxed-folders">Boxed folders</label>
|
|
<input type="checkbox" id="toplevel-open"> <label for="toplevel-open">Toplevel domains open</label>
|
|
|
|
<div class="buttons">
|
|
<button class="save">Save</button>
|
|
</div>
|
|
`
|
|
|
|
const boxedFolders = this.application.settings.get('boxed_folders')
|
|
this.elBoxedFolders = this.dlg.querySelector('#boxed-folders')
|
|
this.elBoxedFolders.checked = boxedFolders
|
|
|
|
const topLevelOpen = this.application.settings.get('toplevel_open')
|
|
this.elToplevelOpen = this.dlg.querySelector('#toplevel-open')
|
|
this.elToplevelOpen.checked = topLevelOpen
|
|
|
|
// Event listeners are connected.
|
|
this.dlg.querySelector('.save').addEventListener('click', () => this.save())
|
|
this.dlg.addEventListener('close', () => this.dlg.remove())
|
|
|
|
// Can't show a dialog that doesn't exist in DOM.
|
|
document.body.appendChild(this.dlg)
|
|
this.dlg.showModal()
|
|
}// }}}
|
|
save() {// {{{
|
|
this.application.settings.set('boxed_folders', this.elBoxedFolders.checked)
|
|
this.application.settings.set('toplevel_open', this.elToplevelOpen.checked)
|
|
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">
|
|
<div>Devices</div>
|
|
<img class="create" src="/images/${_VERSION}/icon_create.svg">
|
|
<input type="text" class="filter" placeholder="Filter">
|
|
</div>
|
|
|
|
<div class="device-list">
|
|
</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">Save</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
this.elDevices = this.dlg.querySelector('.devices')
|
|
this.elDeviceList = this.dlg.querySelector('.device-list')
|
|
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')
|
|
|
|
this.dlg.querySelector('.create').addEventListener('click', () => this.createDevice())
|
|
this.dlg.querySelector('.filter').addEventListener('input', () => this.filterDevices())
|
|
this.dlg.querySelector('.update').addEventListener('click', () => this.updateDevice())
|
|
this.dlg.querySelector('.delete').addEventListener('click', () => this.deleteDevice())
|
|
this.dlg.addEventListener('close', () => this.dlg.remove())
|
|
|
|
this.updateDeviceList()
|
|
|
|
document.body.appendChild(this.dlg)
|
|
this.dlg.showModal()
|
|
}// }}}
|
|
filterDevices() {// {{{
|
|
const filter = this.dlg.querySelector('.filter').value.toLowerCase()
|
|
|
|
this.elDeviceList.querySelectorAll('.device').forEach(dev=>{
|
|
if (dev.innerText.toLowerCase().includes(filter))
|
|
dev.classList.remove('filtered')
|
|
else
|
|
dev.classList.add('filtered')
|
|
})
|
|
}// }}}
|
|
updateDeviceList() {// {{{
|
|
this.elDeviceList.replaceChildren()
|
|
|
|
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.dataset.name = dev.name()
|
|
devEl.innerText = dev.name()
|
|
devEl.addEventListener('click', () => this.editDevice(dev, devEl))
|
|
this.elDeviceList.appendChild(devEl)
|
|
})
|
|
}// }}}
|
|
|
|
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 createDevice() {// {{{
|
|
let name = prompt('Name of new device')
|
|
if (name === null || name.trim() === '')
|
|
return
|
|
name = name.trim().toLowerCase()
|
|
|
|
// Make sure it doesn't already exist.
|
|
if (this.devices.has(name)) {
|
|
alert('The device already exist.')
|
|
return
|
|
}
|
|
|
|
const dev = new Device({Name: name, Port: 443}, true)
|
|
|
|
this.devices.set(name, dev)
|
|
this.updateDeviceList()
|
|
|
|
const devEl = this.elDeviceList.querySelector(`[data-name="${dev.name()}"]`)
|
|
this.editDevice(dev, devEl)
|
|
}// }}}
|
|
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()
|
|
if (!json.OK) {
|
|
alert(json.Error)
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
}// }}}
|
|
async deleteDevice() {// {{{
|
|
const devname = this.device.name()
|
|
if (!confirm(`Do you want to delete '${devname}'?`))
|
|
return
|
|
|
|
try {
|
|
const data = await fetch(`/device/${devname}`, { method: 'DELETE' })
|
|
const json = await data.json()
|
|
if (!json.OK) {
|
|
alert(json.Error)
|
|
return
|
|
}
|
|
|
|
this.elName.value = ''
|
|
this.elAddress.value = ''
|
|
this.elPort.value = ''
|
|
this.elUsername.value = ''
|
|
this.elPassword.value = ''
|
|
|
|
this.devices.delete(devname)
|
|
this.device = null
|
|
this.updateDeviceList()
|
|
_mbus.dispatch('device_deleted', { devName: devname })
|
|
} catch (err) {
|
|
console.error(err)
|
|
alert(err)
|
|
}
|
|
}// }}}
|
|
}
|