From 6892a679ee8f771fc207f85879a26e7cfbce676e Mon Sep 17 00:00:00 2001 From: James Read Date: Wed, 7 Feb 2024 09:54:22 +0100 Subject: [PATCH] feature: Dashboards, at long last (#224) * feature: Dashboards, at long last * fmt: action button IDs now use hypens. Removed ; --- OliveTin.proto | 10 +- integration-tests/test/multipleDropdowns.js | 8 +- internal/config/config.go | 16 +- internal/grpcapi/grpcApi.go | 2 + internal/grpcapi/grpcApiDashboard.go | 42 +++ internal/httpservers/webuiServer.go | 34 ++- webui/index.html | 21 +- webui/js/ActionButton.js | 3 +- webui/js/marshaller.js | 314 +++++++++++++++++++- webui/main.js | 25 +- webui/style.css | 65 ++-- 11 files changed, 452 insertions(+), 88 deletions(-) create mode 100644 internal/grpcapi/grpcApiDashboard.go 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

+ + 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; }