From 97058d036d81b00f3f8ff5b7669fcda0b98fb4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 24 Feb 2026 17:21:55 +0100 Subject: [PATCH] Recursive tree version --- dns.go | 29 +-- static/css/index.css | 225 +++++++++++------- static/images/icon_folder.svg | 20 +- static/images/icon_folder_open.svg | 25 +- static/images/icon_record.svg | 67 ++++++ static/js/dns.mjs | 355 +++++++++++++++++++++++++++-- views/pages/index.gotmpl | 5 +- webserver.go | 7 +- 8 files changed, 599 insertions(+), 134 deletions(-) create mode 100644 static/images/icon_record.svg diff --git a/dns.go b/dns.go index 66bb0f7..b356dad 100644 --- a/dns.go +++ b/dns.go @@ -9,12 +9,13 @@ import ( ) type DNSRecord struct { - ID string `json:".id"` - Disabled string - Dynamic string - Name string - TTL string - Type string + ID string `json:".id"` + Disabled string + Dynamic string + Name string + TTL string + Type string + ParsedValue string // not from RouterOS, here to not having to have the value logics in frontend too. Address string CNAME string @@ -159,13 +160,13 @@ func (dp *DomainPart) ToHTMLElements(parts []string) []HTMLElement {
`, - topmost, // .top-most - restPart, // data-top - fqdn, // data-self + topmost, // .top-most + restPart, // data-top + fqdn, // data-self (len(newParts)-1)*32, // margin-left - VERSION, // images/ - VERSION, // images/ - mostSpecificPart, // innerText + VERSION, // images/ + VERSION, // images/ + mostSpecificPart, // innerText restPart, ) lines = append(lines, HTMLElement{Header: true, HTML: html}) @@ -195,8 +196,8 @@ func (dp *DomainPart) ToHTMLElements(parts []string) []HTMLElement { for _, rec := range subpart.Record { html := fmt.Sprintf( ` -
%s%s
-
%s
+
%s%s
+
%s
%s
`, (len(newParts)-1)*32, diff --git a/static/css/index.css b/static/css/index.css index 7e921e4..c269876 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -1,3 +1,19 @@ +:root { + --line-color: #ccc; + --line-color-record: #eee; + + --type-background: #ddd; + --type-foreground: #000; + + --label-first: #800033; + --label-rest: #666; + + --label-background: #f8f8f8; + --label-border: #ccc; + + --copy-color: #d48700; +} + html { box-sizing: border-box; } @@ -14,102 +30,151 @@ html { body { font-family: sans-serif; + font-size: 12pt; + margin-left: 32px; } -.records-tree { - display: grid; - grid-template-columns: min-content min-content 1fr; +#records-tree { white-space: nowrap; - .show { - display: block !important; - } + .folder { + padding-left: 32px; - .top, - .record, - .type, - .value { - display: none; - border-bottom: 1px solid #ccc; - padding: 4px 0px; - } - - .top { - font-weight: bold; - background-color: #f8f8f8; - user-select: none; - grid-template-columns: repeat(3, min-content); - align-items: center; + &.top-most { + padding-left: 0px; + } &.open { - .folder.open { - display: inline-block; + &>.label>img.open { + display: block; } - .folder.closed { + } + + &.closed { + &>.label>img.closed { + display: block; + } + + &>.subfolders { + display: none; + } + + &>.records { display: none; } } - &:not(.open) { - .folder.open { + &>.label { + display: grid; + grid-template-columns: min-content min-content 1fr; + align-items: center; + padding: 5px 0px; + cursor: pointer; + user-select: none; + + /* + background-color: var(--label-background); + width: min-content; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--label-border); + margin-top: 8px; + margin-bottom: 8px; + */ + + img { + height: 20px; + margin-right: 6px; display: none; } - .folder.closed { - display: inline-block; + } + + &>.subfolders { + border-left: 1px solid var(--line-color); + margin-left: 10px; + } + + &>.records { + padding-left: 30px; + margin-left: 10px; + + display: grid; + grid-template-columns: repeat(3, min-content) 1fr; + grid-gap: 4px 10px; + align-items: center; + border-left: 1px solid var(--line-color); + + &>img { + display: block; + padding-left: 4px; } + + .copy { + color: var(--copy-color) !important; + + span { + color: var(--copy-color) !important; + } + } + + .fqdn { + cursor: pointer; + user-select: none; + + .first-label { + color: var(--label-first); + } + + .rest-label { + color: var(--label-rest); + } + } + + .type { + background-color: var(--type-background); + color: var(--type-foreground); + padding: 4px 8px; + border-radius: 4px; + margin-top: 2px; + margin-bottom: 2px; + width: min-content; + font-weight: bold; + font-size: 0.85em; + } + + .value { + cursor: pointer; + user-select: none; + } + + .separator { + grid-column: 1 / -1; + + &:not(:last-child) { + border-bottom: 1px solid var(--line-color-record); + } + } + } - img { - - height: 16px; - display: none; - margin-right: 4px; - } - - - span:first-child { - color: #800033; - } - - span:last-child { - color: #444; - font-weight: normal; - } - } - - .top+.type, - .top+.type+.value { - background-color: #f8f8f8; - } - - .top-most, - .top-most + .type, - .top-most + .type + .value - { - display:grid; - } - - .record { - display: none; - font-weight: normal; - color: #444; - - span:first-child { - color: #800033; - } - - span:last-child { - color: #888; - } - } - - .record+.type, - .record+.type+.value { - display: none; - padding-left: 16px; - } - - .record+.type+.value { - color: #800033; } } + + +#record-dialog { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 8px; + align-items: center; + + .buttons { + grid-column: 1 / -1; + text-align: right; + } + +} + +input, select, button { + font-size: 1em; + padding: 4px 8px; +} diff --git a/static/images/icon_folder.svg b/static/images/icon_folder.svg index 3ab4b80..332fac2 100644 --- a/static/images/icon_folder.svg +++ b/static/images/icon_folder.svg @@ -3,11 +3,11 @@ folder-outline + inkscape:zoom="17.076364" + inkscape:cx="35.487649" + inkscape:cy="7.9642249" + inkscape:window-width="2190" + inkscape:window-height="1404" + inkscape:window-x="1463" + inkscape:window-y="16" + inkscape:window-maximized="0" + inkscape:current-layer="layer1" + showgrid="false" /> + + + + + + + + + image/svg+xml + + + + + + text-box-outline + + + diff --git a/static/js/dns.mjs b/static/js/dns.mjs index 91187d5..f12cc32 100644 --- a/static/js/dns.mjs +++ b/static/js/dns.mjs @@ -1,16 +1,85 @@ export class Application { - constructor() { - this.addTopHandlers() - } + constructor(records) {// {{{ + this.records = this.parseRecords(records) + this.folders = this.createFolders() + this.render() + }// }}} + parseRecords(recordsData) {// {{{ + const records = recordsData.map(d => new Record(d)) + return records + }// }}} + createFolders() {// {{{ + this.records.sort(this.sortRecords) + const topFolder = new Folder(this, 'root') - addTopHandlers() { - for (const top of document.querySelectorAll('.top')) - top.addEventListener('click', event => this.handlerTop(event)) - } + for (const rec of this.records) { + const labels = rec.labels().reverse().slice(0, -1) - handlerTop(event) { + let currFolder = topFolder + let accFolderLabels = [] + for (const i in labels) { + const label = labels[i] + + accFolderLabels.push(label) + const accFolderName = accFolderLabels.map(v => v).reverse().join('.') + + let folder = currFolder.subfolders.get(label) + if (folder === undefined) { + folder = new Folder(this, accFolderName) + currFolder.subfolders.set(label, folder) + } + currFolder = folder + + // Add the record to the innermost folder + } + currFolder.addRecord(rec) + } + + return topFolder + }// }}} + 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() {// {{{ + const tree = document.getElementById('records-tree') + tree.replaceChildren() + + // Top root folder doesn't have to be shown. + const folders = Array.from(this.folders.subfolders.values()) + folders.sort(this.sortFolders) + + for (const folder of folders) + tree.append(folder.toElement()) + + }// }}} + handlerTop(event) {// {{{ const topEl = event.target.closest('.top') - console.log(topEl.dataset.self) let records, types, values if (topEl.classList.contains('open')) { @@ -30,12 +99,22 @@ export class Application { topEl.classList.remove('open') } else { - records = document.querySelectorAll(`[data-top="${topEl.dataset.self}"]`) - types = document.querySelectorAll(`[data-top="${topEl.dataset.self}"] + .type`) - values = document.querySelectorAll(`[data-top="${topEl.dataset.self}"] + .type + .value`) + if (event.shiftKey) { + records = document.querySelectorAll(`[data-top$="${topEl.dataset.self}"]`) + types = document.querySelectorAll(`[data-top$="${topEl.dataset.self}"] + .type`) + values = document.querySelectorAll(`[data-top$="${topEl.dataset.self}"] + .type + .value`) + } else { + records = document.querySelectorAll(`[data-top="${topEl.dataset.self}"]`) + types = document.querySelectorAll(`[data-top="${topEl.dataset.self}"] + .type`) + values = document.querySelectorAll(`[data-top="${topEl.dataset.self}"] + .type + .value`) + console.log(records) + } - for (const r of records) + for (const r of records) { r.classList.add('show') + if (event.shiftKey && r.classList.contains('top')) + r.classList.add('open') + } for (const r of types) r.classList.add('show') for (const r of values) @@ -45,5 +124,255 @@ export class Application { } + }// }}} +} + +class Folder { + constructor(app, name) {// {{{ + this.application = app + this.open = false + this.folderName = name + this.subfolders = new Map() + this.records = [] + + this.div = null + this.divSubfolders = null + this.divRecords = null + }// }}} + name() {// {{{ + return this.folderName.toLowerCase() + }// }}} + labels() {// {{{ + return this.name().split('.') + }// }}} + addRecord(rec) {// {{{ + this.records.push(rec) + }// }}} + 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) + }// }}} + toElement() {// {{{ + 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') + this.div.dataset.top = this.labels().slice(1).join('.') + this.div.dataset.self = this.name() + + const firstLabel = this.labels()[0] + const restLabels = this.labels().slice(1).join('.') + + this.div.innerHTML = ` +
+ + + ${firstLabel}${restLabels != '' ? '.' + restLabels : ''} +
+
+
+ ` + this.divSubfolders = this.div.querySelector('.subfolders') + this.divRecords = this.div.querySelector('.records') + + this.div.querySelector('.label').addEventListener('click', event => this.toggleFolder(event)) + } + + + // Subfolders are refreshed. + this.divSubfolders.replaceChildren() + const subfolders = Array.from(this.subfolders.values()) + subfolders.sort(this.application.sortFolders) + + for (const folder of subfolders) + this.divSubfolders.append(folder.toElement()) + + // Records are refreshed. + this.divRecords.replaceChildren() + for (const rec of Array.from(this.records)) + this.divRecords.append(...rec.toElements()) + + return this.div + }// }}} +} + +class Record { + constructor(data) {// {{{ + this.data = data + + this.imgIcon = null + this.divFQDN = null + this.divType = null + this.divValue = null + this.divSeparator = 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 + }// }}} + type() {// {{{ + return this.data.Type.toUpperCase() + }// }}} + value() {// {{{ + switch (this.type()) { + case 'A': + case 'AAAA': + return this.data.Address + + case 'CNAME': + return this.data.CNAME + } + }// }}} + 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 != 'Value') { + this.data[key] = value + return + } + + switch(this.data.type.toUpperCase()) { + case 'A': + case 'AAAA': + this.data.Address = value + break + + case 'CNAME': + this.data.CNAME = value + break + } + } + toElements() {// {{{ + if (this.divFQDN === null) { + this.imgIcon = document.createElement('img') + this.divFQDN = document.createElement('div') + this.divType = document.createElement('div') + this.divValue = document.createElement('div') + this.divSeparator = document.createElement('div') + + this.imgIcon.src = `/images/${_VERSION}/icon_record.svg` + this.divFQDN.classList.add('fqdn') + this.divType.classList.add('type') + this.divValue.classList.add('value') + this.divSeparator.classList.add('separator') + + this.divFQDN.innerHTML = ` + ${this.labels()[0]} + ${this.labels().slice(1).join('.')} + ` + + 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.innerText = this.type() + this.divValue.innerText = this.value() + + return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divSeparator] + }// }}} +} + +class RecordDialog { + constructor(record) { + this.record = record + } + + show() { + const dlg = document.createElement('dialog') + dlg.id = "record-dialog" + dlg.innerHTML = ` +
Name
+ + +
Type
+ + +
Value
+ + +
TTL
+ + +
+ + +
+ ` + + dlg.querySelector('.name').value = this.record.name() + dlg.querySelector('.type').value = this.record.type() + dlg.querySelector('.value').value = this.record.value() + dlg.querySelector('.ttl').value = this.record.ttl() + + dlg.querySelector('.save').addEventListener('click', ()=>this.save()) + dlg.querySelector('.close').addEventListener('click', ()=>dlg.close()) + + dlg.addEventListener('close', ()=>dlg.remove()) + document.body.appendChild(dlg) + dlg.showModal() + } + + save() { + this.record.set('Name', dlg.querySelector('.name').value) + this.record.set('Type', dlg.querySelector('.type').value) + this.record.set('Value', dlg.querySelector('.value').value) + this.record.set('TTL', dlg.querySelector('.ttl').value) } } diff --git a/views/pages/index.gotmpl b/views/pages/index.gotmpl index 3f05e0f..bc017da 100644 --- a/views/pages/index.gotmpl +++ b/views/pages/index.gotmpl @@ -1,13 +1,12 @@ {{ define "page" }}

{{ .Data.Identity }}

-
-{{ .Data.Tree }} +
{{ end }} diff --git a/webserver.go b/webserver.go index d4fb454..6ea9550 100644 --- a/webserver.go +++ b/webserver.go @@ -6,12 +6,12 @@ import ( // Standard "embed" - "encoding/json" + _ "encoding/json" "fmt" "net/http" "os" "slices" - "html/template" + _ "html/template" ) var ( @@ -66,8 +66,10 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { slices.SortFunc(entries, SortDNSRecord) data["DNSRecords"] = entries + /* tree := BuildRecordsTree(entries) htmlElements := tree.ToHTMLElements([]string{}) + data["TreeData"] = tree var html string for _, el := range htmlElements { @@ -78,6 +80,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { j, _ := json.Marshal(tree) os.WriteFile("/tmp/tree.json", j, 0644) + */ data["VERSION"] = VERSION page.Data = data