feature: Dashboards, at long last (#224)

* feature: Dashboards, at long last

* fmt: action button IDs now use hypens. Removed ;
This commit is contained in:
James Read 2024-02-07 09:54:22 +01:00 committed by GitHub
parent 1b13a2bc4b
commit 6892a679ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 452 additions and 88 deletions

View File

@ -10,7 +10,7 @@ message Action {
string icon = 3; string icon = 3;
bool can_exec = 4; bool can_exec = 4;
repeated ActionArgument arguments = 5; repeated ActionArgument arguments = 5;
bool popup_on_start = 6; string popup_on_start = 6;
} }
message ActionArgument { message ActionArgument {
@ -39,10 +39,18 @@ message GetDashboardComponentsResponse {
string title = 1; string title = 1;
repeated Action actions = 2; repeated Action actions = 2;
repeated Entity entities = 3; repeated Entity entities = 3;
repeated DashboardItem dashboards = 4;
} }
message GetDashboardComponentsRequest {} message GetDashboardComponentsRequest {}
message DashboardItem {
string title = 1;
string type = 2;
repeated DashboardItem contents = 3;
string link = 4;
}
message StartActionRequest { message StartActionRequest {
string action_name = 1; string action_name = 1;

View File

@ -12,15 +12,15 @@ describe('config: multipleDropdowns', function () {
it('Multiple dropdowns are possible', async function() { it('Multiple dropdowns are possible', async function() {
await webdriver.get(runner.baseUrl()) 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() 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) await webdriver.wait(until.elementIsVisible(dialog), 2000)

View File

@ -18,7 +18,7 @@ type Action struct {
ExecOnFileChangedInDir []string ExecOnFileChangedInDir []string
MaxConcurrent int MaxConcurrent int
Arguments []ActionArgument Arguments []ActionArgument
PopupOnStart bool PopupOnStart string
} }
// ActionArgument objects appear on Actions. // ActionArgument objects appear on Actions.
@ -72,6 +72,7 @@ type Config struct {
ExternalRestAddress string ExternalRestAddress string
LogLevel string LogLevel string
Actions []Action `mapstructure:"actions"` Actions []Action `mapstructure:"actions"`
Dashboards []DashboardItem `mapstructure:"dashboards"`
Entities []Entity `mapstructure:"entities"` Entities []Entity `mapstructure:"entities"`
CheckForUpdates bool CheckForUpdates bool
PageTitle string PageTitle string
@ -89,6 +90,14 @@ type Config struct {
AccessControlLists []AccessControlList AccessControlLists []AccessControlList
WebUIDir string WebUIDir string
CronSupportForSeconds bool 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. // DefaultConfig gets a new Config structure with sensible default values.
@ -112,6 +121,7 @@ func DefaultConfig() *Config {
config.AuthJwtClaimUserGroup = "group" config.AuthJwtClaimUserGroup = "group"
config.WebUIDir = "./webui" config.WebUIDir = "./webui"
config.CronSupportForSeconds = false config.CronSupportForSeconds = false
config.SectionNavigationStyle = "sidebar"
return &config return &config
} }

View File

@ -214,6 +214,8 @@ func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *pb.GetDashb
log.Tracef("GetDashboardComponents: %v", res) log.Tracef("GetDashboardComponents: %v", res)
dashboardCfgToPb(res, cfg.Dashboards)
return res, nil return res, nil
} }

View File

@ -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
}

View File

@ -20,6 +20,7 @@ type webUISettings struct {
AvailableVersion string AvailableVersion string
CurrentVersion string CurrentVersion string
PageTitle string PageTitle string
SectionNavigationStyle string
} }
func findWebuiDir() string { func findWebuiDir() string {
@ -57,6 +58,7 @@ func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
AvailableVersion: updatecheck.AvailableVersion, AvailableVersion: updatecheck.AvailableVersion,
CurrentVersion: updatecheck.CurrentVersion, CurrentVersion: updatecheck.CurrentVersion,
PageTitle: cfg.PageTitle, PageTitle: cfg.PageTitle,
SectionNavigationStyle: cfg.SectionNavigationStyle,
}) })
_, err := w.Write([]byte(jsonRet)) _, err := w.Write([]byte(jsonRet))

View File

@ -24,16 +24,16 @@
<h1 id = "page-title">OliveTin</h1> <h1 id = "page-title">OliveTin</h1>
</div> </div>
<div id = "content-sidebar">
<input type = "checkbox" id = "hide-sidebar-checkbox" hidden checked /> <input type = "checkbox" id = "hide-sidebar-checkbox" hidden checked />
<aside> <nav hidden>
<ul> <ul id = "navigation-links">
<li><a id = "showActions">Actions</a></li> <li><a id = "showActions">Actions</a></li>
</ul>
<ul id = "supplemental-links">
<li><a id = "showLogs">Logs</a></li> <li><a id = "showLogs">Logs</a></li>
</ul> </ul>
</aside> </nav>
</div>
<section id = "contentLogs" title = "Logs" hidden> <section id = "contentLogs" title = "Logs" hidden>
<div class = "toolbar"> <div class = "toolbar">
@ -53,7 +53,8 @@
</section> </section>
<section id = "contentActions" title = "Actions" hidden > <section id = "contentActions" title = "Actions" hidden >
<fieldset id = "root-group" title = "Dashboard of buttons"> <fieldset id = "root-group" title = "Actions">
<legend hidden>Actions</legend>
</fieldset> </fieldset>
</section> </section>

View File

@ -40,7 +40,7 @@ class ActionButton extends window.HTMLElement {
this.domTitle.innerText = this.btn.title this.domTitle.innerText = this.btn.title
this.domIcon.innerHTML = this.unicodeIcon this.domIcon.innerHTML = this.unicodeIcon
this.setAttribute('id', 'actionButton_' + json.id) this.setAttribute('id', 'actionButton-' + json.id)
} }
updateFromJson (json) { updateFromJson (json) {
@ -68,7 +68,6 @@ class ActionButton extends window.HTMLElement {
startAction (actionArgs) { startAction (actionArgs) {
// this.btn.disabled = true // this.btn.disabled = true
// this.isWaiting = true // this.isWaiting = true
// this.updateDom()
this.btn.classList = [] // Removes old animation classes this.btn.classList = [] // Removes old animation classes
if (actionArgs === undefined) { if (actionArgs === undefined) {

View File

@ -1,32 +1,321 @@
import './ActionButton.js' // To define action-button 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() const currentIterationTimestamp = Date.now()
for (const jsonButton of json.actions) { window.actionButtons = {}
let htmlButton = document.querySelector('#execution-' + jsonButton.id)
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 = document.createElement('action-button')
htmlButton.constructFromJson(jsonButton) htmlButton.constructFromJson(jsonButton)
document.getElementById('root-group').appendChild(htmlButton) window.actionButtons[jsonButton.title] = htmlButton
} else {
htmlButton.updateFromJson(jsonButton)
htmlButton.updateDom()
} }
htmlButton.updateFromJson(jsonButton)
htmlButton.updateIterationTimestamp = currentIterationTimestamp htmlButton.updateIterationTimestamp = currentIterationTimestamp
} }
// Remove existing, but stale buttons (that were not updated in this round) // 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) { if (existingButton.updateIterationTimestamp !== currentIterationTimestamp) {
existingButton.remove() 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 = ' &raquo; '
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 = '<span class = "icon">&#128193;</span> ' + 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 = '&laquo;'
directoryBackButton.title = 'Go back one directory'
directoryBackButton.onclick = () => {
changeDirectory('..')
}
fs.appendChild(directoryBackButton)
marshalContainerContents(item, section, fs)
section.appendChild(fs)
}
export function marshalLogsJsonToHtml (json) { export function marshalLogsJsonToHtml (json) {
for (const logEntry of json.logs) { for (const logEntry of json.logs) {
const tpl = document.getElementById('tplLogRow') const tpl = document.getElementById('tplLogRow')
@ -69,3 +358,10 @@ export function marshalLogsJsonToHtml (json) {
document.querySelector('#logTableBody').prepend(row) document.querySelector('#logTableBody').prepend(row)
} }
} }
window.addEventListener('popstate', (e) => {
e.preventDefault()
if (e.state != null && typeof e.state.dir !== 'undefined') {
changeDirectory(e.state.dir)
}
})

View File

@ -1,6 +1,6 @@
'use strict' 'use strict'
import { marshalActionButtonsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js' import { setupSectionNavigation, marshalDashboardComponentsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js'
import { checkWebsocketConnection } from './js/websocket.js' import { checkWebsocketConnection } from './js/websocket.js'
function searchLogs (e) { function searchLogs (e) {
@ -24,25 +24,6 @@ function searchLogsClear () {
document.getElementById('logSearchBox').value = '' 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 () { function setupLogSearchBox () {
document.getElementById('logSearchBox').oninput = searchLogs document.getElementById('logSearchBox').oninput = searchLogs
document.getElementById('searchLogsClear').onclick = searchLogsClear document.getElementById('searchLogsClear').onclick = searchLogsClear
@ -91,7 +72,7 @@ function fetchGetDashboardComponents () {
} }
window.restAvailable = true window.restAvailable = true
marshalActionButtonsJsonToHtml(res) marshalDashboardComponentsJsonToHtml(res)
refreshServerConnectionLabel() // in-case it changed, update the label quicker refreshServerConnectionLabel() // in-case it changed, update the label quicker
}).catch((err) => { // err is 1st arg }).catch((err) => { // err is 1st arg
@ -142,8 +123,8 @@ function processWebuiSettingsJson (settings) {
} }
function main () { function main () {
setupSections()
setupLogSearchBox() setupLogSearchBox()
setupSectionNavigation('sidebar')
window.fetch('webUiSettings.json').then(res => { window.fetch('webUiSettings.json').then(res => {
return res.json() return res.json()

View File

@ -1,28 +1,25 @@
body { body {
background-color: #dee3e7; background-color: #dee3e7;
color: black; color: black;
text-align: center;
font-family: sans-serif; font-family: sans-serif;
padding: 0;
margin: 0; margin: 0;
padding: 0;
text-align: center;
} }
dialog { dialog {
box-shadow: 0 0 6px 0 #444; box-shadow: 0 0 6px 0 #444;
max-width: 600px; max-width: 600px;
text-align: left;
padding: 1em; padding: 1em;
text-align: left;
} }
fieldset { fieldset {
padding: 0;
}
fieldset#root-group {
display: grid; 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-template-rows: auto auto auto auto;
grid-gap: 1em; grid-gap: 1em;
padding: 0;
text-align: center; text-align: center;
border: 0; border: 0;
} }
@ -62,48 +59,67 @@ footer a {
color: black; color: black;
} }
aside { nav.sidebar {
background-color: white;
position: absolute; position: absolute;
width: 180px; width: 180px;
height: 100%; height: 100vh;
left: 0; left: 0;
top: 0; top: 0;
padding-top: 4em;
transition: 0.5s ease; transition: 0.5s ease;
background-color: white;
border: 0 0 10px 0;
box-shadow: 0 0 10px 0 #444; box-shadow: 0 0 10px 0 #444;
z-index: 3; 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; left: -250px;
} }
aside ul { nav ul {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
aside ul li { nav ul li {
list-style: none; list-style: none;
text-align: left; text-align: left;
border-bottom: 1px inset black; border-bottom: 1px inset black;
} }
aside ul li a { nav ul li a {
display: block; display: block;
padding-left: 1em; padding-left: 1em;
padding-top: 0.5em; padding-top: 0.5em;
padding-bottom: 0.5em; padding-bottom: 0.5em;
user-select: none;
} }
aside ul li a:hover { nav ul li a:hover {
color: black; color: black;
background-color: #efefef; background-color: #efefef;
cursor: pointer; cursor: pointer;
} }
nav.topbar {
background-color: white;
}
nav.topbar ul li {
display: inline-block;
}
table { table {
background-color: white; background-color: white;
border-collapse: collapse; border-collapse: collapse;
@ -204,8 +220,8 @@ action-button {
} }
action-button button { action-button button {
width: 100%;
flex-grow: 1; flex-grow: 1;
width: 100%;
z-index: 2; z-index: 2;
} }
@ -307,6 +323,9 @@ img.logo {
main { main {
padding: 1em; padding: 1em;
}
body.has-sidebar main {
padding-top: 3em; padding-top: 3em;
} }
@ -417,6 +436,10 @@ div.toolbar * {
} }
@media screen and (width <= 600px) { @media screen and (width <= 600px) {
fieldset {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
label { label {
text-align: left; text-align: left;
margin-bottom: .6em; margin-bottom: .6em;
@ -464,7 +487,7 @@ div.toolbar * {
color: white; color: white;
} }
aside { nav {
background-color: #111; background-color: #111;
color: white; color: white;
} }
@ -474,7 +497,7 @@ div.toolbar * {
color: gray; color: gray;
} }
aside ul li a:hover { nav ul li a:hover {
background-color: #666; background-color: #666;
color: white; color: white;
} }