Explorar el Código

feature: Dashboards, at long last (#224)

* feature: Dashboards, at long last

* fmt: action button IDs now use hypens. Removed ;
James Read hace 2 años
padre
commit
6892a679ee

+ 9 - 1
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;
 

+ 4 - 4
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)
 

+ 13 - 3
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
 }

+ 2 - 0
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
 }
 

+ 42 - 0
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
+}

+ 18 - 16
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))

+ 11 - 10
webui/index.html

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

+ 1 - 2
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) {

+ 304 - 8
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()
 
+  window.actionButtons = {}
+
   for (const jsonButton of json.actions) {
-    let htmlButton = document.querySelector('#execution-' + jsonButton.id)
+    let htmlButton = window.actionButtons[jsonButton.id]
 
-    if (htmlButton == null) {
+    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)
+  }
+})

+ 3 - 22
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()

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