Recursive tree version

This commit is contained in:
Magnus Åhall 2026-02-24 17:21:55 +01:00
parent 42c5d43610
commit 97058d036d
8 changed files with 599 additions and 134 deletions

29
dns.go
View file

@ -9,12 +9,13 @@ import (
) )
type DNSRecord struct { type DNSRecord struct {
ID string `json:".id"` ID string `json:".id"`
Disabled string Disabled string
Dynamic string Dynamic string
Name string Name string
TTL string TTL string
Type string Type string
ParsedValue string // not from RouterOS, here to not having to have the value logics in frontend too.
Address string Address string
CNAME string CNAME string
@ -159,13 +160,13 @@ func (dp *DomainPart) ToHTMLElements(parts []string) []HTMLElement {
<div class="type"></div> <div class="type"></div>
<div class="value"></div> <div class="value"></div>
`, `,
topmost, // .top-most topmost, // .top-most
restPart, // data-top restPart, // data-top
fqdn, // data-self fqdn, // data-self
(len(newParts)-1)*32, // margin-left (len(newParts)-1)*32, // margin-left
VERSION, // images/ VERSION, // images/
VERSION, // images/ VERSION, // images/
mostSpecificPart, // innerText mostSpecificPart, // innerText
restPart, restPart,
) )
lines = append(lines, HTMLElement{Header: true, HTML: html}) lines = append(lines, HTMLElement{Header: true, HTML: html})
@ -195,8 +196,8 @@ func (dp *DomainPart) ToHTMLElements(parts []string) []HTMLElement {
for _, rec := range subpart.Record { for _, rec := range subpart.Record {
html := fmt.Sprintf( html := fmt.Sprintf(
` `
<div class="record" style="padding-left: %dpx" data-top="%s"><span>%s</span><span>%s</span></div> <div class="record" style="padding-left: %dpx" data-top="%s"><div><span>%s</span><span>%s</span></div></div>
<div class="type">%s</div> <div class="type"><div>%s</div></div>
<div class="value">%s</div> <div class="value">%s</div>
`, `,
(len(newParts)-1)*32, (len(newParts)-1)*32,

View file

@ -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 { html {
box-sizing: border-box; box-sizing: border-box;
} }
@ -14,102 +30,151 @@ html {
body { body {
font-family: sans-serif; font-family: sans-serif;
font-size: 12pt;
margin-left: 32px;
} }
.records-tree { #records-tree {
display: grid;
grid-template-columns: min-content min-content 1fr;
white-space: nowrap; white-space: nowrap;
.show { .folder {
display: block !important; padding-left: 32px;
}
.top, &.top-most {
.record, padding-left: 0px;
.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;
&.open { &.open {
.folder.open { &>.label>img.open {
display: inline-block; display: block;
} }
.folder.closed { }
&.closed {
&>.label>img.closed {
display: block;
}
&>.subfolders {
display: none;
}
&>.records {
display: none; display: none;
} }
} }
&:not(.open) { &>.label {
.folder.open { 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; 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;
}

View file

@ -3,11 +3,11 @@
<svg <svg
width="21.200001" width="21.200001"
height="16.000025" height="18"
viewBox="0 0 5.6091668 4.2333399" viewBox="0 0 5.6091668 4.7625"
version="1.1" version="1.1"
id="svg1" id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" inkscape:version="1.4 (e7c3feb, 2024-10-09)"
sodipodi:docname="icon_folder.svg" sodipodi:docname="icon_folder.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
@ -24,12 +24,12 @@
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:zoom="22.627417" inkscape:zoom="22.627417"
inkscape:cx="4.065864" inkscape:cx="16.175068"
inkscape:cy="7.6455921" inkscape:cy="10.341437"
inkscape:window-width="1916" inkscape:window-width="2190"
inkscape:window-height="1041" inkscape:window-height="1404"
inkscape:window-x="1920" inkscape:window-x="1463"
inkscape:window-y="1098" inkscape:window-y="16"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" /> inkscape:current-layer="layer1" />
<defs <defs
@ -42,7 +42,7 @@
<title <title
id="title1">folder-outline</title> id="title1">folder-outline</title>
<rect <rect
style="fill:#ffeeaa;stroke-width:0.352777;stroke-opacity:0.24447;stroke:none" style="fill:#ffeeaa;stroke:none;stroke-width:0.352777;stroke-opacity:0.24447"
id="rect1" id="rect1"
width="4.6421375" width="4.6421375"
height="3.0401909" height="3.0401909"

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

View file

@ -3,11 +3,11 @@
<svg <svg
width="21.200001" width="21.200001"
height="16.000025" height="18"
viewBox="0 0 5.6091669 4.2333399" viewBox="0 0 5.6091669 4.7625"
version="1.1" version="1.1"
id="svg1" id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)" inkscape:version="1.4 (e7c3feb, 2024-10-09)"
sodipodi:docname="icon_folder_open.svg" sodipodi:docname="icon_folder_open.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
@ -23,15 +23,16 @@
inkscape:pagecheckerboard="0" inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px" inkscape:document-units="px"
inkscape:zoom="22.627417" inkscape:zoom="17.076364"
inkscape:cx="16.440233" inkscape:cx="35.487649"
inkscape:cy="12.551145" inkscape:cy="7.9642249"
inkscape:window-width="1916" inkscape:window-width="2190"
inkscape:window-height="1041" inkscape:window-height="1404"
inkscape:window-x="1920" inkscape:window-x="1463"
inkscape:window-y="1098" inkscape:window-y="16"
inkscape:window-maximized="1" inkscape:window-maximized="0"
inkscape:current-layer="layer1" /> inkscape:current-layer="layer1"
showgrid="false" />
<defs <defs
id="defs1" /> id="defs1" />
<g <g

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="18"
height="18"
viewBox="0 0 4.7624999 4.7625001"
version="1.1"
id="svg8"
inkscape:version="1.4 (e7c3feb, 2024-10-09)"
sodipodi:docname="icon_record.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"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="9.5"
inkscape:cy="10"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="2190"
inkscape:window-height="1404"
inkscape:window-x="1463"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-90.4875,-148.69583)">
<title
id="title1">text-box-outline</title>
<path
d="m 91.016667,148.69583 c -0.293688,0 -0.529167,0.23548 -0.529167,0.52917 v 3.70417 c 0,0.29368 0.235479,0.52916 0.529167,0.52916 h 3.704167 c 0.293687,0 0.529166,-0.23548 0.529166,-0.52916 V 149.225 c 0,-0.29369 -0.235479,-0.52917 -0.529166,-0.52917 h -3.704167 m 0,0.52917 h 3.704167 v 3.70417 H 91.016667 V 149.225 m 0.529167,0.52917 v 0.52916 h 2.645833 v -0.52916 h -2.645833 m 0,1.05833 v 0.52917 h 2.645833 v -0.52917 h -2.645833 m 0,1.05833 V 152.4 h 1.852083 v -0.52917 z"
id="path1"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -1,16 +1,85 @@
export class Application { export class Application {
constructor() { constructor(records) {// {{{
this.addTopHandlers() 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 rec of this.records) {
for (const top of document.querySelectorAll('.top')) const labels = rec.labels().reverse().slice(0, -1)
top.addEventListener('click', event => this.handlerTop(event))
}
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') const topEl = event.target.closest('.top')
console.log(topEl.dataset.self)
let records, types, values let records, types, values
if (topEl.classList.contains('open')) { if (topEl.classList.contains('open')) {
@ -30,12 +99,22 @@ export class Application {
topEl.classList.remove('open') topEl.classList.remove('open')
} else { } else {
records = document.querySelectorAll(`[data-top="${topEl.dataset.self}"]`) if (event.shiftKey) {
types = document.querySelectorAll(`[data-top="${topEl.dataset.self}"] + .type`) records = document.querySelectorAll(`[data-top$="${topEl.dataset.self}"]`)
values = document.querySelectorAll(`[data-top="${topEl.dataset.self}"] + .type + .value`) 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') r.classList.add('show')
if (event.shiftKey && r.classList.contains('top'))
r.classList.add('open')
}
for (const r of types) for (const r of types)
r.classList.add('show') r.classList.add('show')
for (const r of values) 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 = `
<div class="label">
<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>
</div>
<div class="subfolders"></div>
<div class="records"></div>
`
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 = `
<span class="first-label">${this.labels()[0]}</span>
<span class="rest-label">${this.labels().slice(1).join('.')}</span>
`
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 = `
<div>Name</div>
<input type="text" class="name">
<div>Type</div>
<select class="type">
<option>A</option>
<option>AAAA</option>
<option>CNAME</option>
</select>
<div>Value</div>
<input type="text" class="value">
<div>TTL</div>
<input type="text" class="ttl">
<div class="buttons">
<button class="save">Save</button>
<button class="close">Close</button>
</div>
`
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)
} }
} }

View file

@ -1,13 +1,12 @@
{{ define "page" }} {{ define "page" }}
<script type="module"> <script type="module">
import { Application } from "/js/{{ .Data.VERSION }}/dns.mjs" import { Application } from "/js/{{ .Data.VERSION }}/dns.mjs"
const app = new Application() window._app = new Application({{ .Data.DNSRecords }})
</script> </script>
<h1>{{ .Data.Identity }}</h1> <h1>{{ .Data.Identity }}</h1>
<div class="records-tree"> <div id="records-tree">
{{ .Data.Tree }}
</div> </div>
{{ end }} {{ end }}

View file

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