marshaller.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. import './ActionButton.js' // To define action-button
  2. import { ExecutionDialog } from './ExecutionDialog.js'
  3. import { Terminal } from '@xterm/xterm'
  4. import { FitAddon } from '@xterm/addon-fit'
  5. /**
  6. * This is a weird function that just sets some globals.
  7. */
  8. export function initMarshaller () {
  9. window.changeDirectory = changeDirectory
  10. window.showSection = showSection
  11. window.terminal = new Terminal({
  12. convertEol: true
  13. })
  14. const fitAddon = new FitAddon()
  15. window.terminal.loadAddon(fitAddon)
  16. window.terminal.fit = fitAddon
  17. window.executionDialog = new ExecutionDialog()
  18. window.logEntries = {}
  19. window.initialHash = window.location.hash
  20. window.currentSection = ''
  21. window.addEventListener('EventExecutionFinished', onExecutionFinished)
  22. window.addEventListener('EventOutputChunk', onOutputChunk)
  23. }
  24. export function marshalDashboardComponentsJsonToHtml (json) {
  25. marshalActionsJsonToHtml(json)
  26. marshalDashboardStructureToHtml(json)
  27. document.getElementById('username').innerText = json.authenticatedUser
  28. changeDirectory(null)
  29. document.body.setAttribute('initial-marshal-complete', 'true')
  30. }
  31. function marshalActionsJsonToHtml (json) {
  32. const currentIterationTimestamp = Date.now()
  33. window.actionButtons = {}
  34. for (const jsonButton of json.actions) {
  35. let htmlButton = window.actionButtons[jsonButton.id]
  36. if (typeof htmlButton === 'undefined') {
  37. htmlButton = document.createElement('action-button')
  38. htmlButton.constructFromJson(jsonButton)
  39. window.actionButtons[jsonButton.title] = htmlButton
  40. }
  41. htmlButton.updateFromJson(jsonButton)
  42. htmlButton.updateIterationTimestamp = currentIterationTimestamp
  43. }
  44. // Remove existing, but stale buttons (that were not updated in this round)
  45. for (const existingButton of document.querySelectorAll('action-button')) {
  46. if (existingButton.updateIterationTimestamp !== currentIterationTimestamp) {
  47. existingButton.remove()
  48. }
  49. }
  50. }
  51. function onOutputChunk (evt) {
  52. const chunk = evt.payload
  53. if (chunk.executionTrackingId === window.executionDialog.executionTrackingId) {
  54. window.terminal.write(chunk.output)
  55. window.executionDialog.showOutput()
  56. }
  57. }
  58. function onExecutionFinished (evt) {
  59. const logEntry = evt.payload.logEntry
  60. const actionButton = window.actionButtons[logEntry.actionTitle]
  61. if (actionButton === undefined) {
  62. return
  63. }
  64. switch (actionButton.popupOnStart) {
  65. case 'execution-button':
  66. document.querySelector('execution-button#execution-' + logEntry.executionTrackingId).onExecutionFinished(logEntry)
  67. break
  68. case 'execution-dialog-stdout-only':
  69. case 'execution-dialog':
  70. actionButton.onExecutionFinished(logEntry)
  71. // We don't need to fetch the logEntry for the dialog because we already
  72. // have it, so we open the dialog and it will get updated below.
  73. window.executionDialog.show()
  74. window.executionDialog.executionTrackingId = logEntry.uuid
  75. break
  76. default:
  77. actionButton.onExecutionFinished(logEntry)
  78. break
  79. }
  80. marshalLogsJsonToHtml({
  81. logs: [logEntry]
  82. })
  83. // If the current execution dialog is open, update that too
  84. if (window.executionDialog.dlg.open && window.executionDialog.executionUuid === logEntry.uuid) {
  85. window.executionDialog.renderExecutionResult({
  86. logEntry: logEntry
  87. })
  88. }
  89. }
  90. function showSection (title) {
  91. title = title.replaceAll(' ', '')
  92. window.currentSection = title
  93. for (const section of document.querySelectorAll('section')) {
  94. if (section.title === title) {
  95. section.style.display = 'block'
  96. } else {
  97. section.style.display = 'none'
  98. }
  99. }
  100. setSectionNavigationVisible(false)
  101. changeDirectory(null)
  102. }
  103. function setSectionNavigationVisible (visible) {
  104. const nav = document.querySelector('nav')
  105. const btn = document.getElementById('sidebar-toggler-button')
  106. if (document.body.classList.contains('has-sidebar')) {
  107. if (visible) {
  108. btn.setAttribute('aria-pressed', false)
  109. btn.setAttribute('aria-label', 'Open sidebar navigation')
  110. btn.innerHTML = '«'
  111. nav.classList.add('shown')
  112. nav.style.display = 'flex'
  113. } else {
  114. btn.setAttribute('aria-pressed', true)
  115. btn.setAttribute('aria-label', 'Close sidebar navigation')
  116. btn.innerHTML = '☰'
  117. nav.classList.remove('shown')
  118. setTimeout(() => {
  119. nav.style.display = 'none'
  120. }, 600)
  121. }
  122. } else {
  123. btn.disabled = true
  124. }
  125. }
  126. export function setupSectionNavigation (style) {
  127. const nav = document.querySelector('nav')
  128. const btn = document.getElementById('sidebar-toggler-button')
  129. if (style === 'sidebar') {
  130. nav.classList.add('sidebar')
  131. document.body.classList.add('has-sidebar')
  132. btn.onclick = () => {
  133. if (nav.classList.contains('shown')) {
  134. setSectionNavigationVisible(false)
  135. } else {
  136. setSectionNavigationVisible(true)
  137. }
  138. }
  139. } else {
  140. nav.classList.add('topbar')
  141. document.body.classList.add('has-topbar')
  142. }
  143. document.getElementById('showActions').onclick = () => { showSection('Actions') }
  144. document.getElementById('showLogs').onclick = () => { showSection('Logs') }
  145. }
  146. function marshalDashboardStructureToHtml (json) {
  147. const nav = document.getElementById('navigation-links')
  148. for (const dashboard of json.dashboards) {
  149. const oldsection = document.querySelector('section[title="' + dashboard.title.replace(' ', '') + '"]')
  150. if (oldsection != null) {
  151. oldsection.remove()
  152. }
  153. const section = document.createElement('section')
  154. section.title = dashboard.title.replaceAll(' ', '')
  155. const def = createFieldset('default', section)
  156. section.appendChild(def)
  157. document.getElementsByTagName('main')[0].appendChild(section)
  158. marshalContainerContents(dashboard, section, def, dashboard.title)
  159. const oldLi = nav.querySelector('li[title="' + dashboard.title + '"]')
  160. if (oldLi != null) {
  161. oldLi.remove()
  162. }
  163. const navigationA = document.createElement('a')
  164. navigationA.title = dashboard.title
  165. navigationA.innerText = dashboard.title
  166. navigationA.setAttribute('href', '#' + dashboard.title.replace(' ', ''))
  167. navigationA.onclick = () => {
  168. showSection(dashboard.title.replace(' ', ''))
  169. }
  170. const navigationLi = document.createElement('li')
  171. navigationLi.appendChild(navigationA)
  172. navigationLi.title = dashboard.title
  173. document.getElementById('navigation-links').appendChild(navigationLi)
  174. }
  175. const rootGroup = document.querySelector('#root-group')
  176. for (const btn of Object.values(window.actionButtons)) {
  177. if (btn.parentElement === null) {
  178. rootGroup.appendChild(btn)
  179. }
  180. }
  181. if (window.currentSection !== '') {
  182. showSection(window.currentSection)
  183. } else if (window.initialHash !== '' && document.body.getAttribute('initial-marshal-complete') === null) {
  184. showSection(window.initialHash.replace('#', ''))
  185. } else {
  186. if (rootGroup.querySelectorAll('action-button').length === 0 && json.dashboards.length > 0) {
  187. nav.querySelector('li[title="Actions"]').style.display = 'none'
  188. showSection(json.dashboards[0].title)
  189. } else {
  190. showSection('Actions')
  191. }
  192. }
  193. }
  194. function marshalLink (item, fieldset) {
  195. let btn = window.actionButtons[item.title]
  196. if (typeof btn === 'undefined') {
  197. btn = document.createElement('button')
  198. btn.innerText = 'Action not found: ' + item.title
  199. btn.classList.add('error')
  200. }
  201. fieldset.appendChild(btn)
  202. }
  203. function marshalMreOutput (dashboardComponent, fieldset) {
  204. const pre = document.createElement('pre')
  205. pre.classList.add('mre-output')
  206. pre.innerHTML = 'Waiting...'
  207. const executionStatus = {
  208. actionId: dashboardComponent.title
  209. }
  210. window.fetch(window.restBaseUrl + 'ExecutionStatus', {
  211. method: 'POST',
  212. headers: {
  213. 'Content-Type': 'application/json'
  214. },
  215. body: JSON.stringify(executionStatus)
  216. }).then((res) => {
  217. if (res.ok) {
  218. return res.json()
  219. } else {
  220. pre.innerHTML = 'error'
  221. throw new Error(res.statusText)
  222. }
  223. }).then((json) => {
  224. updateMre(pre, json.logEntry)
  225. })
  226. const updateMre = (pre, json) => {
  227. pre.innerHTML = json.stdout
  228. }
  229. window.addEventListener('ExecutionFinished', (e) => {
  230. // The dashboard component "title" field is used for lots of things
  231. // and in this context for MreOutput it's just to refer an an actionId.
  232. //
  233. // So this is not a typo.
  234. if (e.payload.actionId === dashboardComponent.title) {
  235. updateMre(pre, e.payload)
  236. }
  237. })
  238. fieldset.appendChild(pre)
  239. }
  240. function marshalContainerContents (json, section, fieldset, parentDashboard) {
  241. for (const item of json.contents) {
  242. switch (item.type) {
  243. case 'fieldset':
  244. marshalFieldset(item, section, parentDashboard)
  245. break
  246. case 'directory':
  247. marshalDirectoryButton(item, fieldset)
  248. marshalDirectory(item, section)
  249. break
  250. case 'display':
  251. marshalDisplay(item, fieldset)
  252. break
  253. case 'stdout-most-recent-execution':
  254. marshalMreOutput(item, fieldset)
  255. break
  256. case 'link':
  257. marshalLink(item, fieldset)
  258. break
  259. default:
  260. }
  261. }
  262. }
  263. function createFieldset (title, parentDashboard) {
  264. const legend = document.createElement('legend')
  265. legend.innerText = title
  266. const fs = document.createElement('fieldset')
  267. fs.title = title
  268. fs.appendChild(legend)
  269. if (typeof parentDashboard === 'undefined') {
  270. fs.setAttribute('parent-dashboard', '')
  271. } else {
  272. fs.setAttribute('parent-dashboard', parentDashboard)
  273. }
  274. return fs
  275. }
  276. function marshalFieldset (item, section, parentDashboard) {
  277. const fs = createFieldset(item.title, parentDashboard)
  278. marshalContainerContents(item, section, fs)
  279. section.appendChild(fs)
  280. }
  281. function changeDirectory (selected) {
  282. if (selected === '') {
  283. selected = null
  284. }
  285. if (selected === null) {
  286. window.directoryNavigation = []
  287. } else if (selected === '..') {
  288. window.directoryNavigation.pop()
  289. if (window.directoryNavigation.length > 0) {
  290. selected = window.directoryNavigation[window.directoryNavigation.length - 1]
  291. } else {
  292. selected = null
  293. }
  294. } else {
  295. // If the selected item is already in the nav list, pop elements until we get
  296. // "back" to the existing nav item
  297. while (window.directoryNavigation.includes(selected)) {
  298. window.directoryNavigation.pop()
  299. }
  300. window.directoryNavigation.push(selected)
  301. }
  302. for (const fieldset of document.querySelectorAll('fieldset')) {
  303. if (selected === null) {
  304. if ((fieldset.id === 'root-group' || fieldset.getAttribute('parent-dashboard') !== '') && fieldset.children.length > 1) {
  305. fieldset.style.display = 'grid'
  306. } else {
  307. fieldset.style.display = 'none'
  308. }
  309. } else {
  310. if (fieldset.title === selected) {
  311. fieldset.style.display = 'grid'
  312. } else {
  313. fieldset.style.display = 'none'
  314. }
  315. }
  316. }
  317. const title = document.querySelector('h1')
  318. title.innerHTML = ''
  319. const rootLink = createDirectoryBreadcrumb(window.pageTitle, null)
  320. title.appendChild(rootLink)
  321. for (const dir of window.directoryNavigation) {
  322. const sep = document.createElement('span')
  323. sep.innerHTML = ' » '
  324. title.append(sep)
  325. if (dir === selected) {
  326. title.append(selected)
  327. } else {
  328. title.appendChild(createDirectoryBreadcrumb(dir))
  329. }
  330. }
  331. document.title = title.innerText
  332. if (selected === null) {
  333. window.history.pushState({ dir: null }, null, '#')
  334. } else {
  335. window.history.pushState({ dir: selected }, null, '#' + selected)
  336. }
  337. }
  338. function createDirectoryBreadcrumb (title, link) {
  339. const a = document.createElement('a')
  340. a.innerText = title
  341. a.title = title
  342. if (typeof link === 'undefined') {
  343. link = title
  344. }
  345. if (link === null) {
  346. a.href = '#'
  347. } else {
  348. a.href = '#' + link
  349. }
  350. a.onclick = () => {
  351. changeDirectory(link)
  352. }
  353. return a
  354. }
  355. function marshalDisplay (item, fieldset) {
  356. const display = document.createElement('div')
  357. display.innerHTML = item.title
  358. display.classList.add('display')
  359. fieldset.appendChild(display)
  360. }
  361. function marshalDirectoryButton (item, fieldset) {
  362. const directoryButton = document.createElement('button')
  363. directoryButton.innerHTML = '<span class = "icon">' + item.icon + '</span> ' + item.title
  364. directoryButton.onclick = () => {
  365. changeDirectory(item.title)
  366. }
  367. fieldset.appendChild(directoryButton)
  368. }
  369. function marshalDirectory (item, section) {
  370. const fs = createFieldset(item.title)
  371. fs.style.display = 'none'
  372. const directoryBackButton = document.createElement('button')
  373. directoryBackButton.innerHTML = window.settings.DefaultIconForBack
  374. directoryBackButton.title = 'Go back one directory'
  375. directoryBackButton.onclick = () => {
  376. changeDirectory('..')
  377. }
  378. fs.appendChild(directoryBackButton)
  379. marshalContainerContents(item, section, fs)
  380. section.appendChild(fs)
  381. }
  382. export function marshalLogsJsonToHtml (json) {
  383. for (const logEntry of json.logs) {
  384. const existing = window.logEntries[logEntry.executionTrackingId]
  385. if (existing !== undefined) {
  386. continue
  387. }
  388. window.logEntries[logEntry.executionTrackingId] = logEntry
  389. const tpl = document.getElementById('tplLogRow')
  390. const row = tpl.content.querySelector('tr').cloneNode(true)
  391. let logTableExitCode = logEntry.exitCode
  392. if (logEntry.exitCode === 0) {
  393. logTableExitCode = 'OK'
  394. }
  395. if (logEntry.timedOut) {
  396. logTableExitCode += ' (timed out)'
  397. }
  398. row.querySelector('.timestamp').innerText = logEntry.datetimeStarted
  399. row.querySelector('.content').innerText = logEntry.actionTitle
  400. row.querySelector('.icon').innerHTML = logEntry.actionIcon
  401. row.querySelector('.exit-code').innerText = logTableExitCode
  402. row.setAttribute('title', logEntry.actionTitle)
  403. row.querySelector('.content').onclick = () => {
  404. window.executionDialog.reset()
  405. window.executionDialog.show()
  406. window.executionDialog.renderExecutionResult({
  407. logEntry: window.logEntries[logEntry.executionTrackingId]
  408. })
  409. }
  410. for (const tag of logEntry.tags) {
  411. const domTag = document.createElement('span')
  412. domTag.classList.add('tag')
  413. domTag.innerText = tag
  414. row.querySelector('.tags').append(domTag)
  415. }
  416. document.querySelector('#logTableBody').prepend(row)
  417. }
  418. }
  419. window.addEventListener('popstate', (e) => {
  420. e.preventDefault()
  421. if (e.state != null && typeof e.state.dir !== 'undefined') {
  422. changeDirectory(e.state.dir)
  423. }
  424. })
  425. export function refreshServerConnectionLabel () {
  426. if (window.restAvailable) {
  427. document.querySelector('#serverConnectionRest').classList.remove('error')
  428. } else {
  429. document.querySelector('#serverConnectionRest').classList.add('error')
  430. }
  431. if (window.websocketAvailable) {
  432. document.querySelector('#serverConnectionWebSocket').classList.remove('error')
  433. document.querySelector('#serverConnectionWebSocket').innerText = 'WebSocket'
  434. } else {
  435. document.querySelector('#serverConnectionWebSocket').classList.add('error')
  436. document.querySelector('#serverConnectionWebSocket').innerText = 'WebSocket Error'
  437. }
  438. }