Compare commits
10 commits
567133df67
...
d0b0aba30d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0b0aba30d | ||
|
|
96f3a90ab9 | ||
|
|
fc9583ecd2 | ||
|
|
cbd1b137cf | ||
|
|
b20943cfd2 | ||
|
|
39b9515f76 | ||
|
|
8aef6d8a2e | ||
|
|
df936baa8f | ||
|
|
52bd8b34b3 | ||
|
|
ad7bc0345a |
5 changed files with 483 additions and 53 deletions
|
|
@ -58,7 +58,7 @@ func (dev *RouterosDevice) Init() { // {{{
|
||||||
|
|
||||||
// query sends a RouterOS REST API query and returns the unparsed body.
|
// 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) { // {{{
|
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)
|
logger.Info("URL", "method", method, "url", url)
|
||||||
|
|
||||||
var request *http.Request
|
var request *http.Request
|
||||||
|
|
@ -150,6 +150,19 @@ func (dev *RouterosDevice) UpdateDNSEntry(record DNSEntry) (entry DNSEntry, err
|
||||||
err = json.Unmarshal(body, &entry)
|
err = json.Unmarshal(body, &entry)
|
||||||
return
|
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
|
// FillPeerDetails retrieves RouterOS resource ID, allowed-address and comment from the router
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,6 @@
|
||||||
--line-color: #ccc;
|
--line-color: #ccc;
|
||||||
--line-color-record: #eee;
|
--line-color-record: #eee;
|
||||||
|
|
||||||
--type-background: #ddd;
|
|
||||||
--type-foreground: #000;
|
|
||||||
|
|
||||||
--label-first: #800033;
|
--label-first: #800033;
|
||||||
--label-rest: #666;
|
--label-rest: #666;
|
||||||
|
|
||||||
|
|
@ -12,6 +9,18 @@
|
||||||
--label-border: #ccc;
|
--label-border: #ccc;
|
||||||
|
|
||||||
--copy-color: #d48700;
|
--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 {
|
html {
|
||||||
|
|
@ -97,6 +106,10 @@ button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#search {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
#records-tree {
|
#records-tree {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
|
@ -107,6 +120,10 @@ button {
|
||||||
padding-left: 0px;
|
padding-left: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.no-domain > .label {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
&.open {
|
&.open {
|
||||||
&>.label>img.open {
|
&>.label>img.open {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -129,7 +146,7 @@ button {
|
||||||
|
|
||||||
&>.label {
|
&>.label {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: min-content min-content 1fr;
|
grid-template-columns: repeat(4, min-content);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 5px 0px;
|
padding: 5px 0px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -140,6 +157,18 @@ button {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img.create {
|
||||||
|
display: none;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
img.create {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&>.subfolders {
|
&>.subfolders {
|
||||||
|
|
@ -149,17 +178,51 @@ button {
|
||||||
|
|
||||||
&>.records {
|
&>.records {
|
||||||
padding-left: 30px;
|
padding-left: 30px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, min-content) 1fr;
|
grid-template-columns: repeat(6, min-content);
|
||||||
grid-gap: 4px 10px;
|
width: min-content;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-left: 1px solid var(--line-color);
|
border-left: 1px solid var(--line-color);
|
||||||
|
|
||||||
&>img {
|
.header {
|
||||||
display: block;
|
font-weight: bold;
|
||||||
padding-left: 4px;
|
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 {
|
.copy {
|
||||||
|
|
@ -174,6 +237,14 @@ button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
display: flex;
|
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 {
|
&.created {
|
||||||
* {
|
* {
|
||||||
|
|
@ -198,34 +269,107 @@ button {
|
||||||
|
|
||||||
.rest-label {
|
.rest-label {
|
||||||
color: var(--label-rest);
|
color: var(--label-rest);
|
||||||
|
|
||||||
|
&.no-domain {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.type {
|
.type {
|
||||||
background-color: var(--type-background);
|
padding: 2px 8px;
|
||||||
color: var(--type-foreground);
|
border-bottom: 1px solid var(--header-line);
|
||||||
padding: 4px 8px;
|
border-left: 1px solid var(--header-line);
|
||||||
border-radius: 4px;
|
align-content: center;
|
||||||
margin-top: 2px;
|
cursor: pointer;
|
||||||
margin-bottom: 2px;
|
|
||||||
width: min-content;
|
&.mouse-over {
|
||||||
font-weight: bold;
|
background-color: var(--record-hover);
|
||||||
font-size: 0.85em;
|
}
|
||||||
|
|
||||||
|
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 {
|
.value {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
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 {
|
&.mouse-over {
|
||||||
grid-column: 1 / -1;
|
background-color: var(--record-hover);
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1px solid var(--line-color-record);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
static/images/icon_delete.svg
Normal file
49
static/images/icon_delete.svg
Normal 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 |
|
|
@ -8,10 +8,22 @@ export class Application {
|
||||||
this.topFolder = new Folder(this, null, 'root')
|
this.topFolder = new Folder(this, null, 'root')
|
||||||
this.recordsTree = null
|
this.recordsTree = null
|
||||||
this.settingsIcon = null
|
this.settingsIcon = null
|
||||||
|
this.createIcon = null
|
||||||
|
|
||||||
|
this.searchFor = ''
|
||||||
|
|
||||||
this.renderFolders()
|
this.renderFolders()
|
||||||
this.render()
|
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) {// {{{
|
parseRecords(recordsData) {// {{{
|
||||||
const records = recordsData.map(d => new Record(d))
|
const records = recordsData.map(d => new Record(d))
|
||||||
return records
|
return records
|
||||||
|
|
@ -29,14 +41,24 @@ export class Application {
|
||||||
this.cleanFolders(folder)
|
this.cleanFolders(folder)
|
||||||
})
|
})
|
||||||
}// }}}
|
}// }}}
|
||||||
|
deleteRecord(id) {// {{{
|
||||||
|
const i = this.records.findIndex(rec => rec.id() == id)
|
||||||
|
this.records.splice(i, 1)
|
||||||
|
}// }}}
|
||||||
renderFolders() {// {{{
|
renderFolders() {// {{{
|
||||||
this.records.sort(this.sortRecords)
|
const records = this.filteredRecords()
|
||||||
|
records.sort(this.sortRecords)
|
||||||
|
|
||||||
|
for (const rec of records) {
|
||||||
// rec: for example www.google.com
|
// It felt wrong when records for the base domain (e.g. google.com) was put in the top level domain (.com).
|
||||||
for (const rec of this.records) {
|
// While technically correct, the first label (com) is grouped together with the second label (google).
|
||||||
// com.google (reverse and remove wwww)
|
// Labels are counted reversely since the top domain is most significant.
|
||||||
const labels = rec.labels().reverse().slice(0, -1)
|
//
|
||||||
|
// 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
|
// Start each record from the top and iterate through all its labels
|
||||||
// except the first one since that would be the actual record.
|
// except the first one since that would be the actual record.
|
||||||
|
|
@ -93,10 +115,7 @@ export class Application {
|
||||||
return 0
|
return 0
|
||||||
}// }}}
|
}// }}}
|
||||||
render() {// {{{
|
render() {// {{{
|
||||||
if (this.recordsTree == null) {
|
if (this.createIcon === null) {
|
||||||
this.recordsTree = document.createElement('div')
|
|
||||||
this.recordsTree.id = 'records-tree'
|
|
||||||
|
|
||||||
this.createIcon = document.createElement('img')
|
this.createIcon = document.createElement('img')
|
||||||
this.createIcon.id = 'create-icon'
|
this.createIcon.id = 'create-icon'
|
||||||
this.createIcon.src = `/images/${_VERSION}/icon_create.svg`
|
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.src = `/images/${_VERSION}/icon_settings.svg`
|
||||||
this.settingsIcon.addEventListener('click', () => new SettingsDialog(this).show())
|
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.settingsIcon)
|
||||||
document.body.appendChild(this.createIcon)
|
document.body.appendChild(this.createIcon)
|
||||||
|
document.body.appendChild(searchEl)
|
||||||
|
|
||||||
document.body.addEventListener('keydown', event => this.handlerKeys(event))
|
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.
|
// Top root folder doesn't have to be shown.
|
||||||
const folders = Array.from(this.topFolder.subfolders.values())
|
const folders = Array.from(this.topFolder.subfolders.values())
|
||||||
folders.sort(this.sortFolders)
|
folders.sort(this.sortFolders)
|
||||||
|
|
@ -121,11 +163,31 @@ export class Application {
|
||||||
for (const folder of folders)
|
for (const folder of folders)
|
||||||
this.recordsTree.append(folder.render())
|
this.recordsTree.append(folder.render())
|
||||||
|
|
||||||
|
this.removeEmptyFolders()
|
||||||
|
|
||||||
// Subscribe to settings update since the elements they will change
|
// Subscribe to settings update since the elements they will change
|
||||||
// exists now.
|
// exists now.
|
||||||
_mbus.subscribe('settings_updated', event => this.handlerSettingsUpdated(event.detail))
|
_mbus.subscribe('settings_updated', event => this.handlerSettingsUpdated(event.detail))
|
||||||
this.setBoxedFolders(this.settings.get('boxed_folders'))
|
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) {// {{{
|
handlerKeys(event) {// {{{
|
||||||
let handled = true
|
let handled = true
|
||||||
|
|
@ -143,6 +205,10 @@ export class Application {
|
||||||
new RecordDialog(new Record()).show()
|
new RecordDialog(new Record()).show()
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'f':
|
||||||
|
this.searchField.focus()
|
||||||
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
handled = false
|
handled = false
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +229,16 @@ export class Application {
|
||||||
else
|
else
|
||||||
document.body.classList.remove('boxed-folders')
|
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 {
|
class Folder {
|
||||||
|
|
@ -184,7 +260,18 @@ class Folder {
|
||||||
return this.folderName.toLowerCase()
|
return this.folderName.toLowerCase()
|
||||||
}// }}}
|
}// }}}
|
||||||
labels() {// {{{
|
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) {// {{{
|
addRecord(rec) {// {{{
|
||||||
this.records.push(rec)
|
this.records.push(rec)
|
||||||
|
|
@ -230,14 +317,27 @@ class Folder {
|
||||||
<img class="closed" src="/images/${_VERSION}/icon_folder.svg">
|
<img class="closed" src="/images/${_VERSION}/icon_folder.svg">
|
||||||
<img class="open" src="/images/${_VERSION}/icon_folder_open.svg">
|
<img class="open" src="/images/${_VERSION}/icon_folder_open.svg">
|
||||||
<span>${firstLabel}</span><span>${restLabels != '' ? '.' + restLabels : ''}</span>
|
<span>${firstLabel}</span><span>${restLabels != '' ? '.' + restLabels : ''}</span>
|
||||||
|
<img class="create" src="/images/${_VERSION}/icon_create.svg">
|
||||||
</div>
|
</div>
|
||||||
<div class="subfolders"></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.divSubfolders = this.div.querySelector('.subfolders')
|
||||||
this.divRecords = this.div.querySelector('.records')
|
this.divRecords = this.div.querySelector('.records')
|
||||||
|
|
||||||
this.div.querySelector('.label').addEventListener('click', event => this.toggleFolder(event))
|
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())
|
this.divSubfolders.append(folder.render())
|
||||||
|
|
||||||
// Records are refreshed.
|
// 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))
|
for (const rec of Array.from(this.records))
|
||||||
this.divRecords.append(...rec.render())
|
this.divRecords.append(...rec.render())
|
||||||
|
|
||||||
// Open this folder automatically if it is a toplevel folder and the settings change to open.
|
// 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')
|
if (key !== 'toplevel_open')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -263,6 +378,11 @@ class Folder {
|
||||||
|
|
||||||
return this.div
|
return this.div
|
||||||
}// }}}
|
}// }}}
|
||||||
|
createRecord(event) {// {{{
|
||||||
|
event.stopPropagation()
|
||||||
|
const record = new Record({ Name: this.name() })
|
||||||
|
new RecordDialog(record).show()
|
||||||
|
}// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Record {
|
class Record {
|
||||||
|
|
@ -274,7 +394,8 @@ class Record {
|
||||||
this.divFQDN = null
|
this.divFQDN = null
|
||||||
this.divType = null
|
this.divType = null
|
||||||
this.divValue = null
|
this.divValue = null
|
||||||
this.divSeparator = null
|
this.divTTL = null
|
||||||
|
this.divActions = null
|
||||||
}// }}}
|
}// }}}
|
||||||
|
|
||||||
id() {// {{{
|
id() {// {{{
|
||||||
|
|
@ -302,7 +423,24 @@ class Record {
|
||||||
return this.data.MatchSubdomain === 'true'
|
return this.data.MatchSubdomain === 'true'
|
||||||
}// }}}
|
}// }}}
|
||||||
labels() {// {{{
|
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) {// {{{
|
copy(el, text) {// {{{
|
||||||
|
|
@ -318,6 +456,8 @@ class Record {
|
||||||
if (value.slice(0, 2) == '*.') {
|
if (value.slice(0, 2) == '*.') {
|
||||||
this.data['Name'] = value.slice(2)
|
this.data['Name'] = value.slice(2)
|
||||||
this.data['MatchSubdomain'] = 'true'
|
this.data['MatchSubdomain'] = 'true'
|
||||||
|
} else if (value.slice(-11) == '._no.domain') {
|
||||||
|
this.data['Name'] = value.slice(0, -11)
|
||||||
} else {
|
} else {
|
||||||
this.data['Name'] = value
|
this.data['Name'] = value
|
||||||
this.data['MatchSubdomain'] = 'false'
|
this.data['MatchSubdomain'] = 'false'
|
||||||
|
|
@ -332,23 +472,39 @@ class Record {
|
||||||
}// }}}
|
}// }}}
|
||||||
render() {// {{{
|
render() {// {{{
|
||||||
if (this.divFQDN === null) {
|
if (this.divFQDN === null) {
|
||||||
this.imgIcon = document.createElement('img')
|
this.imgIcon = document.createElement('div')
|
||||||
this.divFQDN = document.createElement('div')
|
this.divFQDN = document.createElement('div')
|
||||||
this.divType = document.createElement('div')
|
this.divType = document.createElement('div')
|
||||||
this.divValue = 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.divFQDN.classList.add('fqdn')
|
||||||
this.divType.classList.add('type')
|
this.divType.classList.add('type')
|
||||||
this.divValue.classList.add('value')
|
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 = `
|
this.divFQDN.innerHTML = `
|
||||||
<span class="subdomains">*.</span>
|
<span class="subdomains">*.</span>
|
||||||
<span class="first-label"></span>
|
<span class="first-label"></span>
|
||||||
<span class="rest-label"></span>
|
<span class="rest-label"></span>
|
||||||
`
|
`
|
||||||
|
this.divActions.innerHTML = `
|
||||||
|
<img class="delete" src="/images/${_VERSION}/icon_delete.svg">
|
||||||
|
`
|
||||||
|
|
||||||
this.divFQDN.addEventListener('click', event => {
|
this.divFQDN.addEventListener('click', event => {
|
||||||
if (event.shiftKey)
|
if (event.shiftKey)
|
||||||
|
|
@ -356,13 +512,15 @@ class Record {
|
||||||
else
|
else
|
||||||
this.edit()
|
this.edit()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.divValue.addEventListener('click', event => {
|
this.divValue.addEventListener('click', event => {
|
||||||
if (event.shiftKey)
|
if (event.shiftKey)
|
||||||
this.copy(event.target.closest('.value'), this.value())
|
this.copy(event.target.closest('.value'), this.value())
|
||||||
else
|
else
|
||||||
this.edit()
|
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.
|
// FQDN is updated.
|
||||||
|
|
@ -373,14 +531,37 @@ class Record {
|
||||||
|
|
||||||
const fl = this.labels()[0]
|
const fl = this.labels()[0]
|
||||||
const rl = this.labels().slice(1).join('.')
|
const rl = this.labels().slice(1).join('.')
|
||||||
this.divFQDN.querySelector('.first-label').innerText = fl
|
const flEl = this.divFQDN.querySelector('.first-label')
|
||||||
this.divFQDN.querySelector('.rest-label').innerText = rl != '' ? `.${rl}` : ''
|
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.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() {// {{{
|
save() {// {{{
|
||||||
const created = (this.id() == '')
|
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) {// {{{
|
openParentFolders(folder) {// {{{
|
||||||
if (folder === undefined)
|
if (folder === undefined)
|
||||||
|
|
@ -505,8 +703,8 @@ class RecordDialog {
|
||||||
class Settings {
|
class Settings {
|
||||||
constructor() {// {{{
|
constructor() {// {{{
|
||||||
this.settings = new Map([
|
this.settings = new Map([
|
||||||
['boxed_folders', true],
|
['boxed_folders', false],
|
||||||
['toplevel_open', true],
|
['toplevel_open', false],
|
||||||
])
|
])
|
||||||
|
|
||||||
// Read any configured settings from local storage, but keeping default value
|
// Read any configured settings from local storage, but keeping default value
|
||||||
|
|
|
||||||
28
webserver.go
28
webserver.go
|
|
@ -7,6 +7,7 @@ import (
|
||||||
// Standard
|
// Standard
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -33,6 +34,7 @@ func registerWebserverHandlers() {
|
||||||
|
|
||||||
http.HandleFunc("/", rootHandler)
|
http.HandleFunc("/", rootHandler)
|
||||||
http.HandleFunc("/record/save", actionRecordSave)
|
http.HandleFunc("/record/save", actionRecordSave)
|
||||||
|
http.HandleFunc("/record/delete/{id}", actionRecordDelete)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWebserver() {
|
func startWebserver() {
|
||||||
|
|
@ -110,6 +112,30 @@ func actionRecordSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
record = NewDNSRecord(entry)
|
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)
|
w.Write(j)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue