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

View File

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

View File

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

View File

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

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 {
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))

View File

@ -24,16 +24,16 @@
<h1 id = "page-title">OliveTin</h1>
</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">
<input type = "checkbox" id = "hide-sidebar-checkbox" hidden checked />
<aside>
<ul>
<li><a id = "showActions">Actions</a></li>
<li><a id = "showLogs">Logs</a></li>
</ul>
</aside>
</div>
<ul id = "supplemental-links">
<li><a id = "showLogs">Logs</a></li>
</ul>
</nav>
<section id = "contentLogs" title = "Logs" hidden>
<div class = "toolbar">
@ -53,7 +53,8 @@
</section>
<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>
</section>

View File

@ -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) {

View File

@ -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 = ' &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) {
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)
}
})

View File

@ -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()

View File

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