chore: Port huge amount of code to OliveTin 3k

This commit is contained in:
jamesread
2025-08-20 00:05:40 +01:00
parent 17c716c599
commit 6b342cbedb
59 changed files with 3973 additions and 3290 deletions

View File

@@ -75,42 +75,6 @@
</div>
</dialog>
<template id = "tplLoginForm">
<section id = "content-login" title = "Login" hidden>
<div class = "flex-col">
<form class = "box-shadow padded-content border-radius" id = "local-user-login">
<p class = "login-disabled">This server is not configured with either OAuth, or local users, so you cannot login.</p>
<div class = "login-oauth2" hidden>
<h2>OAuth Login</h2>
</div>
<br />
<div class = "login-local" hidden>
<h2>Local Login</h2>
<div class = "error"></div>
<div class = "arguments">
<label for = "in-username">
<span>Username:</span>
</label>
<input type = "text" name = "username" id = "in-username" class = "username" autocomplete = "username"/>
<span></span>
<label for = "in-password">
<span>Password:</span>
</label>
<input type = "password" name = "password" id = "in-password" class = "password" />
<span></span>
<button type = "submit">Login</button>
</div>
</div>
</form>
</div>
</section>
</template>
<template id = "tplArgumentForm">
<dialog title = "Arguments" id = "argument-popup">
<form class = "action-arguments padded-content">

View File

@@ -66,6 +66,10 @@ export class OutputTerminal {
this.terminal.open(el)
}
close () {
this.terminal.dispose()
}
resize (cols, rows) {
this.terminal.resize(cols, rows)
}

View File

