diff --git a/routeros_device.go b/routeros_device.go
index 4c65e25..50c19d1 100644
--- a/routeros_device.go
+++ b/routeros_device.go
@@ -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
diff --git a/static/css/index.css b/static/css/index.css
index 6957ab1..ba6c2a1 100644
--- a/static/css/index.css
+++ b/static/css/index.css
@@ -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;
+ }
+ }
}
}
diff --git a/static/images/icon_delete.svg b/static/images/icon_delete.svg
new file mode 100644
index 0000000..0bc82d4
--- /dev/null
+++ b/static/images/icon_delete.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/js/dns.mjs b/static/js/dns.mjs
index 431521c..8f9195d 100644
--- a/static/js/dns.mjs
+++ b/static/js/dns.mjs
@@ -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 = `
+
+
+ `
+ 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 {
${firstLabel}${restLabels != '' ? '.' + restLabels : ''}
+