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.
@ -71,8 +71,9 @@ type Config struct {
ListenAddressGrpcActions string ListenAddressGrpcActions string
ExternalRestAddress string ExternalRestAddress string
LogLevel string LogLevel string
Actions []Action `mapstructure:"actions"` Actions []Action `mapstructure:"actions"`
Entities []Entity `mapstructure:"entities"` Dashboards []DashboardItem `mapstructure:"dashboards"`
Entities []Entity `mapstructure:"entities"`
CheckForUpdates bool CheckForUpdates bool
PageTitle string PageTitle string
ShowFooter bool ShowFooter bool
@ -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

@ -12,14 +12,15 @@ import (
) )
type webUISettings struct { type webUISettings struct {
Rest string Rest string
ThemeName string ThemeName string
ShowFooter bool ShowFooter bool
ShowNavigation bool ShowNavigation bool
ShowNewVersions bool ShowNewVersions bool
AvailableVersion string AvailableVersion string
CurrentVersion string CurrentVersion string
PageTitle string PageTitle string
SectionNavigationStyle string
} }
func findWebuiDir() string { func findWebuiDir() string {
@ -49,14 +50,15 @@ func findWebuiDir() string {
func generateWebUISettings(w http.ResponseWriter, r *http.Request) { func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
jsonRet, _ := json.Marshal(webUISettings{ jsonRet, _ := json.Marshal(webUISettings{
Rest: cfg.ExternalRestAddress + "/api/", Rest: cfg.ExternalRestAddress + "/api/",
ThemeName: cfg.ThemeName, ThemeName: cfg.ThemeName,
ShowFooter: cfg.ShowFooter, ShowFooter: cfg.ShowFooter,
ShowNavigation: cfg.ShowNavigation, ShowNavigation: cfg.ShowNavigation,
ShowNewVersions: cfg.ShowNewVersions, ShowNewVersions: cfg.ShowNewVersions,
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>
<input type = "checkbox" id = "hide-sidebar-checkbox" hidden checked />
<nav hidden>
<ul id = "navigation-links">
<li><a id = "showActions">Actions</a></li>
</ul>
<div id = "content-sidebar"> <ul id = "supplemental-links">
<input type = "checkbox" id = "hide-sidebar-checkbox" hidden checked /> <li><a id = "showLogs">Logs</a></li>
<aside> </ul>
<ul> </nav>
<li><a id = "showActions">Actions</a></li>
<li><a id = "showLogs">Logs</a></li>
</ul>
</aside>
</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;
} }