@@ -1,117 +1,14 @@
function checkAndTriggerActionFromQueryParam () {
const params = getQueryParams()
const action = params.get('action')
if (action && window.actionButtons) {
// Look for an action button with matching title
const actionButton = window.actionButtons[action]
if (actionButton) {
// Only trigger actions that have arguments
const jsonButton = window.actionButtonsJson[action]
if (jsonButton && jsonButton.arguments && jsonButton.arguments.length > 0) {
// Trigger the action button click
setTimeout(() => {
actionButton.btn.click()
}, 500) // Small delay to ensure UI is fully loaded
return true
}
}
}
return false
}
function createElement (tag, attributes) {
const el = document.createElement(tag)
if (attributes !== null) {
if (attributes.classNames !== undefined) {
el.classList.add(...attributes.classNames)
}
if (attributes.innerText !== undefined) {
el.innerText = attributes.innerText
}
}
return el
}
function createTag (val) {
const domTag = createElement('span', {
innerText: val,
classNames: ['tag']
})
return domTag
}
function createAnnotation (key, val) {
const domAnnotation = createElement('span', {
classNames: ['annotation']
})
domAnnotation.appendChild(createElement('span', {
innerText: key,
classNames: ['annotation-key']
}))
domAnnotation.appendChild(createElement('span', {
innerText: val,
classNames: ['annotation-value']
}))
return domAnnotation
}
/**
* This is a weird function that just sets some globals.
*/
export function initMarshaller () {
window.logEntries = new Map()
window.addEventListener('EventExecutionStarted', onExecutionStarted)
window.addEventListener('EventExecutionFinished', onExecutionFinished)
window.addEventListener('EventOutputChunk', onOutputChunk)
}
function setUsername (username, provider) {
document.getElementById('username').innerText = username
document.getElementById('username').setAttribute('title', provider)
if (window.settings.AuthLocalLogin || window.settings.AuthOAuth2Providers !== null) {
if (username === 'guest') {
document.getElementById('link-login').hidden = false
document.getElementById('link-logout').hidden = true
} else {
document.getElementById('link-login').hidden = true
if (provider === 'local' || provider === 'oauth2') {
document.getElementById('link-logout').hidden = false
}
}
}
}
export function marshalDashboardComponentsJsonToHtml (json) {
if (json == null) { // eg: HTTP 403
setUsername('guest', 'system')
if (window.settings.AuthLoginUrl !== '') {
window.location = window.settings.AuthLoginUrl
} else {
showSection('/login')
}
} else {
setUsername(json.authenticatedUser, json.authenticatedUserProvider)
marshalDashboardStructureToHtml(json)
}
document.body.setAttribute('initial-marshal-complete', 'true')
}
function onOutputChunk (evt) {
const chunk = evt.payload
@@ -172,319 +69,3 @@ function onExecutionFinished (evt) {
})
}
}
function convertPathToBreadcrumb (path) {
const parts = path.split('/')
const result = []
for (let i = 0; i < parts.length; i++) {
if (parts[i] === '') {
continue
}
result.push(parts.slice(0, i + 1).join('/'))
}
return result
}
function showExecutionResult (pathName) {
const executionTrackingId = pathName.split('/')[2]
window.executionDialog.fetchExecutionResult(executionTrackingId)
window.executionDialog.show()
}
function setSectionNavigationVisible (visible) {
const nav = document.querySelector('nav')
const btn = document.getElementById('sidebar-toggler-button')
nav.removeAttribute('hidden')
if (document.body.classList.contains('has-sidebar')) {
if (visible) {
btn.setAttribute('aria-pressed', false)
btn.setAttribute('aria-label', 'Open sidebar navigation')
btn.innerHTML = '&laquo;'
nav.classList.add('shown')
} else {
btn.setAttribute('aria-pressed', true)
btn.setAttribute('aria-label', 'Close sidebar navigation')
btn.innerHTML = '&#9776;'
nav.classList.remove('shown')
}
} else {
btn.disabled = true
}
}
export function setupSectionNavigation (style) {
const nav = document.querySelector('nav')
const btn = document.getElementById('sidebar-toggler-button')
if (style === 'sidebar') {
nav.classList.add('sidebar')
document.body.classList.add('has-sidebar')
btn.onclick = () => {
if (nav.classList.contains('shown')) {
setSectionNavigationVisible(false)
} else {
setSectionNavigationVisible(true)
}
}
} else {
nav.classList.add('topbar')
document.body.classList.add('has-topbar')
}
}
function marshalSingleDashboard (dashboard, nav) {
const oldsection = document.querySelector('section[title="' + getSystemTitle(dashboard.title) + '"]')
if (oldsection != null) {
oldsection.remove()
}
const section = document.createElement('section')
section.setAttribute('system-title', getSystemTitle(dashboard.title))
section.title = section.getAttribute('system-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 systemTitleUrl = '/' + getSystemTitle(dashboard.title)
window.navbar.createLink(dashboard.title, systemTitleUrl, false)
}
function marshalDashboardStructureToHtml (json) {
return
const nav = document.getElementById('navigation-links')
for (const dashboard of json.dashboards) {
marshalSingleDashboard(dashboard, nav)
}
const rootGroup = document.querySelector('#root-group')
for (const btn of Object.values(window.actionButtons)) {
if (btn.parentElement === null) {
rootGroup.appendChild(btn)
}
}
const shouldHideActions = rootGroup.querySelectorAll('action-button').length === 0 && json.dashboards.length > 0
if (shouldHideActions) {
nav.querySelector('li[title="Actions"]').style.display = 'none'
}
if (window.currentPath !== '') {
showSection(window.currentPath)
} else if (window.location.pathname !== '/' && document.body.getAttribute('initial-marshal-complete') === null) {
showSection(window.location.pathname)
} else {
if (shouldHideActions) {
showSection('/' + getSystemTitle(json.dashboards[0].title))
} else {
showSection('/')
}
}
}
function marshalLink (item, fieldset) {
return
let btn = window.actionButtons[item.title]
if (typeof btn === 'undefined') {
btn = document.createElement('button')
btn.innerText = 'Action not found: ' + item.title
btn.classList.add('error')
}
if (item.cssClass !== '') {
btn.classList.add(item.cssClass)
}
fieldset.appendChild(btn)
}
function marshalMreOutput (dashboardComponent, fieldset) {
const pre = document.createElement('pre')
pre.classList.add('mre-output')
pre.innerHTML = 'Waiting...'
const args = {
actionId: dashboardComponent.title
}
try {
const status = window.client.executionStatus(args)
updateMre(pre, status.logEntry)
} catch (err) {
pre.innerHTML = 'error'
throw new Error(res.statusText)
}
const updateMre = (pre, json) => {
pre.innerHTML = json.output
}
window.addEventListener('ExecutionFinished', (e) => {
// The dashboard component "title" field is used for lots of things
// and in this context for MreOutput it's just to refer an an actionId.
//
// So this is not a typo.
if (e.payload.actionId === dashboardComponent.title) {
updateMre(pre, e.payload)
}
})
fieldset.appendChild(pre)
}
function marshalContainerContents (json, section, fieldset, parentDashboard) {
for (const item of json.contents) {
switch (item.type) {
case 'fieldset':
marshalFieldset(item, section, parentDashboard)
break
case 'directory': {
const directoryPath = marshalDirectory(item, section)
marshalDirectoryButton(item, fieldset, directoryPath)
}
break
case 'display':
marshalDisplay(item, fieldset)
break
case 'stdout-most-recent-execution':
marshalMreOutput(item, fieldset)
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, parentDashboard)
section.appendChild(fs)
}
function createNavigationBreadcrumbDisplay (path) {
const a = document.createElement('a')
a.href = 'javascript:void(0)'
if (path.view === null) {
a.title = path.section
a.innerText = path.section
} else {
a.innerText = path.view
a.title = path.view
}
a.onclick = () => {
showSectionView(path.view)
}
return a
}
function marshalDisplay (item, fieldset) {
const display = document.createElement('div')
display.innerHTML = item.title
display.classList.add('display')
if (item.cssClass !== '') {
display.classList.add(item.cssClass)
}
fieldset.appendChild(display)
}
function marshalDirectoryButton (item, fieldset, path) {
const directoryButton = document.createElement('button')
directoryButton.innerHTML = '<span class = "icon">' + item.icon + '</span> ' + item.title
directoryButton.onclick = () => {
showSection(path)
}
fieldset.appendChild(directoryButton)
}
function marshalDirectory (item, section) {
const fs = createFieldset(item.title)
fs.style.display = 'none'
const directoryBackButton = document.createElement('button')
directoryBackButton.innerHTML = window.settings.DefaultIconForBack
directoryBackButton.title = 'Go back one directory'
directoryBackButton.onclick = () => {
showSection('/' + section.title)
}
fs.appendChild(directoryBackButton)
marshalContainerContents(item, section, fs)
section.appendChild(fs)
const path = '/' + section.title + '/' + getSystemTitle(item.title)
return path
}
export function refreshServerConnectionLabel () {
if (window.restAvailable) {
document.querySelector('#serverConnectionRest').classList.remove('error')
} else {
document.querySelector('#serverConnectionRest').classList.add('error')
}
if (window.websocketAvailable) {
document.querySelector('#serverConnectionWebSocket').classList.remove('error')
document.querySelector('#serverConnectionWebSocket').innerText = 'WebSocket'
} else {
document.querySelector('#serverConnectionWebSocket').classList.add('error')
document.querySelector('#serverConnectionWebSocket').innerText = 'WebSocket Error'
}
}

View File

@@ -1,6 +1,4 @@
import {
refreshServerConnectionLabel
} from './marshaller.js'
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
export function checkWebsocketConnection () {
reconnectWebsocket()
@@ -29,8 +27,6 @@ async function reconnectWebsocket () {
function handleEvent (msg) {
const typeName = msg.event.value.$typeName.replace('olivetin.api.v1.', '')
console.log("Websocket event receved: ", typeName)
const j = new Event(typeName)
j.payload = msg.event.value
@@ -38,9 +34,12 @@ function handleEvent (msg) {
case 'EventOutputChunk':
case 'EventConfigChanged':
case 'EventEntityChanged':
window.dispatchEvent(j)
break
case 'EventExecutionFinished':
case 'EventExecutionStarted':
window.dispatchEvent(j)
console.log('EventExecutionStarted', msg.event.value.logEntry.executionTrackingId)
buttonResults[msg.event.value.logEntry.executionTrackingId] = msg.event.value.logEntry
break
default:
console.warn('Unhandled websocket message type from server: ', typeName)

View File

@@ -11,121 +11,10 @@ import App from './resources/vue/App.vue';
import {
initMarshaller,
setupSectionNavigation,
marshalDashboardComponentsJsonToHtml,
refreshServerConnectionLabel
} from './js/marshaller.js'
import { checkWebsocketConnection } from './js/websocket.js'
function searchLogs (e) {
document.getElementById('searchLogsClear').disabled = false
const searchText = e.target.value.toLowerCase()
for (const row of document.querySelectorAll('tr.log-row')) {
const actionTitle = row.getAttribute('title').toLowerCase()
row.hidden = !actionTitle.includes(searchText)
}
}
function searchLogsClear () {
for (const row of document.querySelectorAll('tr.log-row')) {
row.hidden = false
}
document.getElementById('searchLogsClear').disabled = true
document.getElementById('logSearchBox').value = ''
}
function refreshLoop () {
checkWebsocketConnection()
// fetchGetDashboardComponents()
// fetchGetLogs()
refreshServerConnectionLabel()
}
async function fetchGetDashboardComponents () {
try {
const res = await window.client.getDashboardComponents()
marshalDashboardComponentsJsonToHtml(res)
refreshServerConnectionLabel() // in-case it changed, update the label quicker
} catch(err) {
window.showBigError('fetch-buttons', 'getting buttons', err, false)
}
}
function processWebuiSettingsJson (settings) {
setupSectionNavigation(settings.SectionNavigationStyle)
window.restBaseUrl = settings.Rest
document.querySelector('#currentVersion').innerText = settings.CurrentVersion
if (settings.ShowNewVersions && settings.AvailableVersion !== 'none') {
document.querySelector('#available-version').innerText = 'New Version Available: ' + settings.AvailableVersion
document.querySelector('#available-version').hidden = false
}
if (!settings.ShowNavigation) {
document.querySelector('header').style.display = 'none'
}
if (!settings.ShowFooter) {
document.querySelector('footer[title="footer"]').style.display = 'none'
}
if (settings.EnableCustomJs) {
const script = document.createElement('script')
script.src = './custom-webui/custom.js'
document.head.appendChild(script)
}
window.pageTitle = 'OliveTin'
if (settings.PageTitle) {
window.pageTitle = settings.PageTitle
document.title = window.pageTitle
const titleElem = document.querySelector('#page-title')
if (titleElem) titleElem.innerText = window.pageTitle
}
processAdditionalLinks(settings.AdditionalLinks)
window.settings = settings
}
function processAdditionalLinks (links) {
if (links === null) {
return
}
if (links.length > 0) {
for (const link of links) {
const linkA = document.createElement('a')
linkA.href = link.Url
linkA.innerText = link.Title
if (link.Target === '') {
linkA.target = '_blank'
} else {
linkA.target = link.Target
}
const linkLi = document.createElement('li')
linkLi.appendChild(linkA)
document.getElementById('supplemental-links').prepend(linkLi)
}
}
}
function initClient () {
const transport = createConnectTransport({
baseUrl: window.location.protocol + '//' + window.location.host + '/api/',
@@ -143,30 +32,16 @@ function setupVue () {
}
function main () {
setupVue();
initClient()
checkWebsocketConnection()
setupVue();
initMarshaller()
window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
window.fetch('webUiSettings.json').then(res => {
return res.json()
}).then(res => {
processWebuiSettingsJson(res)
fetchGetDashboardComponents()
window.restAvailable = true
window.refreshLoop = refreshLoop
window.refreshLoop()
setInterval(refreshLoop, 3000)
}).catch(err => {
window.showBigError('fetch-webui-settings', 'getting webui settings', err)
})
// window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
// window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
}
main() // call self

View File

@@ -11,6 +11,8 @@
"dependencies": {
"@connectrpc/connect": "^2.0.3",
"@connectrpc/connect-web": "^2.0.3",
"@hugeicons/core-free-icons": "^1.0.16",
"@hugeicons/vue": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
@@ -722,6 +724,20 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/@hugeicons/core-free-icons": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-1.0.16.tgz",
"integrity": "sha512-rm48rjUN8a58yOZ7mpk3HkMvZPmOMTkMtNdXXq9m0Af6BsRQWJZl+4zd6ssj52y+A9Zn4Yg/TptobNtNpx3GCg==",
"license": "MIT"
},
"node_modules/@hugeicons/vue": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.3.tgz",
"integrity": "sha512-DF9A277Ej4Eahu11Hkd3v6V0eZ1NHWZWs9OOByJaxGekgG8q7DAbkhltIo3bqsoxVprxaKSX3Mmn5a2dyzLsHA==",
"peerDependencies": {
"vue": "^2.6.0 || ^3.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
@@ -2589,9 +2605,9 @@
}
},
"node_modules/femtocrank": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-1.2.2.tgz",
"integrity": "sha512-tXvXllBCZ1Wt5QRHr07+cSjsRr/lBqDWow7WhVn67zg7BE8MZ1Niv8BzSbx9c7JKYaso7OIVm02Yo/9wJYzhGw==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-1.2.4.tgz",
"integrity": "sha512-OAyowQ45LOwl7xWaOFyXlmP9MzdV9sQrTz1130vgqF2TxDkOx6y2uP8QK/+12MGMP0v7KkiTO8fE/TN6orb9fg==",
"license": "AGPL-3.0",
"dependencies": {
"vite": "^6.3.5"

View File

@@ -29,10 +29,12 @@
"dependencies": {
"@connectrpc/connect": "^2.0.3",
"@connectrpc/connect-web": "^2.0.3",
"@hugeicons/core-free-icons": "^1.0.16",
"@hugeicons/vue": "^1.0.3",
"@vitejs/plugin-vue": "^6.0.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"femtocrank": "^1.2.2",
"femtocrank": "^1.2.4",
"unplugin-vue-components": "^28.8.0",
"vite": "^7.0.6",
"vue-router": "^4.5.1"

View File

@@ -1,4 +1,4 @@
// @generated by protoc-gen-es v2.6.2
// @generated by protoc-gen-es v2.6.3
// @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
/* eslint-disable */
@@ -15,9 +15,9 @@ export declare const file_olivetin_api_v1_olivetin: GenFile;
*/
export declare type Action = Message<"olivetin.api.v1.Action"> & {
/**
* @generated from field: string id = 1;
* @generated from field: string binding_id = 1;
*/
id: string;
bindingId: string;
/**
* @generated from field: string title = 2;
@@ -133,14 +133,14 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
title: string;
/**
* @generated from field: string icon = 2;
* @generated from field: string unique_key = 2;
*/
icon: string;
uniqueKey: string;
/**
* @generated from field: repeated olivetin.api.v1.Action actions = 3;
* @generated from field: string type = 3;
*/
actions: Action[];
type: string;
};
/**
@@ -150,40 +150,25 @@ export declare type Entity = Message<"olivetin.api.v1.Entity"> & {
export declare const EntitySchema: GenMessage<Entity>;
/**
* @generated from message olivetin.api.v1.GetDashboardComponentsResponse
* @generated from message olivetin.api.v1.GetDashboardResponse
*/
export declare type GetDashboardComponentsResponse = Message<"olivetin.api.v1.GetDashboardComponentsResponse"> & {
export declare type GetDashboardResponse = Message<"olivetin.api.v1.GetDashboardResponse"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: repeated olivetin.api.v1.Dashboard dashboards = 4;
* @generated from field: olivetin.api.v1.Dashboard dashboard = 4;
*/
dashboards: Dashboard[];
/**
* @generated from field: string authenticated_user = 5;
*/
authenticatedUser: string;
/**
* @generated from field: string authenticated_user_provider = 6;
*/
authenticatedUserProvider: string;
/**
* @generated from field: olivetin.api.v1.EffectivePolicy effective_policy = 7;
*/
effectivePolicy?: EffectivePolicy;
dashboard?: Dashboard;
};
/**
* Describes the message olivetin.api.v1.GetDashboardComponentsResponse.
* Use `create(GetDashboardComponentsResponseSchema)` to create a new message.
* Describes the message olivetin.api.v1.GetDashboardResponse.
* Use `create(GetDashboardResponseSchema)` to create a new message.
*/
export declare const GetDashboardComponentsResponseSchema: GenMessage<GetDashboardComponentsResponse>;
export declare const GetDashboardResponseSchema: GenMessage<GetDashboardResponse>;
/**
* @generated from message olivetin.api.v1.EffectivePolicy
@@ -207,16 +192,20 @@ export declare type EffectivePolicy = Message<"olivetin.api.v1.EffectivePolicy">
export declare const EffectivePolicySchema: GenMessage<EffectivePolicy>;
/**
* @generated from message olivetin.api.v1.GetDashboardComponentsRequest
* @generated from message olivetin.api.v1.GetDashboardRequest
*/
export declare type GetDashboardComponentsRequest = Message<"olivetin.api.v1.GetDashboardComponentsRequest"> & {
export declare type GetDashboardRequest = Message<"olivetin.api.v1.GetDashboardRequest"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
};
/**
* Describes the message olivetin.api.v1.GetDashboardComponentsRequest.
* Use `create(GetDashboardComponentsRequestSchema)` to create a new message.
* Describes the message olivetin.api.v1.GetDashboardRequest.
* Use `create(GetDashboardRequestSchema)` to create a new message.
*/
export declare const GetDashboardComponentsRequestSchema: GenMessage<GetDashboardComponentsRequest>;
export declare const GetDashboardRequestSchema: GenMessage<GetDashboardRequest>;
/**
* @generated from message olivetin.api.v1.Dashboard
@@ -267,6 +256,11 @@ export declare type DashboardComponent = Message<"olivetin.api.v1.DashboardCompo
* @generated from field: string css_class = 5;
*/
cssClass: string;
/**
* @generated from field: olivetin.api.v1.Action action = 6;
*/
action?: Action;
};
/**
@@ -280,9 +274,9 @@ export declare const DashboardComponentSchema: GenMessage<DashboardComponent>;
*/
export declare type StartActionRequest = Message<"olivetin.api.v1.StartActionRequest"> & {
/**
* @generated from field: string action_id = 1;
* @generated from field: string binding_id = 1;
*/
actionId: string;
bindingId: string;
/**
* @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 2;
@@ -569,6 +563,16 @@ export declare type GetLogsResponse = Message<"olivetin.api.v1.GetLogsResponse">
* @generated from field: int64 page_size = 3;
*/
pageSize: bigint;
/**
* @generated from field: int64 total_count = 4;
*/
totalCount: bigint;
/**
* @generated from field: int64 start_offset = 5;
*/
startOffset: bigint;
};
/**
@@ -1187,17 +1191,294 @@ export declare type GetDiagnosticsResponse = Message<"olivetin.api.v1.GetDiagnos
*/
export declare const GetDiagnosticsResponseSchema: GenMessage<GetDiagnosticsResponse>;
/**
* @generated from message olivetin.api.v1.InitRequest
*/
export declare type InitRequest = Message<"olivetin.api.v1.InitRequest"> & {
};
/**
* Describes the message olivetin.api.v1.InitRequest.
* Use `create(InitRequestSchema)` to create a new message.
*/
export declare const InitRequestSchema: GenMessage<InitRequest>;
/**
* @generated from message olivetin.api.v1.InitResponse
*/
export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
/**
* @generated from field: bool showFooter = 1;
*/
showFooter: boolean;
/**
* @generated from field: bool showNavigation = 2;
*/
showNavigation: boolean;
/**
* @generated from field: bool showNewVersions = 3;
*/
showNewVersions: boolean;
/**
* @generated from field: string availableVersion = 4;
*/
availableVersion: string;
/**
* @generated from field: string currentVersion = 5;
*/
currentVersion: string;
/**
* @generated from field: string pageTitle = 6;
*/
pageTitle: string;
/**
* @generated from field: string sectionNavigationStyle = 7;
*/
sectionNavigationStyle: string;
/**
* @generated from field: string defaultIconForBack = 8;
*/
defaultIconForBack: string;
/**
* @generated from field: bool enableCustomJs = 9;
*/
enableCustomJs: boolean;
/**
* @generated from field: string authLoginUrl = 10;
*/
authLoginUrl: string;
/**
* @generated from field: bool authLocalLogin = 11;
*/
authLocalLogin: boolean;
/**
* @generated from field: repeated string styleMods = 12;
*/
styleMods: string[];
/**
* @generated from field: repeated olivetin.api.v1.OAuth2Provider oAuth2Providers = 13;
*/
oAuth2Providers: OAuth2Provider[];
/**
* @generated from field: repeated olivetin.api.v1.AdditionalLink additionalLinks = 14;
*/
additionalLinks: AdditionalLink[];
/**
* @generated from field: repeated string rootDashboards = 15;
*/
rootDashboards: string[];
/**
* @generated from field: string authenticated_user = 16;
*/
authenticatedUser: string;
/**
* @generated from field: string authenticated_user_provider = 17;
*/
authenticatedUserProvider: string;
/**
* @generated from field: olivetin.api.v1.EffectivePolicy effective_policy = 18;
*/
effectivePolicy?: EffectivePolicy;
/**
* @generated from field: string banner_message = 19;
*/
bannerMessage: string;
/**
* @generated from field: string banner_css = 20;
*/
bannerCss: string;
};
/**
* Describes the message olivetin.api.v1.InitResponse.
* Use `create(InitResponseSchema)` to create a new message.
*/
export declare const InitResponseSchema: GenMessage<InitResponse>;
/**
* @generated from message olivetin.api.v1.AdditionalLink
*/
export declare type AdditionalLink = Message<"olivetin.api.v1.AdditionalLink"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string url = 2;
*/
url: string;
};
/**
* Describes the message olivetin.api.v1.AdditionalLink.
* Use `create(AdditionalLinkSchema)` to create a new message.
*/
export declare const AdditionalLinkSchema: GenMessage<AdditionalLink>;
/**
* @generated from message olivetin.api.v1.OAuth2Provider
*/
export declare type OAuth2Provider = Message<"olivetin.api.v1.OAuth2Provider"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string url = 2;
*/
url: string;
/**
* @generated from field: string icon = 3;
*/
icon: string;
};
/**
* Describes the message olivetin.api.v1.OAuth2Provider.
* Use `create(OAuth2ProviderSchema)` to create a new message.
*/
export declare const OAuth2ProviderSchema: GenMessage<OAuth2Provider>;
/**
* @generated from message olivetin.api.v1.GetActionBindingRequest
*/
export declare type GetActionBindingRequest = Message<"olivetin.api.v1.GetActionBindingRequest"> & {
/**
* @generated from field: string binding_id = 1;
*/
bindingId: string;
};
/**
* Describes the message olivetin.api.v1.GetActionBindingRequest.
* Use `create(GetActionBindingRequestSchema)` to create a new message.
*/
export declare const GetActionBindingRequestSchema: GenMessage<GetActionBindingRequest>;
/**
* @generated from message olivetin.api.v1.GetActionBindingResponse
*/
export declare type GetActionBindingResponse = Message<"olivetin.api.v1.GetActionBindingResponse"> & {
/**
* @generated from field: olivetin.api.v1.Action action = 1;
*/
action?: Action;
};
/**
* Describes the message olivetin.api.v1.GetActionBindingResponse.
* Use `create(GetActionBindingResponseSchema)` to create a new message.
*/
export declare const GetActionBindingResponseSchema: GenMessage<GetActionBindingResponse>;
/**
* @generated from message olivetin.api.v1.GetEntitiesRequest
*/
export declare type GetEntitiesRequest = Message<"olivetin.api.v1.GetEntitiesRequest"> & {
};
/**
* Describes the message olivetin.api.v1.GetEntitiesRequest.
* Use `create(GetEntitiesRequestSchema)` to create a new message.
*/
export declare const GetEntitiesRequestSchema: GenMessage<GetEntitiesRequest>;
/**
* @generated from message olivetin.api.v1.GetEntitiesResponse
*/
export declare type GetEntitiesResponse = Message<"olivetin.api.v1.GetEntitiesResponse"> & {
/**
* @generated from field: repeated olivetin.api.v1.EntityDefinition entity_definitions = 1;
*/
entityDefinitions: EntityDefinition[];
};
/**
* Describes the message olivetin.api.v1.GetEntitiesResponse.
* Use `create(GetEntitiesResponseSchema)` to create a new message.
*/
export declare const GetEntitiesResponseSchema: GenMessage<GetEntitiesResponse>;
/**
* @generated from message olivetin.api.v1.EntityDefinition
*/
export declare type EntityDefinition = Message<"olivetin.api.v1.EntityDefinition"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: repeated olivetin.api.v1.Entity instances = 2;
*/
instances: Entity[];
/**
* @generated from field: repeated string used_on_dashboards = 3;
*/
usedOnDashboards: string[];
};
/**
* Describes the message olivetin.api.v1.EntityDefinition.
* Use `create(EntityDefinitionSchema)` to create a new message.
*/
export declare const EntityDefinitionSchema: GenMessage<EntityDefinition>;
/**
* @generated from message olivetin.api.v1.GetEntityRequest
*/
export declare type GetEntityRequest = Message<"olivetin.api.v1.GetEntityRequest"> & {
/**
* @generated from field: string unique_key = 1;
*/
uniqueKey: string;
/**
* @generated from field: string type = 2;
*/
type: string;
};
/**
* Describes the message olivetin.api.v1.GetEntityRequest.
* Use `create(GetEntityRequestSchema)` to create a new message.
*/
export declare const GetEntityRequestSchema: GenMessage<GetEntityRequest>;
/**
* @generated from service olivetin.api.v1.OliveTinApiService
*/
export declare const OliveTinApiService: GenService<{
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetDashboardComponents
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetDashboard
*/
getDashboardComponents: {
getDashboard: {
methodKind: "unary";
input: typeof GetDashboardComponentsRequestSchema;
output: typeof GetDashboardComponentsResponseSchema;
input: typeof GetDashboardRequestSchema;
output: typeof GetDashboardResponseSchema;
},
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.StartAction
@@ -1343,5 +1624,37 @@ export declare const OliveTinApiService: GenService<{
input: typeof GetDiagnosticsRequestSchema;
output: typeof GetDiagnosticsResponseSchema;
},
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.Init
*/
init: {
methodKind: "unary";
input: typeof InitRequestSchema;
output: typeof InitResponseSchema;
},
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetActionBinding
*/
getActionBinding: {
methodKind: "unary";
input: typeof GetActionBindingRequestSchema;
output: typeof GetActionBindingResponseSchema;
},
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetEntities
*/
getEntities: {
methodKind: "unary";
input: typeof GetEntitiesRequestSchema;
output: typeof GetEntitiesResponseSchema;
},
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetEntity
*/
getEntity: {
methodKind: "unary";
input: typeof GetEntityRequestSchema;
output: typeof EntitySchema;
},
}>;

File diff suppressed because one or more lines are too long

View File

@@ -1,27 +1,43 @@
<template>
<div :id="`actionButton-${actionId}`" role="none" class="action-button">
<button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
:class="buttonClasses" @click="handleClick">
<span class="icon" v-html="unicodeIcon"></span>
<span class="title" aria-live="polite">{{ displayTitle }}</span>
</button>
<div :id="`actionButton-${actionId}`" role="none" class="action-button">
<button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
:class="buttonClasses" @click="handleClick">
<ArgumentForm v-if="showArgumentForm" :action-data="actionData" @submit="handleArgumentSubmit"
@cancel="handleArgumentCancel" @close="handleArgumentClose" />
</div>
<div class="navigate-on-start-container">
<div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
<HugeiconsIcon :icon="ComputerTerminal01Icon" />
</div>
<div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
<HugeiconsIcon :icon="TypeCursorIcon" />
</div>
<div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
<HugeiconsIcon :icon="WorkoutRunIcon" />
</div>
</div>
<span class="icon" v-html="unicodeIcon"></span>
<span class="title" aria-live="polite">{{ displayTitle }}
</span>
</button>
</div>
</template>
<script setup>
import ArgumentForm from './ArgumentForm.vue'
import ArgumentForm from './views/ArgumentForm.vue'
import { buttonResults } from './stores/buttonResults'
import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue'
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
import { ref, computed, watch, onMounted, inject } from 'vue'
import { ref, watch, onMounted, inject } from 'vue'
const executionDialog = inject('executionDialog');
const router = useRouter()
const navigateOnStart = ref('')
const props = defineProps({
actionData: {
type: Object,
required: true
type: Object,
required: true
}
})
@@ -46,16 +62,15 @@ const updateIterationTimestamp = ref(0)
function getUnicodeIcon(icon) {
if (icon === '') {
return '&#x1f4a9;'
return '&#x1f4a9;'
} else {
return unescape(icon)
return unescape(icon)
}
}
function constructFromJson(json) {
updateIterationTimestamp.value = 0
// Class attributes
updateFromJson(json)
actionId.value = json.id
@@ -63,6 +78,12 @@ function constructFromJson(json) {
canExec.value = json.canExec
popupOnStart.value = json.popupOnStart
if (popupOnStart.value.includes('execution-dialog')) {
navigateOnStart.value = 'pop'
} else if (props.actionData.arguments.length > 0) {
navigateOnStart.value = 'arg'
}
isDisabled.value = !json.canExec
displayTitle.value = title.value
unicodeIcon.value = getUnicodeIcon(json.icon)
@@ -77,29 +98,17 @@ function updateFromJson(json) {
async function handleClick() {
if (props.actionData.arguments && props.actionData.arguments.length > 0) {
updateUrlWithAction()
showArgumentForm.value = true
router.push(`/actionBinding/${props.actionData.bindingId}/argumentForm`)
} else {
await startAction()
await startAction()
}
}
function updateUrlWithAction() {
// Get the current URL and create a new URL object
const url = new URL(window.location.href)
// Set the action parameter
url.searchParams.set('action', title.value)
// Update the URL without reloading the page
window.history.replaceState({}, '', url.toString())
}
function getUniqueId() {
if (window.isSecureContext) {
return window.crypto.randomUUID()
return window.crypto.randomUUID()
} else {
return Date.now().toString()
return Date.now().toString()
}
}
@@ -107,70 +116,59 @@ async function startAction(actionArgs) {
buttonClasses.value = [] // Removes old animation classes
if (actionArgs === undefined) {
actionArgs = []
actionArgs = []
}
// UUIDs are create client side, so that we can setup a "execution-button"
// to track the execution before we send the request to the server.
const startActionArgs = {
actionId: actionId.value,
arguments: actionArgs,
uniqueTrackingId: getUniqueId()
bindingId: props.actionData.bindingId,
arguments: actionArgs,
uniqueTrackingId: getUniqueId()
}
onActionStarted(startActionArgs.uniqueTrackingId)
console.log('Watching buttonResults for', startActionArgs.uniqueTrackingId)
watch(
() => buttonResults[startActionArgs.uniqueTrackingId],
(newResult, oldResult) => {
onLogEntryChanged(newResult)
}
)
try {
await window.client.startAction(startActionArgs)
await window.client.startAction(startActionArgs)
} catch (err) {
console.error('Failed to start action:', err)
console.error('Failed to start action:', err)
}
}
function onActionStarted(execTrackingId) {
console.log('onActionStarted', execTrackingId)
console.log('executionDialog', executionDialog)
function onLogEntryChanged(logEntry) {
if (logEntry.executionFinished) {
onExecutionFinished(logEntry)
} else {
onExecutionStarted(logEntry)
}
}
function onExecutionStarted(logEntry) {
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
if (executionDialog.value) {
executionDialog.value.reset();
if (popupOnStart.value === 'execution-dialog-stdout-only') {
executionDialog.value.hideEverythingApartFromOutput();
}
}
executionDialog.value.executionTrackingId = execTrackingId;
executionDialog.value.show()
router.push(`/logs/${logEntry.executionTrackingId}`)
}
isDisabled.value = true
}
function handleArgumentSubmit(args) {
startAction(args)
showArgumentForm.value = false
}
function handleArgumentCancel() {
showArgumentForm.value = false
}
function handleArgumentClose() {
showArgumentForm.value = false
}
// ExecutionFeedbackButton methods
function onExecutionFinished(logEntry) {
if (logEntry.timedOut) {
renderExecutionResult('action-timeout', 'Timed out')
renderExecutionResult('action-timeout', 'Timed out')
} else if (logEntry.blocked) {
renderExecutionResult('action-blocked', 'Blocked!')
renderExecutionResult('action-blocked', 'Blocked!')
} else if (logEntry.exitCode !== 0) {
renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
} else {
const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
renderExecutionResult('action-success', 'Success!')
const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
renderExecutionResult('action-success', 'Success!')
}
}
@@ -181,9 +179,9 @@ function renderExecutionResult(resultCssClass, temporaryStatusMessage) {
function updateDom(resultCssClass, newTitle) {
if (resultCssClass == null) {
buttonClasses.value = []
buttonClasses.value = []
} else {
buttonClasses.value = [resultCssClass]
buttonClasses.value = [resultCssClass]
}
displayTitle.value = newTitle
@@ -193,7 +191,7 @@ function onExecStatusChanged() {
isDisabled.value = false
setTimeout(() => {
updateDom(null, title.value)
updateDom(null, title.value)
}, 2000)
}
@@ -204,84 +202,93 @@ onMounted(() => {
watch(
() => props.actionData,
(newData) => {
updateFromJson(newData)
updateFromJson(newData)
},
{ deep: true }
)
defineExpose({
onExecutionFinished
})
</script>
<style scoped>
.action-button {
display: flex;
flex-direction: column;
flex-grow: 1;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.action-button button {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
gap: 0.5em;
padding: 0.5em 1em;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 .6em #aaa;
font-size: .85em;
border-radius: .7em;
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
padding: 0.5em;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 .6em #aaa;
font-size: .85em;
border-radius: .7em;
}
.action-button button:hover:not(:disabled) {
background: #f5f5f5;
border-color: #999;
background: #f5f5f5;
border-color: #999;
}
.action-button button:disabled {
opacity: 0.6;
cursor: not-allowed;
opacity: 0.6;
cursor: not-allowed;
}
.action-button button .icon {
font-size: 3em;
font-size: 3em;
flex-grow: 1;
align-content: center;
}
.action-button button .title {
font-weight: 500;
font-weight: 500;
padding: 0.2em;
}
/* Animation classes */
.action-button button.action-timeout {
background: #fff3cd;
border-color: #ffeaa7;
color: #856404;
background: #fff3cd;
border-color: #ffeaa7;
color: #856404;
}
.action-button button.action-blocked {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
background: #f8d7da !important;
border-color: #f5c6cb;
color: #721c24;
}
.action-button button.action-nonzero-exit {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
background: #f8d7da !important;
border-color: #f5c6cb;
color: #721c24;
}
.action-button button.action-success {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
background: #d4edda !important;
border-color: #c3e6cb;
color: #155724;
}
.action-button-footer {
margin-top: 0.5em;
margin-top: 0.5em;
}
.navigate-on-start-container {
position: relative;
margin-left: auto;
height: 0;
right: 0;
top: 0;
}
</style>

View File

@@ -1,29 +1,29 @@
<template>
<header>
<img src="../../OliveTinLogo.png" alt="OliveTin logo" class="logo" />
<div id="sidebar-button" class="flex-row" @click="toggleSidebar">
<img src="../../OliveTinLogo.png" alt="OliveTin logo" class="logo" />
<h1 id="page-title">
<router-link to="/">OliveTin</router-link>
</h1>
<h1 id="page-title">OliveTin</h1>
<button id="sidebar-toggler-button" aria-label="Open sidebar navigation" aria-pressed="false"
aria-haspopup="menu" @click="toggleSidebar">&#9776;</button>
<div class="fg1" />
<button id="sidebar-toggler-button" aria-label="Open sidebar navigation" aria-pressed="false" aria-haspopup="menu" class="neutral">
<HugeiconsIcon :icon="Menu01Icon" width = "1em" height = "1em" />
</button>
</div>
<div class="fg1">
<Breadcrumbs />
</div>
<div class="fg1" />
<div id="banner" v-if="bannerMessage" :style="bannerCss">
<p>{{ bannerMessage }}</p>
</div>
<div class="userinfo">
<span id="link-login" hidden><router-link to="/login">Login</router-link> |</span>
<span id="link-logout" hidden><a href="/api/Logout">Logout</a> |</span>
<span id="username">&nbsp;</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 24 24">
<g fill="none" fill-rule="evenodd">
<path
d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z" />
<path fill="currentColor"
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10s10-4.477 10-10S17.523 2 12 2M8.5 9.5a3.5 3.5 0 1 1 7 0a3.5 3.5 0 0 1-7 0m9.758 7.484A7.99 7.99 0 0 1 12 20a7.99 7.99 0 0 1-6.258-3.016C7.363 15.821 9.575 15 12 15s4.637.821 6.258 1.984" />
</g>
</svg>
<div class="flex-row" style="gap: .5em;">
<span id="link-login" v-if="!isLoggedIn"><router-link to="/login">Login</router-link></span>
<span id="link-logout" v-if="isLoggedIn"><a href="/api/Logout">Logout</a></span>
<span id="username-text" :title="'Provider: ' + userProvider">{{ username }}</span>
<HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" />
</div>
</header>
@@ -32,15 +32,15 @@
<div id="content">
<main title="Main content">
<router-view />
<router-view :key="$route.fullPath" />
</main>
<ExecutionDialog ref="executionDialog" />
<footer title="footer">
<p><img title="application icon" src="../../OliveTinLogo.png" alt="OliveTin logo" height="1em"
<p>
<img title="application icon" src="../../OliveTinLogo.png" alt="OliveTin logo" height="1em"
class="logo" />
OliveTin</p>
OliveTin 3000!
</p>
<p>
<span>
<a href="https://docs.olivetin.app" target="_new">Documentation</a>
@@ -51,10 +51,9 @@
GitHub</a>
</span>
<span id="currentVersion">?</span>
<span>{{ currentVersion }}</span>
<span id="serverConnectionRest">REST</span>
<span id="serverConnectionWebSocket">WebSocket</span>
<span>{{ serverConnection }}</span>
</p>
<p>
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
@@ -65,19 +64,51 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, onMounted } from 'vue';
import Sidebar from './components/Sidebar.vue';
import ExecutionDialog from './ExecutionDialog.vue';
import { HugeiconsIcon } from '@hugeicons/vue'
import { Menu01Icon } from '@hugeicons/core-free-icons'
import { UserCircle02Icon } from '@hugeicons/core-free-icons'
import { provide } from 'vue';
const sidebar = ref(null);
const executionDialog = ref(null);
provide('executionDialog', executionDialog.value);
const username = ref('guest');
const userProvider = ref('system');
const isLoggedIn = ref(false);
const serverConnection = ref('Connected');
const currentVersion = ref('?');
const bannerMessage = ref('');
const bannerCss = ref('');
function toggleSidebar() {
if (sidebar.value && typeof sidebar.value.isOpen !== 'undefined') {
sidebar.value.isOpen = !sidebar.value.isOpen;
sidebar.value.toggle()
}
async function requestInit() {
try {
const initResponse = await window.client.init({})
console.log("init response", initResponse)
username.value = initResponse.authenticatedUser
currentVersion.value = initResponse.currentVersion
bannerMessage.value = initResponse.bannerMessage || '';
bannerCss.value = initResponse.bannerCss || '';
for (const rootDashboard of initResponse.rootDashboards) {
sidebar.value.addNavigationLink({
id: rootDashboard,
title: rootDashboard,
path: `/dashboards/${rootDashboard}`,
icon: '📊'
})
}
} catch (error) {
console.error("Error initializing client", error)
}
}
onMounted(() => {
serverConnection.value = 'Connected';
requestInit()
})
</script>

View File

@@ -1,432 +0,0 @@
<template>
<dialog
ref="dialog"
title="Arguments"
class="action-arguments"
@close="handleClose"
>
<form class="padded-content" @submit.prevent="handleSubmit">
<div class="wrapper">
<div class="action-header">
<span class="icon" v-html="icon"></span>
<h2>{{ title }}</h2>
</div>
<div class="arguments">
<div
v-for="arg in arguments"
:key="arg.name"
class="argument-group"
>
<label :for="arg.name">
{{ formatLabel(arg.title) }}
</label>
<datalist
v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0"
:id="`${arg.name}-choices`"
>
<option
v-for="(suggestion, key) in arg.suggestions"
:key="key"
:value="key"
>
{{ suggestion }}
</option>
</datalist>
<component
:is="getInputComponent(arg)"
:id="arg.name"
:name="arg.name"
:value="getArgumentValue(arg)"
:list="arg.suggestions ? `${arg.name}-choices` : undefined"
:type="getInputType(arg)"
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
:step="arg.type === 'datetime' ? 1 : undefined"
:pattern="getPattern(arg)"
:required="arg.required"
@input="handleInput(arg, $event)"
@change="handleChange(arg, $event)"
/>
<span
v-if="arg.description"
class="argument-description"
v-html="arg.description"
></span>
</div>
</div>
<div class="buttons">
<button
name="start"
type="submit"
:disabled="!isFormValid || (hasConfirmation && !confirmationChecked)"
>
Start
</button>
<button
name="cancel"
type="button"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</form>
</dialog>
</template>
<script>
export default {
name: 'ArgumentForm',
props: {
actionData: {
type: Object,
required: true
}
},
data() {
return {
title: '',
icon: '',
arguments: [],
argValues: {},
confirmationChecked: false,
hasConfirmation: false,
formErrors: {}
}
},
computed: {
isFormValid() {
return Object.keys(this.formErrors).length === 0
}
},
mounted() {
this.setup()
},
methods: {
setup() {
this.title = this.actionData.title
this.icon = this.actionData.icon
this.arguments = this.actionData.arguments || []
this.argValues = {}
this.formErrors = {}
this.confirmationChecked = false
this.hasConfirmation = false
// Initialize values from query params or defaults
this.arguments.forEach(arg => {
const paramValue = this.getQueryParamValue(arg.name)
this.argValues[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
if (arg.type === 'confirmation') {
this.hasConfirmation = true
}
})
},
getQueryParamValue(paramName) {
const params = new URLSearchParams(window.location.search.substring(1))
return params.get(paramName)
},
formatLabel(title) {
const lastChar = title.charAt(title.length - 1)
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
return title
}
return title + ':'
},
getInputComponent(arg) {
if (arg.type === 'html') {
return 'div'
} else if (arg.type === 'raw_string_multiline') {
return 'textarea'
} else if (arg.choices && arg.choices.length > 0 && (arg.type === 'select' || arg.type === '')) {
return 'select'
} else {
return 'input'
}
},
getInputType(arg) {
if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
return undefined
}
return arg.type
},
getPattern(arg) {
if (arg.type && arg.type.startsWith('regex:')) {
return arg.type.replace('regex:', '')
}
return undefined
},
getArgumentValue(arg) {
if (arg.type === 'checkbox') {
return this.argValues[arg.name] === '1' || this.argValues[arg.name] === true
}
return this.argValues[arg.name] || ''
},
handleInput(arg, event) {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
this.argValues[arg.name] = value
this.updateUrlWithArg(arg.name, value)
},
handleChange(arg, event) {
if (arg.type === 'confirmation') {
this.confirmationChecked = event.target.checked
return
}
// Validate the input
this.validateArgument(arg, event.target.value)
},
async validateArgument(arg, value) {
if (!arg.type || arg.type.startsWith('regex:')) {
return
}
try {
const validateArgumentTypeArgs = {
value: value,
type: arg.type
}
const validation = await window.validateArgumentType(validateArgumentTypeArgs)
if (validation.valid) {
this.$delete(this.formErrors, arg.name)
} else {
this.$set(this.formErrors, arg.name, validation.description)
}
} catch (err) {
console.warn('Validation failed:', err)
}
},
updateUrlWithArg(name, value) {
if (name && value !== undefined) {
const url = new URL(window.location.href)
// Don't add passwords to URL
const arg = this.arguments.find(a => a.name === name)
if (arg && arg.type === 'password') {
return
}
url.searchParams.set(name, value)
window.history.replaceState({}, '', url.toString())
}
},
getArgumentValues() {
const ret = []
for (const arg of this.arguments) {
let value = this.argValues[arg.name] || ''
if (arg.type === 'checkbox') {
value = value ? '1' : '0'
}
ret.push({
name: arg.name,
value: value
})
}
return ret
},
handleSubmit() {
// Validate all inputs
let isValid = true
for (const arg of this.arguments) {
const value = this.argValues[arg.name]
if (arg.required && (!value || value === '')) {
this.$set(this.formErrors, arg.name, 'This field is required')
isValid = false
}
}
if (!isValid) {
return
}
const argvs = this.getArgumentValues()
this.$emit('submit', argvs)
this.close()
},
handleCancel() {
this.clearBookmark()
this.$emit('cancel')
this.close()
},
handleClose() {
this.$emit('close')
},
clearBookmark() {
// Remove the action from the URL
window.history.replaceState({
path: window.location.pathname
}, '', window.location.pathname)
},
show() {
this.$refs.dialog.showModal()
},
close() {
this.$refs.dialog.close()
}
}
}
</script>
<style scoped>
.action-arguments {
border: none;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 500px;
width: 90vw;
}
.wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
.action-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.action-header .icon {
font-size: 1.5em;
}
.action-header h2 {
margin: 0;
font-size: 1.2em;
}
.arguments {
display: flex;
flex-direction: column;
gap: 1rem;
}
.argument-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.argument-group label {
font-weight: 500;
color: #333;
}
.argument-group input,
.argument-group select,
.argument-group textarea {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.argument-group input:focus,
.argument-group select:focus,
.argument-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.argument-group input:invalid,
.argument-group select:invalid,
.argument-group textarea:invalid {
border-color: #dc3545;
}
.argument-group textarea {
resize: vertical;
min-height: 100px;
}
.argument-description {
font-size: 0.875rem;
color: #666;
margin-top: 0.25rem;
}
.buttons {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.buttons button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.buttons button:hover:not(:disabled) {
background: #f8f9fa;
border-color: #adb5bd;
}
.buttons button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.buttons button[name="start"] {
background: #007bff;
color: white;
border-color: #007bff;
}
.buttons button[name="start"]:hover:not(:disabled) {
background: #0056b3;
border-color: #0056b3;
}
/* Checkbox specific styling */
.argument-group input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
}
.argument-group input[type="checkbox"] + label {
display: inline;
font-weight: normal;
}
</style>

View File

@@ -1,32 +1,84 @@
<template>
<section class = "transparent">
<fieldset>
<div v-if="!dashboard" style = "text-align: center">
<p>Loading... {{ title }}</p>
</div>
<div v-else>
<section v-if="dashboard.contents.length == 0">
<legend>{{ dashboard.title }}</legend>
<p>This dashboard is empty.</p>
</section>
<ActionButton :actionData = "action" v-for = "action in dashboard.contents" :key = "action.id" />
</fieldset>
</section>
<section class="transparent" v-else>
<div v-for="component in dashboard.contents" :key="component.title">
<div v-if="component.type == 'fieldset'">
<fieldset>
<legend v-if = "dashboard.title != 'Default'">{{ component.title }}</legend>
<template v-for="subcomponent in component.contents">
<div v-if="subcomponent.type == 'display'" class="display">
<div v-html="subcomponent.title" />
</div>
<ActionButton v-else-if="subcomponent.type == 'link'" :actionData="subcomponent.action"
:key="subcomponent.title" />
<div v-else-if="subcomponent.type == 'directory'">
<router-link :to="{ name: 'Dashboard', params: { title: subcomponent.title } }"
class="dashboard-link">
<button>
{{ subcomponent.title }}
</button>
</router-link>
</div>
<div v-else>
OTHER: {{ subcomponent.type }}
{{ subcomponent }}
</div>
</template>
</fieldset>
</div>
<ActionButton v-else :actionData="action" v-for="action in component.contents" :key="action.title" />
</div>
</section>
</div>
</template>
<script setup>
import ActionButton from './ActionButton.vue'
import { onMounted, ref } from 'vue'
defineProps({
dashboard: {
type: Object,
const props = defineProps({
title: {
type: String,
required: true
}
})
const dashboard = ref(null)
async function getDashboard() {
console.log("getting dashboard", props.title)
const ret = await window.client.getDashboard({
title: props.title,
})
dashboard.value = ret.dashboard
}
onMounted(() => {
getDashboard()
})
</script>
<style>
fieldset {
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
grid-auto-rows: 1fr;
justify-content: center;
place-items: stretch;
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
grid-auto-rows: 1fr;
justify-content: center;
place-items: stretch;
}
</style>

View File

@@ -1,421 +0,0 @@
<template>
<dialog
ref="dialog"
title="Execution Results"
:class="{ big: isBig }"
@close="handleClose"
>
<div class="action-header padded-content">
<span class="icon" role="img" v-html="icon"></span>
<h2>
<span :title="titleTooltip">{{ title }}</span>
</h2>
<button
v-show="!hideToggleButton"
@click="toggleSize"
title="Toggle dialog size"
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z"/>
</svg>
</button>
</div>
<div v-show="!hideBasics" class="padded-content-sides">
<strong>Duration: </strong><span v-html="duration"></span>
</div>
<div v-show="!hideDetails && logEntry" class="padded-content-sides">
<p>
<strong>Status: </strong>
<ActionStatusDisplay :log-entry="logEntry" v-if="logEntry"/>
</p>
</div>
<div ref="xtermOutput" v-show="!isHtmlOutput"></div>
<div
v-show="isHtmlOutput"
class="padded-content"
v-html="htmlOutput"
></div>
<div class="buttons padded-content">
<button
:disabled="!canRerun"
@click="rerunAction"
title="Rerun"
>
Rerun
</button>
<button
:disabled="!canKill"
@click="killAction"
title="Kill"
>
Kill
</button>
<form method="dialog">
<button name="Cancel" title="Close">Close</button>
</form>
</div>
</dialog>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import ActionStatusDisplay from './components/ActionStatusDisplay.vue'
import { OutputTerminal } from '../../js/OutputTerminal.js'
// Refs for DOM elements
const xtermOutput = ref(null)
const dialog = ref(null)
// State
const state = reactive({
isBig: false,
hideToggleButton: false,
hideBasics: false,
hideDetails: false,
hideDetailsOnResult: false,
// Execution data
executionSeconds: 0,
executionTrackingId: 'notset',
executionTicker: null,
// Display data
icon: '',
title: 'Waiting for result...',
titleTooltip: '',
duration: '',
htmlOutput: '',
isHtmlOutput: false,
// Action data
logEntry: null,
canRerun: false,
canKill: false,
// Terminal
terminal: null
})
// Expose for template
const isBig = ref(false)
const hideToggleButton = ref(false)
const hideBasics = ref(false)
const hideDetails = ref(false)
const hideDetailsOnResult = ref(false)
const executionSeconds = ref(0)
const executionTrackingId = ref('notset')
const icon = ref('')
const title = ref('Waiting for result...')
const titleTooltip = ref('')
const duration = ref('')
const htmlOutput = ref('')
const isHtmlOutput = ref(false)
const logEntry = ref(null)
const canRerun = ref(false)
const canKill = ref(false)
let executionTicker = null
let terminal = null
function syncStateToRefs() {
isBig.value = state.isBig
hideToggleButton.value = state.hideToggleButton
hideBasics.value = state.hideBasics
hideDetails.value = state.hideDetails
hideDetailsOnResult.value = state.hideDetailsOnResult
executionSeconds.value = state.executionSeconds
executionTrackingId.value = state.executionTrackingId
icon.value = state.icon
title.value = state.title
titleTooltip.value = state.titleTooltip
duration.value = state.duration
htmlOutput.value = state.htmlOutput
isHtmlOutput.value = state.isHtmlOutput
logEntry.value = state.logEntry
canRerun.value = state.canRerun
canKill.value = state.canKill
}
function syncRefsToState() {
state.isBig = isBig.value
state.hideToggleButton = hideToggleButton.value
state.hideBasics = hideBasics.value
state.hideDetails = hideDetails.value
state.hideDetailsOnResult = hideDetailsOnResult.value
state.executionSeconds = executionSeconds.value
state.executionTrackingId = executionTrackingId.value
state.icon = icon.value
state.title = title.value
state.titleTooltip = titleTooltip.value
state.duration = duration.value
state.htmlOutput = htmlOutput.value
state.isHtmlOutput = isHtmlOutput.value
state.logEntry = logEntry.value
state.canRerun = canRerun.value
state.canKill = canKill.value
}
function initializeTerminal() {
terminal = new OutputTerminal()
terminal.open(xtermOutput.value)
terminal.resize(80, 24)
window.terminal = terminal
state.terminal = terminal
}
function toggleSize() {
state.isBig = !state.isBig
isBig.value = state.isBig
if (state.isBig) {
terminal.fit()
} else {
terminal.resize(80, 24)
}
}
async function reset() {
state.executionSeconds = 0
state.executionTrackingId = 'notset'
state.isBig = false
state.hideToggleButton = false
state.hideBasics = false
state.hideDetails = false
state.hideDetailsOnResult = false
state.icon = ''
state.title = 'Waiting for result...'
state.titleTooltip = ''
state.duration = ''
state.htmlOutput = ''
state.isHtmlOutput = false
state.canRerun = false
state.canKill = false
state.logEntry = null
syncStateToRefs()
if (terminal) {
await terminal.reset()
terminal.fit()
}
}
function show(actionButton) {
if (actionButton) {
state.icon = actionButton.domIcon.innerText
icon.value = state.icon
}
state.canKill = true
canKill.value = true
// Clear existing ticker
if (executionTicker) {
clearInterval(executionTicker)
}
state.executionSeconds = 0
executionSeconds.value = 0
executionTick()
executionTicker = setInterval(() => {
executionTick()
}, 1000)
state.executionTicker = executionTicker
// Close if already open
if (dialog.value && dialog.value.open) {
dialog.value.close()
}
dialog.value && dialog.value.showModal()
}
function rerunAction() {
if (state.logEntry && state.logEntry.actionId) {
const actionButton = document.getElementById('actionButton-' + state.logEntry.actionId)
if (actionButton && actionButton.btn) {
actionButton.btn.click()
}
}
dialog.value && dialog.value.close()
}
async function killAction() {
if (!state.executionTrackingId || state.executionTrackingId === 'notset') {
return
}
const killActionArgs = {
executionTrackingId: state.executionTrackingId
}
try {
await window.client.killAction(killActionArgs)
} catch (err) {
console.error('Failed to kill action:', err)
}
}
function executionTick() {
state.executionSeconds++
executionSeconds.value = state.executionSeconds
updateDuration(null)
}
function hideEverythingApartFromOutput() {
state.hideDetailsOnResult = true
state.hideBasics = true
hideDetailsOnResult.value = true
hideBasics.value = true
}
async function fetchExecutionResult(executionTrackingIdParam) {
state.executionTrackingId = executionTrackingIdParam
executionTrackingId.value = executionTrackingIdParam
const executionStatusArgs = {
executionTrackingId: state.executionTrackingId
}
try {
const logEntryResult = await window.client.executionStatus(executionStatusArgs)
await renderExecutionResult(logEntryResult)
} catch (err) {
renderError(err)
throw err
}
}
function updateDuration(logEntryParam) {
let logEntry = logEntryParam
if (logEntry == null) {
duration.value = state.executionSeconds + ' seconds'
state.duration = duration.value
} else if (!logEntry.executionStarted) {
duration.value = logEntry.datetimeStarted + ' (request time). Not executed.'
state.duration = duration.value
} else if (logEntry.executionStarted && !logEntry.executionFinished) {
duration.value = logEntry.datetimeStarted
state.duration = duration.value
} else {
let delta = ''
try {
delta = (new Date(logEntry.datetimeStarted) - new Date(logEntry.datetimeStarted)) / 1000
delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
} catch (e) {
console.warn('Failed to calculate delta', e)
}
duration.value = logEntry.datetimeStarted + ' &rarr; ' + logEntry.datetimeFinished
if (delta !== '') {
duration.value += ' (' + delta + ')'
}
state.duration = duration.value
}
}
async function renderExecutionResult(res) {
// Clear ticker
if (executionTicker) {
clearInterval(executionTicker)
}
state.executionTicker = null
if (res.type === 'execution-dialog-output-html') {
state.isHtmlOutput = true
state.htmlOutput = res.logEntry.output
state.hideDetailsOnResult = true
isHtmlOutput.value = true
htmlOutput.value = res.logEntry.output
hideDetailsOnResult.value = true
} else {
state.isHtmlOutput = false
state.htmlOutput = ''
isHtmlOutput.value = false
htmlOutput.value = ''
}
if (state.hideDetailsOnResult) {
state.hideDetails = true
hideDetails.value = true
}
state.executionTrackingId = res.logEntry.executionTrackingId
executionTrackingId.value = res.logEntry.executionTrackingId
state.canRerun = res.logEntry.executionFinished
canRerun.value = res.logEntry.executionFinished
state.canKill = res.logEntry.canKill
canKill.value = res.logEntry.canKill
state.logEntry = res.logEntry
logEntry.value = res.logEntry
state.icon = res.logEntry.actionIcon
icon.value = res.logEntry.actionIcon
state.title = res.logEntry.actionTitle
title.value = res.logEntry.actionTitle
state.titleTooltip = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
titleTooltip.value = state.titleTooltip
updateDuration(res.logEntry)
if (terminal) {
await terminal.reset()
await terminal.write(res.logEntry.output, () => {
terminal.fit()
})
}
}
function renderError(err) {
window.showBigError('execution-dlg-err', 'in the execution dialog', 'Failed to fetch execution result. ' + err, false)
}
function handleClose() {
// Clean up when dialog is closed
if (executionTicker) {
clearInterval(executionTicker)
}
state.executionTicker = null
}
function cleanup() {
if (executionTicker) {
clearInterval(executionTicker)
}
state.executionTicker = null
if (terminal) {
terminal.close()
}
state.terminal = null
}
onMounted(() => {
nextTick(() => {
initializeTerminal()
})
})
onBeforeUnmount(() => {
cleanup()
})
// Expose methods for parent/imperative use
defineExpose({
reset,
show,
rerunAction,
killAction,
fetchExecutionResult,
renderExecutionResult,
hideEverythingApartFromOutput,
handleClose
})
</script>

View File

@@ -1,72 +0,0 @@
<script setup>
setup () {
const tpl = document.getElementById('tplLoginForm')
this.content = tpl.content.cloneNode(true)
this.appendChild(this.content)
this.querySelector('#local-user-login').addEventListener('submit', (e) => {
e.preventDefault()
this.localLoginRequest()
})
}
async localLoginRequest () {
const username = this.querySelector('input.username').value
const password = this.querySelector('input.password').value
document.querySelector('.error').innerHTML = ''
const args = {
username: username,
password: password
}
const loginResult = await window.client.localUserLogin(args)
if (loginResult.success) {
window.location.href = '/'
} else {
document.querySelector('.error').innerHTML = 'Login failed.'
}
}
processOAuth2Providers (providers) {
if (providers === null) {
return
}
if (providers.length > 0) {
this.querySelector('.login-oauth2').hidden = false
this.querySelector('.login-disabled').hidden = true
for (const provider of providers) {
const providerForm = document.createElement('form')
providerForm.method = 'GET'
providerForm.action = '/oauth/login'
const hiddenField = document.createElement('input')
hiddenField.type = 'hidden'
hiddenField.name = 'provider'
hiddenField.value = provider.Name
providerForm.appendChild(hiddenField)
const providerButton = document.createElement('button')
providerButton.type = 'submit'
providerButton.innerHTML = '<span class = "oauth2-icon">' + provider.Icon + '</span> Login with ' + provider.Title
providerForm.appendChild(providerButton)
this.querySelector('.login-oauth2').appendChild(providerForm)
}
}
}
processLocalLogin (enabled) {
if (enabled) {
this.querySelector('.login-local').hidden = false
this.querySelector('.login-disabled').hidden = true
}
}
</script>

View File

@@ -1,111 +0,0 @@
<template>
<section title="Logs" class="">
<div class="toolbar">
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input placeholder="Search for action name" id="logSearchBox" />
<button id="searchLogsClear" title="Clear search filter" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</label>
</div>
<table id="logsTable" title="Logs" hidden>
<thead>
<tr title="untitled">
<th>Timestamp</th>
<th>Action</th>
<th>Metadata</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="logEntry in logEntries" :key="logEntry.executionTrackingId"></tr>
<td>{{ logEntry.datetimeStarted }}</td>
<td>{{ logEntry.actionTitle }}</td>
<td>{{ logEntry.actionIcon }}</td>
<td>{{ logEntry.tags }}</td>
<td>{{ logEntry.user }}</td>
</tr>
</tbody>
</table>
<p id="logsTableEmpty">There are no logs to display. <a href="/">Return to index</a></p>
<p><strong>Note:</strong> The server is configured to only send <strong id="logs-server-page-size">?</strong>
log entries at a time. The search box at the top of this page only searches this current page of logs.</p>
</section>
</template>
<script setup>
import { onMounted } from 'vue'
function setupLogSearchBox () {
document.getElementById('logSearchBox').oninput = searchLogs
document.getElementById('searchLogsClear').onclick = searchLogsClear
}
function marshalLogsJsonToHtml (json) {
// This function is called internally with a "fake" server response, that does
// not have pageSize set. So we need to check if it's set before trying to use it.
if (json.pageSize !== undefined) {
document.getElementById('logs-server-page-size').innerText = json.pageSize
}
if (json.logs != null && json.logs.length > 0) {
document.getElementById('logsTable').hidden = false
document.getElementById('logsTableEmpty').hidden = true
} else {
return
}
for (const logEntry of json.logs) {
let row = document.getElementById('log-' + logEntry.executionTrackingId)
if (row == null) {
const tpl = document.getElementById('tplLogRow')
row = tpl.content.querySelector('tr').cloneNode(true)
row.id = 'log-' + logEntry.executionTrackingId
row.querySelector('.content').onclick = () => {
window.executionDialog.reset()
window.executionDialog.show()
window.executionDialog.renderExecutionResult({
logEntry: window.logEntries.get(logEntry.executionTrackingId)
})
pushNewNavigationPath('/logs/' + logEntry.executionTrackingId)
}
logEntry.dom = row
window.logEntries.set(logEntry.executionTrackingId, logEntry)
document.querySelector('#logTableBody').prepend(row)
}
row.querySelector('.timestamp').innerText = logEntry.datetimeStarted
row.querySelector('.content').innerText = logEntry.actionTitle
row.querySelector('.icon').innerHTML = logEntry.actionIcon
row.setAttribute('title', logEntry.actionTitle)
row.exitCodeDisplay.update(logEntry)
row.querySelector('.tags').innerHTML = ''
for (const tag of logEntry.tags) {
row.querySelector('.tags').append(createTag(tag))
}
row.querySelector('.tags').append(createAnnotation('user', logEntry.user))
}
}
onMounted(() => {
setupLogSearchBox()
})
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div id = "breadcrumbs">
<template v-for="(link, index) in links" :key="link.name">
<router-link :to="link.href">{{ link.name }}</router-link>
<span v-if="index < links.length - 1" class="separator">
&raquo;
</span>
</template>
</div>
</template>
<style scoped>
span {
color: #bbb;
}
a {
text-decoration: none;
padding: 0.4em;
border-radius: 0.2em;
}
a:hover {
text-decoration: underline;
background-color: #000;
}
</style>
<script setup>
import { ref } from 'vue';
import { watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const links = ref([]);
watch(() => route.matched, (matched) => {
links.value = [];
matched.forEach((record) => {
if (record.meta && record.meta.breadcrumb) {
record.meta.breadcrumb.forEach((item) => {
links.value.push({
name: item.name,
href: item.href || record.path || '/'
});
});
} else if (record.name) {
links.value.push({
name: record.name,
href: record.path || '/'
});
}
});
}, { immediate: true });
</script>

View File

@@ -0,0 +1,284 @@
<template>
<div class="pagination">
<div class="pagination-info">
<span class="pagination-text">
Showing {{ startItem + 1 }}-{{ endItem }} of {{ total }} {{ itemTitle }}
</span>
</div>
<div class="pagination-controls">
<button
class="pagination-btn"
:disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
title="Previous page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M15.41 7.41L14 6l-6 6l6 6l1.41-1.41L10.83 12z"/>
</svg>
</button>
<div class="pagination-pages">
<!-- First page -->
<button
v-if="showFirstPage"
class="pagination-btn"
:class="{ active: currentPage === 1 }"
@click="goToPage(1)"
>
1
</button>
<!-- Ellipsis after first page -->
<span v-if="showFirstEllipsis" class="pagination-ellipsis">...</span>
<!-- Page numbers around current page -->
<button
v-for="page in visiblePages"
:key="page"
class="pagination-btn"
:class="{ active: currentPage === page }"
@click="goToPage(page)"
>
{{ page }}
</button>
<!-- Ellipsis before last page -->
<span v-if="showLastEllipsis" class="pagination-ellipsis">...</span>
<!-- Last page -->
<button
v-if="showLastPage"
class="pagination-btn"
:class="{ active: currentPage === totalPages }"
@click="goToPage(totalPages)"
>
{{ totalPages }}
</button>
</div>
<button
class="pagination-btn"
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
title="Next page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M8.59 16.59L10 18l6-6l-6-6L8.59 7.41L13.17 12z"/>
</svg>
</button>
</div>
<div class="pagination-size" v-if="canChangePageSize">
<label for="page-size">Items per page:</label>
<select
id="page-size"
v-model="localPageSize"
@change="handlePageSizeChange"
class="page-size-select"
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
pageSize: {
type: Number,
default: 25
},
total: {
type: Number,
required: true
},
currentPage: {
type: Number,
default: 1
},
canChangePageSize: {
type: Boolean,
default: false
},
itemTitle: {
type: String,
default: 'items'
}
})
const emit = defineEmits(['page-change', 'page-size-change'])
const localPageSize = ref(props.pageSize)
const localCurrentPage = ref(props.currentPage)
// Computed properties
const totalPages = computed(() => Math.ceil(props.total / localPageSize.value))
const startItem = computed(() => (localCurrentPage.value - 1) * localPageSize.value)
const endItem = computed(() => Math.min(localCurrentPage.value * localPageSize.value, props.total))
// Pagination logic
const maxVisiblePages = 5
const visiblePages = computed(() => {
const pages = []
const halfVisible = Math.floor(maxVisiblePages / 2)
let start = Math.max(1, localCurrentPage.value - halfVisible)
let end = Math.min(totalPages.value, start + maxVisiblePages - 1)
// Adjust start if we're near the end
if (end - start < maxVisiblePages - 1) {
start = Math.max(1, end - maxVisiblePages + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const showFirstPage = computed(() => visiblePages.value[0] > 1)
const showLastPage = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value)
const showFirstEllipsis = computed(() => visiblePages.value[0] > 2)
const showLastEllipsis = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value - 1)
// Methods
function goToPage(page) {
if (page >= 1 && page <= totalPages.value && page !== localCurrentPage.value) {
localCurrentPage.value = page
emit('page-change', page)
}
}
function handlePageSizeChange() {
// Reset to first page when changing page size
localCurrentPage.value = 1
emit('page-size-change', localPageSize.value)
emit('page-change', 1)
}
// Watch for prop changes
watch(() => props.currentPage, (newPage) => {
localCurrentPage.value = newPage
})
watch(() => props.pageSize, (newSize) => {
localPageSize.value = newSize
})
</script>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.pagination-info {
flex: 1;
}
.pagination-text {
font-size: 0.875rem;
color: #6c757d;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pagination-pages {
display: flex;
align-items: center;
gap: 0.25rem;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
padding: 0.5rem;
border: 1px solid #dee2e6;
background: #fff;
color: #495057;
text-decoration: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.pagination-btn:hover:not(:disabled) {
background: #e9ecef;
border-color: #adb5bd;
color: #495057;
}
.pagination-btn.active {
background: #c6d0d7;
color: #333;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-ellipsis {
padding: 0.5rem;
color: #6c757d;
font-size: 0.875rem;
}
.pagination-size {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #6c757d;
}
.page-size-select {
padding: 0.25rem 0.5rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #fff;
font-size: 0.875rem;
}
.page-size-select:focus {
outline: none;
border-color: #5681af;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Responsive design */
@media (max-width: 768px) {
.pagination {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.pagination-controls {
justify-content: center;
}
.pagination-size {
justify-content: center;
}
}
</style>

View File

@@ -1,30 +1,39 @@
<template>
<aside :class="{ 'shown': isOpen, 'stuck': isStuck }" class="sidebar">
<button
class="stick-toggle"
:aria-pressed="isStuck"
:title="isStuck ? 'Unstick sidebar' : 'Stick sidebar'"
@click="toggleStick"
>
<span v-if="isStuck">📌 Unstick</span>
<span v-else>📍 Stick</span>
</button>
<div class = "flex-row">
<h2>Navigation</h2>
<div class = "fg1" />
<button
class="stick-toggle"
:aria-pressed="isStuck"
:title="isStuck ? 'Unstick sidebar' : 'Stick sidebar'"
@click="toggleStick"
>
<span v-if="isStuck">
<HugeiconsIcon :icon="Pin02Icon" width = "1em" height = "1em" />
</span>
<span v-else>
<HugeiconsIcon :icon="PinIcon" width = "1em" height = "1em" />
</span>
</button>
</div>
<nav class="mainnav">
<ul class="navigation-links">
<li v-for="link in navigationLinks" :key="link.id" :title="link.title">
<router-link :to="link.path" :class="{ active: isActive(link.path) }">
<span v-if="link.icon" class="icon" v-html="link.icon"></span>
<span class="title">{{ link.title }}</span>
<HugeiconsIcon :icon="link.icon" />
<span>{{ link.title }}</span>
</router-link>
</li>
</ul>
<ul class="supplemental-links">
<li v-for="link in supplementalLinks" :key="link.id" :title="link.title">
<a :href="link.url" :target="link.target || '_self'">
<span v-if="link.icon" class="icon" v-html="link.icon"></span>
<span class="title">{{ link.title }}</span>
</a>
<router-link :to="link.path" :class="{ active: isActive(link.path) }">
<HugeiconsIcon :icon="link.icon" />
<span>{{ link.title }}</span>
</router-link>
</li>
</ul>
</nav>
@@ -34,6 +43,13 @@
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'
import { useRoute } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue'
import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
import { LeftToRightListDashIcon } from '@hugeicons/core-free-icons'
import { Wrench01Icon } from '@hugeicons/core-free-icons'
import { Pin02Icon } from '@hugeicons/core-free-icons'
import { PinIcon } from '@hugeicons/core-free-icons'
import { CellsIcon } from '@hugeicons/core-free-icons'
const isOpen = ref(false)
const isStuck = ref(false)
@@ -42,25 +58,32 @@ const navigationLinks = ref([
id: 'actions',
title: 'Actions',
path: '/',
icon: '⚡'
icon: DashboardSquare01Icon,
}
])
const supplementalLinks = ref([
{
id: 'entities',
title: 'Entities',
path: '/entities',
icon: CellsIcon,
},
{
id: 'logs',
title: 'Logs',
path: '/logs',
icon: '📋'
icon: LeftToRightListDashIcon,
},
{
id: 'diagnostics',
title: 'Diagnostics',
path: '/diagnostics',
icon: '🔧'
icon: Wrench01Icon,
}
])
const supplementalLinks = ref([])
const route = useRoute()
const instance = getCurrentInstance()
function toggleStick() {
isStuck.value = !isStuck.value
@@ -68,6 +91,7 @@ function toggleStick() {
function toggle() {
isOpen.value = !isOpen.value
isStuck.value = false
}
function open() {
@@ -76,6 +100,7 @@ function open() {
function close() {
isOpen.value = false
isStuck.value = false
}
function isActive(path) {
@@ -84,6 +109,8 @@ function isActive(path) {
// Method to add navigation links from other components
function addNavigationLink(link) {
link.icon = DashboardSquare01Icon
const existingIndex = navigationLinks.value.findIndex(l => l.id === link.id)
if (existingIndex >= 0) {
navigationLinks.value[existingIndex] = { ...link }
@@ -168,23 +195,15 @@ defineExpose({
</script>
<style scoped>
.mainnav {
padding: 1rem 0;
.active {
text-decoration: underline;
}
.navigation-links,
.supplemental-links {
list-style: none;
li {
margin: 0;
padding: 0;
}
.navigation-links li,
.supplemental-links li {
margin: 0;
padding: 0;
}
.navigation-links a,
.supplemental-links a {
display: flex;
@@ -192,7 +211,6 @@ defineExpose({
gap: 0.75rem;
padding: 0.75rem 1rem;
color: #333;
text-decoration: none;
transition: background-color 0.2s ease;
border-left: 3px solid transparent;
}
@@ -203,41 +221,15 @@ defineExpose({
color: #007bff;
}
.navigation-links a.active {
background: #e3f2fd;
color: #007bff;
border-left-color: #007bff;
}
.navigation-links a.router-link-active {
background: #e3f2fd;
color: #007bff;
border-left-color: #007bff;
}
.icon {
font-size: 1.2em;
width: 1.5rem;
text-align: center;
}
.title {
font-weight: 500;
}
.supplemental-links {
border-top: 1px solid #eee;
margin-top: 1rem;
padding-top: 1rem;
}
.supplemental-links a {
font-size: 0.9rem;
color: #666;
}
.supplemental-links a:hover {
color: #007bff;
}
/* Responsive design */

View File

@@ -1,179 +0,0 @@
<template>
<div class="sidebar-example">
<h3>Sidebar Management Example</h3>
<div class="controls">
<button @click="addCustomNavLink" class="btn btn-primary">
Add Custom Nav Link
</button>
<button @click="addCustomSupplementalLink" class="btn btn-secondary">
Add Custom Supplemental Link
</button>
<button @click="removeCustomLinks" class="btn btn-danger">
Remove Custom Links
</button>
<button @click="toggleSidebar" class="btn btn-info">
Toggle Sidebar
</button>
</div>
<div class="info">
<p><strong>Available Sidebar Methods:</strong></p>
<ul>
<li><code>window.sidebar.addNavigationLink(link)</code> - Add navigation link</li>
<li><code>window.sidebar.addSupplementalLink(link)</code> - Add supplemental link</li>
<li><code>window.sidebar.removeNavigationLink(id)</code> - Remove navigation link</li>
<li><code>window.sidebar.removeSupplementalLink(id)</code> - Remove supplemental link</li>
<li><code>window.sidebar.toggle()</code> - Toggle sidebar visibility</li>
<li><code>window.sidebar.open()</code> - Open sidebar</li>
<li><code>window.sidebar.close()</code> - Close sidebar</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: 'SidebarExample',
data() {
return {
customLinkId: 1
}
},
methods: {
addCustomNavLink() {
if (window.sidebar) {
window.sidebar.addNavigationLink({
id: `custom-nav-${this.customLinkId}`,
title: `Custom Nav ${this.customLinkId}`,
path: `/custom-${this.customLinkId}`,
icon: '🔗'
})
this.customLinkId++
} else {
console.warn('Sidebar not available')
}
},
addCustomSupplementalLink() {
if (window.sidebar) {
window.sidebar.addSupplementalLink({
id: `custom-supplemental-${this.customLinkId}`,
title: `Custom Link ${this.customLinkId}`,
url: `https://example.com/custom-${this.customLinkId}`,
target: '_blank',
icon: '🔗'
})
this.customLinkId++
} else {
console.warn('Sidebar not available')
}
},
removeCustomLinks() {
if (window.sidebar) {
// Remove all custom links
for (let i = 1; i < this.customLinkId; i++) {
window.sidebar.removeNavigationLink(`custom-nav-${i}`)
window.sidebar.removeSupplementalLink(`custom-supplemental-${i}`)
}
this.customLinkId = 1
} else {
console.warn('Sidebar not available')
}
},
toggleSidebar() {
if (window.sidebar) {
window.sidebar.toggle()
} else {
console.warn('Sidebar not available')
}
}
}
}
</script>
<style scoped>
.sidebar-example {
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
margin: 1rem 0;
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover {
background: #138496;
}
.info {
background: white;
padding: 1rem;
border-radius: 4px;
border-left: 4px solid #007bff;
}
.info ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.info code {
background: #f1f3f4;
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.875rem;
}
</style>

View File

@@ -1,25 +1,65 @@
import { createRouter, createWebHistory } from 'vue-router'
// Import components
import App from './App.vue'
import ExecutionDialog from './ExecutionDialog.vue'
import ActionButton from './ActionButton.vue'
import ArgumentForm from './ArgumentForm.vue'
// Define routes
const routes = [
{
path: '/',
name: 'Home',
component: () => import('./views/DashboardRoot.vue'),
component: () => import('./Dashboard.vue'),
props: { title: 'default' },
meta: { title: 'OliveTin - Dashboard' }
},
{
path: '/dashboards/:title',
name: 'Dashboard',
component: () => import('./Dashboard.vue'),
props: true,
meta: { title: 'OliveTin - Dashboard' }
},
{
path: '/actionBinding/:bindingId/argumentForm',
name: 'ActionBinding',
component: () => import('./views/ArgumentForm.vue'),
props: true,
meta: { title: 'OliveTin - Action Binding' }
},
{
path: '/logs',
name: 'Logs',
component: () => import('./views/LogsView.vue'),
component: () => import('./views/LogsListView.vue'),
meta: { title: 'OliveTin - Logs' }
},
{
path: '/entities',
name: 'Entities',
component: () => import('./views/EntitiesView.vue'),
meta: { title: 'OliveTin - Entities' }
},
{
path: '/entity-details/:entityType/:entityKey',
name: 'EntityDetails',
component: () => import('./views/EntityDetailsView.vue'),
props: true,
meta: {
title: 'OliveTin - Entity Details',
breadcrumb: [
{ name: "Entities", href: "/entities" },
{ name: "Entity Details" }
]
}
},
{
path: '/logs/:executionTrackingId',
name: 'Execution',
component: () => import('./views/ExecutionView.vue'),
props: true,
meta: {
title: 'OliveTin - Execution',
breadcrumb: [
{ name: "Logs", href: "/logs" },
{ name: "Execution" },
]
}
},
{
path: '/diagnostics',
name: 'Diagnostics',

View File

@@ -0,0 +1,3 @@
import { reactive } from 'vue'
export const buttonResults = reactive({})

View File

@@ -0,0 +1,337 @@
<template>
<section>
<div class="section-header">
<h2>Start action: {{ title }}</h2>
</div>
<div class="section-content">
<form @submit.prevent="handleSubmit">
<template v-if="actionArguments.length > 0">
<template v-for="arg in actionArguments" :key="arg.name" class="argument-group">
<label :for="arg.name">
{{ formatLabel(arg.title) }}
</label>
<datalist v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0" :id="`${arg.name}-choices`">
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
{{ suggestion }}
</option>
</datalist>
<component :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
:list="arg.suggestions ? `${arg.name}-choices` : undefined" :type="getInputType(arg)"
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)" :required="arg.required"
@input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
<span v-if="arg.description" class="argument-description" v-html="arg.description"></span>
</template>
</template>
<div v-else>
<p>No arguments required</p>
</div>
<div class="buttons">
<button name="start" type="submit" :disabled="!isFormValid || (hasConfirmation && !confirmationChecked)">
Start
</button>
<button name="cancel" type="button" @click="handleCancel">
Cancel
</button>
</div>
</form>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const emit = defineEmits(['submit', 'cancel', 'close'])
// Reactive data
const dialog = ref(null)
const title = ref('')
const icon = ref('')
//const arguments = ref([])
const argValues = ref({})
const confirmationChecked = ref(false)
const hasConfirmation = ref(false)
const formErrors = ref({})
const actionArguments = ref([])
// Computed properties
const isFormValid = computed(() => Object.keys(formErrors.value).length === 0)
const props = defineProps({
bindingId: {
type: String,
required: true
}
})
// Methods
async function setup() {
const ret = await window.client.getActionBinding({
bindingId: props.bindingId
})
const action = ret.action
console.log('action', action)
title.value = action.title
icon.value = action.icon
actionArguments.value = action.arguments || []
argValues.value = {}
formErrors.value = {}
confirmationChecked.value = false
hasConfirmation.value = false
// Initialize values from query params or defaults
arguments.value.forEach(arg => {
const paramValue = getQueryParamValue(arg.name)
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
if (arg.type === 'confirmation') {
hasConfirmation.value = true
}
})
}
function getQueryParamValue(paramName) {
const params = new URLSearchParams(window.location.search.substring(1))
return params.get(paramName)
}
function formatLabel(title) {
const lastChar = title.charAt(title.length - 1)
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
return title
}
return title + ':'
}
function getInputComponent(arg) {
if (arg.type === 'html') {
return 'div'
} else if (arg.type === 'raw_string_multiline') {
return 'textarea'
} else if (arg.choices && arg.choices.length > 0 && (arg.type === 'select' || arg.type === '')) {
return 'select'
} else {
return 'input'
}
}
function getInputType(arg) {
if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
return undefined
}
if (arg.type === 'ascii_identifier') {
return 'text'
}
return arg.type
}
function getPattern(arg) {
if (arg.type && arg.type.startsWith('regex:')) {
return arg.type.replace('regex:', '')
}
return undefined
}
function getArgumentValue(arg) {
if (arg.type === 'checkbox') {
return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true
}
return argValues.value[arg.name] || ''
}
function handleInput(arg, event) {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value)
}
function handleChange(arg, event) {
if (arg.type === 'confirmation') {
confirmationChecked.value = event.target.checked
return
}
// Validate the input
validateArgument(arg, event.target.value)
}
async function validateArgument(arg, value) {
if (!arg.type || arg.type.startsWith('regex:')) {
return
}
try {
const validateArgumentTypeArgs = {
value: value,
type: arg.type
}
const validation = await window.validateArgumentType(validateArgumentTypeArgs)
if (validation.valid) {
delete formErrors.value[arg.name]
} else {
formErrors.value[arg.name] = validation.description
}
} catch (err) {
console.warn('Validation failed:', err)
}
}
function updateUrlWithArg(name, value) {
if (name && value !== undefined) {
const url = new URL(window.location.href)
// Don't add passwords to URL
const arg = arguments.value.find(a => a.name === name)
if (arg && arg.type === 'password') {
return
}
url.searchParams.set(name, value)
window.history.replaceState({}, '', url.toString())
}
}
function getArgumentValues() {
const ret = []
for (const arg of arguments.value) {
let value = argValues.value[arg.name] || ''
if (arg.type === 'checkbox') {
value = value ? '1' : '0'
}
ret.push({
name: arg.name,
value: value
})
}
return ret
}
function handleSubmit() {
// Validate all inputs
let isValid = true
for (const arg of arguments.value) {
const value = argValues.value[arg.name]
if (arg.required && (!value || value === '')) {
formErrors.value[arg.name] = 'This field is required'
isValid = false
}
}
if (!isValid) {
return
}
const argvs = getArgumentValues()
emit('submit', argvs)
close()
}
function handleCancel() {
router.back()
clearBookmark()
emit('cancel')
close()
}
function handleClose() {
emit('close')
}
function clearBookmark() {
// Remove the action from the URL
window.history.replaceState({
path: window.location.pathname
}, '', window.location.pathname)
}
function show() {
if (dialog.value) {
dialog.value.showModal()
}
}
function close() {
if (dialog.value) {
dialog.value.close()
}
}
// Expose methods for parent components
defineExpose({
show,
close
})
// Lifecycle
onMounted(() => {
setup()
})
</script>
<style scoped>
form {
grid-template-columns: max-content auto auto;
}
.argument-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.argument-group label {
font-weight: 500;
color: #333;
}
.argument-group input:invalid,
.argument-group select:invalid,
.argument-group textarea:invalid {
border-color: #dc3545;
}
.argument-description {
font-size: 0.875rem;
color: #666;
margin-top: 0.25rem;
}
.buttons {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid #eee;
}
/* Checkbox specific styling */
.argument-group input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
}
.argument-group input[type="checkbox"]+label {
display: inline;
font-weight: normal;
}
</style>

View File

@@ -1,23 +0,0 @@
<template>
<div v-for="dashboard in dashboards" :key="dashboard.id">
<Dashboard :dashboard="dashboard" />
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import Dashboard from '../Dashboard.vue'
const dashboards = ref([])
async function refreshActions() {
const ret = await window.client.getDashboardComponents();
console.log(ret.dashboards)
dashboards.value = ret.dashboards
}
onMounted(() => {
refreshActions()
})
</script>

View File

@@ -1,94 +1,51 @@
<template>
<div class="diagnostics-view">
<div class="diagnostics-content">
<p class="note">
<strong>Note:</strong> Diagnostics are only generated on OliveTin startup - they are not updated in real-time or
when you refresh this page.
They are intended as a "quick reference" to help you.
</p>
<p class="note">
If you are having problems with OliveTin and want to raise a support request, please don't take a screenshot or
copy text from this page,
but instead it is highly recommended to include a
<a href="https://docs.olivetin.app/sosreport.html" target="_blank">sosreport</a>
which is more detailed, and makes it easier to help you.
</p>
<div class="diagnostics-section">
<h3>SSH</h3>
<table class="diagnostics-table">
<tbody>
<tr>
<td width="10%">Found Key</td>
<td>{{ diagnostics.sshFoundKey || '?' }}</td>
</tr>
<tr>
<td>Found Config</td>
<td>{{ diagnostics.sshFoundConfig || '?' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="diagnostics.system" class="diagnostics-section">
<h3>System</h3>
<table class="diagnostics-table">
<tbody>
<tr v-for="(value, key) in diagnostics.system" :key="key">
<td width="10%">{{ formatKey(key) }}</td>
<td>{{ value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="diagnostics.network" class="diagnostics-section">
<h3>Network</h3>
<table class="diagnostics-table">
<tbody>
<tr v-for="(value, key) in diagnostics.network" :key="key">
<td width="10%">{{ formatKey(key) }}</td>
<td>{{ value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="diagnostics.storage" class="diagnostics-section">
<h3>Storage</h3>
<table class="diagnostics-table">
<tbody>
<tr v-for="(value, key) in diagnostics.storage" :key="key">
<td width="10%">{{ formatKey(key) }}</td>
<td>{{ value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="diagnostics.services" class="diagnostics-section">
<h3>Services</h3>
<table class="diagnostics-table">
<tbody>
<tr v-for="(value, key) in diagnostics.services" :key="key">
<td width="10%">{{ formatKey(key) }}</td>
<td>{{ value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="diagnostics.errors && diagnostics.errors.length > 0" class="diagnostics-section">
<h3>Errors</h3>
<div class="error-list">
<div v-for="(error, index) in diagnostics.errors" :key="index" class="error-item">
{{ error }}
</div>
</div>
</div>
<section>
<div class="section-header">
<h2>Get support</h2>
</div>
</div>
<div class="section-content">
<p>If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.
</p>
<ul>
<li>
<a href="https://docs.olivetin.app/sosreport.html" target="_blank">sosreport Documentation</a>
</li>
<li>
<a href = "https://docs.olivetin.app/troubleshooting/wheretofindhelp.html" target="_blank">Where to find help</a>
</li>
</ul>
</div>
</section>
<section>
<div class="section-header">
<h2>SSH</h2>
</div>
<div class="section-content">
<dl>
<dt>Found Key</dt>
<dd>{{ diagnostics.sshFoundKey || '?' }}</dd>
<dt>Found Config</dt>
<dd>{{ diagnostics.sshFoundConfig || '?' }}</dd>
</dl>
</div>
</section>
<section>
<div class="section-header">
<h2>SOS Report</h2>
</div>
<div class="section-content">
<p>This section allows you to generate a detailed report of your configuration and environment. It is a good idea to include this when raising a support request.</p>
<div role="toolbar">
<button @click="generateSosReport" :disabled="loading" class = "good">Generate SOS Report</button>
</div>
<textarea v-model="sosReport" readonly style="flex: 1; min-height: 200px; resize: vertical;"></textarea>
</div>
</section>
</template>
<script setup>
@@ -96,6 +53,7 @@ import { ref, onMounted } from 'vue'
const diagnostics = ref({})
const loading = ref(false)
const sosReport = ref('Waiting to start...')
async function fetchDiagnostics() {
loading.value = true
@@ -103,8 +61,8 @@ async function fetchDiagnostics() {
try {
const response = await window.client.getDiagnostics();
diagnostics.value = {
sshFoundKey: response.sshFoundKey,
sshFoundConfig: response.sshFoundConfig
sshFoundKey: response.SshFoundKey,
sshFoundConfig: response.SshFoundConfig
};
} catch (err) {
console.error('Failed to fetch diagnostics:', err);
@@ -123,6 +81,12 @@ function formatKey(key) {
.trim()
}
async function generateSosReport() {
const response = await window.client.sosReport()
console.log("response", response)
sosReport.value = response.alert
}
onMounted(() => {
fetchDiagnostics()
})
@@ -157,23 +121,6 @@ onMounted(() => {
text-decoration: underline;
}
.diagnostics-section {
margin-bottom: 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.diagnostics-section h3 {
margin: 0;
padding: 1rem;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
font-size: 1.1rem;
font-weight: 600;
}
.diagnostics-table {
width: 100%;
border-collapse: collapse;
@@ -212,4 +159,15 @@ onMounted(() => {
.error-item:last-child {
margin-bottom: 0;
}
.flex-col {
display: flex;
flex-direction: column;
}
.section-content {
display: flex;
flex-direction: column;
gap: 1em;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<section class = "with-header-and-content" v-if="entityDefinitions.length === 0">
<div class = "section-header">
<h2 class="loading-message">
Loading entity definitions...
</h2>
</div>
</section>
<template v-else>
<section v-for="def in entityDefinitions" :key="def.name" class="with-header-and-content">
<div class = "section-header">
<h2>Entity: {{ def.title }}</h2>
</div>
<div class = "section-content">
<p>{{ def.instances.length }} instances.</p>
<ul>
<li v-for="inst in def.instances" :key="inst.id">
<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
{{ inst.title }}
</router-link>
</li>
</ul>
<h3>Used on Dashboards:</h3>
<ul>
<li v-for="dash in def.usedOnDashboards">
<router-link :to="{ name: 'Dashboard', params: { title: dash } }">
{{ dash }}
</router-link>
</li>
</ul>
</div>
</section>
</template>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const entityDefinitions = ref([])
async function fetchEntities() {
const ret = await window.client.getEntities()
entityDefinitions.value = ret.entityDefinitions
}
onMounted(() => {
fetchEntities()
})
</script>

View File

@@ -0,0 +1,43 @@
<template>
<section class="with-header-and-content">
<div class="section-header">
<h2>Entity Details</h2>
</div>
<div class="section-content">
<p v-if="!entityDetails">Loading entity details...</p>
<p v-else-if="!entityDetails.title">No details available for this entity.</p>
<p v-else>{{ entityDetails.title }}</p>
</div>
</section>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const entityDetails = ref(null)
const props = defineProps({
entityType: String,
entityKey: String
})
async function fetchEntityDetails() {
try {
const response = await window.client.getEntity({
type: props.entityType,
uniqueKey: props.entityKey
})
entityDetails.value = response
} catch (err) {
console.error('Failed to fetch entity details:', err)
window.showBigError('fetch-entity-details', 'getting entity details', err, false)
}
}
onMounted(() => {
fetchEntityDetails()
})
</script>

View File

@@ -0,0 +1,314 @@
<template>
<section class="with-header-and-content">
<div class="section-header">
<h2>Execution Results: {{ title }}</h2>
<button @click="toggleSize" title="Toggle dialog size">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
</svg>
</button>
</div>
<div class="section-content">
<div v-if="logEntry">
<div class="action-header padded-content" style="float: right">
<span class="icon" role="img" v-html="icon"></span>
</div>
<dl>
<dt>Duration</dt>
<dd><span v-html="duration"></span></dd>
<dt>Status</dt>
<dd>
<ActionStatusDisplay :log-entry="logEntry" />
</dd>
</dl>
</div>
<div ref="xtermOutput"></div>
<br />
<div class="flex-row g1 buttons padded-content">
<button @click="goBack" title="Go back">
<HugeiconsIcon :icon="ArrowLeftIcon" />
Back
</button>
<div class = "fg1" />
<button :disabled="!canRerun" @click="rerunAction" title="Rerun">
<HugeiconsIcon :icon="WorkoutRunIcon" />
Rerun
</button>
<button :disabled="!canKill" @click="killAction" title="Kill">
<HugeiconsIcon :icon="Cancel02Icon" />
Kill
</button>
</div>
</div>
</section>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import { OutputTerminal } from '../../../js/OutputTerminal.js'
import { HugeiconsIcon } from '@hugeicons/vue'
import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
import { useRouter } from 'vue-router'
const router = useRouter()
// Refs for DOM elements
const xtermOutput = ref(null)
const dialog = ref(null)
const props = defineProps({
executionTrackingId: {
type: String,
required: true
}
})
const executionTrackingId = ref(props.executionTrackingId)
const isBig = ref(false)
const hideBasics = ref(false)
const hideDetails = ref(false)
const hideDetailsOnResult = ref(false)
const executionSeconds = ref(0)
const icon = ref('')
const title = ref('Waiting for result...')
const titleTooltip = ref('')
const duration = ref('')
const logEntry = ref(null)
const canRerun = ref(false)
const canKill = ref(false)
let executionTicker = null
let terminal = null
function initializeTerminal() {
terminal = new OutputTerminal()
console.log('initializeTerminal', xtermOutput.value)
terminal.open(xtermOutput.value)
terminal.resize(80, 24)
window.terminal = terminal
}
function toggleSize() {
isBig.value = !isBig.value
if (isBig.value) {
terminal.fit()
} else {
terminal.resize(80, 24)
}
}
async function reset() {
executionSeconds.value = 0
executionTrackingId.value = 'notset'
isBig.value = false
hideBasics.value = false
hideDetails.value = false
hideDetailsOnResult.value = false
icon.value = ''
title.value = 'Waiting for result...'
titleTooltip.value = ''
duration.value = ''
canRerun.value = false
canKill.value = false
logEntry.value = null
if (terminal) {
await terminal.reset()
terminal.fit()
}
}
function show(actionButton) {
if (actionButton) {
icon.value = actionButton.domIcon.innerText
}
canKill.value = true
// Clear existing ticker
if (executionTicker) {
clearInterval(executionTicker)
}
executionSeconds.value = 0
executionTick()
executionTicker = setInterval(() => {
executionTick()
}, 1000)
}
function rerunAction() {
if (logEntry.value && logEntry.value.actionId) {
const actionButton = document.getElementById('actionButton-' + logEntry.value.actionId)
if (actionButton && actionButton.btn) {
actionButton.btn.click()
}
}
}
async function killAction() {
if (!executionTrackingId.value || executionTrackingId.value === 'notset') {
return
}
const killActionArgs = {
executionTrackingId: executionTrackingId.value
}
try {
await window.client.killAction(killActionArgs)
} catch (err) {
console.error('Failed to kill action:', err)
}
}
function executionTick() {
executionSeconds.value++
updateDuration(null)
}
function hideEverythingApartFromOutput() {
hideDetailsOnResult.value = true
hideBasics.value = true
hideDetailsOnResult.value = true
hideBasics.value = true
}
async function fetchExecutionResult(executionTrackingIdParam) {
console.log("fetchExecutionResult", executionTrackingIdParam)
executionTrackingId.value = executionTrackingIdParam
const executionStatusArgs = {
executionTrackingId: executionTrackingId.value
}
try {
const logEntryResult = await window.client.executionStatus(executionStatusArgs)
await renderExecutionResult(logEntryResult)
} catch (err) {
renderError(err)
throw err
}
}
function updateDuration(logEntryParam) {
logEntry.value = logEntryParam
if (logEntry.value == null) {
duration.value = executionSeconds.value + ' seconds'
duration.value = duration.value
} else if (!logEntry.value.executionStarted) {
duration.value = logEntry.value.datetimeStarted + ' (request time). Not executed.'
} else if (logEntry.value.executionStarted && !logEntry.value.executionFinished) {
duration.value = logEntry.value.datetimeStarted
} else {
let delta = ''
try {
delta = (new Date(logEntry.value.datetimeStarted) - new Date(logEntry.value.datetimeStarted)) / 1000
delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
} catch (e) {
console.warn('Failed to calculate delta', e)
}
duration.value = logEntry.value.datetimeStarted + ' &rarr; ' + logEntry.value.datetimeFinished
if (delta !== '') {
duration.value += ' (' + delta + ')'
}
}
}
async function renderExecutionResult(res) {
logEntry.value = res.logEntry
// Clear ticker
if (executionTicker) {
clearInterval(executionTicker)
}
executionTicker = null
if (hideDetailsOnResult.value) {
hideDetails.value = true
}
executionTrackingId.value = res.logEntry.executionTrackingId
canRerun.value = res.logEntry.executionFinished
canKill.value = res.logEntry.canKill
icon.value = res.logEntry.actionIcon
title.value = res.logEntry.actionTitle
titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
updateDuration(res.logEntry)
if (terminal) {
await terminal.reset()
await terminal.write(res.logEntry.output, () => {
terminal.fit()
})
}
}
function renderError(err) {
window.showBigError('execution-dlg-err', 'in the execution dialog', 'Failed to fetch execution result. ' + err, false)
}
function handleClose() {
if (executionTicker) {
clearInterval(executionTicker)
}
executionTicker = null
}
function cleanup() {
if (executionTicker) {
clearInterval(executionTicker)
}
executionTicker = null
if (terminal != null) {
terminal.close()
}
terminal = null
}
function goBack() {
router.back()
}
onMounted(() => {
initializeTerminal()
fetchExecutionResult(props.executionTrackingId)
})
onBeforeUnmount(() => {
cleanup()
})
// Expose methods for parent/imperative use
defineExpose({
reset,
show,
rerunAction,
killAction,
fetchExecutionResult,
renderExecutionResult,
hideEverythingApartFromOutput,
handleClose
})
</script>

View File

@@ -1,9 +1,11 @@
<template>
<section class = "small">
<section class = "small" style = "margin: auto;">
<div class = "section-header">
<div class="login-container">
<div class="login-form" style="display: grid; grid-template-columns: max-content 1fr; gap: 1em;">
<h2>Login to OliveTin</h2>
</div>
<div class = "section-content">
<div v-if="!hasOAuth && !hasLocalLogin" class="login-disabled">
<p>This server is not configured with either OAuth, or local users, so you cannot login.</p>
</div>
@@ -40,6 +42,7 @@
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,256 @@
<template>
<section>
<div class="section-header">
<h2>Logs</h2>
<div>
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input placeholder="Filter current page" v-model="searchText" />
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</label>
</div>
</div>
<div class="section-content">
<p>This is a list of logs from actions that have been executed. You can filter the list by action title.</p>
<div v-show="filteredLogs.length > 0">
<table class="logs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Metadata</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td>
<span class="icon" v-html="log.actionIcon"></span>
<router-link :to="`/logs/${log.executionTrackingId}`">
{{ log.actionTitle }}
</router-link>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ log.user }}</span>
</span>
<span v-if="log.tags && log.tags.length > 0" class="tag-list">
<span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<span :class="getStatusClass(log) + ' annotation'">
{{ getStatusText(log) }}
</span>
</td>
</tr>
</tbody>
</table>
<Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" @page-change="handlePageChange"
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
</div>
<div v-show="logs.length === 0" class="empty-state">
<p>There are no logs to display.</p>
<router-link to="/">Return to index</router-link>
</div>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Pagination from '../components/Pagination.vue'
const logs = ref([])
const searchText = ref('')
const pageSize = ref(10)
const currentPage = ref(1)
const loading = ref(false)
const totalCount = ref(0)
const filteredLogs = computed(() => {
if (!searchText.value) {
return logs.value
}
const searchLower = searchText.value.toLowerCase()
return logs.value.filter(log =>
log.actionTitle.toLowerCase().includes(searchLower)
)
})
async function fetchLogs() {
loading.value = true
try {
const startOffset = (currentPage.value - 1) * pageSize.value
const args = {
"startOffset": BigInt(startOffset),
}
const response = await window.client.getLogs(args)
logs.value = response.logs
pageSize.value = Number(response.pageSize) || 0
totalCount.value = Number(response.totalCount) || 0
} catch (err) {
console.error('Failed to fetch logs:', err)
window.showBigError('fetch-logs', 'getting logs', err, false)
} finally {
loading.value = false
}
}
function clearSearch() {
searchText.value = ''
}
function formatTimestamp(timestamp) {
if (!timestamp) return 'Unknown'
try {
const date = new Date(timestamp)
return date.toLocaleString()
} catch (err) {
return timestamp
}
}
function getStatusClass(log) {
if (log.timedOut) return 'status-timeout'
if (log.blocked) return 'status-blocked'
if (log.exitCode !== 0) return 'status-error'
return 'status-success'
}
function getStatusText(log) {
if (log.timedOut) return 'Timed out'
if (log.blocked) return 'Blocked'
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
return 'Completed'
}
function handlePageChange(page) {
currentPage.value = page
fetchLogs()
}
function handlePageSizeChange(newPageSize) {
pageSize.value = newPageSize
currentPage.value = 1 // Reset to first page
}
onMounted(() => {
fetchLogs()
})
</script>
<style scoped>
.logs-view {
padding: 1rem;
}
.input-with-icons {
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
}
.input-with-icons input {
border: none;
outline: none;
flex: 1;
font-size: 1rem;
}
.input-with-icons button {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 3px;
}
.input-with-icons button:hover:not(:disabled) {
background: #f5f5f5;
}
.input-with-icons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.timestamp {
font-family: monospace;
font-size: 0.875rem;
color: #666;
}
.icon {
margin-right: 0.5rem;
font-size: 1.2em;
}
.content {
color: #007bff;
text-decoration: none;
cursor: pointer;
}
.content:hover {
text-decoration: underline;
}
.status-success {
color: #28a745;
font-weight: 500;
}
.status-error {
color: #dc3545;
font-weight: 500;
}
.status-timeout {
color: #ffc107;
font-weight: 500;
}
.status-blocked {
color: #6c757d;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
}
.empty-state a {
color: #007bff;
text-decoration: none;
}
.empty-state a:hover {
text-decoration: underline;
}
</style>

View File

@@ -1,318 +0,0 @@
<template>
<div class="logs-view">
<div class="toolbar">
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"/>
</svg>
<input
placeholder="Search for action name"
v-model="searchText"
@input="handleSearch"
/>
<button
title="Clear search filter"
:disabled="!searchText"
@click="clearSearch"
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"/>
</svg>
</button>
</label>
</div>
<table v-show="filteredLogs.length > 0" class="logs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Metadata</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in filteredLogs"
:key="log.executionTrackingId"
class="log-row"
:title="log.actionTitle"
>
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td>
<span class="icon" v-html="log.actionIcon"></span>
<a href="javascript:void(0)" class="content" @click="showLogDetails(log)">
{{ log.actionTitle }}
</a>
</td>
<td class="tags">
<span v-if="log.tags && log.tags.length > 0" class="tag-list">
<span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<span :class="getStatusClass(log)">
{{ getStatusText(log) }}
</span>
</td>
</tr>
</tbody>
</table>
<div v-show="filteredLogs.length === 0" class="empty-state">
<p>There are no logs to display.</p>
<router-link to="/">Return to index</router-link>
</div>
<p class="note">
<strong>Note:</strong> The server is configured to only send
<strong>{{ pageSize }}</strong> log entries at a time.
The search box at the top of this page only searches this current page of logs.
</p>
</div>
</template>
<script>
export default {
name: 'LogsView',
data() {
return {
logs: [],
searchText: '',
pageSize: '?',
loading: false
}
},
computed: {
filteredLogs() {
if (!this.searchText) {
return this.logs
}
const searchLower = this.searchText.toLowerCase()
return this.logs.filter(log =>
log.actionTitle.toLowerCase().includes(searchLower)
)
}
},
mounted() {
this.fetchLogs()
this.fetchPageSize()
},
methods: {
async fetchLogs() {
this.loading = true
try {
const response = await window.client.getLogs()
this.logs = response.logEntries || []
} catch (err) {
console.error('Failed to fetch logs:', err)
window.showBigError('fetch-logs', 'getting logs', err, false)
} finally {
this.loading = false
}
},
async fetchPageSize() {
try {
const response = await fetch('webUiSettings.json')
const settings = await response.json()
this.pageSize = settings.LogsPageSize || '?'
} catch (err) {
console.warn('Failed to fetch page size:', err)
}
},
handleSearch() {
// Search is handled by computed property
},
clearSearch() {
this.searchText = ''
},
formatTimestamp(timestamp) {
if (!timestamp) return 'Unknown'
try {
const date = new Date(timestamp)
return date.toLocaleString()
} catch (err) {
return timestamp
}
},
getStatusClass(log) {
if (log.timedOut) return 'status-timeout'
if (log.blocked) return 'status-blocked'
if (log.exitCode !== 0) return 'status-error'
return 'status-success'
},
getStatusText(log) {
if (log.timedOut) return 'Timed out'
if (log.blocked) return 'Blocked'
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
return 'Success'
},
showLogDetails(log) {
// Emit event to parent or use global execution dialog
if (window.executionDialog) {
window.executionDialog.reset()
window.executionDialog.show()
window.executionDialog.fetchExecutionResult(log.executionTrackingId)
}
}
}
}
</script>
<style scoped>
.logs-view {
padding: 1rem;
}
.toolbar {
margin-bottom: 1rem;
}
.input-with-icons {
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
}
.input-with-icons input {
border: none;
outline: none;
flex: 1;
font-size: 1rem;
}
.input-with-icons button {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 3px;
}
.input-with-icons button:hover:not(:disabled) {
background: #f5f5f5;
}
.input-with-icons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.logs-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.logs-table th {
background: #f8f9fa;
padding: 0.75rem;
text-align: left;
font-weight: 600;
border-bottom: 1px solid #dee2e6;
}
.logs-table td {
padding: 0.75rem;
border-bottom: 1px solid #f1f3f4;
}
.logs-table tr:hover {
background: #f8f9fa;
}
.timestamp {
font-family: monospace;
font-size: 0.875rem;
color: #666;
}
.icon {
margin-right: 0.5rem;
font-size: 1.2em;
}
.content {
color: #007bff;
text-decoration: none;
cursor: pointer;
}
.content:hover {
text-decoration: underline;
}
.tag-list {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tag {
background: #e9ecef;
color: #495057;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
}
.status-success {
color: #28a745;
font-weight: 500;
}
.status-error {
color: #dc3545;
font-weight: 500;
}
.status-timeout {
color: #ffc107;
font-weight: 500;
}
.status-blocked {
color: #6c757d;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #666;
}
.empty-state a {
color: #007bff;
text-decoration: none;
}
.empty-state a:hover {
text-decoration: underline;
}
.note {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.875rem;
color: #666;
}
</style>

View File

@@ -4,13 +4,12 @@
<div class="not-found-content">
<h1>404</h1>
<h2>Page Not Found</h2>
<p>The page you're looking for doesn't exist.</p>
<div class="actions">
<router-link to="/" class="btn btn-primary">
<button class = "button good" @click="goToHome">
Go to Home
</router-link>
<button @click="goBack" class="btn btn-secondary">
</button>
<button class="button neutral" @click="goBack">
Go Back
</button>
</div>
@@ -25,29 +24,15 @@ export default {
methods: {
goBack() {
this.$router.go(-1)
},
goToHome() {
this.$router.push('/')
}
}
}
</script>
<style scoped>
.not-found-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
}
.not-found-container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 500px;
width: 100%;
}
.not-found-content {
padding: 3rem 2rem;
@@ -57,7 +42,6 @@ export default {
.not-found-content h1 {
font-size: 6rem;
margin: 0;
color: #007bff;
font-weight: 700;
line-height: 1;
}
@@ -73,40 +57,4 @@ export default {
color: #666;
margin-bottom: 2rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.2s ease;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
</style>

View File

@@ -1,8 +1,13 @@
@import 'femtocrank/style.css';
section.transparent {
background-color: transparent;
box-shadow: none;
header {
position: fixed;
width: 100%;
z-index: 5;
}
aside {
padding-top: 4em;
}
fieldset {
@@ -13,6 +18,10 @@ fieldset {
place-items: stretch;
}
main {
padding-top: 4em;
}
action-button {
display: flex;
flex-direction: column;
@@ -39,3 +48,66 @@ dialog {
footer span {
margin-right: 1em;
}
legend {
font-weight: bold;
text-align: center;
padding: 1em;
padding-top: 1.5em;
}
button.neutral {
background-color: transparent;
color: white;
}
section {
padding: 0;
}
.display {
border: 1px solid #666;
padding: 1em;
border-radius: .7em;
box-shadow: 0 0 .6em #aaa;
text-align: center;
font-size: small;
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
align-items: center;
}
aside .flex-row {
padding-left: 1em;
padding-right: .5em;
}
#sidebar-toggler-button {
margin-right: .5em;
}
div.buttons button svg {
vertical-align: middle;
}
section .section-content {
padding-top: 0;
}
footer {
font-size: small;
}
th {
background-color: #fff;
}
aside {
z-index: 3; /* Make sure the sidebar is on top of the terminal */
}
section.small {
border-radius: .4em;
}

View File

@@ -33,9 +33,9 @@ const (
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// OliveTinApiServiceGetDashboardComponentsProcedure is the fully-qualified name of the
// OliveTinApiService's GetDashboardComponents RPC.
OliveTinApiServiceGetDashboardComponentsProcedure = "/olivetin.api.v1.OliveTinApiService/GetDashboardComponents"
// OliveTinApiServiceGetDashboardProcedure is the fully-qualified name of the OliveTinApiService's
// GetDashboard RPC.
OliveTinApiServiceGetDashboardProcedure = "/olivetin.api.v1.OliveTinApiService/GetDashboard"
// OliveTinApiServiceStartActionProcedure is the fully-qualified name of the OliveTinApiService's
// StartAction RPC.
OliveTinApiServiceStartActionProcedure = "/olivetin.api.v1.OliveTinApiService/StartAction"
@@ -90,11 +90,22 @@ const (
// OliveTinApiServiceGetDiagnosticsProcedure is the fully-qualified name of the OliveTinApiService's
// GetDiagnostics RPC.
OliveTinApiServiceGetDiagnosticsProcedure = "/olivetin.api.v1.OliveTinApiService/GetDiagnostics"
// OliveTinApiServiceInitProcedure is the fully-qualified name of the OliveTinApiService's Init RPC.
OliveTinApiServiceInitProcedure = "/olivetin.api.v1.OliveTinApiService/Init"
// OliveTinApiServiceGetActionBindingProcedure is the fully-qualified name of the
// OliveTinApiService's GetActionBinding RPC.
OliveTinApiServiceGetActionBindingProcedure = "/olivetin.api.v1.OliveTinApiService/GetActionBinding"
// OliveTinApiServiceGetEntitiesProcedure is the fully-qualified name of the OliveTinApiService's
// GetEntities RPC.
OliveTinApiServiceGetEntitiesProcedure = "/olivetin.api.v1.OliveTinApiService/GetEntities"
// OliveTinApiServiceGetEntityProcedure is the fully-qualified name of the OliveTinApiService's
// GetEntity RPC.
OliveTinApiServiceGetEntityProcedure = "/olivetin.api.v1.OliveTinApiService/GetEntity"
)
// OliveTinApiServiceClient is a client for the olivetin.api.v1.OliveTinApiService service.
type OliveTinApiServiceClient interface {
GetDashboardComponents(context.Context, *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error)
GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
@@ -113,6 +124,10 @@ type OliveTinApiServiceClient interface {
Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error)
EventStream(context.Context, *connect.Request[v1.EventStreamRequest]) (*connect.ServerStreamForClient[v1.EventStreamResponse], error)
GetDiagnostics(context.Context, *connect.Request[v1.GetDiagnosticsRequest]) (*connect.Response[v1.GetDiagnosticsResponse], error)
Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error)
GetActionBinding(context.Context, *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error)
GetEntities(context.Context, *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error)
GetEntity(context.Context, *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error)
}
// NewOliveTinApiServiceClient constructs a client for the olivetin.api.v1.OliveTinApiService
@@ -126,10 +141,10 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string,
baseURL = strings.TrimRight(baseURL, "/")
oliveTinApiServiceMethods := v1.File_olivetin_api_v1_olivetin_proto.Services().ByName("OliveTinApiService").Methods()
return &oliveTinApiServiceClient{
getDashboardComponents: connect.NewClient[v1.GetDashboardComponentsRequest, v1.GetDashboardComponentsResponse](
getDashboard: connect.NewClient[v1.GetDashboardRequest, v1.GetDashboardResponse](
httpClient,
baseURL+OliveTinApiServiceGetDashboardComponentsProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboardComponents")),
baseURL+OliveTinApiServiceGetDashboardProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboard")),
connect.WithClientOptions(opts...),
),
startAction: connect.NewClient[v1.StartActionRequest, v1.StartActionResponse](
@@ -240,12 +255,36 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDiagnostics")),
connect.WithClientOptions(opts...),
),
init: connect.NewClient[v1.InitRequest, v1.InitResponse](
httpClient,
baseURL+OliveTinApiServiceInitProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("Init")),
connect.WithClientOptions(opts...),
),
getActionBinding: connect.NewClient[v1.GetActionBindingRequest, v1.GetActionBindingResponse](
httpClient,
baseURL+OliveTinApiServiceGetActionBindingProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionBinding")),
connect.WithClientOptions(opts...),
),
getEntities: connect.NewClient[v1.GetEntitiesRequest, v1.GetEntitiesResponse](
httpClient,
baseURL+OliveTinApiServiceGetEntitiesProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntities")),
connect.WithClientOptions(opts...),
),
getEntity: connect.NewClient[v1.GetEntityRequest, v1.Entity](
httpClient,
baseURL+OliveTinApiServiceGetEntityProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntity")),
connect.WithClientOptions(opts...),
),
}
}
// oliveTinApiServiceClient implements OliveTinApiServiceClient.
type oliveTinApiServiceClient struct {
getDashboardComponents *connect.Client[v1.GetDashboardComponentsRequest, v1.GetDashboardComponentsResponse]
getDashboard *connect.Client[v1.GetDashboardRequest, v1.GetDashboardResponse]
startAction *connect.Client[v1.StartActionRequest, v1.StartActionResponse]
startActionAndWait *connect.Client[v1.StartActionAndWaitRequest, v1.StartActionAndWaitResponse]
startActionByGet *connect.Client[v1.StartActionByGetRequest, v1.StartActionByGetResponse]
@@ -264,11 +303,15 @@ type oliveTinApiServiceClient struct {
logout *connect.Client[v1.LogoutRequest, v1.LogoutResponse]
eventStream *connect.Client[v1.EventStreamRequest, v1.EventStreamResponse]
getDiagnostics *connect.Client[v1.GetDiagnosticsRequest, v1.GetDiagnosticsResponse]
init *connect.Client[v1.InitRequest, v1.InitResponse]
getActionBinding *connect.Client[v1.GetActionBindingRequest, v1.GetActionBindingResponse]
getEntities *connect.Client[v1.GetEntitiesRequest, v1.GetEntitiesResponse]
getEntity *connect.Client[v1.GetEntityRequest, v1.Entity]
}
// GetDashboardComponents calls olivetin.api.v1.OliveTinApiService.GetDashboardComponents.
func (c *oliveTinApiServiceClient) GetDashboardComponents(ctx context.Context, req *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error) {
return c.getDashboardComponents.CallUnary(ctx, req)
// GetDashboard calls olivetin.api.v1.OliveTinApiService.GetDashboard.
func (c *oliveTinApiServiceClient) GetDashboard(ctx context.Context, req *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error) {
return c.getDashboard.CallUnary(ctx, req)
}
// StartAction calls olivetin.api.v1.OliveTinApiService.StartAction.
@@ -361,9 +404,29 @@ func (c *oliveTinApiServiceClient) GetDiagnostics(ctx context.Context, req *conn
return c.getDiagnostics.CallUnary(ctx, req)
}
// Init calls olivetin.api.v1.OliveTinApiService.Init.
func (c *oliveTinApiServiceClient) Init(ctx context.Context, req *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) {
return c.init.CallUnary(ctx, req)
}
// GetActionBinding calls olivetin.api.v1.OliveTinApiService.GetActionBinding.
func (c *oliveTinApiServiceClient) GetActionBinding(ctx context.Context, req *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error) {
return c.getActionBinding.CallUnary(ctx, req)
}
// GetEntities calls olivetin.api.v1.OliveTinApiService.GetEntities.
func (c *oliveTinApiServiceClient) GetEntities(ctx context.Context, req *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error) {
return c.getEntities.CallUnary(ctx, req)
}
// GetEntity calls olivetin.api.v1.OliveTinApiService.GetEntity.
func (c *oliveTinApiServiceClient) GetEntity(ctx context.Context, req *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error) {
return c.getEntity.CallUnary(ctx, req)
}
// OliveTinApiServiceHandler is an implementation of the olivetin.api.v1.OliveTinApiService service.
type OliveTinApiServiceHandler interface {
GetDashboardComponents(context.Context, *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error)
GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error)
StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error)
StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error)
StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error)
@@ -382,6 +445,10 @@ type OliveTinApiServiceHandler interface {
Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error)
EventStream(context.Context, *connect.Request[v1.EventStreamRequest], *connect.ServerStream[v1.EventStreamResponse]) error
GetDiagnostics(context.Context, *connect.Request[v1.GetDiagnosticsRequest]) (*connect.Response[v1.GetDiagnosticsResponse], error)
Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error)
GetActionBinding(context.Context, *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error)
GetEntities(context.Context, *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error)
GetEntity(context.Context, *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error)
}
// NewOliveTinApiServiceHandler builds an HTTP handler from the service implementation. It returns
@@ -391,10 +458,10 @@ type OliveTinApiServiceHandler interface {
// and JSON codecs. They also support gzip compression.
func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
oliveTinApiServiceMethods := v1.File_olivetin_api_v1_olivetin_proto.Services().ByName("OliveTinApiService").Methods()
oliveTinApiServiceGetDashboardComponentsHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetDashboardComponentsProcedure,
svc.GetDashboardComponents,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboardComponents")),
oliveTinApiServiceGetDashboardHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetDashboardProcedure,
svc.GetDashboard,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboard")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceStartActionHandler := connect.NewUnaryHandler(
@@ -505,10 +572,34 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDiagnostics")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceInitHandler := connect.NewUnaryHandler(
OliveTinApiServiceInitProcedure,
svc.Init,
connect.WithSchema(oliveTinApiServiceMethods.ByName("Init")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceGetActionBindingHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetActionBindingProcedure,
svc.GetActionBinding,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionBinding")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceGetEntitiesHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetEntitiesProcedure,
svc.GetEntities,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntities")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceGetEntityHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetEntityProcedure,
svc.GetEntity,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetEntity")),
connect.WithHandlerOptions(opts...),
)
return "/olivetin.api.v1.OliveTinApiService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case OliveTinApiServiceGetDashboardComponentsProcedure:
oliveTinApiServiceGetDashboardComponentsHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetDashboardProcedure:
oliveTinApiServiceGetDashboardHandler.ServeHTTP(w, r)
case OliveTinApiServiceStartActionProcedure:
oliveTinApiServiceStartActionHandler.ServeHTTP(w, r)
case OliveTinApiServiceStartActionAndWaitProcedure:
@@ -545,6 +636,14 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
oliveTinApiServiceEventStreamHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetDiagnosticsProcedure:
oliveTinApiServiceGetDiagnosticsHandler.ServeHTTP(w, r)
case OliveTinApiServiceInitProcedure:
oliveTinApiServiceInitHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetActionBindingProcedure:
oliveTinApiServiceGetActionBindingHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetEntitiesProcedure:
oliveTinApiServiceGetEntitiesHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetEntityProcedure:
oliveTinApiServiceGetEntityHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
@@ -554,8 +653,8 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
// UnimplementedOliveTinApiServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedOliveTinApiServiceHandler struct{}
func (UnimplementedOliveTinApiServiceHandler) GetDashboardComponents(context.Context, *connect.Request[v1.GetDashboardComponentsRequest]) (*connect.Response[v1.GetDashboardComponentsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetDashboardComponents is not implemented"))
func (UnimplementedOliveTinApiServiceHandler) GetDashboard(context.Context, *connect.Request[v1.GetDashboardRequest]) (*connect.Response[v1.GetDashboardResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetDashboard is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error) {
@@ -629,3 +728,19 @@ func (UnimplementedOliveTinApiServiceHandler) EventStream(context.Context, *conn
func (UnimplementedOliveTinApiServiceHandler) GetDiagnostics(context.Context, *connect.Request[v1.GetDiagnosticsRequest]) (*connect.Response[v1.GetDiagnosticsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetDiagnostics is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) Init(context.Context, *connect.Request[v1.InitRequest]) (*connect.Response[v1.InitResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.Init is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) GetActionBinding(context.Context, *connect.Request[v1.GetActionBindingRequest]) (*connect.Response[v1.GetActionBindingResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetActionBinding is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) GetEntities(context.Context, *connect.Request[v1.GetEntitiesRequest]) (*connect.Response[v1.GetEntitiesResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetEntities is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) GetEntity(context.Context, *connect.Request[v1.GetEntityRequest]) (*connect.Response[v1.Entity], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetEntity is not implemented"))
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package api
import (
ctx "context"
"encoding/json"
"connectrpc.com/connect"
@@ -15,9 +16,9 @@ import (
acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config"
entities "github.com/OliveTin/OliveTin/internal/entities"
executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
)
type oliveTinAPI struct {
@@ -87,19 +88,17 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
args[arg.Name] = arg.Value
}
api.executor.MapActionIdToBindingLock.RLock()
pair := api.executor.MapActionIdToBinding[req.Msg.ActionId]
api.executor.MapActionIdToBindingLock.RUnlock()
pair := api.executor.FindBindingByID(req.Msg.BindingId)
if pair == nil || pair.Action == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
}
authenticatedUser := acl.UserFromContext(ctx, api.cfg)
execReq := executor.ExecutionRequest{
Action: pair.Action,
EntityPrefix: pair.EntityPrefix,
Entity: pair.Entity,
TrackingID: req.Msg.UniqueTrackingId,
Arguments: args,
AuthenticatedUser: authenticatedUser,
@@ -159,7 +158,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
user := acl.UserFromContext(ctx, api.cfg)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.Msg.ActionId),
Action: api.executor.FindActionByBindingID(req.Msg.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: user,
@@ -184,7 +183,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
args := make(map[string]string)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.Msg.ActionId),
Action: api.executor.FindActionByBindingID(req.Msg.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
@@ -204,7 +203,7 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
user := acl.UserFromContext(ctx, api.cfg)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.Msg.ActionId),
Action: api.executor.FindActionByBindingID(req.Msg.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: user,
@@ -299,34 +298,6 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
return connect.NewResponse(res), nil
}
/**
func (api *oliveTinAPI) WatchExecution(req *apiv1.WatchExecutionRequest, srv apiv1.OliveTinApi_WatchExecutionServer) error {
log.Infof("Watch")
if logEntry, ok := api.executor.Logs[req.ExecutionUuid]; !ok {
log.Errorf("Execution not found: %v", req.ExecutionUuid)
return nil
} else {
if logEntry.ExecutionStarted {
for !logEntry.ExecutionCompleted {
tmp := make([]byte, 256)
red, err := io.ReadAtLeast(logEntry.StdoutBuffer, tmp, 1)
log.Infof("%v %v", red, err)
srv.Send(&apiv1.WatchExecutionUpdate{
Update: string(tmp),
})
}
}
return nil
}
}
*/
func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
//user := acl.UserFromContext(ctx, cfg)
@@ -336,14 +307,26 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
return nil, nil
}
func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardComponentsRequest]) (*connect.Response[apiv1.GetDashboardComponentsResponse], error) {
func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
binding := api.executor.FindBindingByID(req.Msg.BindingId)
return connect.NewResponse(&apiv1.GetActionBindingResponse{
Action: buildAction(req.Msg.BindingId, binding, &DashboardRenderRequest{
cfg: api.cfg,
AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
ex: api.executor,
}),
}), nil
}
func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin {
return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard"))
}
res := buildDashboardResponse(api.executor, api.cfg, user)
res := buildDashboardResponse(api.executor, api.cfg, user, req.Msg.Title)
/*
if len(res.Actions) == 0 {
@@ -367,7 +350,7 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
ret := &apiv1.GetLogsResponse{}
logEntries, countRemaining := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
for _, logEntry := range logEntries {
action := api.cfg.FindAction(logEntry.ActionTitle)
@@ -379,8 +362,10 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
}
}
ret.CountRemaining = countRemaining
ret.PageSize = api.cfg.LogHistoryPageSize
ret.CountRemaining = pagingResult.CountRemaining
ret.PageSize = pagingResult.PageSize
ret.TotalCount = pagingResult.TotalCount
ret.StartOffset = pagingResult.StartOffset
return connect.NewResponse(ret), nil
}
@@ -442,8 +427,10 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum
return connect.NewResponse(res), nil
}
jsonstring, _ := json.MarshalIndent(entities.GetAll(), "", " ")
fmt.Printf("%s", &jsonstring)
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
res.Contents = sv.GetAll()
return connect.NewResponse(res), nil
}
@@ -463,7 +450,7 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Requ
for k, v := range api.executor.MapActionIdToBinding {
res.Contents[k] = &apiv1.ActionEntityPair{
ActionTitle: v.Action.Title,
EntityPrefix: v.EntityPrefix,
EntityPrefix: "?",
}
}
@@ -562,6 +549,72 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
return connect.NewResponse(res), nil
}
func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
res := &apiv1.InitResponse{
ShowFooter: api.cfg.ShowFooter,
ShowNavigation: api.cfg.ShowNavigation,
ShowNewVersions: api.cfg.ShowNewVersions,
AvailableVersion: installationinfo.Runtime.AvailableVersion,
CurrentVersion: installationinfo.Build.Version,
PageTitle: api.cfg.PageTitle,
SectionNavigationStyle: api.cfg.SectionNavigationStyle,
DefaultIconForBack: api.cfg.DefaultIconForBack,
EnableCustomJs: api.cfg.EnableCustomJs,
AuthLoginUrl: api.cfg.AuthLoginUrl,
AuthLocalLogin: api.cfg.AuthLocalUsers.Enabled,
OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
StyleMods: api.cfg.StyleMods,
RootDashboards: buildRootDashboards(api.cfg.Dashboards),
AuthenticatedUser: user.Username,
AuthenticatedUserProvider: user.Provider,
EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy),
BannerMessage: api.cfg.BannerMessage,
BannerCss: api.cfg.BannerCSS,
}
return connect.NewResponse(res), nil
}
func buildRootDashboards(dashboards []*config.DashboardComponent) []string {
var rootDashboards []string
for _, dashboard := range dashboards {
rootDashboards = append(rootDashboards, dashboard.Title)
}
return rootDashboards
}
func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
var publicProviders []*apiv1.OAuth2Provider
for _, provider := range cfg.AuthOAuth2Providers {
publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
Title: provider.Title,
Url: provider.AuthUrl,
Icon: provider.Icon,
})
}
return publicProviders
}
func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLink {
var additionalLinks []*apiv1.AdditionalLink
for _, link := range links {
additionalLinks = append(additionalLinks, &apiv1.AdditionalLink{
Title: link.Title,
Url: link.Url,
})
}
return additionalLinks
}
func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
for _, client := range api.connectedClients {
select {
@@ -579,6 +632,73 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
}
}
func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
res := &apiv1.GetEntitiesResponse{
EntityDefinitions: make([]*apiv1.EntityDefinition, 0),
}
for name, entityInstances := range entities.GetEntities() {
def := &apiv1.EntityDefinition{
Title: name,
UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
}
for _, e := range entityInstances {
entity := &apiv1.Entity{
Title: e.Title,
UniqueKey: e.UniqueKey,
Type: name,
}
def.Instances = append(def.Instances, entity)
}
res.EntityDefinitions = append(res.EntityDefinitions, def)
}
return connect.NewResponse(res), nil
}
func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
var foundDashboards []string
findEntityInComponents(entityTitle, "", dashboards, &foundDashboards)
return foundDashboards
}
func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string) {
for _, component := range components {
if component.Entity == entityTitle {
*foundDashboards = append(*foundDashboards, parentTitle)
}
if len(component.Contents) > 0 {
findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards)
}
}
}
func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
res := &apiv1.Entity{}
instances := entities.GetEntityInstances(req.Msg.Type)
log.Infof("msg: %+v", req.Msg)
if instances == nil || len(instances) == 0 {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
}
if entity, ok := instances[req.Msg.UniqueKey]; !ok {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
} else {
res.Title = entity.Title
return connect.NewResponse(res), nil
}
}
func newServer(ex *executor.Executor) *oliveTinAPI {
server := oliveTinAPI{}
server.cfg = ex.Cfg

View File

@@ -4,71 +4,40 @@ import (
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config"
entities "github.com/OliveTin/OliveTin/internal/entities"
executor "github.com/OliveTin/OliveTin/internal/executor"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus"
)
type DashboardRenderRequest struct {
AuthenticatedUser *acl.AuthenticatedUser
AllowedActionTitles []string `json:"allows_action_titles"`
cfg *config.Config
ex *executor.Executor
usedActions map[string]bool
AuthenticatedUser *acl.AuthenticatedUser
cfg *config.Config
ex *executor.Executor
}
func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
for _, action := range rr.cfg.Actions {
log.Infof("Checking action %s against %s", title, action.Title)
if action.Title == title {
return buildAction(action.ID, nil, rr)
for id, binding := range rr.ex.MapActionIdToBinding {
if binding.Action.Title == title {
return buildAction(id, binding, rr)
}
}
return nil
}
func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl.AuthenticatedUser) *apiv1.GetDashboardComponentsResponse {
res := &apiv1.GetDashboardComponentsResponse{
AuthenticatedUser: user.Username,
AuthenticatedUserProvider: user.Provider,
}
/*
sort.Slice(res.Actions, func(i, j int) bool {
if res.Actions[i].Order == res.Actions[j].Order {
return res.Actions[i].Title < res.Actions[j].Title
} else {
return res.Actions[i].Order < res.Actions[j].Order
}
})
*/
func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl.AuthenticatedUser, dashboardTitle string) *apiv1.GetDashboardResponse {
res := &apiv1.GetDashboardResponse{}
rr := &DashboardRenderRequest{
AuthenticatedUser: user,
// AllowedActionTitles: getActionTitles(res.Actions),
cfg: cfg,
ex: ex,
usedActions: make(map[string]bool),
cfg: cfg,
ex: ex,
}
res.EffectivePolicy = buildEffectivePolicy(user.EffectivePolicy)
res.Dashboards = dashboardCfgToPb(rr)
res.Dashboard = dashboardCfgToPb(rr, dashboardTitle)
return res
}
func getActionTitles(actions []*apiv1.Action) []string {
titles := make([]string, 0, len(actions))
for _, action := range actions {
titles = append(titles, action.Title)
}
return titles
}
func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePolicy {
ret := &apiv1.EffectivePolicy{
ShowDiagnostics: policy.ShowDiagnostics,
@@ -78,13 +47,13 @@ func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePo
return ret
}
func buildAction(actionId string, actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
func buildAction(bindingId string, actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
action := actionBinding.Action
btn := apiv1.Action{
Id: actionId,
Title: sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Title),
Icon: sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Icon),
BindingId: bindingId,
Title: entities.ParseTemplateWith(action.Title, actionBinding.Entity),
Icon: entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
PopupOnStart: action.PopupOnStart,
Order: int32(actionBinding.ConfigOrder),
@@ -118,14 +87,12 @@ func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice {
func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string) []*apiv1.ActionArgumentChoice {
ret := []*apiv1.ActionArgumentChoice{}
entityCount := sv.GetEntityCount(entityTitle)
for i := 0; i < entityCount; i++ {
prefix := sv.GetEntityPrefix(entityTitle, i)
entList := entities.GetEntityInstances(entityTitle)
for _, ent := range entList {
ret = append(ret, &apiv1.ActionArgumentChoice{
Value: sv.ReplaceEntityVars(prefix, firstChoice.Value),
Title: sv.ReplaceEntityVars(prefix, firstChoice.Title),
Value: entities.ParseTemplateWith(firstChoice.Value, ent),
Title: entities.ParseTemplateWith(firstChoice.Title, ent),
})
}

View File

@@ -3,17 +3,17 @@ package api
import (
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"golang.org/x/exp/slices"
entities "github.com/OliveTin/OliveTin/internal/entities"
log "github.com/sirupsen/logrus"
)
func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
ret := make([]*apiv1.DashboardComponent, 0)
entityCount := sv.GetEntityCount(entityTitle)
entities := entities.GetEntityInstances(entityTitle)
for i := range entityCount {
fs := buildEntityFieldset(tpl, entityTitle, i, rr)
for _, ent := range entities {
fs := buildEntityFieldset(tpl, ent, rr)
if len(fs.Contents) > 0 {
ret = append(ret, fs)
@@ -23,32 +23,38 @@ func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr
return ret
}
func buildEntityFieldset(tpl *config.DashboardComponent, entityTitle string, entityIndex int, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
prefix := sv.GetEntityPrefix(entityTitle, entityIndex)
func buildEntityFieldset(tpl *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
return &apiv1.DashboardComponent{
Title: sv.ReplaceEntityVars(prefix, tpl.Title),
Title: entities.ParseTemplateWith(tpl.Title, ent),
Type: "fieldset",
Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, prefix, rr)),
CssClass: sv.ReplaceEntityVars(prefix, tpl.CssClass),
Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(tpl.Contents, ent, rr)),
CssClass: entities.ParseTemplateWith(tpl.CssClass, ent),
Action: rr.findAction(tpl.Title),
}
}
func removeFieldsetIfHasNoLinks(contents []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
for _, subitem := range contents {
if subitem.Type == "link" {
return contents
return contents
/*
for _, subitem := range contents {
if subitem.Type == "link" {
return contents
}
}
}
return nil
log.Infof("removeFieldsetIfHasNoLinks: %+v", contents)
return nil
*/
}
func buildEntityFieldsetContents(contents []config.DashboardComponent, prefix string, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
ret := make([]*apiv1.DashboardComponent, 0)
for _, subitem := range contents {
c := cloneItem(&subitem, prefix, rr)
c := cloneItem(subitem, ent, rr)
log.Infof("cloneItem: %+v", c)
if c != nil {
ret = append(ret, c)
@@ -58,19 +64,16 @@ func buildEntityFieldsetContents(contents []config.DashboardComponent, prefix st
return ret
}
func cloneItem(subitem *config.DashboardComponent, prefix string, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
func cloneItem(subitem *config.DashboardComponent, ent *entities.Entity, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
clone := &apiv1.DashboardComponent{}
clone.CssClass = sv.ReplaceEntityVars(prefix, subitem.CssClass)
clone.CssClass = entities.ParseTemplateWith(subitem.CssClass, ent)
if subitem.Type == "" || subitem.Type == "link" {
clone.Type = "link"
clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
if !slices.Contains(rr.AllowedActionTitles, clone.Title) {
return nil
}
clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
clone.Action = rr.findAction(subitem.Title)
} else {
clone.Title = sv.ReplaceEntityVars(prefix, subitem.Title)
clone.Title = entities.ParseTemplateWith(subitem.Title, ent)
clone.Type = subitem.Type
}

View File

@@ -1,19 +1,26 @@
package api
import (
"sort"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
"golang.org/x/exp/slices"
)
func dashboardCfgToPb(rr *DashboardRenderRequest) []*apiv1.Dashboard {
ret := make([]*apiv1.Dashboard, 0)
func dashboardCfgToPb(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {
if dashboardTitle == "default" {
return buildDefaultDashboard(rr)
}
for _, dashboard := range rr.cfg.Dashboards {
pbdb := &apiv1.Dashboard{
if dashboard.Title != dashboardTitle {
continue
}
return &apiv1.Dashboard{
Title: dashboard.Title,
Contents: removeNulls(getDashboardComponentContents(dashboard, rr)),
// Contents: removeNulls(getDashboardComponentContents(dashboard, rr)),
Contents: sortActions(removeNulls(getDashboardComponentContents(dashboard, rr))),
}
/*
@@ -25,13 +32,9 @@ func dashboardCfgToPb(rr *DashboardRenderRequest) []*apiv1.Dashboard {
continue
}
*/
ret = append(ret, pbdb)
}
ret = append(ret, buildDefaultDashboard(rr))
return ret
return nil
}
func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
@@ -48,28 +51,50 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
continue
}
if rr.usedActions[id] {
if binding.IsOnDashboard {
continue
}
rr.usedActions[id] = true
actions = append(actions, buildAction(id, binding, rr))
}
for _, action := range actions {
fieldset.Contents = append(fieldset.Contents, &apiv1.DashboardComponent{
Type: "link",
Title: action.Title,
Icon: action.Icon,
Type: "link",
Title: action.Title,
Icon: action.Icon,
Action: action,
})
}
fieldset.Contents = sortActions(fieldset.Contents)
return &apiv1.Dashboard{
Title: "Default",
Contents: []*apiv1.DashboardComponent{fieldset},
}
}
func sortActions(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
sort.Slice(components, func(i, j int) bool {
if components[i].Action == nil {
return false
}
if components[j].Action == nil {
return true
}
if components[i].Action.Order == components[j].Action.Order {
return components[i].Action.Title < components[j].Action.Title
} else {
return components[i].Action.Order < components[j].Action.Order
}
})
return components
}
func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
ret := make([]*apiv1.DashboardComponent, 0)
@@ -89,9 +114,9 @@ func getDashboardComponentContents(dashboard *config.DashboardComponent, rr *Das
for _, subitem := range dashboard.Contents {
if subitem.Type == "fieldset" && subitem.Entity != "" {
ret = append(ret, buildEntityFieldsets(subitem.Entity, &subitem, rr)...)
ret = append(ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
} else {
ret = append(ret, buildDashboardComponentSimple(&subitem, rr))
ret = append(ret, buildDashboardComponentSimple(subitem, rr))
}
}
@@ -99,18 +124,13 @@ func getDashboardComponentContents(dashboard *config.DashboardComponent, rr *Das
}
func buildDashboardComponentSimple(subitem *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
if subitem.Type == "" || subitem.Type == "link" {
if !slices.Contains(rr.AllowedActionTitles, subitem.Title) {
return nil
}
}
newitem := &apiv1.DashboardComponent{
Title: subitem.Title,
Type: getDashboardComponentType(subitem),
Contents: getDashboardComponentContents(subitem, rr),
Icon: getDashboardComponentIcon(subitem, rr.cfg),
CssClass: subitem.CssClass,
Action: rr.findAction(subitem.Title),
}
return newitem

View File

@@ -151,6 +151,8 @@ type Config struct {
AdditionalNavigationLinks []*NavigationLink
ServiceHostMode string
StyleMods []string
BannerMessage string
BannerCSS string
usedConfigDir string
}
@@ -209,7 +211,7 @@ type DashboardComponent struct {
Entity string
Icon string
CssClass string
Contents []DashboardComponent
Contents []*DashboardComponent
}
func DefaultConfig() *Config {

View File

@@ -1,18 +1,17 @@
package entityfiles
package entities
import (
"bytes"
"encoding/json"
"fmt"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/filehelper"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"math"
"os"
"path/filepath"
"strings"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/filehelper"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
var (
@@ -20,6 +19,12 @@ var (
listeners []func()
)
type Entity struct {
Data any
UniqueKey string
Title string
}
func AddListener(l func()) {
listeners = append(listeners, l)
}
@@ -121,16 +126,10 @@ func loadEntityFileYaml(filename string, entityname string) {
func updateSvFromFile(entityname string, data []map[string]any) {
log.Debugf("updateSvFromFile: %+v", data)
count := len(data)
sv.RemoveKeysThatStartWith("entities." + entityname)
sv.SetEntityCount(entityname, count)
ClearEntities(entityname)
for i, mapp := range data {
prefix := "entities." + entityname + "." + fmt.Sprintf("%v", i)
serializeValueToSv(prefix, mapp)
AddEntity(entityname, fmt.Sprintf("%d", i), mapp)
}
for _, l := range listeners {
@@ -138,6 +137,7 @@ func updateSvFromFile(entityname string, data []map[string]any) {
}
}
/*
//gocyclo:ignore
func serializeValueToSv(prefix string, value any) {
if m, ok := value.(map[string]any); ok { // if value is a map we need to flatten it
@@ -173,3 +173,5 @@ func serializeSliceToSv(prefix string, s []any) {
serializeValueToSv(prefix+"."+fmt.Sprintf("%v", i), v)
}
}
*/

View File

@@ -1,4 +1,4 @@
package entityfiles
package entities
import (
sv "github.com/OliveTin/OliveTin/internal/stringvariables"

View File

@@ -0,0 +1,111 @@
package entities
/**
* The ephemeralvariablemap is used "only" for variable substitution in config
* titles, shell arguments, etc, in the foorm of {{ key }}, like Jinja2.
*
* OliveTin itself really only ever "writes" to this map, mostly by loading
* EntityFiles, and the only form of "reading" is for the variable substitution
* in configs.
*/
import (
"strings"
"sync"
"github.com/OliveTin/OliveTin/internal/installationinfo"
)
type entityInstancesByKey map[string]*Entity
type entitiesByClass map[string]entityInstancesByKey
type variableBase struct {
OliveTin installationInfo
Entities entitiesByClass
CurrentEntity interface{}
Arguments map[string]string
}
type installationInfo struct {
Build *installationinfo.BuildInfo
Runtime *installationinfo.RuntimeInfo
}
var (
contents *variableBase
rwmutex = sync.RWMutex{}
)
func init() {
rwmutex.Lock()
contents = &variableBase{
OliveTin: installationInfo{
Build: installationinfo.Build,
Runtime: installationinfo.Runtime,
},
Entities: make(entitiesByClass, 0),
}
rwmutex.Unlock()
}
func GetAll() *variableBase {
rwmutex.RLock()
defer rwmutex.RUnlock()
return contents
}
func GetEntities() entitiesByClass {
return contents.Entities
}
func GetEntityInstances(entityName string) entityInstancesByKey {
if entities, ok := contents.Entities[entityName]; ok {
return entities
}
return nil
}
func AddEntity(entityName string, entityKey string, data any) {
rwmutex.Lock()
if _, ok := contents.Entities[entityName]; !ok {
contents.Entities[entityName] = make(entityInstancesByKey, 0)
}
contents.Entities[entityName][entityKey] = &Entity {
Data: data,
UniqueKey: entityKey,
Title: findEntityTitle(data),
}
rwmutex.Unlock()
}
func findEntityTitle(data any) string {
if mapData, ok := data.(map[string]any); ok {
keys := make(map[string]string)
for k := range mapData {
lookupKey := strings.ToLower(k)
keys[lookupKey] = k
}
for _, key := range []string{"title", "name", "id"} {
if lookupKey, exists := keys[strings.ToLower(key)]; exists {
if value, ok := mapData[lookupKey]; ok {
if valueStr, ok := value.(string); ok {
return valueStr
}
}
}
}
}
return "Untitled Entity"
}

View File

@@ -0,0 +1,106 @@
package entities
import (
"fmt"
"regexp"
"strings"
"text/template"
log "github.com/sirupsen/logrus"
)
var tpl = template.New("tpl")
var legacyEntityRegex = regexp.MustCompile(`{{ ([a-zA-Z0-9_]+)\.*?([a-zA-Z0-9_\.]+) }}`)
func migrateLegacyArgumentNames(rawShellCommand string) string {
foundArgumentNames := legacyEntityRegex.FindAllStringSubmatch(rawShellCommand, -1)
for _, match := range foundArgumentNames {
entityName := match[1]
argName := match[2]
if strings.Contains(argName, ".") {
replacement := ".CurrentEntity"
rawShellCommand = strings.Replace(rawShellCommand, entityName, replacement, -1)
log.WithFields(log.Fields{
"old": entityName,
"new": replacement,
}).Warnf("Legacy entity variable name found, changing to CurrentEntity")
continue
}
if !strings.HasPrefix(argName, ".Arguments.") {
log.WithFields(log.Fields{
"old": argName,
"new": ".Arguments." + argName,
}).Warnf("Legacy variable name found, changing to Argument")
rawShellCommand = strings.Replace(rawShellCommand, argName, ".Arguments."+argName, -1)
}
}
return rawShellCommand
}
func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) string {
source = migrateLegacyArgumentNames(source)
ret := ""
t, err := tpl.Parse(source)
if err != nil {
log.WithFields(log.Fields{
"source": source,
"err": err,
}).Error("Error parsing template")
return fmt.Sprintf("tpl parse error: %v", err.Error())
}
var entdata any
if ent != nil {
entdata = ent.Data
}
templateVariables := &variableBase{
OliveTin: contents.OliveTin,
Arguments: args,
CurrentEntity: entdata,
}
var sb strings.Builder
err = t.Execute(&sb, &templateVariables)
if err != nil {
log.WithFields(log.Fields{
"source": source,
"err": err,
"currentEntity": ent,
}).Errorf("Error executing template")
ret = fmt.Sprintf("tpl exec error: %v", err.Error())
} else {
ret = sb.String()
}
return ret
}
func ParseTemplateWith(source string, ent *Entity) string {
return ParseTemplateWithArgs(source, ent, nil)
}
func ParseTemplateBoolWith(source string, ent *Entity) bool {
source = strings.TrimSpace(source)
tplBool := ParseTemplateWith(source, ent)
return tplBool == "true"
}
func ClearEntities(entityType string) {
delete(contents.Entities, entityType)
}

View File

@@ -2,7 +2,7 @@ package executor
import (
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"github.com/OliveTin/OliveTin/internal/entities"
log "github.com/sirupsen/logrus"
"fmt"
@@ -16,16 +16,16 @@ import (
var (
typecheckRegex = map[string]string{
"very_dangerous_raw_string": "",
"int": "^[\\d]+$",
"unicode_identifier": "^[\\w\\/\\\\.\\_ \\d]+$",
"ascii": "^[a-zA-Z0-9]+$",
"ascii_identifier": "^[a-zA-Z0-9\\-\\.\\_]+$",
"ascii_sentence": "^[a-zA-Z0-9 \\,\\.]+$",
"int": `^\d+$`,
"unicode_identifier": `^[\w\-\.\_\d]+$`,
"ascii": `^[a-zA-Z0-9]+$`,
"ascii_identifier": `^[a-zA-Z0-9\-\._]+$`,
"ascii_sentence": `^[a-zA-Z0-9\-\._, ]+$`,
}
)
func parseCommandForReplacements(shellCommand string, values map[string]string) (string, error) {
r := regexp.MustCompile("{{ *?([a-zA-Z0-9_]+?) *?}}")
func parseCommandForReplacements(shellCommand string, values map[string]string, entity any) (string, error) {
r := regexp.MustCompile(`{{ *?\.Arguments\.([a-zA-Z0-9_]+?) *?}}`)
foundArgumentNames := r.FindAllStringSubmatch(shellCommand, -1)
for _, match := range foundArgumentNames {
@@ -42,12 +42,14 @@ func parseCommandForReplacements(shellCommand string, values map[string]string)
return shellCommand, nil
}
func parseActionArguments(values map[string]string, action *config.Action, entityPrefix string) (string, error) {
func parseActionArguments(rawShellCommand string, values map[string]string, action *config.Action, entity *entities.Entity) (string, error) {
log.WithFields(log.Fields{
"actionTitle": action.Title,
"cmd": action.Shell,
}).Infof("Action parse args - Before")
rawShellCommand, err := parseCommandForReplacements(rawShellCommand, values, entity)
for _, arg := range action.Arguments {
argName := arg.Name
argValue := values[argName]
@@ -64,8 +66,7 @@ func parseActionArguments(values map[string]string, action *config.Action, entit
}).Debugf("Arg assigned")
}
parsedShellCommand, err := parseCommandForReplacements(action.Shell, values)
parsedShellCommand = sv.ReplaceEntityVars(entityPrefix, parsedShellCommand)
parsedShellCommand := entities.ParseTemplateWith(rawShellCommand, entity)
redactedShellCommand := redactShellCommand(parsedShellCommand, action.Arguments, values)
if err != nil {
@@ -173,8 +174,8 @@ func typecheckChoice(value string, arg *config.ActionArgument) error {
func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
templateChoice := arg.Choices[0].Value
for _, ent := range sv.GetEntities(arg.Entity) {
choice := sv.ReplaceEntityVars(ent, templateChoice)
for _, ent := range entities.GetEntityInstances(arg.Entity) {
choice := entities.ParseTemplateWith(templateChoice, ent)
if value == choice {
return nil

View File

@@ -3,7 +3,7 @@ package executor
import (
acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
@@ -23,7 +23,7 @@ import (
const (
DefaultExitCodeNotExecuted = -1337
MaxTriggerDepth = 10
MaxTriggerDepth = 10
)
var (
@@ -34,9 +34,10 @@ var (
)
type ActionBinding struct {
Action *config.Action
EntityPrefix string
ConfigOrder int
Action *config.Action
Entity *entities.Entity
ConfigOrder int
IsOnDashboard bool
}
// Executor represents a helper class for executing commands. It's main method
@@ -68,7 +69,7 @@ type ExecutionRequest struct {
Tags []string
Cfg *config.Config
AuthenticatedUser *acl.AuthenticatedUser
EntityPrefix string
Entity *entities.Entity
TriggerDepth int
logEntry *InternalLogEntry
@@ -170,10 +171,25 @@ func getPagingStartIndex(startOffset int64, totalLogCount int64) int64 {
return startIndex - 1
}
func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*InternalLogEntry, int64) {
type PagingResult struct {
CountRemaining int64
PageSize int64
TotalCount int64
StartOffset int64
}
func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
pagingResult := &PagingResult{
CountRemaining: 0,
PageSize: pageCount,
TotalCount: 0,
StartOffset: startOffset,
}
e.logmutex.RLock()
totalLogCount := int64(len(e.logsTrackingIdsByDate))
pagingResult.TotalCount = totalLogCount
startIndex := getPagingStartIndex(startOffset, totalLogCount)
@@ -199,9 +215,9 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
e.logmutex.RUnlock()
remainingLogs := endIndex
pagingResult.CountRemaining = endIndex
return trackingIds, remainingLogs
return trackingIds, pagingResult
}
func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
@@ -257,7 +273,6 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
ActionTitle: "notfound",
ActionIcon: "&#x1f4a9;",
Username: req.AuthenticatedUser.Username,
EntityPrefix: req.EntityPrefix,
}
_, isDuplicate := e.GetLog(req.TrackingID)
@@ -351,9 +366,12 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
then := time.Now().Add(-duration)
for _, logEntry := range req.executor.GetLogsByActionId(req.Action.ID) {
if logEntry.EntityPrefix != req.EntityPrefix {
continue
}
// FIXME
/*
if logEntry.EntityPrefix != req.EntityPrefix {
continue
}
*/
if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked {
@@ -412,7 +430,7 @@ func stepParseArgs(req *ExecutionRequest) bool {
mangleInvalidArgumentValues(req)
req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Action, req.EntityPrefix)
req.finalParsedCommand, err = parseActionArguments(req.Action.Shell, req.Arguments, req.Action, req.Entity)
if err != nil {
req.logEntry.Output = err.Error()
@@ -448,7 +466,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
metricActionsRequested.Inc()
req.logEntry.ActionConfigTitle = req.Action.Title
req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Action.Title, req.Entity)
req.logEntry.ActionIcon = req.Action.Icon
req.logEntry.ActionId = req.Action.ID
req.logEntry.Tags = req.Tags
@@ -613,7 +631,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
"ot_username": req.AuthenticatedUser.Username,
}
finalParsedCommand, err := parseCommandForReplacements(req.Action.ShellAfterCompleted, args)
finalParsedCommand, err := parseCommandForReplacements(req.Action.ShellAfterCompleted, args, req.Entity)
if err != nil {
msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n"

View File

@@ -3,23 +3,38 @@ package executor
import (
"crypto/sha256"
"fmt"
"slices"
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"github.com/OliveTin/OliveTin/internal/entities"
log "github.com/sirupsen/logrus"
"strconv"
)
func (e *Executor) FindActionBindingByID(id string) *config.Action {
func (e *Executor) FindActionByBindingID(id string) *config.Action {
binding := e.FindBindingByID(id)
if binding == nil {
return nil
}
return binding.Action
}
func (e *Executor) FindBindingByID(id string) *ActionBinding {
e.MapActionIdToBindingLock.RLock()
pair, found := e.MapActionIdToBinding[id]
e.MapActionIdToBindingLock.RUnlock()
if found {
log.Infof("findActionBinding %v, %v", id, pair.Action.ID)
return pair.Action
if !found {
return nil
}
return nil
return pair
}
type RebuildActionMapRequest struct {
Cfg *config.Config
DashboardActionTitles []string
}
func (e *Executor) RebuildActionMap() {
@@ -27,11 +42,20 @@ func (e *Executor) RebuildActionMap() {
clear(e.MapActionIdToBinding)
req := &RebuildActionMapRequest{
Cfg: e.Cfg,
DashboardActionTitles: make([]string, 0),
}
findDashboardActionTitles(req)
log.Infof("dashboardActionTitles: %v", req.DashboardActionTitles)
for configOrder, action := range e.Cfg.Actions {
if action.Entity != "" {
registerActionsFromEntities(e, configOrder, action.Entity, action)
registerActionsFromEntities(e, configOrder, action.Entity, action, req)
} else {
registerAction(e, configOrder, action)
registerAction(e, configOrder, action, req)
}
}
@@ -42,33 +66,49 @@ func (e *Executor) RebuildActionMap() {
}
}
func registerAction(e *Executor, configOrder int, action *config.Action) {
func findDashboardActionTitles(req *RebuildActionMapRequest) {
for _, dashboard := range req.Cfg.Dashboards {
recurseDashboardForActionTitles(dashboard, req)
}
}
func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) {
for _, sub := range component.Contents {
if sub.Type == "link" || sub.Type == "" {
req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title)
}
if len(sub.Contents) > 0 {
recurseDashboardForActionTitles(sub, req)
}
}
}
func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
actionId := hashActionToID(action, "")
e.MapActionIdToBinding[actionId] = &ActionBinding{
Action: action,
EntityPrefix: "noent",
ConfigOrder: configOrder,
Action: action,
Entity: nil,
ConfigOrder: configOrder,
IsOnDashboard: slices.Contains(req.DashboardActionTitles, action.Title),
}
}
func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action) {
entityCount, _ := strconv.Atoi(sv.Get("entities." + entityTitle + ".count"))
for i := 0; i < entityCount; i++ {
registerActionFromEntity(e, configOrder, tpl, entityTitle, i)
func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action, req *RebuildActionMapRequest) {
for _, ent := range entities.GetEntityInstances(entityTitle) {
registerActionFromEntity(e, configOrder, tpl, ent, req)
}
}
func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, entityTitle string, entityIndex int) {
prefix := sv.GetEntityPrefix(entityTitle, entityIndex)
virtualActionId := hashActionToID(tpl, prefix)
func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, ent *entities.Entity, req *RebuildActionMapRequest) {
virtualActionId := hashActionToID(tpl, "ent")
e.MapActionIdToBinding[virtualActionId] = &ActionBinding{
Action: tpl,
EntityPrefix: prefix,
ConfigOrder: configOrder,
Action: tpl,
Entity: ent,
ConfigOrder: configOrder,
IsOnDashboard: slices.Contains(req.DashboardActionTitles, tpl.Title),
}
}

View File

@@ -9,14 +9,15 @@ away, and several other issues.
*/
import (
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/api"
"github.com/OliveTin/OliveTin/internal/executor"
log "github.com/sirupsen/logrus"
"net/http"
"net/http/httputil"
"net/url"
"path"
"github.com/OliveTin/OliveTin/internal/api"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
log "github.com/sirupsen/logrus"
)
func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
@@ -63,7 +64,6 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
webuiServer := NewWebUIServer(cfg)
mux.HandleFunc("/webUiSettings.json", webuiServer.generateWebUISettings)
mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
mux.Handle("/custom-webui/", webuiServer.handleCustomWebui())
mux.HandleFunc("/", webuiServer.handleWebui)

View File

@@ -1,18 +1,17 @@
package httpservers
import (
"encoding/json"
// cors "github.com/OliveTin/OliveTin/internal/cors"
log "github.com/sirupsen/logrus"
"net/http"
"os"
"path"
log "github.com/sirupsen/logrus"
"github.com/jamesread/golure/pkg/dirs"
config "github.com/OliveTin/OliveTin/internal/config"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
)
type webUIServer struct {
@@ -26,24 +25,6 @@ var (
customThemeCssRead = false
)
type webUISettings struct {
Rest string
ShowFooter bool
ShowNavigation bool
ShowNewVersions bool
AvailableVersion string
CurrentVersion string
PageTitle string
SectionNavigationStyle string
DefaultIconForBack string
EnableCustomJs bool
AuthLoginUrl string
AuthLocalLogin bool
StyleMods []string
AuthOAuth2Providers []publicOAuth2Provider
AdditionalLinks []*config.NavigationLink
}
func NewWebUIServer(cfg *config.Config) *webUIServer {
s := &webUIServer{
cfg: cfg,
@@ -66,11 +47,10 @@ func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
} else {
log.Infof("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
http.ServeFile(w, r, path.Join(s.webuiDir, r.URL.Path))
// http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
// http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
}
}
func (s *webUIServer) findWebuiDir() string {
directoriesToSearch := []string{
s.cfg.WebUIDir,
@@ -108,9 +88,6 @@ func (s *webUIServer) setupCustomWebuiDir() {
if err != nil {
log.Warnf("Could not create themes directory: %v", err)
sv.Set("internal.themesdir", err.Error())
} else {
sv.Set("internal.themesdir", dir)
}
}
@@ -132,55 +109,6 @@ func (s *webUIServer) generateThemeCss(w http.ResponseWriter, r *http.Request) {
w.Write(customThemeCss)
}
type publicOAuth2Provider struct {
Name string
Title string
Icon string
}
func buildPublicOAuth2ProvidersList(cfg *config.Config) []publicOAuth2Provider {
var publicProviders []publicOAuth2Provider
for _, provider := range cfg.AuthOAuth2Providers {
publicProviders = append(publicProviders, publicOAuth2Provider{
Name: provider.Name,
Title: provider.Title,
Icon: provider.Icon,
})
}
return publicProviders
}
func (s *webUIServer) generateWebUISettings(w http.ResponseWriter, r *http.Request) {
log.Infof("Generating webui settings for %s", r.RemoteAddr)
jsonRet, _ := json.Marshal(webUISettings{
Rest: s.cfg.ExternalRestAddress + "/api/",
ShowFooter: s.cfg.ShowFooter,
ShowNavigation: s.cfg.ShowNavigation,
ShowNewVersions: s.cfg.ShowNewVersions,
AvailableVersion: installationinfo.Runtime.AvailableVersion,
CurrentVersion: installationinfo.Build.Version,
PageTitle: s.cfg.PageTitle,
SectionNavigationStyle: s.cfg.SectionNavigationStyle,
DefaultIconForBack: s.cfg.DefaultIconForBack,
EnableCustomJs: s.cfg.EnableCustomJs,
AuthLoginUrl: s.cfg.AuthLoginUrl,
AuthLocalLogin: s.cfg.AuthLocalUsers.Enabled,
AuthOAuth2Providers: buildPublicOAuth2ProvidersList(s.cfg),
AdditionalLinks: s.cfg.AdditionalNavigationLinks,
StyleMods: s.cfg.StyleMods,
})
w.Header().Add("Content-Type", "application/json")
_, err := w.Write([]byte(jsonRet))
if err != nil {
log.Warnf("Could not write webui settings: %v", err)
}
}
func (s *webUIServer) handleCustomWebui() (http.Handler) {
func (s *webUIServer) handleCustomWebui() http.Handler {
return http.StripPrefix("/custom-webui/", http.FileServer(http.Dir(s.findCustomWebuiDir())))
}

View File

@@ -1,9 +1,9 @@
package installationinfo
type buildInfo struct {
type BuildInfo struct {
Commit string
Version string
Date string
}
var Build = &buildInfo{}
var Build = &BuildInfo{}

View File

@@ -1,16 +0,0 @@
package installationinfo
import (
"fmt"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
)
func init() {
sv.Set("OliveTin.build.commit", Build.Commit)
sv.Set("OliveTin.build.version", Build.Version)
sv.Set("OliveTin.build.date", Build.Date)
sv.Set("OliveTin.runtime.os", Runtime.OS)
sv.Set("OliveTin.runtime.os.pretty", Runtime.OSReleasePrettyName)
sv.Set("OliveTin.runtime.arch", Runtime.Arch)
sv.Set("OliveTin.runtime.incontainer", fmt.Sprintf("%v", Runtime.InContainer))
}

View File

@@ -10,7 +10,7 @@ import (
"strings"
)
type runtimeInfo struct {
type RuntimeInfo struct {
OS string
OSReleasePrettyName string
Arch string
@@ -21,9 +21,11 @@ type runtimeInfo struct {
SshFoundKey string
SshFoundConfig string
AvailableVersion string
WebuiDirectory string
ThemesDirectory string
}
var Runtime = &runtimeInfo{
var Runtime = &RuntimeInfo{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
InContainer: isInContainer(),

View File

@@ -2,13 +2,15 @@ package installationinfo
import (
"fmt"
config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"gopkg.in/yaml.v3"
"time"
config "github.com/OliveTin/OliveTin/internal/config"
"gopkg.in/yaml.v3"
)
var Config *config.Config
var (
Config *config.Config
)
type sosReportConfig struct {
CountOfActions int
@@ -22,7 +24,6 @@ type sosReportConfig struct {
TimeNow string
ConfigDirectory string
WebuiDirectory string
ThemesDirectory string
}
func configToSosreport(cfg *config.Config) *sosReportConfig {
@@ -37,8 +38,7 @@ func configToSosreport(cfg *config.Config) *sosReportConfig {
Timezone: time.Now().Location().String(),
TimeNow: time.Now().String(),
ConfigDirectory: cfg.GetDir(),
WebuiDirectory: sv.Get("internal.webuidir"),
ThemesDirectory: sv.Get("internal.themesdir"),
WebuiDirectory: cfg.WebUIDir,
}
}

View File

@@ -1,59 +0,0 @@
package stringvariables
import (
"fmt"
"regexp"
"strconv"
"strings"
// log "github.com/sirupsen/logrus"
)
var r *regexp.Regexp
func init() {
r = regexp.MustCompile(`{{ *?([a-zA-Z0-9_]+)\.([a-zA-Z0-9_\.]+) *?}}`)
}
func ReplaceEntityVars(prefix string, source string) string {
matches := r.FindAllStringSubmatch(source, -1)
for _, matches := range matches {
if len(matches) == 3 {
property := matches[2]
val := Get(prefix + "." + property)
source = strings.Replace(source, matches[0], val, 1)
}
}
return source
}
func GetEntities(entityTitle string) []string {
var ret []string
count := GetEntityCount(entityTitle)
for i := 0; i < count; i++ {
prefix := GetEntityPrefix(entityTitle, i)
ret = append(ret, prefix)
}
return ret
}
func GetEntityPrefix(entityTitle string, entityIndex int) string {
return "entities." + entityTitle + "." + fmt.Sprintf("%v", entityIndex)
}
func GetEntityCount(entityTitle string) int {
count, _ := strconv.Atoi(Get("entities." + entityTitle + ".count"))
return count
}
func SetEntityCount(entityTitle string, count int) {
Set("entities."+entityTitle+".count", fmt.Sprintf("%v", count))
}

View File

@@ -1,12 +0,0 @@
package stringvariables
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestEntityCount(t *testing.T) {
SetEntityCount("waffles", 3)
assert.Equal(t, 3, GetEntityCount("waffles"))
}

View File

@@ -1,76 +0,0 @@
/**
* The ephemeralvariablemap is used "only" for variable substitution in config
* titles, shell arguments, etc, in the foorm of {{ key }}, like Jinja2.
*
* OliveTin itself really only ever "writes" to this map, mostly by loading
* EntityFiles, and the only form of "reading" is for the variable substitution
* in configs.
*/
package stringvariables
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"strings"
"sync"
)
var (
contents map[string]string
metricSvCount = promauto.NewGauge(prometheus.GaugeOpts{
Name: "olivetin_sv_count",
Help: "The number entries in the sv map",
})
rwmutex = sync.RWMutex{}
)
func init() {
rwmutex.Lock()
contents = make(map[string]string)
rwmutex.Unlock()
}
func Get(key string) string {
rwmutex.RLock()
v, ok := contents[key]
rwmutex.RUnlock()
if !ok {
return ""
} else {
return v
}
}
func GetAll() map[string]string {
return contents
}
func Set(key string, value string) {
rwmutex.Lock()
contents[key] = value
metricSvCount.Set(float64(len(contents)))
rwmutex.Unlock()
}
func RemoveKeysThatStartWith(search string) {
rwmutex.Lock()
for k := range contents {
if strings.HasPrefix(k, search) {
delete(contents, k)
}
}
rwmutex.Unlock()
}

View File

@@ -1,20 +0,0 @@
package stringvariables
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestGetAndSet(t *testing.T) {
Set("foo", "bar")
Set("salutation", "hello")
assert.Equal(t, "bar", Get("foo"))
assert.Equal(t, "", Get("not exist"))
}
func TestGetall(t *testing.T) {
ret := GetAll()
assert.NotEmpty(t, ret)
}

View File

@@ -5,7 +5,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/OliveTin/OliveTin/internal/entityfiles"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
"github.com/OliveTin/OliveTin/internal/httpservers"
"github.com/OliveTin/OliveTin/internal/installationinfo"
@@ -17,11 +17,12 @@ import (
updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
"github.com/OliveTin/OliveTin/internal/websocket"
"os"
"strconv"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
"os"
"strconv"
)
var (
@@ -172,9 +173,9 @@ func main() {
go onfileindir.WatchFilesInDirectory(cfg, executor)
go oncalendarfile.Schedule(cfg, executor)
entityfiles.AddListener(websocket.OnEntityChanged)
entityfiles.AddListener(executor.RebuildActionMap)
go entityfiles.SetupEntityFileWatchers(cfg)
entities.AddListener(websocket.OnEntityChanged)
entities.AddListener(executor.RebuildActionMap)
go entities.SetupEntityFileWatchers(cfg)
go updatecheck.StartUpdateChecker(cfg)