import { MessageBus } from '@mbus' export class Application { constructor(records) {// {{{ window._mbus = new MessageBus() this.settings = new Settings() this.records = this.parseRecords(records) this.topFolder = new Folder(this, null, 'root') this.recordsTree = null this.settingsIcon = null this.searchFor = '' this.renderFolders() this.render() }// }}} filteredRecords() {// {{{ if (this.searchFor === '') return this.records const records = this.records.filter(r => { return (r.name().includes(this.searchFor)) }) return records }// }}} parseRecords(recordsData) {// {{{ const records = recordsData.map(d => new Record(d)) return records }// }}} // cleanFolders removes all records from all folders. // renderFolders 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) }// }}} renderFolders() {// {{{ const records = this.filteredRecords() records.sort(this.sortRecords) // rec: for example www.google.com for (const rec of records) { // com.google (reverse and remove wwww) const labels = rec.labels().reverse().slice(0, -1) // 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) } }// }}} 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.recordsTree == 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()) this.recordsTree = document.createElement('div') this.recordsTree.id = 'records-tree' document.body.appendChild(searchEl) document.body.appendChild(this.recordsTree) document.body.appendChild(this.settingsIcon) document.body.appendChild(this.createIcon) document.body.addEventListener('keydown', event => this.handlerKeys(event)) } // 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) {// {{{ 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.searchField.focus() 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() {// {{{ this.searchFor = this.searchField.value.trim().toLowerCase() this.cleanFolders() this.renderFolders() this.render() }// }}} } 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() {// {{{ return this.name().split('.') }// }}} 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 = `
${firstLabel}${restLabels != '' ? '.' + restLabels : ''}
FQDN
Type
Value
TTL
Actions
` 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)) } // 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() {// {{{ return this.name().split('.') }// }}} 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 { 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 = `` 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 = `
` this.divFQDN.innerHTML = ` *. ` this.divActions.innerHTML = ` ` 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('.') this.divFQDN.querySelector('.first-label').innerText = fl this.divFQDN.querySelector('.rest-label').innerText = rl != '' ? `.${rl}` : '' 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.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.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('/record/save', { 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.renderFolders() _app.render() // renderFolders 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(`/record/delete/${this.id()}`) .then(data => data.json()) .then(json => { if (!json.OK) { alert(json.Error) return } _app.deleteRecord(this.id()) _app.cleanFolders() _app.renderFolders() _app.render() }) }// }}} 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 = `
Name
Type
Value
TTL
` 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', true], ]) // 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 = `
` 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() }// }}} }