Compare commits

...

10 commits

Author SHA1 Message Date
Magnus Åhall
d0b0aba30d Show _no.domain in italic 2026-02-26 09:28:14 +01:00
Magnus Åhall
96f3a90ab9 Fixed creating records beneath _no.domain 2026-02-26 09:17:48 +01:00
Magnus Åhall
fc9583ecd2 Concatenate the two most significant labels 2026-02-26 09:12:05 +01:00
Magnus Åhall
cbd1b137cf Recreate the records tree when searching 2026-02-26 08:31:27 +01:00
Magnus Åhall
b20943cfd2 WIP search 2026-02-25 23:50:31 +01:00
Magnus Åhall
39b9515f76 Basic search 2026-02-25 21:46:28 +01:00
Magnus Åhall
8aef6d8a2e Creating records in folders 2026-02-25 21:18:00 +01:00
Magnus Åhall
df936baa8f Delete records 2026-02-25 21:05:02 +01:00
Magnus Åhall
52bd8b34b3 Hover over row 2026-02-25 16:54:39 +01:00
Magnus Åhall
ad7bc0345a Added headers and table for records 2026-02-25 16:43:41 +01:00
5 changed files with 483 additions and 53 deletions

View file

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

View file

@ -2,9 +2,6 @@
--line-color: #ccc;
--line-color-record: #eee;
--type-background: #ddd;
--type-foreground: #000;
--label-first: #800033;
--label-rest: #666;
@ -12,6 +9,18 @@
--label-border: #ccc;
--copy-color: #d48700;
--header-line: #d0d0d0;
--record-A: #89a02c;
--record-AAAA: #2f4858;
--record-CNAME: #f6ae2d;
--record-FWD: #f26419;
--record-TXT: #86bbd8;
--record-NXDOMAIN: #aa0000;
--record-other: #888;
--record-hover: #fffff4;
}
html {
@ -97,6 +106,10 @@ button {
cursor: pointer;
}
#search {
margin-bottom: 16px;
}
#records-tree {
white-space: nowrap;
@ -107,6 +120,10 @@ button {
padding-left: 0px;
}
&.no-domain > .label {
font-style: italic;
}
&.open {
&>.label>img.open {
display: block;
@ -129,7 +146,7 @@ button {
&>.label {
display: grid;
grid-template-columns: min-content min-content 1fr;
grid-template-columns: repeat(4, min-content);
align-items: center;
padding: 5px 0px;
cursor: pointer;
@ -140,6 +157,18 @@ button {
margin-right: 6px;
display: none;
}
img.create {
display: none;
height: 16px;
margin-left: 8px;
}
&:hover {
img.create {
display: block;
}
}
}
&>.subfolders {
@ -149,17 +178,51 @@ button {
&>.records {
padding-left: 30px;
padding-top: 8px;
padding-bottom: 8px;
margin-left: 10px;
display: grid;
grid-template-columns: repeat(3, min-content) 1fr;
grid-gap: 4px 10px;
grid-template-columns: repeat(6, min-content);
width: min-content;
align-items: center;
border-left: 1px solid var(--line-color);
&>img {
display: block;
padding-left: 4px;
.header {
font-weight: bold;
font-size: 0.75em;
background: #eee;
padding: 4px 8px;
border-left: 1px solid var(--header-line);
border-top: 1px solid var(--header-line);
border-bottom: 1px solid var(--header-line);
&:first-child {
grid-column: 1 / 3;
border-top-left-radius: 4px;
}
&.last {
border-right: 1px solid var(--header-line);
border-top-right-radius: 4px;
}
}
&>:not(.header) {
height: 40px;
}
&>.record-icon {
border-left: 1px solid var(--header-line);
align-content: center;
padding-left: 8px;
border-bottom: 1px solid var(--header-line);
img {
display: block;
padding-left: 4px;
}
}
.copy {
@ -174,6 +237,14 @@ button {
cursor: pointer;
user-select: none;
display: flex;
padding: 0 16px 0 8px;
border-bottom: 1px solid var(--header-line);
height: 100%;
align-items: center;
&.mouse-over {
background-color: var(--record-hover);
}
&.created {
* {
@ -198,34 +269,107 @@ button {
.rest-label {
color: var(--label-rest);
&.no-domain {
font-style: italic;
}
}
}
.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;
padding: 2px 8px;
border-bottom: 1px solid var(--header-line);
border-left: 1px solid var(--header-line);
align-content: center;
cursor: pointer;
&.mouse-over {
background-color: var(--record-hover);
}
div {
background-color: var(--record-other);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
width: min-content;
height: min-content;
font-weight: bold;
font-size: 0.85em;
}
&.A div {
background-color: var(--record-A);
}
&.AAAA div {
background-color: var(--record-AAAA);
}
&.CNAME div {
background-color: var(--record-CNAME);
color: #000;
}
&.FWD div {
background-color: var(--record-FWD);
}
&.TXT div {
background-color: var(--record-TXT);
color: #000;
}
&.NXDOMAIN div {
background-color: var(--record-NXDOMAIN);
}
}
.value {
cursor: pointer;
user-select: none;
}
border-left: 1px solid var(--header-line);
border-bottom: 1px solid var(--header-line);
padding: 0px 8px;
height: 100%;
align-content: center;
.separator {
grid-column: 1 / -1;
&:not(:last-child) {
border-bottom: 1px solid var(--line-color-record);
&.mouse-over {
background-color: var(--record-hover);
}
}
.ttl {
cursor: pointer;
border-left: 1px solid var(--header-line);
border-bottom: 1px solid var(--header-line);
padding: 0px 8px;
height: 100%;
align-content: center;
&.mouse-over {
background-color: var(--record-hover);
}
}
.actions {
border-left: 1px solid var(--header-line);
border-right: 1px solid var(--header-line);
border-bottom: 1px solid var(--header-line);
padding: 0px 8px;
height: 100%;
align-content: center;
&.mouse-over {
background-color: var(--record-hover);
}
img {
display: block;
cursor: pointer;
}
}
}
}

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="4.2333398mm"
height="4.7624998mm"
viewBox="0 0 4.2333398 4.7624998"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="icon_delete.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1"
inkscape:cx="8"
inkscape:cy="9"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-102.65833,-146.05)">
<title
id="title1">trash-can-outline</title>
<path
d="m 103.98125,146.05 v 0.26458 h -1.32292 v 0.52917 h 0.26459 v 3.43958 a 0.52916667,0.52916667 0 0 0 0.52916,0.52917 h 2.64584 a 0.52916667,0.52916667 0 0 0 0.52916,-0.52917 v -3.43958 h 0.26459 v -0.52917 h -1.32292 V 146.05 h -1.5875 m -0.52917,0.79375 h 2.64584 v 3.43958 h -2.64584 v -3.43958 m 0.52917,0.52917 v 2.38125 h 0.52917 v -2.38125 h -0.52917 m 1.05833,0 v 2.38125 h 0.52917 v -2.38125 z"
id="path1"
style="stroke-width:0.264583;fill:#800000" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -8,10 +8,22 @@ export class Application {
this.topFolder = new Folder(this, null, 'root')
this.recordsTree = null
this.settingsIcon = null
this.createIcon = 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
@ -29,14 +41,24 @@ export class Application {
this.cleanFolders(folder)
})
}// }}}
deleteRecord(id) {// {{{
const i = this.records.findIndex(rec => rec.id() == id)
this.records.splice(i, 1)
}// }}}
renderFolders() {// {{{
this.records.sort(this.sortRecords)
const records = this.filteredRecords()
records.sort(this.sortRecords)
// rec: for example www.google.com
for (const rec of this.records) {
// com.google (reverse and remove wwww)
const labels = rec.labels().reverse().slice(0, -1)
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.
@ -93,10 +115,7 @@ export class Application {
return 0
}// }}}
render() {// {{{
if (this.recordsTree == null) {
this.recordsTree = document.createElement('div')
this.recordsTree.id = 'records-tree'
if (this.createIcon === null) {
this.createIcon = document.createElement('img')
this.createIcon.id = 'create-icon'
this.createIcon.src = `/images/${_VERSION}/icon_create.svg`
@ -107,13 +126,36 @@ export class Application {
this.settingsIcon.src = `/images/${_VERSION}/icon_settings.svg`
this.settingsIcon.addEventListener('click', () => new SettingsDialog(this).show())
document.body.appendChild(this.recordsTree)
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)
@ -121,11 +163,31 @@ export class Application {
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
@ -143,6 +205,10 @@ export class Application {
new RecordDialog(new Record()).show()
break
case 'f':
this.searchField.focus()
break
default:
handled = false
}
@ -163,6 +229,16 @@ export class Application {
else
document.body.classList.remove('boxed-folders')
}// }}}
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()
}// }}}
}
class Folder {
@ -184,7 +260,18 @@ class Folder {
return this.folderName.toLowerCase()
}// }}}
labels() {// {{{
return this.name().split('.')
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)
@ -230,14 +317,27 @@ class Folder {
<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>
<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') {
console.log('wut')
this.div.classList.add('no-domain')
}
}
@ -249,11 +349,26 @@ class Folder {
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 }}) => {
_mbus.subscribe('settings_updated', ({ detail: { key, value } }) => {
if (key !== 'toplevel_open')
return
@ -263,6 +378,11 @@ class Folder {
return this.div
}// }}}
createRecord(event) {// {{{
event.stopPropagation()
const record = new Record({ Name: this.name() })
new RecordDialog(record).show()
}// }}}
}
class Record {
@ -274,7 +394,8 @@ class Record {
this.divFQDN = null
this.divType = null
this.divValue = null
this.divSeparator = null
this.divTTL = null
this.divActions = null
}// }}}
id() {// {{{
@ -302,7 +423,24 @@ class Record {
return this.data.MatchSubdomain === 'true'
}// }}}
labels() {// {{{
return this.name().split('.')
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) {// {{{
@ -318,6 +456,8 @@ class Record {
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'
@ -332,23 +472,39 @@ class Record {
}// }}}
render() {// {{{
if (this.divFQDN === null) {
this.imgIcon = document.createElement('img')
this.imgIcon = document.createElement('div')
this.divFQDN = document.createElement('div')
this.divType = document.createElement('div')
this.divValue = document.createElement('div')
this.divSeparator = 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.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.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)
@ -356,13 +512,15 @@ class Record {
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.
@ -373,14 +531,37 @@ class Record {
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}` : ''
const flEl = this.divFQDN.querySelector('.first-label')
const rlEl = this.divFQDN.querySelector('.rest-label')
flEl.innerText = fl
rlEl.innerText = rl != '' ? `.${rl}` : ''
this.divType.innerText = this.type()
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.divSeparator]
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() == '')
@ -418,6 +599,23 @@ class Record {
}
})
}// }}}
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)
@ -505,8 +703,8 @@ class RecordDialog {
class Settings {
constructor() {// {{{
this.settings = new Map([
['boxed_folders', true],
['toplevel_open', true],
['boxed_folders', false],
['toplevel_open', false],
])
// Read any configured settings from local storage, but keeping default value

View file

@ -7,6 +7,7 @@ import (
// Standard
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -33,6 +34,7 @@ func registerWebserverHandlers() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/record/save", actionRecordSave)
http.HandleFunc("/record/delete/{id}", actionRecordDelete)
}
func startWebserver() {
@ -110,6 +112,30 @@ func actionRecordSave(w http.ResponseWriter, r *http.Request) {
record = NewDNSRecord(entry)
j, _ := json.Marshal(struct{ OK bool; Record DNSRecord }{true, record})
j, _ := json.Marshal(struct {
OK bool
Record DNSRecord
}{true, record})
w.Write(j)
}
func actionRecordDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
httpError(w, errors.New("No ID provided"))
return
}
err := device.DeleteDNSEntry(id)
if err != nil {
httpError(w, err)
logger.Error("webserver", "op", "record_delete", "error", err)
return
}
j, _ := json.Marshal(struct {
OK bool
}{true})
w.Write(j)
}