diff --git a/OliveTin.proto b/OliveTin.proto
index b7e52e99..3bbfc280 100644
--- a/OliveTin.proto
+++ b/OliveTin.proto
@@ -10,7 +10,7 @@ message Action {
string icon = 3;
bool can_exec = 4;
repeated ActionArgument arguments = 5;
- bool popup_on_start = 6;
+ string popup_on_start = 6;
}
message ActionArgument {
@@ -39,10 +39,18 @@ message GetDashboardComponentsResponse {
string title = 1;
repeated Action actions = 2;
repeated Entity entities = 3;
+ repeated DashboardItem dashboards = 4;
}
message GetDashboardComponentsRequest {}
+message DashboardItem {
+ string title = 1;
+ string type = 2;
+ repeated DashboardItem contents = 3;
+ string link = 4;
+}
+
message StartActionRequest {
string action_name = 1;
diff --git a/integration-tests/test/multipleDropdowns.js b/integration-tests/test/multipleDropdowns.js
index 5dd344d1..6d40d3d9 100644
--- a/integration-tests/test/multipleDropdowns.js
+++ b/integration-tests/test/multipleDropdowns.js
@@ -12,15 +12,15 @@ describe('config: multipleDropdowns', function () {
it('Multiple dropdowns are possible', async function() {
await webdriver.get(runner.baseUrl())
- await webdriver.manage().setTimeouts({ implicit: 2000 });
+ await webdriver.manage().setTimeouts({ implicit: 2000 })
- const button = await webdriver.findElement(By.id('actionButton_bdc45101bbd12c1397557790d9f3e059')).findElement(By.tagName('button'));
+ const button = await webdriver.findElement(By.id('actionButton-bdc45101bbd12c1397557790d9f3e059')).findElement(By.tagName('button'))
- expect(button).to.not.be.undefined;
+ expect(button).to.not.be.undefined
await button.click()
- const dialog = await webdriver.findElement(By.id('argument-popup'));
+ const dialog = await webdriver.findElement(By.id('argument-popup'))
await webdriver.wait(until.elementIsVisible(dialog), 2000)
diff --git a/internal/config/config.go b/internal/config/config.go
index cd5fcdd2..cc5298d0 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -18,7 +18,7 @@ type Action struct {
ExecOnFileChangedInDir []string
MaxConcurrent int
Arguments []ActionArgument
- PopupOnStart bool
+ PopupOnStart string
}
// ActionArgument objects appear on Actions.
@@ -71,8 +71,9 @@ type Config struct {
ListenAddressGrpcActions string
ExternalRestAddress string
LogLevel string
- Actions []Action `mapstructure:"actions"`
- Entities []Entity `mapstructure:"entities"`
+ Actions []Action `mapstructure:"actions"`
+ Dashboards []DashboardItem `mapstructure:"dashboards"`
+ Entities []Entity `mapstructure:"entities"`
CheckForUpdates bool
PageTitle string
ShowFooter bool
@@ -89,6 +90,14 @@ type Config struct {
AccessControlLists []AccessControlList
WebUIDir string
CronSupportForSeconds bool
+ SectionNavigationStyle string
+}
+
+type DashboardItem struct {
+ Title string
+ Type string
+ Link string
+ Contents []DashboardItem
}
// DefaultConfig gets a new Config structure with sensible default values.
@@ -112,6 +121,7 @@ func DefaultConfig() *Config {
config.AuthJwtClaimUserGroup = "group"
config.WebUIDir = "./webui"
config.CronSupportForSeconds = false
+ config.SectionNavigationStyle = "sidebar"
return &config
}
diff --git a/internal/grpcapi/grpcApi.go b/internal/grpcapi/grpcApi.go
index 617b1355..317d0832 100644
--- a/internal/grpcapi/grpcApi.go
+++ b/internal/grpcapi/grpcApi.go
@@ -214,6 +214,8 @@ func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashb
log.Tracef("GetDashboardComponents: %v", res)
+ dashboardCfgToPb(res, cfg.Dashboards)
+
return res, nil
}
diff --git a/internal/grpcapi/grpcApiDashboard.go b/internal/grpcapi/grpcApiDashboard.go
new file mode 100644
index 00000000..dd20e583
--- /dev/null
+++ b/internal/grpcapi/grpcApiDashboard.go
@@ -0,0 +1,42 @@
+package grpcapi
+
+import (
+ pb "github.com/OliveTin/OliveTin/gen/grpc"
+ config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func dashboardCfgToPb(res *pb.GetDashboardComponentsResponse, dashboards []config.DashboardItem) {
+ for _, dashboard := range dashboards {
+ res.Dashboards = append(res.Dashboards, &pb.DashboardItem{
+ Type: "dashboard",
+ Title: dashboard.Title,
+ Contents: getDashboardContents(&dashboard),
+ })
+ }
+}
+
+func getDashboardContents(dashboard *config.DashboardItem) []*pb.DashboardItem {
+ ret := make([]*pb.DashboardItem, 0)
+
+ for _, subitem := range dashboard.Contents {
+ newitem := &pb.DashboardItem{
+ Title: subitem.Title,
+ Type: subitem.Type,
+ }
+
+ if len(subitem.Contents) > 0 {
+ if newitem.Type != "fieldset" {
+ newitem.Type = "directory"
+ }
+
+ newitem.Contents = getDashboardContents(&subitem)
+ } else {
+ newitem.Type = "link"
+ newitem.Link = subitem.Link
+ }
+
+ ret = append(ret, newitem)
+ }
+
+ return ret
+}
diff --git a/internal/httpservers/webuiServer.go b/internal/httpservers/webuiServer.go
index 59fcc417..8f13bcc3 100644
--- a/internal/httpservers/webuiServer.go
+++ b/internal/httpservers/webuiServer.go
@@ -12,14 +12,15 @@ import (
)
type webUISettings struct {
- Rest string
- ThemeName string
- ShowFooter bool
- ShowNavigation bool
- ShowNewVersions bool
- AvailableVersion string
- CurrentVersion string
- PageTitle string
+ Rest string
+ ThemeName string
+ ShowFooter bool
+ ShowNavigation bool
+ ShowNewVersions bool
+ AvailableVersion string
+ CurrentVersion string
+ PageTitle string
+ SectionNavigationStyle string
}
func findWebuiDir() string {
@@ -49,14 +50,15 @@ func findWebuiDir() string {
func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
jsonRet, _ := json.Marshal(webUISettings{
- Rest: cfg.ExternalRestAddress + "/api/",
- ThemeName: cfg.ThemeName,
- ShowFooter: cfg.ShowFooter,
- ShowNavigation: cfg.ShowNavigation,
- ShowNewVersions: cfg.ShowNewVersions,
- AvailableVersion: updatecheck.AvailableVersion,
- CurrentVersion: updatecheck.CurrentVersion,
- PageTitle: cfg.PageTitle,
+ Rest: cfg.ExternalRestAddress + "/api/",
+ ThemeName: cfg.ThemeName,
+ ShowFooter: cfg.ShowFooter,
+ ShowNavigation: cfg.ShowNavigation,
+ ShowNewVersions: cfg.ShowNewVersions,
+ AvailableVersion: updatecheck.AvailableVersion,
+ CurrentVersion: updatecheck.CurrentVersion,
+ PageTitle: cfg.PageTitle,
+ SectionNavigationStyle: cfg.SectionNavigationStyle,
})
_, err := w.Write([]byte(jsonRet))
diff --git a/webui/index.html b/webui/index.html
index 825da7a7..88cec57e 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -24,16 +24,16 @@
OliveTin
+
+
@@ -53,7 +53,8 @@
diff --git a/webui/js/ActionButton.js b/webui/js/ActionButton.js
index 889eed43..6da3aaa4 100644
--- a/webui/js/ActionButton.js
+++ b/webui/js/ActionButton.js
@@ -40,7 +40,7 @@ class ActionButton extends window.HTMLElement {
this.domTitle.innerText = this.btn.title
this.domIcon.innerHTML = this.unicodeIcon
- this.setAttribute('id', 'actionButton_' + json.id)
+ this.setAttribute('id', 'actionButton-' + json.id)
}
updateFromJson (json) {
@@ -68,7 +68,6 @@ class ActionButton extends window.HTMLElement {
startAction (actionArgs) {
// this.btn.disabled = true
// this.isWaiting = true
- // this.updateDom()
this.btn.classList = [] // Removes old animation classes
if (actionArgs === undefined) {
diff --git a/webui/js/marshaller.js b/webui/js/marshaller.js
index 65b8677a..a43db09a 100644
--- a/webui/js/marshaller.js
+++ b/webui/js/marshaller.js
@@ -1,32 +1,321 @@
import './ActionButton.js' // To define action-button
-export function marshalActionButtonsJsonToHtml (json) {
+export function marshalDashboardComponentsJsonToHtml (json) {
+ marshalActionsJsonToHtml(json)
+ marshalDashboardStructureToHtml(json)
+
+ window.changeDirectory = changeDirectory
+ window.showSection = showSection
+
+ changeDirectory(null)
+}
+
+function marshalActionsJsonToHtml (json) {
const currentIterationTimestamp = Date.now()
- for (const jsonButton of json.actions) {
- let htmlButton = document.querySelector('#execution-' + jsonButton.id)
+ window.actionButtons = {}
- if (htmlButton == null) {
+ for (const jsonButton of json.actions) {
+ let htmlButton = window.actionButtons[jsonButton.id]
+
+ if (typeof htmlButton === 'undefined') {
htmlButton = document.createElement('action-button')
htmlButton.constructFromJson(jsonButton)
- document.getElementById('root-group').appendChild(htmlButton)
- } else {
- htmlButton.updateFromJson(jsonButton)
- htmlButton.updateDom()
+ window.actionButtons[jsonButton.title] = htmlButton
}
+ htmlButton.updateFromJson(jsonButton)
htmlButton.updateIterationTimestamp = currentIterationTimestamp
}
// Remove existing, but stale buttons (that were not updated in this round)
- for (const existingButton of document.querySelector('#contentActions').querySelectorAll('action-button')) {
+ for (const existingButton of document.querySelectorAll('action-button')) {
if (existingButton.updateIterationTimestamp !== currentIterationTimestamp) {
existingButton.remove()
}
}
}
+function showSection (title) {
+ for (const section of document.querySelectorAll('section')) {
+ if (section.title === title) {
+ section.style.display = 'block'
+ } else {
+ section.style.display = 'none'
+ }
+ }
+
+ for (const otherName of ['Actions', 'Logs']) {
+ document.getElementById('show' + otherName).classList.remove('activeSection')
+ document.getElementById('content' + otherName).hidden = true
+ }
+
+ // document.getElementById('show' + name).classList.add('activeSection')
+ // document.getElementById('content' + name).hidden = false
+
+ document.getElementById('hide-sidebar-checkbox').checked = true
+
+ changeDirectory(null)
+}
+
+export function setupSectionNavigation (style) {
+ const nav = document.querySelector('nav')
+
+ if (style === 'sidebar') {
+ nav.classList += 'sidebar'
+
+ document.body.classList += 'has-sidebar'
+ } else {
+ nav.classList += 'topbar'
+
+ document.body.classList += 'has-topbar'
+ }
+
+ nav.hidden = false
+
+ document.getElementById('showActions').onclick = () => { showSection('Actions') }
+ document.getElementById('showLogs').onclick = () => { showSection('Logs') }
+}
+
+function marshalDashboardStructureToHtml (json) {
+ const nav = document.getElementById('navigation-links')
+
+ for (const dashboard of json.dashboards) {
+ const oldsection = document.querySelector('section[title="' + dashboard.title + '"]')
+
+ if (oldsection != null) {
+ oldsection.remove()
+ }
+
+ const section = document.createElement('section')
+ section.title = dashboard.title
+
+ const def = createFieldset('default', section)
+ section.appendChild(def)
+
+ document.getElementsByTagName('main')[0].appendChild(section)
+ marshalContainerContents(dashboard, section, def, dashboard.title)
+
+ const oldLi = nav.querySelector('li[title="' + dashboard.title + '"]')
+
+ if (oldLi != null) {
+ oldLi.remove()
+ }
+
+ const navigationA = document.createElement('a')
+ navigationA.title = dashboard.title
+ navigationA.innerText = dashboard.title
+ navigationA.onclick = () => {
+ showSection(dashboard.title)
+ }
+
+ const navigationLi = document.createElement('li')
+ navigationLi.appendChild(navigationA)
+ navigationLi.title = dashboard.title
+
+ document.getElementById('navigation-links').appendChild(navigationLi)
+ }
+
+ if (json.dashboards.length === 0) {
+ showSection('Actions')
+ } else {
+ showSection(json.dashboards[0].title)
+ }
+
+ const rootGroup = document.querySelector('#root-group')
+
+ let hasRootActions = false
+
+ for (const btn of Object.values(window.actionButtons)) {
+ if (btn.parentElement === null) {
+ rootGroup.appendChild(btn)
+ hasRootActions = true
+ }
+ }
+
+ if (!hasRootActions) {
+ nav.querySelector('li[title="Actions"]').style.display = 'none'
+ }
+}
+
+function marshalLink (item, fieldset) {
+ let btn = window.actionButtons[item.link]
+
+ if (typeof btn === 'undefined') {
+ btn = document.createElement('button')
+ btn.innerText = 'Action not found: ' + item.link
+ btn.classList.add('error')
+ }
+
+ fieldset.appendChild(btn)
+}
+
+function marshalContainerContents (json, section, fieldset, parentDashboard) {
+ for (const item of json.contents) {
+ switch (item.type) {
+ case 'fieldset':
+ marshalFieldset(item, section, parentDashboard)
+ break
+ case 'directory':
+ marshalDirectoryButton(item, fieldset)
+ marshalDirectory(item, section)
+ break
+ case 'link':
+ marshalLink(item, fieldset)
+ break
+ default:
+ }
+ }
+}
+
+function createFieldset (title, parentDashboard) {
+ const legend = document.createElement('legend')
+ legend.innerText = title
+
+ const fs = document.createElement('fieldset')
+ fs.title = title
+ fs.appendChild(legend)
+
+ if (typeof parentDashboard === 'undefined') {
+ fs.setAttribute('parent-dashboard', '')
+ } else {
+ fs.setAttribute('parent-dashboard', parentDashboard)
+ }
+
+ return fs
+}
+
+function marshalFieldset (item, section, parentDashboard) {
+ const fs = createFieldset(item.title, parentDashboard)
+
+ marshalContainerContents(item, section, fs)
+
+ section.appendChild(fs)
+}
+
+function changeDirectory (selected) {
+ if (selected === '') {
+ selected = null
+ }
+
+ if (selected === null) {
+ window.directoryNavigation = []
+ } else if (selected === '..') {
+ window.directoryNavigation.pop()
+
+ if (window.directoryNavigation.length > 0) {
+ selected = window.directoryNavigation[window.directoryNavigation.length - 1]
+ } else {
+ selected = null
+ }
+ } else {
+ // If the selected item is already in the nav list, pop elements until we get
+ // "back" to the existing nav item
+ while (window.directoryNavigation.includes(selected)) {
+ window.directoryNavigation.pop()
+ }
+
+ window.directoryNavigation.push(selected)
+ }
+
+ for (const fieldset of document.querySelectorAll('fieldset')) {
+ if (selected === null) {
+ if ((fieldset.id === 'root-group' || fieldset.getAttribute('parent-dashboard') !== '') && fieldset.children.length > 1) {
+ fieldset.style.display = 'grid'
+ } else {
+ fieldset.style.display = 'none'
+ }
+ } else {
+ if (fieldset.title === selected) {
+ fieldset.style.display = 'grid'
+ } else {
+ fieldset.style.display = 'none'
+ }
+ }
+ }
+
+ const title = document.querySelector('h1')
+ title.innerHTML = ''
+
+ const rootLink = createDirectoryBreadcrumb('OliveTin', null)
+ title.appendChild(rootLink)
+
+ for (const dir of window.directoryNavigation) {
+ const sep = document.createElement('span')
+ sep.innerHTML = ' » '
+ title.append(sep)
+
+ if (dir === selected) {
+ title.append(selected)
+ } else {
+ title.appendChild(createDirectoryBreadcrumb(dir))
+ }
+ }
+
+ document.title = title.innerText
+
+ if (selected === null) {
+ window.location.hash = null
+
+ window.history.pushState({ dir: null }, null, '#')
+ } else {
+ window.location.hash = selected
+
+ window.history.pushState({ dir: selected }, null, '#' + selected)
+ }
+}
+
+function createDirectoryBreadcrumb (title, link) {
+ const a = document.createElement('a')
+ a.innerText = title
+ a.title = title
+
+ if (typeof link === 'undefined') {
+ link = title
+ }
+
+ if (link === null) {
+ a.href = '#'
+ } else {
+ a.href = '#' + link
+ }
+
+ a.onclick = () => {
+ changeDirectory(link)
+ }
+
+ return a
+}
+
+function marshalDirectoryButton (item, fieldset) {
+ const directoryButton = document.createElement('button')
+ directoryButton.innerHTML = '📁 ' + item.title
+ directoryButton.onclick = () => {
+ changeDirectory(item.title)
+ }
+
+ fieldset.appendChild(directoryButton)
+}
+
+function marshalDirectory (item, section) {
+ const fs = createFieldset(item.title)
+ fs.style.display = 'none'
+
+ const directoryBackButton = document.createElement('button')
+ directoryBackButton.innerHTML = '«'
+ directoryBackButton.title = 'Go back one directory'
+ directoryBackButton.onclick = () => {
+ changeDirectory('..')
+ }
+
+ fs.appendChild(directoryBackButton)
+
+ marshalContainerContents(item, section, fs)
+
+ section.appendChild(fs)
+}
+
export function marshalLogsJsonToHtml (json) {
for (const logEntry of json.logs) {
const tpl = document.getElementById('tplLogRow')
@@ -69,3 +358,10 @@ export function marshalLogsJsonToHtml (json) {
document.querySelector('#logTableBody').prepend(row)
}
}
+
+window.addEventListener('popstate', (e) => {
+ e.preventDefault()
+ if (e.state != null && typeof e.state.dir !== 'undefined') {
+ changeDirectory(e.state.dir)
+ }
+})
diff --git a/webui/main.js b/webui/main.js
index ebab0e33..fc7e344d 100644
--- a/webui/main.js
+++ b/webui/main.js
@@ -1,6 +1,6 @@
'use strict'
-import { marshalActionButtonsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js'
+import { setupSectionNavigation, marshalDashboardComponentsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js'
import { checkWebsocketConnection } from './js/websocket.js'
function searchLogs (e) {
@@ -24,25 +24,6 @@ function searchLogsClear () {
document.getElementById('logSearchBox').value = ''
}
-function showSection (name) {
- for (const otherName of ['Actions', 'Logs']) {
- document.getElementById('show' + otherName).classList.remove('activeSection')
- document.getElementById('content' + otherName).hidden = true
- }
-
- document.getElementById('show' + name).classList.add('activeSection')
- document.getElementById('content' + name).hidden = false
-
- document.getElementById('hide-sidebar-checkbox').checked = true
-}
-
-function setupSections () {
- document.getElementById('showActions').onclick = () => { showSection('Actions') }
- document.getElementById('showLogs').onclick = () => { showSection('Logs') }
-
- showSection('Actions')
-}
-
function setupLogSearchBox () {
document.getElementById('logSearchBox').oninput = searchLogs
document.getElementById('searchLogsClear').onclick = searchLogsClear
@@ -91,7 +72,7 @@ function fetchGetDashboardComponents () {
}
window.restAvailable = true
- marshalActionButtonsJsonToHtml(res)
+ marshalDashboardComponentsJsonToHtml(res)
refreshServerConnectionLabel() // in-case it changed, update the label quicker
}).catch((err) => { // err is 1st arg
@@ -142,8 +123,8 @@ function processWebuiSettingsJson (settings) {
}
function main () {
- setupSections()
setupLogSearchBox()
+ setupSectionNavigation('sidebar')
window.fetch('webUiSettings.json').then(res => {
return res.json()
diff --git a/webui/style.css b/webui/style.css
index 1e4501d2..4b695f00 100644
--- a/webui/style.css
+++ b/webui/style.css
@@ -1,28 +1,25 @@
body {
background-color: #dee3e7;
color: black;
- text-align: center;
font-family: sans-serif;
- padding: 0;
margin: 0;
+ padding: 0;
+ text-align: center;
}
dialog {
box-shadow: 0 0 6px 0 #444;
max-width: 600px;
- text-align: left;
padding: 1em;
+ text-align: left;
}
fieldset {
- padding: 0;
-}
-
-fieldset#root-group {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ grid-template-columns: repeat(auto-fit, 160px);
grid-template-rows: auto auto auto auto;
grid-gap: 1em;
+ padding: 0;
text-align: center;
border: 0;
}
@@ -62,48 +59,67 @@ footer a {
color: black;
}
-aside {
+nav.sidebar {
+ background-color: white;
position: absolute;
width: 180px;
- height: 100%;
+ height: 100vh;
left: 0;
top: 0;
- padding-top: 4em;
transition: 0.5s ease;
- background-color: white;
- border: 0 0 10px 0;
box-shadow: 0 0 10px 0 #444;
z-index: 3;
+ display: flex;
+ flex-direction: column;
}
-input:checked ~ aside {
+#navigation-links {
+ padding-top: 4em;
+ flex-grow: 1;
+}
+
+#supplemental-links {
+ flex-grow: 0;
+}
+
+input:checked ~ nav.sidebar {
left: -250px;
}
-aside ul {
+nav ul {
margin: 0;
padding: 0;
}
-aside ul li {
+nav ul li {
list-style: none;
text-align: left;
border-bottom: 1px inset black;
}
-aside ul li a {
+nav ul li a {
display: block;
padding-left: 1em;
padding-top: 0.5em;
padding-bottom: 0.5em;
+ user-select: none;
}
-aside ul li a:hover {
+nav ul li a:hover {
color: black;
background-color: #efefef;
cursor: pointer;
}
+nav.topbar {
+ background-color: white;
+}
+
+nav.topbar ul li {
+ display: inline-block;
+}
+
+
table {
background-color: white;
border-collapse: collapse;
@@ -204,8 +220,8 @@ action-button {
}
action-button button {
- width: 100%;
flex-grow: 1;
+ width: 100%;
z-index: 2;
}
@@ -307,6 +323,9 @@ img.logo {
main {
padding: 1em;
+}
+
+body.has-sidebar main {
padding-top: 3em;
}
@@ -417,6 +436,10 @@ div.toolbar * {
}
@media screen and (width <= 600px) {
+ fieldset {
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ }
+
label {
text-align: left;
margin-bottom: .6em;
@@ -464,7 +487,7 @@ div.toolbar * {
color: white;
}
- aside {
+ nav {
background-color: #111;
color: white;
}
@@ -474,7 +497,7 @@ div.toolbar * {
color: gray;
}
- aside ul li a:hover {
+ nav ul li a:hover {
background-color: #666;
color: white;
}