chore: OliveTin 3k progress

This commit is contained in:
jamesread 2025-08-03 22:10:51 +01:00
parent d4fe9eaa79
commit a62d58f119
85 changed files with 8660 additions and 7771 deletions

9
.gitignore vendored
View File

@ -4,11 +4,12 @@ service/OliveTin
service/OliveTin.armhf service/OliveTin.armhf
service/OliveTin.exe service/OliveTin.exe
service/reports service/reports
service/gen
releases/ releases/
dist/ dist/
installation-id.txt installation-id.txt
tmp/ tmp/
webui/ frontend/dist/
webui.dev/node_modules frontend/node_modules
webui.dev/.parcel-cache custom-frontend
custom-webui integration-tests/screenshots/

View File

@ -47,16 +47,8 @@ devrun: compile
devcontainer: compile podman-image podman-container devcontainer: compile podman-image podman-container
webui-codestyle:
$(MAKE) -wC webui.dev codestyle
webui-dist: webui-dist:
$(call delete-files,webui) $(MAKE) -wC frontend dist
$(call delete-files,webui.dev/dist)
cd webui.dev && npm install
cd webui.dev && npx parcel build --public-url "."
python -c "import shutil;shutil.move('webui.dev/dist', 'webui')"
python -c "import shutil;import glob;[shutil.copy(f, 'webui') for f in glob.glob('webui.dev/*.png')]"
clean: clean:
$(call delete-files,dist) $(call delete-files,dist)

View File

@ -11,6 +11,9 @@ logLevel: "INFO"
# Checking for updates https://docs.olivetin.app/reference/updateChecks.html # Checking for updates https://docs.olivetin.app/reference/updateChecks.html
checkForUpdates: false checkForUpdates: false
authLocalUsers:
enabled: true
# Actions are commands that are executed by OliveTin, and normally show up as # Actions are commands that are executed by OliveTin, and normally show up as
# buttons on the WebUI. # buttons on the WebUI.
# #

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
fund=false

21
frontend/Makefile Normal file
View File

@ -0,0 +1,21 @@
define delete-files
python -c "import shutil;shutil.rmtree('$(1)', ignore_errors=True)"
endef
codestyle:
npm install
npx eslint --fix main.js js/*
npx stylelint style.css
clean:
$(call delete-files,dist)
deps:
npm install
build:
npx vite build
dist: deps clean build
.PHONY: codestyle

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -22,79 +22,11 @@
</head> </head>
<body> <body>
<header> <slot id = "app" />
<button id = "sidebar-toggler-button" aria-label = "Open sidebar navigation" aria-pressed = "false" aria-haspopup = "menu">&#9776;</button>
<h1 id = "page-title">OliveTin</h1>
<nav id = "mainnav" hidden>
<ul id = "navigation-links">
<li title = "Actions">
<a id = "showActions">Actions</a>
</li>
</ul>
<ul id = "supplemental-links">
</ul>
</nav>
<div class = "userinfo">
<span id = "link-login" hidden><a href = "/login">Login</a> |</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>
</header>
<main title = "main content"> <main title = "main content">
<section id = "contentLogs" title = "Logs" class = "box-shadow" hidden>
<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 id = "logTableBody" />
</table>
<p id = "logsTableEmpty">There are no logs to display. <a href = "/">Return to index</a></p> <section id = "contentActions" title = "Actions" class = "transparent" hidden >
<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>
<section id = "contentDiagnostics" title = "Diagnostics" class = "box-shadow" hidden>
<div id = "diagnostics" class = "ta-left">
<p><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>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">sosreport</a> which is more detailed, and makes it easier to help you.</p>
<table>
<tbody>
<th colspan = "99">SSH</th>
<tr>
<td width = "10%">Found Key</td>
<td id = "diagnostics-sshfoundkey">?</td>
</tr>
<tr>
<td>Found Config</td>
<td id = "diagnostics-sshfoundconfig">?</td>
</tbody>
</table>
</div>
</section>
<section id = "contentActions" title = "Actions" hidden >
<fieldset id = "root-group" title = "Actions"> <fieldset id = "root-group" title = "Actions">
<legend hidden>Actions</legend> <legend hidden>Actions</legend>
</fieldset> </fieldset>
@ -105,22 +37,6 @@
</noscript> </noscript>
</main> </main>
<footer title = "footer">
<p><img title = "application icon" src = "OliveTinLogo.png" alt = "OliveTin logo" height = "1em" class = "logo" /> OliveTin</p>
<p>
<a href = "https://docs.olivetin.app" target = "_new">Documentation</a> |
<a href = "https://github.com/OliveTin/OliveTin/issues/new/choose" target = "_new">Raise an issue on GitHub</a> |
<span>Version: <span id = "currentVersion">?</span></span> |
<span>Server connection:
<span id = "serverConnectionRest">REST</span>,
<span id = "serverConnectionWebSocket">WebSocket</span>
</span>
</p>
<p>
<a id = "available-version" href = "http://olivetin.app" target = "_blank" hidden>?</a>
</p>
</footer>
<dialog title = "Big Error Message" id = "big-error" class = "error padded-content"> <dialog title = "Big Error Message" id = "big-error" class = "error padded-content">
</dialog> </dialog>
@ -175,16 +91,16 @@
<h2>Local Login</h2> <h2>Local Login</h2>
<div class = "error"></div> <div class = "error"></div>
<div class = "arguments"> <div class = "arguments">
<label for = "username"> <label for = "in-username">
<span>Username:</span> <span>Username:</span>
</label> </label>
<input type = "text" name = "username" class = "username" /> <input type = "text" name = "username" id = "in-username" class = "username" autocomplete = "username"/>
<span></span> <span></span>
<label for = "password"> <label for = "in-password">
<span>Password:</span> <span>Password:</span>
</label> </label>
<input type = "password" name = "password" class = "password" /> <input type = "password" name = "password" id = "in-password" class = "password" />
<span></span> <span></span>
<button type = "submit">Login</button> <button type = "submit">Login</button>
@ -245,6 +161,9 @@
to at least display a helpful error message if we can't use OliveTin. to at least display a helpful error message if we can't use OliveTin.
*/ */
window.showBigError = function (type, friendlyType, message, isFatal) { window.showBigError = function (type, friendlyType, message, isFatal) {
console.error('Error ' + type + ': ', message)
return;
bigErrorDialog.innerHTML = '<h1>Error ' + friendlyType + '</h1><p>' + message + "</p><p><a href = 'http://docs.olivetin.app/troubleshooting/err-" + type + ".html' target = 'blank'/>" + type + " error in OliveTin Documentation</a></p>" bigErrorDialog.innerHTML = '<h1>Error ' + friendlyType + '</h1><p>' + message + "</p><p><a href = 'http://docs.olivetin.app/troubleshooting/err-" + type + ".html' target = 'blank'/>" + type + " error in OliveTin Documentation</a></p>"
if (isFatal) { if (isFatal) {

View File

@ -180,30 +180,7 @@ class ArgumentForm extends window.HTMLElement {
} }
domEl.onchange = () => { domEl.onchange = () => {
const validateArgumentTypeArgs = { formatValidation(domEl, arg)
value: domEl.value,
type: arg.type
}
window.fetch(window.restBaseUrl + 'ValidateArgumentType', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(validateArgumentTypeArgs)
}).then((res) => {
if (res.ok) {
return res.json()
} else {
throw new Error(res.statusText)
}
}).then((json) => {
if (json.valid) {
domEl.setCustomValidity('')
} else {
domEl.setCustomValidity(json.description)
}
})
} }
} }
} }
@ -232,6 +209,21 @@ class ArgumentForm extends window.HTMLElement {
return domEl return domEl
} }
async formatValidation (domEl, arg) {
const validateArgumentTypeArgs = {
value: domEl.value,
type: arg.type
}
const validation = await window.validateArgumentType(validateArgumentTypeArgs)
if (validation.valid) {
domEl.setCustomValidity('')
} else {
domEl.setCustomValidity(validation.description)
}
}
updateUrlWithArg (ev) { updateUrlWithArg (ev) {
if (!ev.target.name) { if (!ev.target.name) {
return return

View File

@ -37,9 +37,12 @@ export class OutputTerminal {
}) })
} finally { } finally {
unlock() unlock()
if (then != null && then !== undefined) {
then() then()
} }
} }
}
async reset () { async reset () {
const unlock = await this.writeMutex.lock() const unlock = await this.writeMutex.lock()

View File

@ -1,12 +1,3 @@
import './ActionButton.js' // To define action-button
import { NavigationBar } from './NavigationBar.js'
import { ExecutionDialog } from './ExecutionDialog.js'
import { ActionStatusDisplay } from './ActionStatusDisplay.js'
function getQueryParams () {
return new URLSearchParams(window.location.search.substring(1))
}
function checkAndTriggerActionFromQueryParam () { function checkAndTriggerActionFromQueryParam () {
const params = getQueryParams() const params = getQueryParams()
@ -77,18 +68,8 @@ function createAnnotation (key, val) {
* This is a weird function that just sets some globals. * This is a weird function that just sets some globals.
*/ */
export function initMarshaller () { export function initMarshaller () {
window.navbar = new NavigationBar()
window.showSection = showSection
window.showSectionView = showSectionView
window.executionDialog = new ExecutionDialog()
window.logEntries = new Map() window.logEntries = new Map()
window.registeredPaths = new Map()
window.breadcrumbNavigation = []
window.currentPath = ''
window.addEventListener('EventExecutionStarted', onExecutionStarted) window.addEventListener('EventExecutionStarted', onExecutionStarted)
window.addEventListener('EventExecutionFinished', onExecutionFinished) window.addEventListener('EventExecutionFinished', onExecutionFinished)
@ -125,49 +106,16 @@ export function marshalDashboardComponentsJsonToHtml (json) {
} else { } else {
setUsername(json.authenticatedUser, json.authenticatedUserProvider) setUsername(json.authenticatedUser, json.authenticatedUserProvider)
marshalActionsJsonToHtml(json)
marshalDashboardStructureToHtml(json) marshalDashboardStructureToHtml(json)
window.navbar.refreshSectionPolicyLinks(json.effectivePolicy)
refreshDiagnostics(json)
} }
document.body.setAttribute('initial-marshal-complete', 'true') document.body.setAttribute('initial-marshal-complete', 'true')
} }
function marshalActionsJsonToHtml (json) {
const currentIterationTimestamp = Date.now()
window.actionButtons = {}
window.actionButtonsJson = {} // Store the JSON representation
for (const jsonButton of json.actions) {
let htmlButton = window.actionButtons[jsonButton.id]
if (typeof htmlButton === 'undefined') {
htmlButton = document.createElement('action-button')
htmlButton.constructFromJson(jsonButton)
window.actionButtons[jsonButton.title] = htmlButton
window.actionButtonsJson[jsonButton.title] = jsonButton // Store the JSON representation
}
htmlButton.updateFromJson(jsonButton)
htmlButton.updateIterationTimestamp = currentIterationTimestamp
}
// Remove existing, but stale buttons (that were not updated in this round)
for (const existingButton of document.querySelectorAll('action-button')) {
if (existingButton.updateIterationTimestamp !== currentIterationTimestamp) {
existingButton.remove()
}
}
}
function onOutputChunk (evt) { function onOutputChunk (evt) {
const chunk = evt.payload const chunk = evt.payload
return;
if (chunk.executionTrackingId === window.executionDialog.executionTrackingId) { if (chunk.executionTrackingId === window.executionDialog.executionTrackingId) {
window.terminal.write(chunk.output) window.terminal.write(chunk.output)
} }
@ -176,9 +124,9 @@ function onOutputChunk (evt) {
function onExecutionStarted (evt) { function onExecutionStarted (evt) {
const logEntry = evt.payload.logEntry const logEntry = evt.payload.logEntry
marshalLogsJsonToHtml({ // marshalLogsJsonToHtml({
logs: [logEntry] // logs: [logEntry]
}) // })
} }
function onExecutionFinished (evt) { function onExecutionFinished (evt) {
@ -186,11 +134,7 @@ function onExecutionFinished (evt) {
window.logEntries.set(logEntry.executionTrackingId, logEntry) window.logEntries.set(logEntry.executionTrackingId, logEntry)
const actionButton = window.actionButtons[logEntry.actionTitle] return;
if (actionButton === undefined) {
return
}
const executionButton = document.querySelector('execution-button#execution-' + logEntry.executionTrackingId) const executionButton = document.querySelector('execution-button#execution-' + logEntry.executionTrackingId)
let feedbackButton = actionButton let feedbackButton = actionButton
@ -216,9 +160,9 @@ function onExecutionFinished (evt) {
feedbackButton.onExecutionFinished(logEntry) feedbackButton.onExecutionFinished(logEntry)
marshalLogsJsonToHtml({ // marshalLogsJsonToHtml({
logs: [logEntry] // logs: [logEntry]
}) // })
// If the current execution dialog is open, update that too // If the current execution dialog is open, update that too
if (window.executionDialog.dlg.open && window.executionDialog.executionUuid === logEntry.uuid) { if (window.executionDialog.dlg.open && window.executionDialog.executionUuid === logEntry.uuid) {
@ -251,54 +195,6 @@ function showExecutionResult (pathName) {
window.executionDialog.show() window.executionDialog.show()
} }
function showSection (pathName) {
if (pathName.startsWith('/logs/')) {
showExecutionResult(pathName)
pushNewNavigationPath(pathName)
return
}
const path = window.registeredPaths.get(pathName)
if (path === undefined) {
console.warn('Section not found by path: ' + pathName)
showSection('/')
return
}
window.convertPathToBreadcrumb = convertPathToBreadcrumb
window.currentPath = pathName
window.breadcrumbNavigation = convertPathToBreadcrumb(pathName)
for (const section of document.querySelectorAll('section')) {
if (section.title === path.section) {
section.style.display = 'block'
} else {
section.style.display = 'none'
}
}
// Check for action parameter in query string
if (!checkAndTriggerActionFromQueryParam()) {
pushNewNavigationPath(pathName)
}
setSectionNavigationVisible(false)
showSectionView(path.view)
}
function pushNewNavigationPath (pathName) {
// Get the current query string
const queryString = window.location.search
// Push the new state with the path and preserve the query string
window.history.pushState({
path: pathName
}, null, pathName + queryString)
}
function setSectionNavigationVisible (visible) { function setSectionNavigationVisible (visible) {
const nav = document.querySelector('nav') const nav = document.querySelector('nav')
const btn = document.getElementById('sidebar-toggler-button') const btn = document.getElementById('sidebar-toggler-button')
@ -345,41 +241,6 @@ export function setupSectionNavigation (style) {
document.body.classList.add('has-topbar') document.body.classList.add('has-topbar')
} }
registerSection('/', 'Actions', null, document.getElementById('showActions'))
registerSection('/diagnostics', 'Diagnostics', null, null)
registerSection('/logs', 'Logs', null, null)
registerSection('/login', 'Login', null, null)
}
function registerSection (path, section, view, linkElement) {
window.registeredPaths.set(path, {
section: section,
view: view
})
if (linkElement != null) {
addLinkToSection(path, linkElement)
}
}
function addLinkToSection (pathName, element) {
const path = window.registeredPaths.get(pathName)
element.href = 'javascript:void(0)'
element.title = path.section
element.onclick = () => {
showSection(pathName)
}
}
function refreshDiagnostics (json) {
document.getElementById('diagnostics-sshfoundkey').innerHTML = json.diagnostics.SshFoundKey
document.getElementById('diagnostics-sshfoundconfig').innerHTML = json.diagnostics.SshFoundConfig
}
function getSystemTitle (title) {
return title.replaceAll(' ', '')
} }
function marshalSingleDashboard (dashboard, nav) { function marshalSingleDashboard (dashboard, nav) {
@ -409,10 +270,10 @@ function marshalSingleDashboard (dashboard, nav) {
window.navbar.createLink(dashboard.title, systemTitleUrl, false) window.navbar.createLink(dashboard.title, systemTitleUrl, false)
registerSection(systemTitleUrl, section.title, null, null)
} }
function marshalDashboardStructureToHtml (json) { function marshalDashboardStructureToHtml (json) {
return
const nav = document.getElementById('navigation-links') const nav = document.getElementById('navigation-links')
for (const dashboard of json.dashboards) { for (const dashboard of json.dashboards) {
@ -447,6 +308,7 @@ function marshalDashboardStructureToHtml (json) {
} }
function marshalLink (item, fieldset) { function marshalLink (item, fieldset) {
return
let btn = window.actionButtons[item.title] let btn = window.actionButtons[item.title]
if (typeof btn === 'undefined') { if (typeof btn === 'undefined') {
@ -467,27 +329,19 @@ function marshalMreOutput (dashboardComponent, fieldset) {
pre.classList.add('mre-output') pre.classList.add('mre-output')
pre.innerHTML = 'Waiting...' pre.innerHTML = 'Waiting...'
const executionStatus = { const args = {
actionId: dashboardComponent.title actionId: dashboardComponent.title
} }
window.fetch(window.restBaseUrl + 'ExecutionStatus', { try {
method: 'POST', const status = window.client.executionStatus(args)
headers: {
'Content-Type': 'application/json' updateMre(pre, status.logEntry)
}, } catch (err) {
body: JSON.stringify(executionStatus)
}).then((res) => {
if (res.ok) {
return res.json()
} else {
pre.innerHTML = 'error' pre.innerHTML = 'error'
throw new Error(res.statusText) throw new Error(res.statusText)
} }
}).then((json) => {
updateMre(pre, json.logEntry)
})
const updateMre = (pre, json) => { const updateMre = (pre, json) => {
pre.innerHTML = json.output pre.innerHTML = json.output
@ -556,68 +410,6 @@ function marshalFieldset (item, section, parentDashboard) {
section.appendChild(fs) section.appendChild(fs)
} }
function showSectionView (selected) {
if (selected === '') {
selected = null
}
for (const fieldset of document.querySelectorAll('fieldset')) {
if (selected === null) {
if ((fieldset.id === 'root-group' || fieldset.getAttribute('parent-dashboard') !== '') && fieldset.children.length > 1) {
fieldset.style.display = 'grid'
} else {
fieldset.style.display = 'none'
}
} else {
if (fieldset.title === selected) {
fieldset.style.display = 'grid'
} else {
fieldset.style.display = 'none'
}
}
}
const current = window.registeredPaths.get(window.currentPath)
for (const navLink of document.querySelector('nav').querySelectorAll('a')) {
if (navLink.title === current.section) {
navLink.classList.add('selected')
} else {
navLink.classList.remove('selected')
}
}
rebuildH1BreadcrumbNavigation(selected)
pushNewNavigationPath(window.currentPath)
}
function rebuildH1BreadcrumbNavigation () {
const title = document.querySelector('h1')
title.innerHTML = ''
const rootLink = document.createElement('a')
rootLink.innerText = window.pageTitle
rootLink.href = 'javascript:void(0)'
rootLink.onclick = () => {
showSection('/')
}
title.appendChild(rootLink)
for (const pathName of window.breadcrumbNavigation) {
const sep = document.createElement('span')
sep.innerHTML = ' &raquo; '
title.append(sep)
const path = window.registeredPaths.get(pathName)
title.appendChild(createNavigationBreadcrumbDisplay(path))
}
document.title = title.innerText
}
function createNavigationBreadcrumbDisplay (path) { function createNavigationBreadcrumbDisplay (path) {
const a = document.createElement('a') const a = document.createElement('a')
a.href = 'javascript:void(0)' a.href = 'javascript:void(0)'
@ -678,76 +470,9 @@ function marshalDirectory (item, section) {
const path = '/' + section.title + '/' + getSystemTitle(item.title) const path = '/' + section.title + '/' + getSystemTitle(item.title)
registerSection(path, section.title, item.title, null)
return path return path
} }
export 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)
}
row.exitCodeDisplay = new ActionStatusDisplay(row.querySelector('.exit-code'))
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))
}
}
window.addEventListener('popstate', (e) => {
e.preventDefault()
if (e.state != null && typeof e.state.path !== 'undefined') {
showSection(e.state.path)
}
})
export function refreshServerConnectionLabel () { export function refreshServerConnectionLabel () {
if (window.restAvailable) { if (window.restAvailable) {
document.querySelector('#serverConnectionRest').classList.remove('error') document.querySelector('#serverConnectionRest').classList.remove('error')

50
frontend/js/websocket.js Normal file
View File

@ -0,0 +1,50 @@
import {
refreshServerConnectionLabel
} from './marshaller.js'
export function checkWebsocketConnection () {
reconnectWebsocket()
}
window.websocketAvailable = false
async function reconnectWebsocket () {
if (window.websocketAvailable) {
return
}
try {
window.websocketAvailable = true
for await (let e of window.client.eventStream()) {
handleEvent(e)
}
} catch (err) {
console.error('Websocket connection failed: ', err)
}
window.websocketAvailable = false
console.log('Reconnecting websocket...')
}
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
switch (typeName) {
case 'EventOutputChunk':
case 'EventConfigChanged':
case 'EventEntityChanged':
case 'EventExecutionFinished':
case 'EventExecutionStarted':
window.dispatchEvent(j)
break
default:
console.warn('Unhandled websocket message type from server: ', typeName)
window.showBigError('ws-unhandled-message', 'handling websocket message', 'Unhandled websocket message type from server: ' + typeName, true)
}
}

View File

@ -1,15 +1,22 @@
'use strict' 'use strict'
import { createClient } from '@connectrpc/connect'
import { createConnectTransport } from '@connectrpc/connect-web'
import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb'
import { createApp } from 'vue'
import router from './resources/vue/router.js';
import App from './resources/vue/App.vue';
import { import {
initMarshaller, initMarshaller,
setupSectionNavigation, setupSectionNavigation,
marshalDashboardComponentsJsonToHtml, marshalDashboardComponentsJsonToHtml,
marshalLogsJsonToHtml,
refreshServerConnectionLabel refreshServerConnectionLabel
} from './js/marshaller.js' } from './js/marshaller.js'
import { checkWebsocketConnection } from './js/websocket.js'
import { LoginForm } from './js/LoginForm.js' import { checkWebsocketConnection } from './js/websocket.js'
function searchLogs (e) { function searchLogs (e) {
document.getElementById('searchLogsClear').disabled = false document.getElementById('searchLogsClear').disabled = false
@ -32,63 +39,24 @@ function searchLogsClear () {
document.getElementById('logSearchBox').value = '' document.getElementById('logSearchBox').value = ''
} }
function setupLogSearchBox () {
document.getElementById('logSearchBox').oninput = searchLogs
document.getElementById('searchLogsClear').onclick = searchLogsClear
}
function refreshLoop () { function refreshLoop () {
if (window.websocketAvailable) {
// Websocket updates are streamed live, not updated on a loop.
} else if (window.restAvailable) {
// Fallback to rest, but try to reconnect the websocket anyway.
fetchGetDashboardComponents()
fetchGetLogs()
checkWebsocketConnection() checkWebsocketConnection()
} else { // fetchGetDashboardComponents()
// Still try to fetch the dashboard, if successfull window.restAvailable = true // fetchGetLogs()
fetchGetDashboardComponents()
}
refreshServerConnectionLabel() refreshServerConnectionLabel()
} }
function fetchGetDashboardComponents () { async function fetchGetDashboardComponents () {
window.fetch(window.restBaseUrl + 'GetDashboardComponents', { try {
cors: 'cors' const res = await window.client.getDashboardComponents()
}).then(res => {
if (!res.ok && res.status === 403) {
return null
}
return res.json()
}).then(res => {
if (!window.restAvailable) {
window.clearBigErrors()
}
window.restAvailable = true
marshalDashboardComponentsJsonToHtml(res) marshalDashboardComponentsJsonToHtml(res)
refreshServerConnectionLabel() // in-case it changed, update the label quicker refreshServerConnectionLabel() // in-case it changed, update the label quicker
}).catch((err) => { // err is 1st arg } catch(err) {
window.restAvailable = false
window.showBigError('fetch-buttons', 'getting buttons', err, false) window.showBigError('fetch-buttons', 'getting buttons', err, false)
}) }
}
function fetchGetLogs () {
window.fetch(window.restBaseUrl + 'GetLogs', {
cors: 'cors'
}).then(res => {
return res.json()
}).then(res => {
marshalLogsJsonToHtml(res)
}).catch(err => {
window.showBigError('fetch-buttons', 'getting buttons', err, false)
})
} }
function processWebuiSettingsJson (settings) { function processWebuiSettingsJson (settings) {
@ -130,13 +98,6 @@ function processWebuiSettingsJson (settings) {
processAdditionalLinks(settings.AdditionalLinks) processAdditionalLinks(settings.AdditionalLinks)
const loginForm = new LoginForm()
loginForm.setup()
loginForm.processOAuth2Providers(settings.AuthOAuth2Providers)
loginForm.processLocalLogin(settings.AuthLocalLogin)
document.getElementsByTagName('main')[0].appendChild(loginForm)
window.settings = settings window.settings = settings
} }
@ -165,10 +126,28 @@ function processAdditionalLinks (links) {
} }
} }
function main () { function initClient () {
initMarshaller() const transport = createConnectTransport({
baseUrl: window.location.protocol + '//' + window.location.host + '/api/',
setupLogSearchBox() })
window.client = createClient(OliveTinApiService, transport)
}
function setupVue () {
const app = createApp(App)
app.use(router);
app.mount('#app')
}
function main () {
setupVue();
initClient()
initMarshaller()
window.addEventListener('EventConfigChanged', fetchGetDashboardComponents) window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
window.addEventListener('EventEntityChanged', fetchGetDashboardComponents) window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
@ -178,6 +157,8 @@ function main () {
}).then(res => { }).then(res => {
processWebuiSettingsJson(res) processWebuiSettingsJson(res)
fetchGetDashboardComponents()
window.restAvailable = true window.restAvailable = true
window.refreshLoop = refreshLoop window.refreshLoop = refreshLoop
window.refreshLoop() window.refreshLoop()

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,6 @@
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1", "eslint-plugin-promise": "^4.3.1",
"parcel": "^2.11.0",
"parcel-resolver-ignore": "^2.2.0",
"process": "^0.11.10", "process": "^0.11.10",
"stylelint": "^15.6.0", "stylelint": "^15.6.0",
"stylelint-config-standard": "^33.0.0" "stylelint-config-standard": "^33.0.0"
@ -29,7 +27,14 @@
], ],
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@connectrpc/connect": "^2.0.3",
"@connectrpc/connect-web": "^2.0.3",
"@vitejs/plugin-vue": "^6.0.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0" "femtocrank": "^1.2.2",
"unplugin-vue-components": "^28.8.0",
"vite": "^7.0.6",
"vue-router": "^4.5.1"
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,287 @@
<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>
<ArgumentForm v-if="showArgumentForm" :action-data="actionData" @submit="handleArgumentSubmit"
@cancel="handleArgumentCancel" @close="handleArgumentClose" />
</div>
</template>
<script setup>
import ArgumentForm from './ArgumentForm.vue'
import { ref, computed, watch, onMounted, inject } from 'vue'
const executionDialog = inject('executionDialog');
const props = defineProps({
actionData: {
type: Object,
required: true
}
})
const actionId = ref('')
const title = ref('')
const canExec = ref(true)
const popupOnStart = ref('')
// Display properties
const unicodeIcon = ref('&#x1f4a9;')
const displayTitle = ref('')
// State
const isDisabled = ref(false)
const showArgumentForm = ref(false)
// Animation classes
const buttonClasses = ref([])
// Timestamps
const updateIterationTimestamp = ref(0)
function getUnicodeIcon(icon) {
if (icon === '') {
return '&#x1f4a9;'
} else {
return unescape(icon)
}
}
function constructFromJson(json) {
updateIterationTimestamp.value = 0
// Class attributes
updateFromJson(json)
actionId.value = json.id
title.value = json.title
canExec.value = json.canExec
popupOnStart.value = json.popupOnStart
isDisabled.value = !json.canExec
displayTitle.value = title.value
unicodeIcon.value = getUnicodeIcon(json.icon)
}
function updateFromJson(json) {
// Fields that should not be updated
// title - as the callback URL relies on it
unicodeIcon.value = getUnicodeIcon(json.icon)
}
async function handleClick() {
if (props.actionData.arguments && props.actionData.arguments.length > 0) {
updateUrlWithAction()
showArgumentForm.value = true
} else {
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()
} else {
return Date.now().toString()
}
}
async function startAction(actionArgs) {
buttonClasses.value = [] // Removes old animation classes
if (actionArgs === undefined) {
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()
}
onActionStarted(startActionArgs.uniqueTrackingId)
try {
await window.client.startAction(startActionArgs)
} catch (err) {
console.error('Failed to start action:', err)
}
}
function onActionStarted(execTrackingId) {
console.log('onActionStarted', execTrackingId)
console.log('executionDialog', executionDialog)
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()
}
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')
} else if (logEntry.blocked) {
renderExecutionResult('action-blocked', 'Blocked!')
} else if (logEntry.exitCode !== 0) {
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!')
}
}
function renderExecutionResult(resultCssClass, temporaryStatusMessage) {
updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
onExecStatusChanged()
}
function updateDom(resultCssClass, newTitle) {
if (resultCssClass == null) {
buttonClasses.value = []
} else {
buttonClasses.value = [resultCssClass]
}
displayTitle.value = newTitle
}
function onExecStatusChanged() {
isDisabled.value = false
setTimeout(() => {
updateDom(null, title.value)
}, 2000)
}
onMounted(() => {
constructFromJson(props.actionData)
})
watch(
() => props.actionData,
(newData) => {
updateFromJson(newData)
},
{ deep: true }
)
defineExpose({
onExecutionFinished
})
</script>
<style scoped>
.action-button {
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;
}
.action-button button:hover:not(:disabled) {
background: #f5f5f5;
border-color: #999;
}
.action-button button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-button button .icon {
font-size: 3em;
}
.action-button button .title {
font-weight: 500;
}
/* Animation classes */
.action-button button.action-timeout {
background: #fff3cd;
border-color: #ffeaa7;
color: #856404;
}
.action-button button.action-blocked {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
.action-button button.action-nonzero-exit {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
.action-button button.action-success {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
.action-button-footer {
margin-top: 0.5em;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<header>
<img src="../../OliveTinLogo.png" alt="OliveTin logo" class="logo" />
<h1 id="page-title">
<router-link to="/">OliveTin</router-link>
</h1>
<button id="sidebar-toggler-button" aria-label="Open sidebar navigation" aria-pressed="false"
aria-haspopup="menu" @click="toggleSidebar">&#9776;</button>
<div class="fg1" />
<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>
</header>
<div id="layout">
<Sidebar ref="sidebar" />
<div id="content">
<main title="Main content">
<router-view />
</main>
<ExecutionDialog ref="executionDialog" />
<footer title="footer">
<p><img title="application icon" src="../../OliveTinLogo.png" alt="OliveTin logo" height="1em"
class="logo" />
OliveTin</p>
<p>
<span>
<a href="https://docs.olivetin.app" target="_new">Documentation</a>
</span>
<span>
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">Raise an issue on
GitHub</a>
</span>
<span id="currentVersion">?</span>
<span id="serverConnectionRest">REST</span>
<span id="serverConnectionWebSocket">WebSocket</span>
</p>
<p>
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
</p>
</footer>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Sidebar from './components/Sidebar.vue';
import ExecutionDialog from './ExecutionDialog.vue';
import { provide } from 'vue';
const sidebar = ref(null);
const executionDialog = ref(null);
provide('executionDialog', executionDialog.value);
function toggleSidebar() {
if (sidebar.value && typeof sidebar.value.isOpen !== 'undefined') {
sidebar.value.isOpen = !sidebar.value.isOpen;
}
}
</script>

View File

@ -0,0 +1,432 @@
<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

@ -0,0 +1,32 @@
<template>
<section class = "transparent">
<fieldset>
<legend>{{ dashboard.title }}</legend>
<ActionButton :actionData = "action" v-for = "action in dashboard.contents" :key = "action.id" />
</fieldset>
</section>
</template>
<script setup>
import ActionButton from './ActionButton.vue'
defineProps({
dashboard: {
type: Object,
required: true
}
})
</script>
<style>
fieldset {
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
grid-auto-rows: 1fr;
justify-content: center;
place-items: stretch;
}
</style>

View File

@ -0,0 +1,141 @@
<template>
<div
:id="`execution-${executionTrackingId}`"
class="execution-button"
>
<button
:title="`${ellapsed}s`"
@click="show"
>
{{ buttonText }}
</button>
</div>
</template>
<script>
//import { ExecutionFeedbackButton } from '../js/ExecutionFeedbackButton.js'
export default {
name: 'ExecutionButton',
// mixins: [ExecutionFeedbackButton],
props: {
executionTrackingId: {
type: String,
required: true
}
},
data() {
return {
ellapsed: 0,
isWaiting: true
}
},
computed: {
buttonText() {
if (this.isWaiting) {
return 'Executing...'
} else {
return `${this.ellapsed}s`
}
}
},
mounted() {
this.constructFromJson(this.executionTrackingId)
},
methods: {
constructFromJson(json) {
this.executionTrackingId = json
this.ellapsed = 0
this.isWaiting = true
},
show() {
this.$emit('show')
if (window.executionDialog) {
window.executionDialog.reset()
window.executionDialog.show()
window.executionDialog.fetchExecutionResult(this.executionTrackingId)
}
},
onExecStatusChanged() {
this.isWaiting = false
this.domTitle = this.ellapsed + 's'
},
// Override from ExecutionFeedbackButton
onExecutionFinished(logEntry) {
if (logEntry.timedOut) {
this.renderExecutionResult('action-timeout', 'Timed out')
} else if (logEntry.blocked) {
this.renderExecutionResult('action-blocked', 'Blocked!')
} else if (logEntry.exitCode !== 0) {
this.renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
} else {
this.ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
this.renderExecutionResult('action-success', 'Success!')
}
},
renderExecutionResult(resultCssClass, temporaryStatusMessage) {
this.updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
this.onExecStatusChanged()
},
updateDom(resultCssClass, title) {
// For execution button, we don't need to update classes as much
// since it's a simpler component
if (resultCssClass) {
this.$el.classList.add(resultCssClass)
}
}
}
}
</script>
<style scoped>
.execution-button {
display: inline-block;
}
.execution-button button {
padding: 0.25em 0.5em;
border: 1px solid #ccc;
border-radius: 3px;
background: #fff;
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s ease;
}
.execution-button button:hover {
background: #f5f5f5;
border-color: #999;
}
/* Animation classes */
.execution-button button.action-timeout {
background: #fff3cd;
border-color: #ffeaa7;
color: #856404;
}
.execution-button button.action-blocked {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
.execution-button button.action-nonzero-exit {
background: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
.execution-button button.action-success {
background: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
</style>

View File

@ -0,0 +1,421 @@
<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,4 +1,4 @@
export class LoginForm extends window.HTMLElement { <script setup>
setup () { setup () {
const tpl = document.getElementById('tplLoginForm') const tpl = document.getElementById('tplLoginForm')
this.content = tpl.content.cloneNode(true) this.content = tpl.content.cloneNode(true)
@ -11,40 +11,24 @@ export class LoginForm extends window.HTMLElement {
}) })
} }
localLoginRequest () { async localLoginRequest () {
const username = this.querySelector('input.username').value const username = this.querySelector('input.username').value
const password = this.querySelector('input.password').value const password = this.querySelector('input.password').value
document.querySelector('.error').innerHTML = '' document.querySelector('.error').innerHTML = ''
window.fetch('/api/LocalUserLogin', { const args = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: username, username: username,
password: password password: password
}) }
}).then((response) => {
if (response.ok) {
const res = response.json()
if (res !== undefined) { const loginResult = await window.client.localUserLogin(args)
return res
} else { if (loginResult.success) {
throw new Error('Failed to login - no res')
}
} else {
throw new Error('Failed to login')
}
}).then((res) => {
if (res.success) {
window.location.href = '/' window.location.href = '/'
} else { } else {
document.querySelector('.error').innerHTML = 'Login failed.' document.querySelector('.error').innerHTML = 'Login failed.'
} }
})
} }
processOAuth2Providers (providers) { processOAuth2Providers (providers) {
@ -85,6 +69,4 @@ export class LoginForm extends window.HTMLElement {
this.querySelector('.login-disabled').hidden = true this.querySelector('.login-disabled').hidden = true
} }
} }
} </script>
window.customElements.define('login-form', LoginForm)

View File

@ -0,0 +1,111 @@
<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,63 @@
<template>
<span>
<span :class="['action-status', statusClass]">{{ statusText }}</span><span>{{ exitCodeText }}</span>
</span>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
logEntry: {
type: Object,
required: true
}
})
const statusText = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return 'unknown'
if (logEntry.executionFinished) {
if (logEntry.blocked) {
return 'Blocked'
} else if (logEntry.timedOut) {
return 'Timed out'
} else {
return 'Completed'
}
} else {
return 'Still running...'
}
})
const exitCodeText = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return ''
if (logEntry.executionFinished) {
if (logEntry.blocked || logEntry.timedOut) {
return ''
}
return ' Exit code: ' + logEntry.exitCode
}
return ''
})
const statusClass = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return ''
if (logEntry.executionFinished) {
if (logEntry.blocked) {
return 'action-blocked'
} else if (logEntry.timedOut) {
return 'action-timeout'
} else if (logEntry.exitCode === 0) {
return 'action-success'
} else {
return 'action-nonzero-exit'
}
}
return ''
})
</script>

View File

@ -0,0 +1,254 @@
<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>
<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>
</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>
</li>
</ul>
</nav>
</aside>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'
import { useRoute } from 'vue-router'
const isOpen = ref(false)
const isStuck = ref(false)
const navigationLinks = ref([
{
id: 'actions',
title: 'Actions',
path: '/',
icon: '⚡'
},
{
id: 'logs',
title: 'Logs',
path: '/logs',
icon: '📋'
},
{
id: 'diagnostics',
title: 'Diagnostics',
path: '/diagnostics',
icon: '🔧'
}
])
const supplementalLinks = ref([])
const route = useRoute()
const instance = getCurrentInstance()
function toggleStick() {
isStuck.value = !isStuck.value
}
function toggle() {
isOpen.value = !isOpen.value
}
function open() {
isOpen.value = true
}
function close() {
isOpen.value = false
}
function isActive(path) {
return route.path === path
}
// Method to add navigation links from other components
function addNavigationLink(link) {
const existingIndex = navigationLinks.value.findIndex(l => l.id === link.id)
if (existingIndex >= 0) {
navigationLinks.value[existingIndex] = { ...link }
} else {
navigationLinks.value.push({ ...link })
}
}
// Method to add supplemental links from other components
function addSupplementalLink(link) {
const existingIndex = supplementalLinks.value.findIndex(l => l.id === link.id)
if (existingIndex >= 0) {
supplementalLinks.value[existingIndex] = { ...link }
} else {
supplementalLinks.value.push({ ...link })
}
}
// Method to remove links
function removeNavigationLink(linkId) {
navigationLinks.value = navigationLinks.value.filter(link => link.id !== linkId)
}
function removeSupplementalLink(linkId) {
supplementalLinks.value = supplementalLinks.value.filter(link => link.id !== linkId)
}
// Method to clear all links
function clearNavigationLinks() {
navigationLinks.value = []
}
function clearSupplementalLinks() {
supplementalLinks.value = []
}
// Method to get all links
function getNavigationLinks() {
return [...navigationLinks.value]
}
function getSupplementalLinks() {
return [...supplementalLinks.value]
}
onMounted(() => {
// Make the sidebar globally accessible
window.sidebar = {
get isOpen() { return isOpen.value },
set isOpen(val) { isOpen.value = val },
toggle,
open,
close,
addNavigationLink,
addSupplementalLink,
removeNavigationLink,
removeSupplementalLink,
clearNavigationLinks,
clearSupplementalLinks,
getNavigationLinks,
getSupplementalLinks
}
})
defineExpose({
isOpen,
navigationLinks,
supplementalLinks,
toggle,
open,
close,
isActive,
addNavigationLink,
addSupplementalLink,
removeNavigationLink,
removeSupplementalLink,
clearNavigationLinks,
clearSupplementalLinks,
getNavigationLinks,
getSupplementalLinks
})
</script>
<style scoped>
.mainnav {
padding: 1rem 0;
}
.navigation-links,
.supplemental-links {
list-style: none;
margin: 0;
padding: 0;
}
.navigation-links li,
.supplemental-links li {
margin: 0;
padding: 0;
}
.navigation-links a,
.supplemental-links a {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
color: #333;
text-decoration: none;
transition: background-color 0.2s ease;
border-left: 3px solid transparent;
}
.navigation-links a:hover,
.supplemental-links a:hover {
background: #f8f9fa;
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 */
@media (max-width: 768px) {
.sidebar {
width: 100%;
left: -100%;
}
.sidebar.shown {
left: 0;
}
}
</style>

View File

@ -0,0 +1,179 @@
<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

@ -0,0 +1,76 @@
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'),
meta: { title: 'OliveTin - Dashboard' }
},
{
path: '/logs',
name: 'Logs',
component: () => import('./views/LogsView.vue'),
meta: { title: 'OliveTin - Logs' }
},
{
path: '/diagnostics',
name: 'Diagnostics',
component: () => import('./views/DiagnosticsView.vue'),
meta: { title: 'OliveTin - Diagnostics' }
},
{
path: '/login',
name: 'Login',
component: () => import('./views/LoginView.vue'),
meta: { title: 'OliveTin - Login' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('./views/NotFoundView.vue'),
meta: { title: 'OliveTin - Page Not Found' }
}
]
// Create router instance
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// Navigation guard to update page title
router.beforeEach((to, from, next) => {
if (to.meta && to.meta.title) {
document.title = to.meta.title
}
next()
})
// Navigation guard for authentication (if needed)
router.beforeEach((to, from, next) => {
// Check if user is authenticated for protected routes
const isAuthenticated = window.isAuthenticated || true // Default to true for now
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router

View File

@ -0,0 +1,23 @@
<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

@ -0,0 +1,215 @@
<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>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const diagnostics = ref({})
const loading = ref(false)
async function fetchDiagnostics() {
loading.value = true
try {
const response = await window.client.getDiagnostics();
diagnostics.value = {
sshFoundKey: response.sshFoundKey,
sshFoundConfig: response.sshFoundConfig
};
} catch (err) {
console.error('Failed to fetch diagnostics:', err);
diagnostics.value = {
sshFoundKey: 'Unknown',
sshFoundConfig: 'Unknown'
}
}
loading.value = false
}
function formatKey(key) {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim()
}
onMounted(() => {
fetchDiagnostics()
})
</script>
<style scoped>
.diagnostics-view {
padding: 1rem;
}
.diagnostics-content {
max-width: 800px;
margin: 0 auto;
}
.note {
background: #f8f9fa;
border-left: 4px solid #007bff;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 0 4px 4px 0;
font-size: 0.875rem;
color: #495057;
}
.note a {
color: #007bff;
text-decoration: none;
}
.note a:hover {
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;
}
.diagnostics-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f3f4;
}
.diagnostics-table td:first-child {
font-weight: 500;
color: #495057;
background: #f8f9fa;
}
.diagnostics-table tr:last-child td {
border-bottom: none;
}
.error-list {
padding: 1rem;
}
.error-item {
background: #f8d7da;
color: #721c24;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
border-left: 4px solid #dc3545;
font-family: monospace;
font-size: 0.875rem;
}
.error-item:last-child {
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<section class = "small">
<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 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>
<div v-if="hasOAuth" class="login-oauth2">
<h3>OAuth Login</h3>
<div class="oauth-providers">
<button v-for="provider in oauthProviders" :key="provider.name" class="oauth-button"
@click="loginWithOAuth(provider)">
<span v-if="provider.icon" class="provider-icon" v-html="provider.icon"></span>
<span class="provider-name">Login with {{ provider.name }}</span>
</button>
</div>
</div>
<div v-if="hasLocalLogin" class="login-local">
<h3>Local Login</h3>
<form @submit.prevent="handleLocalLogin" class="local-login-form">
<div v-if="loginError" class="error-message">
{{ loginError }}
</div>
<label for="username">Username:</label>
<input id="username" v-model="username" type="text" name="username" autocomplete="username" required />
<label for="password">Password:</label>
<input id="password" v-model="password" type="password" name="password" autocomplete="current-password"
required />
<button type="submit" :disabled="loading" class="login-button">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
</div>
</div>
</div>
</section>
</template>
<script>
export default {
name: 'LoginView',
data() {
return {
username: '',
password: '',
loading: false,
loginError: '',
hasOAuth: false,
hasLocalLogin: false,
oauthProviders: []
}
},
mounted() {
this.fetchLoginOptions()
},
methods: {
async fetchLoginOptions() {
try {
const response = await fetch('webUiSettings.json')
const settings = await response.json()
this.hasOAuth = settings.AuthOAuth2Providers && settings.AuthOAuth2Providers.length > 0
this.hasLocalLogin = settings.AuthLocalLogin
if (this.hasOAuth) {
this.oauthProviders = settings.AuthOAuth2Providers
}
} catch (err) {
console.error('Failed to fetch login options:', err)
}
},
async handleLocalLogin() {
this.loading = true
this.loginError = ''
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: this.username,
password: this.password
})
})
if (response.ok) {
// Redirect to home page on successful login
this.$router.push('/')
} else {
const error = await response.text()
this.loginError = error || 'Login failed. Please check your credentials.'
}
} catch (err) {
console.error('Login error:', err)
this.loginError = 'Network error. Please try again.'
} finally {
this.loading = false
}
},
loginWithOAuth(provider) {
// Redirect to OAuth provider
window.location.href = provider.authUrl
}
}
}
</script>
<style scoped>
.login-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
form {
grid-template-columns: max-content 1fr;
gap: 1em;
}
</style>

View File

@ -0,0 +1,318 @@
<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

@ -0,0 +1,112 @@
<template>
<div class="not-found-view">
<div class="not-found-container">
<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">
Go to Home
</router-link>
<button @click="goBack" class="btn btn-secondary">
Go Back
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'NotFoundView',
methods: {
goBack() {
this.$router.go(-1)
}
}
}
</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;
text-align: center;
}
.not-found-content h1 {
font-size: 6rem;
margin: 0;
color: #007bff;
font-weight: 700;
line-height: 1;
}
.not-found-content h2 {
font-size: 2rem;
margin: 0 0 1rem 0;
color: #333;
}
.not-found-content p {
font-size: 1.1rem;
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>

41
frontend/style.css Normal file
View File

@ -0,0 +1,41 @@
@import 'femtocrank/style.css';
section.transparent {
background-color: transparent;
box-shadow: none;
}
fieldset {
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
grid-auto-rows: 1fr;
justify-content: center;
place-items: stretch;
}
action-button {
display: flex;
flex-direction: column;
flex-grow: 1;
}
action-button > button {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
font-weight: normal;
font-size: 0.85em;
box-shadow: 0 0 .6em #aaa;
}
action-button > button .icon {
font-size: 3em;
}
dialog {
border-radius: 1em;
}
footer span {
margin-right: 1em;
}

29
frontend/vite.config.js Normal file
View File

@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
export default defineConfig({
plugins: [
Components({
dirs: ['resources/vue/'],
extensions: ['vue'],
deep: true,
dts: false,
}),
vue(),
],
server: {
proxy: {
'/webUiSettings.json': {
target: 'http://localhost:1337',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'http://localhost:1337',
changeOrigin: true,
secure: false,
}
},
},
})

View File

@ -1,17 +1,16 @@
version: v2 version: v2
plugins: plugins:
- remote: buf.build/protocolbuffers/go - remote: buf.build/protocolbuffers/go
out: ../service/gen/grpc/ out: ../service/gen/
opt: paths=source_relative opt: paths=source_relative
- remote: buf.build/grpc/go - remote: buf.build/connectrpc/go
out: ../service/gen/grpc/ out: ../service/gen/
opt: paths=source_relative,require_unimplemented_servers=false
- remote: buf.build/grpc-ecosystem/gateway
out: ../service/gen/grpc/
opt: paths=source_relative opt: paths=source_relative
- remote: buf.build/bufbuild/es
out: ../frontend/resources/scripts/gen/
# - name: swagger # - name: swagger
# out: reports/swagger # out: reports/swagger

View File

@ -2,10 +2,7 @@ syntax = "proto3";
package olivetin.api.v1; package olivetin.api.v1;
option go_package = "github.com/jamesread/OliveTin/gen/grpc/olivetin/api/v1;apiv1"; option go_package = "github.com/OliveTin/OliveTin/gen/olivetin/api/v1;apiv1";
import "google/api/annotations.proto";
import "google/api/httpbody.proto";
message Action { message Action {
string id = 1; string id = 1;
@ -43,20 +40,12 @@ message Entity {
message GetDashboardComponentsResponse { message GetDashboardComponentsResponse {
string title = 1; string title = 1;
repeated Action actions = 2; repeated Dashboard dashboards = 4;
repeated Entity entities = 3;
repeated DashboardComponent dashboards = 4;
string authenticated_user = 5; string authenticated_user = 5;
string authenticated_user_provider = 6; string authenticated_user_provider = 6;
EffectivePolicy effective_policy = 7; EffectivePolicy effective_policy = 7;
Diagnostics diagnostics = 8;
}
message Diagnostics {
string SshFoundKey = 1;
string SshFoundConfig = 2;
} }
message EffectivePolicy { message EffectivePolicy {
@ -66,6 +55,11 @@ message EffectivePolicy {
message GetDashboardComponentsRequest {} message GetDashboardComponentsRequest {}
message Dashboard {
string title = 1;
repeated DashboardComponent contents = 2;
}
message DashboardComponent { message DashboardComponent {
string title = 1; string title = 1;
string type = 2; string type = 2;
@ -216,6 +210,19 @@ message GetReadyzResponse {
string status = 1; string status = 1;
} }
message EventStreamRequest {
}
message EventStreamResponse {
oneof event {
EventEntityChanged entity_changed = 2;
EventConfigChanged config_changed = 3;
EventExecutionFinished execution_finished = 4;
EventExecutionStarted execution_started = 5;
EventOutputChunk output_chunk = 6;
}
}
message EventOutputChunk { message EventOutputChunk {
string execution_tracking_id = 1; string execution_tracking_id = 1;
@ -257,117 +264,58 @@ message PasswordHashRequest {
} }
message PasswordHashResponse { message PasswordHashResponse {
string hash = 1;
} }
message LogoutRequest {} message LogoutRequest {}
service OliveTinApiService { message LogoutResponse {
rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) { }
option (google.api.http) = {
get: "/api/GetDashboardComponents" message GetDiagnosticsRequest {
}; }
}
message GetDiagnosticsResponse {
rpc StartAction(StartActionRequest) returns (StartActionResponse) { string SshFoundKey = 1;
option (google.api.http) = { string SshFoundConfig = 2;
post: "/api/StartAction" }
body: "*"
}; service OliveTinApiService {
} rpc GetDashboardComponents(GetDashboardComponentsRequest) returns (GetDashboardComponentsResponse) {}
rpc StartActionAndWait(StartActionAndWaitRequest) returns (StartActionAndWaitResponse) { rpc StartAction(StartActionRequest) returns (StartActionResponse) {}
option (google.api.http) = {
post: "/api/StartActionAndWait" rpc StartActionAndWait(StartActionAndWaitRequest) returns (StartActionAndWaitResponse) {}
body: "*"
}; rpc StartActionByGet(StartActionByGetRequest) returns (StartActionByGetResponse) {}
}
rpc StartActionByGetAndWait(StartActionByGetAndWaitRequest) returns (StartActionByGetAndWaitResponse) {}
rpc StartActionByGet(StartActionByGetRequest) returns (StartActionByGetResponse) {
option (google.api.http) = { rpc KillAction(KillActionRequest) returns (KillActionResponse) {}
get: "/api/StartActionByGet/{action_id}"
}; rpc ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) {}
}
rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {}
rpc StartActionByGetAndWait(StartActionByGetAndWaitRequest) returns (StartActionByGetAndWaitResponse) {
option (google.api.http) = { rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {}
get: "/api/StartActionByGetAndWait/{action_id}"
}; rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse) {}
}
rpc SosReport(SosReportRequest) returns (SosReportResponse) {}
rpc KillAction(KillActionRequest) returns (KillActionResponse) {
option (google.api.http) = { rpc DumpVars(DumpVarsRequest) returns (DumpVarsResponse) {}
post: "/api/KillAction"
body: "*" rpc DumpPublicIdActionMap(DumpPublicIdActionMapRequest) returns (DumpPublicIdActionMapResponse) {}
};
} rpc GetReadyz(GetReadyzRequest) returns (GetReadyzResponse) {}
rpc ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) { rpc LocalUserLogin(LocalUserLoginRequest) returns (LocalUserLoginResponse) {}
option (google.api.http) = {
post: "/api/ExecutionStatus" rpc PasswordHash(PasswordHashRequest) returns (PasswordHashResponse) {}
body: "*"
}; rpc Logout(LogoutRequest) returns (LogoutResponse) {}
}
rpc EventStream(EventStreamRequest) returns (stream EventStreamResponse) {}
rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {
option (google.api.http) = { rpc GetDiagnostics(GetDiagnosticsRequest) returns (GetDiagnosticsResponse) {}
get: "/api/GetLogs"
};
}
rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {
option (google.api.http) = {
post: "/api/ValidateArgumentType"
body: "*"
};
}
rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse) {
option (google.api.http) = {
get: "/api/WhoAmI"
};
}
rpc SosReport(SosReportRequest) returns (google.api.HttpBody) {
option (google.api.http) = {
get: "/api/sosreport"
};
}
rpc DumpVars(DumpVarsRequest) returns (DumpVarsResponse) {
option (google.api.http) = {
get: "/api/DumpVars"
};
}
rpc DumpPublicIdActionMap(DumpPublicIdActionMapRequest) returns (DumpPublicIdActionMapResponse) {
option (google.api.http) = {
get: "/api/DumpActionMap"
};
}
rpc GetReadyz(GetReadyzRequest) returns (GetReadyzResponse) {
option (google.api.http) = {
get: "/api/readyz"
};
}
rpc LocalUserLogin(LocalUserLoginRequest) returns (LocalUserLoginResponse) {
option (google.api.http) = {
post: "/api/LocalUserLogin"
body: "*"
};
}
rpc PasswordHash(PasswordHashRequest) returns (google.api.HttpBody) {
option (google.api.http) = {
post: "/api/PasswordHash"
body: "*"
};
}
rpc Logout(LogoutRequest) returns (google.api.HttpBody) {
option (google.api.http) = {
get: "/api/Logout"
};
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,728 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc (unknown)
// source: olivetin/api/v1/olivetin.proto
package apiv1
import (
context "context"
httpbody "google.golang.org/genproto/googleapis/api/httpbody"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
OliveTinApiService_GetDashboardComponents_FullMethodName = "/olivetin.api.v1.OliveTinApiService/GetDashboardComponents"
OliveTinApiService_StartAction_FullMethodName = "/olivetin.api.v1.OliveTinApiService/StartAction"
OliveTinApiService_StartActionAndWait_FullMethodName = "/olivetin.api.v1.OliveTinApiService/StartActionAndWait"
OliveTinApiService_StartActionByGet_FullMethodName = "/olivetin.api.v1.OliveTinApiService/StartActionByGet"
OliveTinApiService_StartActionByGetAndWait_FullMethodName = "/olivetin.api.v1.OliveTinApiService/StartActionByGetAndWait"
OliveTinApiService_KillAction_FullMethodName = "/olivetin.api.v1.OliveTinApiService/KillAction"
OliveTinApiService_ExecutionStatus_FullMethodName = "/olivetin.api.v1.OliveTinApiService/ExecutionStatus"
OliveTinApiService_GetLogs_FullMethodName = "/olivetin.api.v1.OliveTinApiService/GetLogs"
OliveTinApiService_ValidateArgumentType_FullMethodName = "/olivetin.api.v1.OliveTinApiService/ValidateArgumentType"
OliveTinApiService_WhoAmI_FullMethodName = "/olivetin.api.v1.OliveTinApiService/WhoAmI"
OliveTinApiService_SosReport_FullMethodName = "/olivetin.api.v1.OliveTinApiService/SosReport"
OliveTinApiService_DumpVars_FullMethodName = "/olivetin.api.v1.OliveTinApiService/DumpVars"
OliveTinApiService_DumpPublicIdActionMap_FullMethodName = "/olivetin.api.v1.OliveTinApiService/DumpPublicIdActionMap"
OliveTinApiService_GetReadyz_FullMethodName = "/olivetin.api.v1.OliveTinApiService/GetReadyz"
OliveTinApiService_LocalUserLogin_FullMethodName = "/olivetin.api.v1.OliveTinApiService/LocalUserLogin"
OliveTinApiService_PasswordHash_FullMethodName = "/olivetin.api.v1.OliveTinApiService/PasswordHash"
OliveTinApiService_Logout_FullMethodName = "/olivetin.api.v1.OliveTinApiService/Logout"
)
// OliveTinApiServiceClient is the client API for OliveTinApiService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type OliveTinApiServiceClient interface {
GetDashboardComponents(ctx context.Context, in *GetDashboardComponentsRequest, opts ...grpc.CallOption) (*GetDashboardComponentsResponse, error)
StartAction(ctx context.Context, in *StartActionRequest, opts ...grpc.CallOption) (*StartActionResponse, error)
StartActionAndWait(ctx context.Context, in *StartActionAndWaitRequest, opts ...grpc.CallOption) (*StartActionAndWaitResponse, error)
StartActionByGet(ctx context.Context, in *StartActionByGetRequest, opts ...grpc.CallOption) (*StartActionByGetResponse, error)
StartActionByGetAndWait(ctx context.Context, in *StartActionByGetAndWaitRequest, opts ...grpc.CallOption) (*StartActionByGetAndWaitResponse, error)
KillAction(ctx context.Context, in *KillActionRequest, opts ...grpc.CallOption) (*KillActionResponse, error)
ExecutionStatus(ctx context.Context, in *ExecutionStatusRequest, opts ...grpc.CallOption) (*ExecutionStatusResponse, error)
GetLogs(ctx context.Context, in *GetLogsRequest, opts ...grpc.CallOption) (*GetLogsResponse, error)
ValidateArgumentType(ctx context.Context, in *ValidateArgumentTypeRequest, opts ...grpc.CallOption) (*ValidateArgumentTypeResponse, error)
WhoAmI(ctx context.Context, in *WhoAmIRequest, opts ...grpc.CallOption) (*WhoAmIResponse, error)
SosReport(ctx context.Context, in *SosReportRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
DumpVars(ctx context.Context, in *DumpVarsRequest, opts ...grpc.CallOption) (*DumpVarsResponse, error)
DumpPublicIdActionMap(ctx context.Context, in *DumpPublicIdActionMapRequest, opts ...grpc.CallOption) (*DumpPublicIdActionMapResponse, error)
GetReadyz(ctx context.Context, in *GetReadyzRequest, opts ...grpc.CallOption) (*GetReadyzResponse, error)
LocalUserLogin(ctx context.Context, in *LocalUserLoginRequest, opts ...grpc.CallOption) (*LocalUserLoginResponse, error)
PasswordHash(ctx context.Context, in *PasswordHashRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
}
type oliveTinApiServiceClient struct {
cc grpc.ClientConnInterface
}
func NewOliveTinApiServiceClient(cc grpc.ClientConnInterface) OliveTinApiServiceClient {
return &oliveTinApiServiceClient{cc}
}
func (c *oliveTinApiServiceClient) GetDashboardComponents(ctx context.Context, in *GetDashboardComponentsRequest, opts ...grpc.CallOption) (*GetDashboardComponentsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDashboardComponentsResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_GetDashboardComponents_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartAction(ctx context.Context, in *StartActionRequest, opts ...grpc.CallOption) (*StartActionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartActionResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartAction_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartActionAndWait(ctx context.Context, in *StartActionAndWaitRequest, opts ...grpc.CallOption) (*StartActionAndWaitResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartActionAndWaitResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartActionAndWait_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartActionByGet(ctx context.Context, in *StartActionByGetRequest, opts ...grpc.CallOption) (*StartActionByGetResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartActionByGetResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartActionByGet_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartActionByGetAndWait(ctx context.Context, in *StartActionByGetAndWaitRequest, opts ...grpc.CallOption) (*StartActionByGetAndWaitResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StartActionByGetAndWaitResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartActionByGetAndWait_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) KillAction(ctx context.Context, in *KillActionRequest, opts ...grpc.CallOption) (*KillActionResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(KillActionResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_KillAction_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) ExecutionStatus(ctx context.Context, in *ExecutionStatusRequest, opts ...grpc.CallOption) (*ExecutionStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExecutionStatusResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_ExecutionStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) GetLogs(ctx context.Context, in *GetLogsRequest, opts ...grpc.CallOption) (*GetLogsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLogsResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_GetLogs_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, in *ValidateArgumentTypeRequest, opts ...grpc.CallOption) (*ValidateArgumentTypeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ValidateArgumentTypeResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_ValidateArgumentType_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) WhoAmI(ctx context.Context, in *WhoAmIRequest, opts ...grpc.CallOption) (*WhoAmIResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(WhoAmIResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_WhoAmI_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) SosReport(ctx context.Context, in *SosReportRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, OliveTinApiService_SosReport_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) DumpVars(ctx context.Context, in *DumpVarsRequest, opts ...grpc.CallOption) (*DumpVarsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DumpVarsResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_DumpVars_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) DumpPublicIdActionMap(ctx context.Context, in *DumpPublicIdActionMapRequest, opts ...grpc.CallOption) (*DumpPublicIdActionMapResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DumpPublicIdActionMapResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_DumpPublicIdActionMap_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) GetReadyz(ctx context.Context, in *GetReadyzRequest, opts ...grpc.CallOption) (*GetReadyzResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetReadyzResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_GetReadyz_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) LocalUserLogin(ctx context.Context, in *LocalUserLoginRequest, opts ...grpc.CallOption) (*LocalUserLoginResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LocalUserLoginResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_LocalUserLogin_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) PasswordHash(ctx context.Context, in *PasswordHashRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, OliveTinApiService_PasswordHash_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, OliveTinApiService_Logout_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// OliveTinApiServiceServer is the server API for OliveTinApiService service.
// All implementations should embed UnimplementedOliveTinApiServiceServer
// for forward compatibility.
type OliveTinApiServiceServer interface {
GetDashboardComponents(context.Context, *GetDashboardComponentsRequest) (*GetDashboardComponentsResponse, error)
StartAction(context.Context, *StartActionRequest) (*StartActionResponse, error)
StartActionAndWait(context.Context, *StartActionAndWaitRequest) (*StartActionAndWaitResponse, error)
StartActionByGet(context.Context, *StartActionByGetRequest) (*StartActionByGetResponse, error)
StartActionByGetAndWait(context.Context, *StartActionByGetAndWaitRequest) (*StartActionByGetAndWaitResponse, error)
KillAction(context.Context, *KillActionRequest) (*KillActionResponse, error)
ExecutionStatus(context.Context, *ExecutionStatusRequest) (*ExecutionStatusResponse, error)
GetLogs(context.Context, *GetLogsRequest) (*GetLogsResponse, error)
ValidateArgumentType(context.Context, *ValidateArgumentTypeRequest) (*ValidateArgumentTypeResponse, error)
WhoAmI(context.Context, *WhoAmIRequest) (*WhoAmIResponse, error)
SosReport(context.Context, *SosReportRequest) (*httpbody.HttpBody, error)
DumpVars(context.Context, *DumpVarsRequest) (*DumpVarsResponse, error)
DumpPublicIdActionMap(context.Context, *DumpPublicIdActionMapRequest) (*DumpPublicIdActionMapResponse, error)
GetReadyz(context.Context, *GetReadyzRequest) (*GetReadyzResponse, error)
LocalUserLogin(context.Context, *LocalUserLoginRequest) (*LocalUserLoginResponse, error)
PasswordHash(context.Context, *PasswordHashRequest) (*httpbody.HttpBody, error)
Logout(context.Context, *LogoutRequest) (*httpbody.HttpBody, error)
}
// UnimplementedOliveTinApiServiceServer should be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedOliveTinApiServiceServer struct{}
func (UnimplementedOliveTinApiServiceServer) GetDashboardComponents(context.Context, *GetDashboardComponentsRequest) (*GetDashboardComponentsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDashboardComponents not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartAction(context.Context, *StartActionRequest) (*StartActionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartAction not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartActionAndWait(context.Context, *StartActionAndWaitRequest) (*StartActionAndWaitResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartActionAndWait not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartActionByGet(context.Context, *StartActionByGetRequest) (*StartActionByGetResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartActionByGet not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartActionByGetAndWait(context.Context, *StartActionByGetAndWaitRequest) (*StartActionByGetAndWaitResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartActionByGetAndWait not implemented")
}
func (UnimplementedOliveTinApiServiceServer) KillAction(context.Context, *KillActionRequest) (*KillActionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method KillAction not implemented")
}
func (UnimplementedOliveTinApiServiceServer) ExecutionStatus(context.Context, *ExecutionStatusRequest) (*ExecutionStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExecutionStatus not implemented")
}
func (UnimplementedOliveTinApiServiceServer) GetLogs(context.Context, *GetLogsRequest) (*GetLogsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetLogs not implemented")
}
func (UnimplementedOliveTinApiServiceServer) ValidateArgumentType(context.Context, *ValidateArgumentTypeRequest) (*ValidateArgumentTypeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidateArgumentType not implemented")
}
func (UnimplementedOliveTinApiServiceServer) WhoAmI(context.Context, *WhoAmIRequest) (*WhoAmIResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method WhoAmI not implemented")
}
func (UnimplementedOliveTinApiServiceServer) SosReport(context.Context, *SosReportRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method SosReport not implemented")
}
func (UnimplementedOliveTinApiServiceServer) DumpVars(context.Context, *DumpVarsRequest) (*DumpVarsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DumpVars not implemented")
}
func (UnimplementedOliveTinApiServiceServer) DumpPublicIdActionMap(context.Context, *DumpPublicIdActionMapRequest) (*DumpPublicIdActionMapResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DumpPublicIdActionMap not implemented")
}
func (UnimplementedOliveTinApiServiceServer) GetReadyz(context.Context, *GetReadyzRequest) (*GetReadyzResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetReadyz not implemented")
}
func (UnimplementedOliveTinApiServiceServer) LocalUserLogin(context.Context, *LocalUserLoginRequest) (*LocalUserLoginResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LocalUserLogin not implemented")
}
func (UnimplementedOliveTinApiServiceServer) PasswordHash(context.Context, *PasswordHashRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method PasswordHash not implemented")
}
func (UnimplementedOliveTinApiServiceServer) Logout(context.Context, *LogoutRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
}
func (UnimplementedOliveTinApiServiceServer) testEmbeddedByValue() {}
// UnsafeOliveTinApiServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to OliveTinApiServiceServer will
// result in compilation errors.
type UnsafeOliveTinApiServiceServer interface {
mustEmbedUnimplementedOliveTinApiServiceServer()
}
func RegisterOliveTinApiServiceServer(s grpc.ServiceRegistrar, srv OliveTinApiServiceServer) {
// If the following call pancis, it indicates UnimplementedOliveTinApiServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&OliveTinApiService_ServiceDesc, srv)
}
func _OliveTinApiService_GetDashboardComponents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDashboardComponentsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).GetDashboardComponents(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_GetDashboardComponents_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).GetDashboardComponents(ctx, req.(*GetDashboardComponentsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartAction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartAction(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartAction_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartAction(ctx, req.(*StartActionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartActionAndWait_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionAndWaitRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartActionAndWait(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartActionAndWait_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartActionAndWait(ctx, req.(*StartActionAndWaitRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartActionByGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionByGetRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartActionByGet(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartActionByGet_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartActionByGet(ctx, req.(*StartActionByGetRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartActionByGetAndWait_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionByGetAndWaitRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartActionByGetAndWait(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartActionByGetAndWait_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartActionByGetAndWait(ctx, req.(*StartActionByGetAndWaitRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_KillAction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(KillActionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).KillAction(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_KillAction_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).KillAction(ctx, req.(*KillActionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_ExecutionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExecutionStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).ExecutionStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_ExecutionStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).ExecutionStatus(ctx, req.(*ExecutionStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_GetLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLogsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).GetLogs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_GetLogs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).GetLogs(ctx, req.(*GetLogsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_ValidateArgumentType_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ValidateArgumentTypeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).ValidateArgumentType(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_ValidateArgumentType_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).ValidateArgumentType(ctx, req.(*ValidateArgumentTypeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_WhoAmI_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(WhoAmIRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).WhoAmI(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_WhoAmI_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).WhoAmI(ctx, req.(*WhoAmIRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_SosReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SosReportRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).SosReport(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_SosReport_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).SosReport(ctx, req.(*SosReportRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_DumpVars_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DumpVarsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).DumpVars(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_DumpVars_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).DumpVars(ctx, req.(*DumpVarsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_DumpPublicIdActionMap_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DumpPublicIdActionMapRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).DumpPublicIdActionMap(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_DumpPublicIdActionMap_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).DumpPublicIdActionMap(ctx, req.(*DumpPublicIdActionMapRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_GetReadyz_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetReadyzRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).GetReadyz(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_GetReadyz_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).GetReadyz(ctx, req.(*GetReadyzRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_LocalUserLogin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LocalUserLoginRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).LocalUserLogin(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_LocalUserLogin_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).LocalUserLogin(ctx, req.(*LocalUserLoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_PasswordHash_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PasswordHashRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).PasswordHash(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_PasswordHash_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).PasswordHash(ctx, req.(*PasswordHashRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).Logout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_Logout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).Logout(ctx, req.(*LogoutRequest))
}
return interceptor(ctx, in, info, handler)
}
// OliveTinApiService_ServiceDesc is the grpc.ServiceDesc for OliveTinApiService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var OliveTinApiService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "olivetin.api.v1.OliveTinApiService",
HandlerType: (*OliveTinApiServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetDashboardComponents",
Handler: _OliveTinApiService_GetDashboardComponents_Handler,
},
{
MethodName: "StartAction",
Handler: _OliveTinApiService_StartAction_Handler,
},
{
MethodName: "StartActionAndWait",
Handler: _OliveTinApiService_StartActionAndWait_Handler,
},
{
MethodName: "StartActionByGet",
Handler: _OliveTinApiService_StartActionByGet_Handler,
},
{
MethodName: "StartActionByGetAndWait",
Handler: _OliveTinApiService_StartActionByGetAndWait_Handler,
},
{
MethodName: "KillAction",
Handler: _OliveTinApiService_KillAction_Handler,
},
{
MethodName: "ExecutionStatus",
Handler: _OliveTinApiService_ExecutionStatus_Handler,
},
{
MethodName: "GetLogs",
Handler: _OliveTinApiService_GetLogs_Handler,
},
{
MethodName: "ValidateArgumentType",
Handler: _OliveTinApiService_ValidateArgumentType_Handler,
},
{
MethodName: "WhoAmI",
Handler: _OliveTinApiService_WhoAmI_Handler,
},
{
MethodName: "SosReport",
Handler: _OliveTinApiService_SosReport_Handler,
},
{
MethodName: "DumpVars",
Handler: _OliveTinApiService_DumpVars_Handler,
},
{
MethodName: "DumpPublicIdActionMap",
Handler: _OliveTinApiService_DumpPublicIdActionMap_Handler,
},
{
MethodName: "GetReadyz",
Handler: _OliveTinApiService_GetReadyz_Handler,
},
{
MethodName: "LocalUserLogin",
Handler: _OliveTinApiService_LocalUserLogin_Handler,
},
{
MethodName: "PasswordHash",
Handler: _OliveTinApiService_PasswordHash_Handler,
},
{
MethodName: "Logout",
Handler: _OliveTinApiService_Logout_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "olivetin/api/v1/olivetin.proto",
}

View File

@ -0,0 +1,631 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: olivetin/api/v1/olivetin.proto
package apiv1connect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
v1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// OliveTinApiServiceName is the fully-qualified name of the OliveTinApiService service.
OliveTinApiServiceName = "olivetin.api.v1.OliveTinApiService"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// 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"
// OliveTinApiServiceStartActionProcedure is the fully-qualified name of the OliveTinApiService's
// StartAction RPC.
OliveTinApiServiceStartActionProcedure = "/olivetin.api.v1.OliveTinApiService/StartAction"
// OliveTinApiServiceStartActionAndWaitProcedure is the fully-qualified name of the
// OliveTinApiService's StartActionAndWait RPC.
OliveTinApiServiceStartActionAndWaitProcedure = "/olivetin.api.v1.OliveTinApiService/StartActionAndWait"
// OliveTinApiServiceStartActionByGetProcedure is the fully-qualified name of the
// OliveTinApiService's StartActionByGet RPC.
OliveTinApiServiceStartActionByGetProcedure = "/olivetin.api.v1.OliveTinApiService/StartActionByGet"
// OliveTinApiServiceStartActionByGetAndWaitProcedure is the fully-qualified name of the
// OliveTinApiService's StartActionByGetAndWait RPC.
OliveTinApiServiceStartActionByGetAndWaitProcedure = "/olivetin.api.v1.OliveTinApiService/StartActionByGetAndWait"
// OliveTinApiServiceKillActionProcedure is the fully-qualified name of the OliveTinApiService's
// KillAction RPC.
OliveTinApiServiceKillActionProcedure = "/olivetin.api.v1.OliveTinApiService/KillAction"
// OliveTinApiServiceExecutionStatusProcedure is the fully-qualified name of the
// OliveTinApiService's ExecutionStatus RPC.
OliveTinApiServiceExecutionStatusProcedure = "/olivetin.api.v1.OliveTinApiService/ExecutionStatus"
// OliveTinApiServiceGetLogsProcedure is the fully-qualified name of the OliveTinApiService's
// GetLogs RPC.
OliveTinApiServiceGetLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetLogs"
// OliveTinApiServiceValidateArgumentTypeProcedure is the fully-qualified name of the
// OliveTinApiService's ValidateArgumentType RPC.
OliveTinApiServiceValidateArgumentTypeProcedure = "/olivetin.api.v1.OliveTinApiService/ValidateArgumentType"
// OliveTinApiServiceWhoAmIProcedure is the fully-qualified name of the OliveTinApiService's WhoAmI
// RPC.
OliveTinApiServiceWhoAmIProcedure = "/olivetin.api.v1.OliveTinApiService/WhoAmI"
// OliveTinApiServiceSosReportProcedure is the fully-qualified name of the OliveTinApiService's
// SosReport RPC.
OliveTinApiServiceSosReportProcedure = "/olivetin.api.v1.OliveTinApiService/SosReport"
// OliveTinApiServiceDumpVarsProcedure is the fully-qualified name of the OliveTinApiService's
// DumpVars RPC.
OliveTinApiServiceDumpVarsProcedure = "/olivetin.api.v1.OliveTinApiService/DumpVars"
// OliveTinApiServiceDumpPublicIdActionMapProcedure is the fully-qualified name of the
// OliveTinApiService's DumpPublicIdActionMap RPC.
OliveTinApiServiceDumpPublicIdActionMapProcedure = "/olivetin.api.v1.OliveTinApiService/DumpPublicIdActionMap"
// OliveTinApiServiceGetReadyzProcedure is the fully-qualified name of the OliveTinApiService's
// GetReadyz RPC.
OliveTinApiServiceGetReadyzProcedure = "/olivetin.api.v1.OliveTinApiService/GetReadyz"
// OliveTinApiServiceLocalUserLoginProcedure is the fully-qualified name of the OliveTinApiService's
// LocalUserLogin RPC.
OliveTinApiServiceLocalUserLoginProcedure = "/olivetin.api.v1.OliveTinApiService/LocalUserLogin"
// OliveTinApiServicePasswordHashProcedure is the fully-qualified name of the OliveTinApiService's
// PasswordHash RPC.
OliveTinApiServicePasswordHashProcedure = "/olivetin.api.v1.OliveTinApiService/PasswordHash"
// OliveTinApiServiceLogoutProcedure is the fully-qualified name of the OliveTinApiService's Logout
// RPC.
OliveTinApiServiceLogoutProcedure = "/olivetin.api.v1.OliveTinApiService/Logout"
// OliveTinApiServiceEventStreamProcedure is the fully-qualified name of the OliveTinApiService's
// EventStream RPC.
OliveTinApiServiceEventStreamProcedure = "/olivetin.api.v1.OliveTinApiService/EventStream"
// OliveTinApiServiceGetDiagnosticsProcedure is the fully-qualified name of the OliveTinApiService's
// GetDiagnostics RPC.
OliveTinApiServiceGetDiagnosticsProcedure = "/olivetin.api.v1.OliveTinApiService/GetDiagnostics"
)
// 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)
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)
StartActionByGetAndWait(context.Context, *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error)
KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error)
GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error)
ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error)
SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error)
DumpVars(context.Context, *connect.Request[v1.DumpVarsRequest]) (*connect.Response[v1.DumpVarsResponse], error)
DumpPublicIdActionMap(context.Context, *connect.Request[v1.DumpPublicIdActionMapRequest]) (*connect.Response[v1.DumpPublicIdActionMapResponse], error)
GetReadyz(context.Context, *connect.Request[v1.GetReadyzRequest]) (*connect.Response[v1.GetReadyzResponse], error)
LocalUserLogin(context.Context, *connect.Request[v1.LocalUserLoginRequest]) (*connect.Response[v1.LocalUserLoginResponse], error)
PasswordHash(context.Context, *connect.Request[v1.PasswordHashRequest]) (*connect.Response[v1.PasswordHashResponse], error)
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)
}
// NewOliveTinApiServiceClient constructs a client for the olivetin.api.v1.OliveTinApiService
// service. By default, it uses the Connect protocol with the binary Protobuf Codec, asks for
// gzipped responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply
// the connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) OliveTinApiServiceClient {
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](
httpClient,
baseURL+OliveTinApiServiceGetDashboardComponentsProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDashboardComponents")),
connect.WithClientOptions(opts...),
),
startAction: connect.NewClient[v1.StartActionRequest, v1.StartActionResponse](
httpClient,
baseURL+OliveTinApiServiceStartActionProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartAction")),
connect.WithClientOptions(opts...),
),
startActionAndWait: connect.NewClient[v1.StartActionAndWaitRequest, v1.StartActionAndWaitResponse](
httpClient,
baseURL+OliveTinApiServiceStartActionAndWaitProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartActionAndWait")),
connect.WithClientOptions(opts...),
),
startActionByGet: connect.NewClient[v1.StartActionByGetRequest, v1.StartActionByGetResponse](
httpClient,
baseURL+OliveTinApiServiceStartActionByGetProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartActionByGet")),
connect.WithClientOptions(opts...),
),
startActionByGetAndWait: connect.NewClient[v1.StartActionByGetAndWaitRequest, v1.StartActionByGetAndWaitResponse](
httpClient,
baseURL+OliveTinApiServiceStartActionByGetAndWaitProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartActionByGetAndWait")),
connect.WithClientOptions(opts...),
),
killAction: connect.NewClient[v1.KillActionRequest, v1.KillActionResponse](
httpClient,
baseURL+OliveTinApiServiceKillActionProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("KillAction")),
connect.WithClientOptions(opts...),
),
executionStatus: connect.NewClient[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse](
httpClient,
baseURL+OliveTinApiServiceExecutionStatusProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("ExecutionStatus")),
connect.WithClientOptions(opts...),
),
getLogs: connect.NewClient[v1.GetLogsRequest, v1.GetLogsResponse](
httpClient,
baseURL+OliveTinApiServiceGetLogsProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
connect.WithClientOptions(opts...),
),
validateArgumentType: connect.NewClient[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse](
httpClient,
baseURL+OliveTinApiServiceValidateArgumentTypeProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("ValidateArgumentType")),
connect.WithClientOptions(opts...),
),
whoAmI: connect.NewClient[v1.WhoAmIRequest, v1.WhoAmIResponse](
httpClient,
baseURL+OliveTinApiServiceWhoAmIProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("WhoAmI")),
connect.WithClientOptions(opts...),
),
sosReport: connect.NewClient[v1.SosReportRequest, v1.SosReportResponse](
httpClient,
baseURL+OliveTinApiServiceSosReportProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("SosReport")),
connect.WithClientOptions(opts...),
),
dumpVars: connect.NewClient[v1.DumpVarsRequest, v1.DumpVarsResponse](
httpClient,
baseURL+OliveTinApiServiceDumpVarsProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("DumpVars")),
connect.WithClientOptions(opts...),
),
dumpPublicIdActionMap: connect.NewClient[v1.DumpPublicIdActionMapRequest, v1.DumpPublicIdActionMapResponse](
httpClient,
baseURL+OliveTinApiServiceDumpPublicIdActionMapProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("DumpPublicIdActionMap")),
connect.WithClientOptions(opts...),
),
getReadyz: connect.NewClient[v1.GetReadyzRequest, v1.GetReadyzResponse](
httpClient,
baseURL+OliveTinApiServiceGetReadyzProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetReadyz")),
connect.WithClientOptions(opts...),
),
localUserLogin: connect.NewClient[v1.LocalUserLoginRequest, v1.LocalUserLoginResponse](
httpClient,
baseURL+OliveTinApiServiceLocalUserLoginProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("LocalUserLogin")),
connect.WithClientOptions(opts...),
),
passwordHash: connect.NewClient[v1.PasswordHashRequest, v1.PasswordHashResponse](
httpClient,
baseURL+OliveTinApiServicePasswordHashProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("PasswordHash")),
connect.WithClientOptions(opts...),
),
logout: connect.NewClient[v1.LogoutRequest, v1.LogoutResponse](
httpClient,
baseURL+OliveTinApiServiceLogoutProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("Logout")),
connect.WithClientOptions(opts...),
),
eventStream: connect.NewClient[v1.EventStreamRequest, v1.EventStreamResponse](
httpClient,
baseURL+OliveTinApiServiceEventStreamProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("EventStream")),
connect.WithClientOptions(opts...),
),
getDiagnostics: connect.NewClient[v1.GetDiagnosticsRequest, v1.GetDiagnosticsResponse](
httpClient,
baseURL+OliveTinApiServiceGetDiagnosticsProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDiagnostics")),
connect.WithClientOptions(opts...),
),
}
}
// oliveTinApiServiceClient implements OliveTinApiServiceClient.
type oliveTinApiServiceClient struct {
getDashboardComponents *connect.Client[v1.GetDashboardComponentsRequest, v1.GetDashboardComponentsResponse]
startAction *connect.Client[v1.StartActionRequest, v1.StartActionResponse]
startActionAndWait *connect.Client[v1.StartActionAndWaitRequest, v1.StartActionAndWaitResponse]
startActionByGet *connect.Client[v1.StartActionByGetRequest, v1.StartActionByGetResponse]
startActionByGetAndWait *connect.Client[v1.StartActionByGetAndWaitRequest, v1.StartActionByGetAndWaitResponse]
killAction *connect.Client[v1.KillActionRequest, v1.KillActionResponse]
executionStatus *connect.Client[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse]
getLogs *connect.Client[v1.GetLogsRequest, v1.GetLogsResponse]
validateArgumentType *connect.Client[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse]
whoAmI *connect.Client[v1.WhoAmIRequest, v1.WhoAmIResponse]
sosReport *connect.Client[v1.SosReportRequest, v1.SosReportResponse]
dumpVars *connect.Client[v1.DumpVarsRequest, v1.DumpVarsResponse]
dumpPublicIdActionMap *connect.Client[v1.DumpPublicIdActionMapRequest, v1.DumpPublicIdActionMapResponse]
getReadyz *connect.Client[v1.GetReadyzRequest, v1.GetReadyzResponse]
localUserLogin *connect.Client[v1.LocalUserLoginRequest, v1.LocalUserLoginResponse]
passwordHash *connect.Client[v1.PasswordHashRequest, v1.PasswordHashResponse]
logout *connect.Client[v1.LogoutRequest, v1.LogoutResponse]
eventStream *connect.Client[v1.EventStreamRequest, v1.EventStreamResponse]
getDiagnostics *connect.Client[v1.GetDiagnosticsRequest, v1.GetDiagnosticsResponse]
}
// 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)
}
// StartAction calls olivetin.api.v1.OliveTinApiService.StartAction.
func (c *oliveTinApiServiceClient) StartAction(ctx context.Context, req *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error) {
return c.startAction.CallUnary(ctx, req)
}
// StartActionAndWait calls olivetin.api.v1.OliveTinApiService.StartActionAndWait.
func (c *oliveTinApiServiceClient) StartActionAndWait(ctx context.Context, req *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error) {
return c.startActionAndWait.CallUnary(ctx, req)
}
// StartActionByGet calls olivetin.api.v1.OliveTinApiService.StartActionByGet.
func (c *oliveTinApiServiceClient) StartActionByGet(ctx context.Context, req *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error) {
return c.startActionByGet.CallUnary(ctx, req)
}
// StartActionByGetAndWait calls olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait.
func (c *oliveTinApiServiceClient) StartActionByGetAndWait(ctx context.Context, req *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error) {
return c.startActionByGetAndWait.CallUnary(ctx, req)
}
// KillAction calls olivetin.api.v1.OliveTinApiService.KillAction.
func (c *oliveTinApiServiceClient) KillAction(ctx context.Context, req *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error) {
return c.killAction.CallUnary(ctx, req)
}
// ExecutionStatus calls olivetin.api.v1.OliveTinApiService.ExecutionStatus.
func (c *oliveTinApiServiceClient) ExecutionStatus(ctx context.Context, req *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error) {
return c.executionStatus.CallUnary(ctx, req)
}
// GetLogs calls olivetin.api.v1.OliveTinApiService.GetLogs.
func (c *oliveTinApiServiceClient) GetLogs(ctx context.Context, req *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error) {
return c.getLogs.CallUnary(ctx, req)
}
// ValidateArgumentType calls olivetin.api.v1.OliveTinApiService.ValidateArgumentType.
func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, req *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) {
return c.validateArgumentType.CallUnary(ctx, req)
}
// WhoAmI calls olivetin.api.v1.OliveTinApiService.WhoAmI.
func (c *oliveTinApiServiceClient) WhoAmI(ctx context.Context, req *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error) {
return c.whoAmI.CallUnary(ctx, req)
}
// SosReport calls olivetin.api.v1.OliveTinApiService.SosReport.
func (c *oliveTinApiServiceClient) SosReport(ctx context.Context, req *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error) {
return c.sosReport.CallUnary(ctx, req)
}
// DumpVars calls olivetin.api.v1.OliveTinApiService.DumpVars.
func (c *oliveTinApiServiceClient) DumpVars(ctx context.Context, req *connect.Request[v1.DumpVarsRequest]) (*connect.Response[v1.DumpVarsResponse], error) {
return c.dumpVars.CallUnary(ctx, req)
}
// DumpPublicIdActionMap calls olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap.
func (c *oliveTinApiServiceClient) DumpPublicIdActionMap(ctx context.Context, req *connect.Request[v1.DumpPublicIdActionMapRequest]) (*connect.Response[v1.DumpPublicIdActionMapResponse], error) {
return c.dumpPublicIdActionMap.CallUnary(ctx, req)
}
// GetReadyz calls olivetin.api.v1.OliveTinApiService.GetReadyz.
func (c *oliveTinApiServiceClient) GetReadyz(ctx context.Context, req *connect.Request[v1.GetReadyzRequest]) (*connect.Response[v1.GetReadyzResponse], error) {
return c.getReadyz.CallUnary(ctx, req)
}
// LocalUserLogin calls olivetin.api.v1.OliveTinApiService.LocalUserLogin.
func (c *oliveTinApiServiceClient) LocalUserLogin(ctx context.Context, req *connect.Request[v1.LocalUserLoginRequest]) (*connect.Response[v1.LocalUserLoginResponse], error) {
return c.localUserLogin.CallUnary(ctx, req)
}
// PasswordHash calls olivetin.api.v1.OliveTinApiService.PasswordHash.
func (c *oliveTinApiServiceClient) PasswordHash(ctx context.Context, req *connect.Request[v1.PasswordHashRequest]) (*connect.Response[v1.PasswordHashResponse], error) {
return c.passwordHash.CallUnary(ctx, req)
}
// Logout calls olivetin.api.v1.OliveTinApiService.Logout.
func (c *oliveTinApiServiceClient) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) {
return c.logout.CallUnary(ctx, req)
}
// EventStream calls olivetin.api.v1.OliveTinApiService.EventStream.
func (c *oliveTinApiServiceClient) EventStream(ctx context.Context, req *connect.Request[v1.EventStreamRequest]) (*connect.ServerStreamForClient[v1.EventStreamResponse], error) {
return c.eventStream.CallServerStream(ctx, req)
}
// GetDiagnostics calls olivetin.api.v1.OliveTinApiService.GetDiagnostics.
func (c *oliveTinApiServiceClient) GetDiagnostics(ctx context.Context, req *connect.Request[v1.GetDiagnosticsRequest]) (*connect.Response[v1.GetDiagnosticsResponse], error) {
return c.getDiagnostics.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)
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)
StartActionByGetAndWait(context.Context, *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error)
KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error)
GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error)
ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error)
SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error)
DumpVars(context.Context, *connect.Request[v1.DumpVarsRequest]) (*connect.Response[v1.DumpVarsResponse], error)
DumpPublicIdActionMap(context.Context, *connect.Request[v1.DumpPublicIdActionMapRequest]) (*connect.Response[v1.DumpPublicIdActionMapResponse], error)
GetReadyz(context.Context, *connect.Request[v1.GetReadyzRequest]) (*connect.Response[v1.GetReadyzResponse], error)
LocalUserLogin(context.Context, *connect.Request[v1.LocalUserLoginRequest]) (*connect.Response[v1.LocalUserLoginResponse], error)
PasswordHash(context.Context, *connect.Request[v1.PasswordHashRequest]) (*connect.Response[v1.PasswordHashResponse], error)
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)
}
// NewOliveTinApiServiceHandler builds an HTTP handler from the service implementation. It returns
// the path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// 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")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceStartActionHandler := connect.NewUnaryHandler(
OliveTinApiServiceStartActionProcedure,
svc.StartAction,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartAction")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceStartActionAndWaitHandler := connect.NewUnaryHandler(
OliveTinApiServiceStartActionAndWaitProcedure,
svc.StartActionAndWait,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartActionAndWait")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceStartActionByGetHandler := connect.NewUnaryHandler(
OliveTinApiServiceStartActionByGetProcedure,
svc.StartActionByGet,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartActionByGet")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceStartActionByGetAndWaitHandler := connect.NewUnaryHandler(
OliveTinApiServiceStartActionByGetAndWaitProcedure,
svc.StartActionByGetAndWait,
connect.WithSchema(oliveTinApiServiceMethods.ByName("StartActionByGetAndWait")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceKillActionHandler := connect.NewUnaryHandler(
OliveTinApiServiceKillActionProcedure,
svc.KillAction,
connect.WithSchema(oliveTinApiServiceMethods.ByName("KillAction")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceExecutionStatusHandler := connect.NewUnaryHandler(
OliveTinApiServiceExecutionStatusProcedure,
svc.ExecutionStatus,
connect.WithSchema(oliveTinApiServiceMethods.ByName("ExecutionStatus")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceGetLogsHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetLogsProcedure,
svc.GetLogs,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceValidateArgumentTypeHandler := connect.NewUnaryHandler(
OliveTinApiServiceValidateArgumentTypeProcedure,
svc.ValidateArgumentType,
connect.WithSchema(oliveTinApiServiceMethods.ByName("ValidateArgumentType")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceWhoAmIHandler := connect.NewUnaryHandler(
OliveTinApiServiceWhoAmIProcedure,
svc.WhoAmI,
connect.WithSchema(oliveTinApiServiceMethods.ByName("WhoAmI")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceSosReportHandler := connect.NewUnaryHandler(
OliveTinApiServiceSosReportProcedure,
svc.SosReport,
connect.WithSchema(oliveTinApiServiceMethods.ByName("SosReport")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceDumpVarsHandler := connect.NewUnaryHandler(
OliveTinApiServiceDumpVarsProcedure,
svc.DumpVars,
connect.WithSchema(oliveTinApiServiceMethods.ByName("DumpVars")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceDumpPublicIdActionMapHandler := connect.NewUnaryHandler(
OliveTinApiServiceDumpPublicIdActionMapProcedure,
svc.DumpPublicIdActionMap,
connect.WithSchema(oliveTinApiServiceMethods.ByName("DumpPublicIdActionMap")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceGetReadyzHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetReadyzProcedure,
svc.GetReadyz,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetReadyz")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceLocalUserLoginHandler := connect.NewUnaryHandler(
OliveTinApiServiceLocalUserLoginProcedure,
svc.LocalUserLogin,
connect.WithSchema(oliveTinApiServiceMethods.ByName("LocalUserLogin")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServicePasswordHashHandler := connect.NewUnaryHandler(
OliveTinApiServicePasswordHashProcedure,
svc.PasswordHash,
connect.WithSchema(oliveTinApiServiceMethods.ByName("PasswordHash")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceLogoutHandler := connect.NewUnaryHandler(
OliveTinApiServiceLogoutProcedure,
svc.Logout,
connect.WithSchema(oliveTinApiServiceMethods.ByName("Logout")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceEventStreamHandler := connect.NewServerStreamHandler(
OliveTinApiServiceEventStreamProcedure,
svc.EventStream,
connect.WithSchema(oliveTinApiServiceMethods.ByName("EventStream")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceGetDiagnosticsHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetDiagnosticsProcedure,
svc.GetDiagnostics,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetDiagnostics")),
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 OliveTinApiServiceStartActionProcedure:
oliveTinApiServiceStartActionHandler.ServeHTTP(w, r)
case OliveTinApiServiceStartActionAndWaitProcedure:
oliveTinApiServiceStartActionAndWaitHandler.ServeHTTP(w, r)
case OliveTinApiServiceStartActionByGetProcedure:
oliveTinApiServiceStartActionByGetHandler.ServeHTTP(w, r)
case OliveTinApiServiceStartActionByGetAndWaitProcedure:
oliveTinApiServiceStartActionByGetAndWaitHandler.ServeHTTP(w, r)
case OliveTinApiServiceKillActionProcedure:
oliveTinApiServiceKillActionHandler.ServeHTTP(w, r)
case OliveTinApiServiceExecutionStatusProcedure:
oliveTinApiServiceExecutionStatusHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetLogsProcedure:
oliveTinApiServiceGetLogsHandler.ServeHTTP(w, r)
case OliveTinApiServiceValidateArgumentTypeProcedure:
oliveTinApiServiceValidateArgumentTypeHandler.ServeHTTP(w, r)
case OliveTinApiServiceWhoAmIProcedure:
oliveTinApiServiceWhoAmIHandler.ServeHTTP(w, r)
case OliveTinApiServiceSosReportProcedure:
oliveTinApiServiceSosReportHandler.ServeHTTP(w, r)
case OliveTinApiServiceDumpVarsProcedure:
oliveTinApiServiceDumpVarsHandler.ServeHTTP(w, r)
case OliveTinApiServiceDumpPublicIdActionMapProcedure:
oliveTinApiServiceDumpPublicIdActionMapHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetReadyzProcedure:
oliveTinApiServiceGetReadyzHandler.ServeHTTP(w, r)
case OliveTinApiServiceLocalUserLoginProcedure:
oliveTinApiServiceLocalUserLoginHandler.ServeHTTP(w, r)
case OliveTinApiServicePasswordHashProcedure:
oliveTinApiServicePasswordHashHandler.ServeHTTP(w, r)
case OliveTinApiServiceLogoutProcedure:
oliveTinApiServiceLogoutHandler.ServeHTTP(w, r)
case OliveTinApiServiceEventStreamProcedure:
oliveTinApiServiceEventStreamHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetDiagnosticsProcedure:
oliveTinApiServiceGetDiagnosticsHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// 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) StartAction(context.Context, *connect.Request[v1.StartActionRequest]) (*connect.Response[v1.StartActionResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.StartAction is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) StartActionAndWait(context.Context, *connect.Request[v1.StartActionAndWaitRequest]) (*connect.Response[v1.StartActionAndWaitResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.StartActionAndWait is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) StartActionByGet(context.Context, *connect.Request[v1.StartActionByGetRequest]) (*connect.Response[v1.StartActionByGetResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.StartActionByGet is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) StartActionByGetAndWait(context.Context, *connect.Request[v1.StartActionByGetAndWaitRequest]) (*connect.Response[v1.StartActionByGetAndWaitResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.KillAction is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.ExecutionStatus is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetLogs is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.ValidateArgumentType is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.WhoAmI is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.SosReport is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) DumpVars(context.Context, *connect.Request[v1.DumpVarsRequest]) (*connect.Response[v1.DumpVarsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.DumpVars is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) DumpPublicIdActionMap(context.Context, *connect.Request[v1.DumpPublicIdActionMapRequest]) (*connect.Response[v1.DumpPublicIdActionMapResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) GetReadyz(context.Context, *connect.Request[v1.GetReadyzRequest]) (*connect.Response[v1.GetReadyzResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetReadyz is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) LocalUserLogin(context.Context, *connect.Request[v1.LocalUserLoginRequest]) (*connect.Response[v1.LocalUserLoginResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.LocalUserLogin is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) PasswordHash(context.Context, *connect.Request[v1.PasswordHashRequest]) (*connect.Response[v1.PasswordHashResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.PasswordHash is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.Logout is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) EventStream(context.Context, *connect.Request[v1.EventStreamRequest], *connect.ServerStream[v1.EventStreamResponse]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.EventStream is not implemented"))
}
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"))
}

View File

@ -93,6 +93,7 @@ require (
github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/go-containerregistry v0.20.6 // indirect
github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jamesread/golure v0.0.0-20250619190948-fa38cbd93cc4 // indirect
github.com/jdx/go-netrc v1.0.0 // indirect github.com/jdx/go-netrc v1.0.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect

View File

@ -220,6 +220,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jamesread/golure v0.0.0-20250619190948-fa38cbd93cc4 h1:MIZEqAaeMP1/saH0w6I5mzGKSv2lw8fAO7Hm2FgJb9k=
github.com/jamesread/golure v0.0.0-20250619190948-fa38cbd93cc4/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ=
github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88= github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88=

595
service/internal/api/api.go Normal file
View File

@ -0,0 +1,595 @@
package api
import (
ctx "context"
"connectrpc.com/connect"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"fmt"
"net/http"
acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config"
executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
)
type oliveTinAPI struct {
executor *executor.Executor
cfg *config.Config
connectedClients []*connectedClients
}
type connectedClients struct {
channel chan *apiv1.EventStreamResponse
AuthenticatedUser *acl.AuthenticatedUser
}
func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
ret := &apiv1.KillActionResponse{
ExecutionTrackingId: req.Msg.ExecutionTrackingId,
}
var execReqLogEntry *executor.InternalLogEntry
execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId)
if !ret.Found {
log.Warnf("Killing execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId)
return connect.NewResponse(ret), nil
}
log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
action := api.cfg.FindAction(execReqLogEntry.ActionTitle)
if action == nil {
log.Warnf("Killing execution request not possible - action not found: %v", execReqLogEntry.ActionTitle)
ret.Killed = false
return connect.NewResponse(ret), nil
}
user := acl.UserFromContext(ctx, api.cfg)
api.killActionByTrackingId(user, action, execReqLogEntry, ret)
return connect.NewResponse(ret), nil
}
func (api *oliveTinAPI) killActionByTrackingId(user *acl.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
if !acl.IsAllowedKill(api.cfg, user, action) {
log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID)
ret.Killed = false
}
err := api.executor.Kill(execReqLogEntry)
if err != nil {
log.Warnf("Killing execution request err: %v", err)
ret.AlreadyCompleted = true
ret.Killed = false
} else {
ret.Killed = true
}
}
func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
args := make(map[string]string)
for _, arg := range req.Msg.Arguments {
args[arg.Name] = arg.Value
}
api.executor.MapActionIdToBindingLock.RLock()
pair := api.executor.MapActionIdToBinding[req.Msg.ActionId]
api.executor.MapActionIdToBindingLock.RUnlock()
if pair == nil || pair.Action == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
}
authenticatedUser := acl.UserFromContext(ctx, api.cfg)
execReq := executor.ExecutionRequest{
Action: pair.Action,
EntityPrefix: pair.EntityPrefix,
TrackingID: req.Msg.UniqueTrackingId,
Arguments: args,
AuthenticatedUser: authenticatedUser,
Cfg: api.cfg,
}
api.executor.ExecRequest(&execReq)
ret := &apiv1.StartActionResponse{
ExecutionTrackingId: execReq.TrackingID,
}
return connect.NewResponse(ret), nil
}
func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
hash, err := createHash(req.Msg.Password)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating hash: %w", err))
}
ret := &apiv1.PasswordHashResponse{
Hash: hash,
}
return connect.NewResponse(ret), nil
}
func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
match := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
if match {
//grpc.SendHeader(ctx, metadata.Pairs("set-username", req.Username))
log.WithFields(log.Fields{
"username": req.Msg.Username,
}).Info("LocalUserLogin: User logged in successfully.")
} else {
log.WithFields(log.Fields{
"username": req.Msg.Username,
}).Warn("LocalUserLogin: User login failed.")
}
return connect.NewResponse(&apiv1.LocalUserLoginResponse{
Success: match,
}), nil
}
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
args := make(map[string]string)
for _, arg := range req.Msg.Arguments {
args[arg.Name] = arg.Value
}
user := acl.UserFromContext(ctx, api.cfg)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.Msg.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: user,
Cfg: api.cfg,
}
wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait()
internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
if ok {
return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
}), nil
} else {
return nil, fmt.Errorf("execution not found")
}
}
func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
args := make(map[string]string)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.Msg.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
Cfg: api.cfg,
}
_, uniqueTrackingId := api.executor.ExecRequest(&execReq)
return connect.NewResponse(&apiv1.StartActionByGetResponse{
ExecutionTrackingId: uniqueTrackingId,
}), nil
}
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
args := make(map[string]string)
user := acl.UserFromContext(ctx, api.cfg)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.Msg.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: user,
Cfg: api.cfg,
}
wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait()
internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
if ok {
return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
}), nil
} else {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
}
}
func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *acl.AuthenticatedUser) *apiv1.LogEntry {
pble := &apiv1.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
DatetimeIndex: logEntry.Index,
Output: logEntry.Output,
TimedOut: logEntry.TimedOut,
Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags,
ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
User: logEntry.Username,
}
if !pble.ExecutionFinished {
pble.CanKill = acl.IsAllowedKill(api.cfg, authenticatedUser, api.cfg.FindAction(logEntry.ActionConfigTitle))
}
return pble
}
func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string) *executor.InternalLogEntry {
logEntry, ok := api.executor.GetLog(executionTrackingId)
if !ok {
return nil
}
return logEntry
}
func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
var ile *executor.InternalLogEntry
logs := api.executor.GetLogsByActionId(actionId)
if len(logs) == 0 {
return nil
} else {
// Get last log entry
ile = logs[len(logs)-1]
}
return ile
}
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
res := &apiv1.ExecutionStatusResponse{}
user := acl.UserFromContext(ctx, api.cfg)
var ile *executor.InternalLogEntry
if req.Msg.ExecutionTrackingId != "" {
ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId)
} else {
ile = getMostRecentExecutionStatusById(api, req.Msg.ActionId)
}
if ile == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", req.Msg.ExecutionTrackingId, req.Msg.ActionId))
} else {
res.LogEntry = api.internalLogEntryToPb(ile, user)
}
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)
//grpc.SendHeader(ctx, metadata.Pairs("logout-provider", user.Provider))
//grpc.SendHeader(ctx, metadata.Pairs("logout-sid", user.SID))
return nil, nil
}
func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardComponentsRequest]) (*connect.Response[apiv1.GetDashboardComponentsResponse], 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)
/*
if len(res.Actions) == 0 {
log.WithFields(log.Fields{
"username": user.Username,
"usergroupLine": user.UsergroupLine,
"provider": user.Provider,
"acls": user.Acls,
"availableActions": len(api.cfg.Actions),
}).Warn("Zero actions found for user")
}
*/
log.Tracef("GetDashboardComponents: %v", res)
return connect.NewResponse(res), nil
}
func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
ret := &apiv1.GetLogsResponse{}
logEntries, countRemaining := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
for _, logEntry := range logEntries {
action := api.cfg.FindAction(logEntry.ActionTitle)
if action == nil || acl.IsAllowedLogs(api.cfg, user, action) {
pbLogEntry := api.internalLogEntryToPb(logEntry, user)
ret.Logs = append(ret.Logs, pbLogEntry)
}
}
ret.CountRemaining = countRemaining
ret.PageSize = api.cfg.LogHistoryPageSize
return connect.NewResponse(ret), nil
}
/*
This function is ONLY a helper for the UI - the arguments are validated properly
on the StartAction -> Executor chain. This is here basically to provide helpful
error messages more quickly before starting the action.
*/
func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
err := executor.TypeSafetyCheck("", req.Msg.Value, req.Msg.Type)
desc := ""
if err != nil {
desc = err.Error()
}
return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
Valid: err == nil,
Description: desc,
}), nil
}
func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
res := &apiv1.WhoAmIResponse{
AuthenticatedUser: user.Username,
Usergroup: user.UsergroupLine,
Provider: user.Provider,
Sid: user.SID,
Acls: user.Acls,
}
return connect.NewResponse(res), nil
}
func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *connect.Request[apiv1.SosReportRequest]) (*connect.Response[apiv1.SosReportResponse], error) {
sos := installationinfo.GetSosReport()
if !api.cfg.InsecureAllowDumpSos {
log.Info(sos)
sos = "Your SOS Report has been logged to OliveTin logs.\n\nIf you are in a safe network, you can temporarily set `insecureAllowDumpSos: true` in your config.yaml, restart OliveTin, and refresh this page - it will put the output directly in the browser."
}
ret := &apiv1.SosReportResponse{
Alert: sos,
}
return connect.NewResponse(ret), nil
}
func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.DumpVarsRequest]) (*connect.Response[apiv1.DumpVarsResponse], error) {
res := &apiv1.DumpVarsResponse{}
if !api.cfg.InsecureAllowDumpVars {
res.Alert = "Dumping variables is not allowed by default because it is insecure."
return connect.NewResponse(res), nil
}
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
}
func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
res := &apiv1.DumpPublicIdActionMapResponse{}
res.Contents = make(map[string]*apiv1.ActionEntityPair)
if !api.cfg.InsecureAllowDumpActionMap {
res.Alert = "Dumping Public IDs is disallowed."
return connect.NewResponse(res), nil
}
api.executor.MapActionIdToBindingLock.RLock()
for k, v := range api.executor.MapActionIdToBinding {
res.Contents[k] = &apiv1.ActionEntityPair{
ActionTitle: v.Action.Title,
EntityPrefix: v.EntityPrefix,
}
}
api.executor.MapActionIdToBindingLock.RUnlock()
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
return connect.NewResponse(res), nil
}
func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.GetReadyzRequest]) (*connect.Response[apiv1.GetReadyzResponse], error) {
res := &apiv1.GetReadyzResponse{
Status: "OK",
}
return connect.NewResponse(res), nil
}
func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
log.Debugf("EventStream: %v", req.Msg)
client := &connectedClients{
channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
}
log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username)
api.connectedClients = append(api.connectedClients, client)
// loop over client channel and send events to connectedClient
for msg := range client.channel {
log.Debugf("Sending event to client: %v", msg)
if err := srv.Send(msg); err != nil {
log.Errorf("Error sending event to client: %v", err)
}
}
log.Infof("EventStream: client disconnected")
return nil
}
func (api *oliveTinAPI) OnActionMapRebuilt() {
for _, client := range api.connectedClients {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ConfigChanged{
ConfigChanged: &apiv1.EventConfigChanged{},
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
}
}
}
func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
for _, client := range api.connectedClients {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionStarted{
ExecutionStarted: &apiv1.EventExecutionStarted{
LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
},
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
}
}
}
func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
for _, client := range api.connectedClients {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionFinished{
ExecutionFinished: &apiv1.EventExecutionFinished{
LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
},
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
}
}
}
func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
res := &apiv1.GetDiagnosticsResponse{
SshFoundKey: installationinfo.Runtime.SshFoundKey,
SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
}
return connect.NewResponse(res), nil
}
func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
for _, client := range api.connectedClients {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_OutputChunk{
OutputChunk: &apiv1.EventOutputChunk{
Output: string(content),
ExecutionTrackingId: executionTrackingId,
},
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
}
}
}
func newServer(ex *executor.Executor) *oliveTinAPI {
server := oliveTinAPI{}
server.cfg = ex.Cfg
server.executor = ex
ex.AddListener(&server)
return &server
}
func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
server := newServer(ex)
return apiv1connect.NewOliveTinApiServiceHandler(server)
}

View File

@ -1,19 +1,32 @@
package grpcapi package api
import ( import (
apiv1 "github.com/OliveTin/OliveTin/gen/grpc/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
acl "github.com/OliveTin/OliveTin/internal/acl" acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
executor "github.com/OliveTin/OliveTin/internal/executor" executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables" sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"sort" log "github.com/sirupsen/logrus"
) )
type DashboardRenderRequest struct { type DashboardRenderRequest struct {
AuthenticatedUser *acl.AuthenticatedUser AuthenticatedUser *acl.AuthenticatedUser
AllowedActionTitles []string `json:"allows_action_titles"` AllowedActionTitles []string `json:"allows_action_titles"`
cfg *config.Config cfg *config.Config
ex *executor.Executor
usedActions map[string]bool
}
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)
}
}
return nil
} }
func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl.AuthenticatedUser) *apiv1.GetDashboardComponentsResponse { func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl.AuthenticatedUser) *apiv1.GetDashboardComponentsResponse {
@ -22,18 +35,7 @@ func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl
AuthenticatedUserProvider: user.Provider, AuthenticatedUserProvider: user.Provider,
} }
ex.MapActionIdToBindingLock.RLock() /*
for actionId, actionBinding := range ex.MapActionIdToBinding {
if !acl.IsAllowedView(cfg, user, actionBinding.Action) {
continue
}
res.Actions = append(res.Actions, buildAction(actionId, actionBinding, user))
}
ex.MapActionIdToBindingLock.RUnlock()
sort.Slice(res.Actions, func(i, j int) bool { sort.Slice(res.Actions, func(i, j int) bool {
if res.Actions[i].Order == res.Actions[j].Order { if res.Actions[i].Order == res.Actions[j].Order {
return res.Actions[i].Title < res.Actions[j].Title return res.Actions[i].Title < res.Actions[j].Title
@ -41,15 +43,17 @@ func buildDashboardResponse(ex *executor.Executor, cfg *config.Config, user *acl
return res.Actions[i].Order < res.Actions[j].Order return res.Actions[i].Order < res.Actions[j].Order
} }
}) })
*/
rr := &DashboardRenderRequest{ rr := &DashboardRenderRequest{
AuthenticatedUser: user, AuthenticatedUser: user,
AllowedActionTitles: getActionTitles(res.Actions), // AllowedActionTitles: getActionTitles(res.Actions),
cfg: cfg, cfg: cfg,
ex: ex,
usedActions: make(map[string]bool),
} }
res.EffectivePolicy = buildEffectivePolicy(user.EffectivePolicy) res.EffectivePolicy = buildEffectivePolicy(user.EffectivePolicy)
res.Diagnostics = buildDiagnostics(res.EffectivePolicy.ShowDiagnostics)
res.Dashboards = dashboardCfgToPb(rr) res.Dashboards = dashboardCfgToPb(rr)
return res return res
@ -74,25 +78,14 @@ func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePo
return ret return ret
} }
func buildDiagnostics(showDiagnostics bool) *apiv1.Diagnostics { func buildAction(actionId string, actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
ret := &apiv1.Diagnostics{}
if showDiagnostics {
ret.SshFoundKey = installationinfo.Runtime.SshFoundKey
ret.SshFoundConfig = installationinfo.Runtime.SshFoundConfig
}
return ret
}
func buildAction(actionId string, actionBinding *executor.ActionBinding, user *acl.AuthenticatedUser) *apiv1.Action {
action := actionBinding.Action action := actionBinding.Action
btn := apiv1.Action{ btn := apiv1.Action{
Id: actionId, Id: actionId,
Title: sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Title), Title: sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Title),
Icon: sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Icon), Icon: sv.ReplaceEntityVars(actionBinding.EntityPrefix, action.Icon),
CanExec: acl.IsAllowedExec(cfg, user, action), CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
PopupOnStart: action.PopupOnStart, PopupOnStart: action.PopupOnStart,
Order: int32(actionBinding.ConfigOrder), Order: int32(actionBinding.ConfigOrder),
} }

View File

@ -1,12 +1,10 @@
package grpcapi package api
// Thank you: https://stackoverflow.com/questions/42102496/testing-a-grpc-service // Thank you: https://stackoverflow.com/questions/42102496/testing-a-grpc-service
import ( import (
"context" "context"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"google.golang.org/grpc"
"google.golang.org/grpc/test/bufconn"
"net" "net"
"testing" "testing"

View File

@ -1,7 +1,7 @@
package grpcapi package api
import ( import (
apiv1 "github.com/OliveTin/OliveTin/gen/grpc/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
sv "github.com/OliveTin/OliveTin/internal/stringvariables" sv "github.com/OliveTin/OliveTin/internal/stringvariables"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"

View File

@ -1,22 +1,22 @@
package grpcapi package api
import ( import (
apiv1 "github.com/OliveTin/OliveTin/gen/grpc/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
func dashboardCfgToPb(rr *DashboardRenderRequest) []*apiv1.DashboardComponent { func dashboardCfgToPb(rr *DashboardRenderRequest) []*apiv1.Dashboard {
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.Dashboard, 0)
for _, dashboard := range cfg.Dashboards { for _, dashboard := range rr.cfg.Dashboards {
pbdb := &apiv1.DashboardComponent{ pbdb := &apiv1.Dashboard{
Type: "dashboard",
Title: dashboard.Title, Title: dashboard.Title,
Contents: removeNulls(getDashboardComponentContents(dashboard, rr)), Contents: removeNulls(getDashboardComponentContents(dashboard, rr)),
// Contents: removeNulls(getDashboardComponentContents(dashboard, rr)),
} }
/*
if len(pbdb.Contents) == 0 { if len(pbdb.Contents) == 0 {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"dashboard": dashboard.Title, "dashboard": dashboard.Title,
@ -24,13 +24,52 @@ func dashboardCfgToPb(rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
}).Debugf("Dashboard has no readable contents, so it will not be visible in the web ui") }).Debugf("Dashboard has no readable contents, so it will not be visible in the web ui")
continue continue
} }
*/
ret = append(ret, pbdb) ret = append(ret, pbdb)
} }
ret = append(ret, buildDefaultDashboard(rr))
return ret return ret
} }
func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
fieldset := &apiv1.DashboardComponent{
Type: "fieldset",
Contents: make([]*apiv1.DashboardComponent, 0),
Title: "Default",
}
actions := make([]*apiv1.Action, 0)
for id, binding := range rr.ex.MapActionIdToBinding {
if binding.Action.Hidden {
continue
}
if rr.usedActions[id] {
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,
})
}
return &apiv1.Dashboard{
Title: "Default",
Contents: []*apiv1.DashboardComponent{fieldset},
}
}
func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent { func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.DashboardComponent, 0)

View File

@ -1,4 +1,4 @@
package grpcapi package api
import ( import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"

View File

@ -21,6 +21,11 @@ import (
"time" "time"
) )
const (
DefaultExitCodeNotExecuted = -1337
MaxTriggerDepth = 10
)
var ( var (
metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{ metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{
Name: "olivetin_actions_requested_count", Name: "olivetin_actions_requested_count",
@ -64,6 +69,7 @@ type ExecutionRequest struct {
Cfg *config.Config Cfg *config.Config
AuthenticatedUser *acl.AuthenticatedUser AuthenticatedUser *acl.AuthenticatedUser
EntityPrefix string EntityPrefix string
TriggerDepth int
logEntry *InternalLogEntry logEntry *InternalLogEntry
finalParsedCommand string finalParsedCommand string
@ -88,6 +94,7 @@ type InternalLogEntry struct {
Username string Username string
Index int64 Index int64
EntityPrefix string EntityPrefix string
ActionConfigTitle string // This is the title of the action as defined in the config, not the final parsed title.
/* /*
The following 3 properties are obviously on Action normally, but it's useful The following 3 properties are obviously on Action normally, but it's useful
@ -243,7 +250,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
DatetimeStarted: time.Now(), DatetimeStarted: time.Now(),
ExecutionTrackingID: req.TrackingID, ExecutionTrackingID: req.TrackingID,
Output: "", Output: "",
ExitCode: -1337, // If an Action is not actually executed, this is the default exit code. ExitCode: DefaultExitCodeNotExecuted,
ExecutionStarted: false, ExecutionStarted: false,
ExecutionFinished: false, ExecutionFinished: false,
ActionId: "", ActionId: "",
@ -440,6 +447,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
metricActionsRequested.Inc() metricActionsRequested.Inc()
req.logEntry.ActionConfigTitle = req.Action.Title
req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title) req.logEntry.ActionTitle = sv.ReplaceEntityVars(req.EntityPrefix, req.Action.Title)
req.logEntry.ActionIcon = req.Action.Icon req.logEntry.ActionIcon = req.Action.Icon
req.logEntry.ActionId = req.Action.ID req.logEntry.ActionId = req.Action.ID
@ -651,6 +659,15 @@ func stepTrigger(req *ExecutionRequest) bool {
return true return true
} }
if req.TriggerDepth >= MaxTriggerDepth {
log.WithFields(log.Fields{
"actionTitle": req.logEntry.ActionTitle,
"depth": req.TriggerDepth,
}).Warnf("Trigger action reached maximum depth of %v. Not triggering further actions.", MaxTriggerDepth)
req.logEntry.Output += fmt.Sprintf("OliveTin::trigger - this action reached maximum trigger depth of %v. Not triggering further actions.", MaxTriggerDepth)
return true
}
if len(req.Tags) > 0 && req.Tags[0] == "trigger" { if len(req.Tags) > 0 && req.Tags[0] == "trigger" {
log.Warnf("Trigger action is triggering another trigger action. This is allowed, but be careful not to create trigger loops.") log.Warnf("Trigger action is triggering another trigger action. This is allowed, but be careful not to create trigger loops.")
} }
@ -669,6 +686,7 @@ func triggerLoop(req *ExecutionRequest) {
AuthenticatedUser: req.AuthenticatedUser, AuthenticatedUser: req.AuthenticatedUser,
Arguments: req.Arguments, Arguments: req.Arguments,
Cfg: req.Cfg, Cfg: req.Cfg,
TriggerDepth: req.TriggerDepth + 1,
} }
req.executor.ExecRequest(trigger) req.executor.ExecRequest(trigger)

View File

@ -1,512 +0,0 @@
package grpcapi
import (
ctx "context"
apiv1 "github.com/OliveTin/OliveTin/gen/grpc/olivetin/api/v1"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"fmt"
"net"
acl "github.com/OliveTin/OliveTin/internal/acl"
config "github.com/OliveTin/OliveTin/internal/config"
executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables"
)
var (
cfg *config.Config
)
type oliveTinAPI struct {
// Uncomment this if you want to allow undefined methods during dev.
// apiv1.UnimplementedOliveTinApiServiceServer
executor *executor.Executor
}
func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *apiv1.KillActionRequest) (*apiv1.KillActionResponse, error) {
ret := &apiv1.KillActionResponse{
ExecutionTrackingId: req.ExecutionTrackingId,
}
var execReqLogEntry *executor.InternalLogEntry
execReqLogEntry, ret.Found = api.executor.GetLog(req.ExecutionTrackingId)
if !ret.Found {
log.Warnf("Killing execution request not possible - not found by tracking ID: %v", req.ExecutionTrackingId)
return ret, nil
}
log.Warnf("Killing execution request by tracking ID: %v", req.ExecutionTrackingId)
action := cfg.FindAction(execReqLogEntry.ActionTitle)
if action == nil {
log.Warnf("Killing execution request not possible - action not found: %v", execReqLogEntry.ActionTitle)
ret.Killed = false
return ret, nil
}
user := acl.UserFromContext(ctx, cfg)
api.killActionByTrackingId(user, action, execReqLogEntry, ret)
return ret, nil
}
func (api *oliveTinAPI) killActionByTrackingId(user *acl.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
if !acl.IsAllowedKill(cfg, user, action) {
log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID)
ret.Killed = false
}
err := api.executor.Kill(execReqLogEntry)
if err != nil {
log.Warnf("Killing execution request err: %v", err)
ret.AlreadyCompleted = true
ret.Killed = false
} else {
ret.Killed = true
}
}
func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *apiv1.StartActionRequest) (*apiv1.StartActionResponse, error) {
args := make(map[string]string)
for _, arg := range req.Arguments {
args[arg.Name] = arg.Value
}
api.executor.MapActionIdToBindingLock.RLock()
pair := api.executor.MapActionIdToBinding[req.ActionId]
api.executor.MapActionIdToBindingLock.RUnlock()
if pair == nil || pair.Action == nil {
return nil, status.Errorf(codes.NotFound, "Action not found.")
}
authenticatedUser := acl.UserFromContext(ctx, cfg)
execReq := executor.ExecutionRequest{
Action: pair.Action,
EntityPrefix: pair.EntityPrefix,
TrackingID: req.UniqueTrackingId,
Arguments: args,
AuthenticatedUser: authenticatedUser,
Cfg: cfg,
}
api.executor.ExecRequest(&execReq)
return &apiv1.StartActionResponse{
ExecutionTrackingId: execReq.TrackingID,
}, nil
}
func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *apiv1.PasswordHashRequest) (*httpbody.HttpBody, error) {
hash, err := createHash(req.Password)
if err != nil {
return nil, status.Errorf(codes.Internal, "Error creating hash.")
}
ret := &httpbody.HttpBody{
ContentType: "text/plain",
Data: []byte("Your password hash is: " + hash),
}
return ret, nil
}
func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *apiv1.LocalUserLoginRequest) (*apiv1.LocalUserLoginResponse, error) {
match := checkUserPassword(cfg, req.Username, req.Password)
if match {
grpc.SendHeader(ctx, metadata.Pairs("set-username", req.Username))
log.WithFields(log.Fields{
"username": req.Username,
}).Info("LocalUserLogin: User logged in successfully.")
} else {
log.WithFields(log.Fields{
"username": req.Username,
}).Warn("LocalUserLogin: User login failed.")
}
return &apiv1.LocalUserLoginResponse{
Success: match,
}, nil
}
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *apiv1.StartActionAndWaitRequest) (*apiv1.StartActionAndWaitResponse, error) {
args := make(map[string]string)
for _, arg := range req.Arguments {
args[arg.Name] = arg.Value
}
user := acl.UserFromContext(ctx, cfg)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: user,
Cfg: cfg,
}
wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait()
internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
if ok {
return &apiv1.StartActionAndWaitResponse{
LogEntry: internalLogEntryToPb(internalLogEntry, user),
}, nil
} else {
return nil, fmt.Errorf("execution not found")
}
}
func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *apiv1.StartActionByGetRequest) (*apiv1.StartActionByGetResponse, error) {
args := make(map[string]string)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, cfg),
Cfg: cfg,
}
_, uniqueTrackingId := api.executor.ExecRequest(&execReq)
return &apiv1.StartActionByGetResponse{
ExecutionTrackingId: uniqueTrackingId,
}, nil
}
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *apiv1.StartActionByGetAndWaitRequest) (*apiv1.StartActionByGetAndWaitResponse, error) {
args := make(map[string]string)
user := acl.UserFromContext(ctx, cfg)
execReq := executor.ExecutionRequest{
Action: api.executor.FindActionBindingByID(req.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: user,
Cfg: cfg,
}
wg, _ := api.executor.ExecRequest(&execReq)
wg.Wait()
internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
if ok {
return &apiv1.StartActionByGetAndWaitResponse{
LogEntry: internalLogEntryToPb(internalLogEntry, user),
}, nil
} else {
return nil, status.Errorf(codes.NotFound, "Execution not found.")
}
}
func internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *acl.AuthenticatedUser) *apiv1.LogEntry {
pble := &apiv1.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
DatetimeIndex: logEntry.Index,
Output: logEntry.Output,
TimedOut: logEntry.TimedOut,
Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags,
ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
User: logEntry.Username,
}
if !pble.ExecutionFinished {
pble.CanKill = acl.IsAllowedKill(cfg, authenticatedUser, cfg.FindAction(logEntry.ActionTitle))
}
return pble
}
func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string) *executor.InternalLogEntry {
logEntry, ok := api.executor.GetLog(executionTrackingId)
if !ok {
return nil
}
return logEntry
}
func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
var ile *executor.InternalLogEntry
logs := api.executor.GetLogsByActionId(actionId)
if len(logs) == 0 {
return nil
} else {
// Get last log entry
ile = logs[len(logs)-1]
}
return ile
}
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *apiv1.ExecutionStatusRequest) (*apiv1.ExecutionStatusResponse, error) {
res := &apiv1.ExecutionStatusResponse{}
user := acl.UserFromContext(ctx, cfg)
var ile *executor.InternalLogEntry
if req.ExecutionTrackingId != "" {
ile = getExecutionStatusByTrackingID(api, req.ExecutionTrackingId)
} else {
ile = getMostRecentExecutionStatusById(api, req.ActionId)
}
if ile == nil {
return nil, status.Error(codes.NotFound, "Execution not found")
} else {
res.LogEntry = internalLogEntryToPb(ile, user)
}
return 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 *apiv1.LogoutRequest) (*httpbody.HttpBody, error) {
user := acl.UserFromContext(ctx, cfg)
grpc.SendHeader(ctx, metadata.Pairs("logout-provider", user.Provider))
grpc.SendHeader(ctx, metadata.Pairs("logout-sid", user.SID))
return nil, nil
}
func (api *oliveTinAPI) GetDashboardComponents(ctx ctx.Context, req *apiv1.GetDashboardComponentsRequest) (*apiv1.GetDashboardComponentsResponse, error) {
user := acl.UserFromContext(ctx, cfg)
if user.IsGuest() && cfg.AuthRequireGuestsToLogin {
return nil, status.Errorf(codes.PermissionDenied, "Guests are not allowed to access the dashboard.")
}
res := buildDashboardResponse(api.executor, cfg, user)
if len(res.Actions) == 0 {
log.WithFields(log.Fields{
"username": user.Username,
"usergroupLine": user.UsergroupLine,
"provider": user.Provider,
"acls": user.Acls,
"availableActions": len(cfg.Actions),
}).Warn("Zero actions found for user")
}
log.Tracef("GetDashboardComponents: %v", res)
return res, nil
}
func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *apiv1.GetLogsRequest) (*apiv1.GetLogsResponse, error) {
user := acl.UserFromContext(ctx, cfg)
ret := &apiv1.GetLogsResponse{}
logEntries, countRemaining := api.executor.GetLogTrackingIds(req.StartOffset, cfg.LogHistoryPageSize)
for _, logEntry := range logEntries {
action := cfg.FindAction(logEntry.ActionTitle)
if action == nil || acl.IsAllowedLogs(cfg, user, action) {
pbLogEntry := internalLogEntryToPb(logEntry, user)
ret.Logs = append(ret.Logs, pbLogEntry)
}
}
ret.CountRemaining = countRemaining
ret.PageSize = cfg.LogHistoryPageSize
return ret, nil
}
/*
This function is ONLY a helper for the UI - the arguments are validated properly
on the StartAction -> Executor chain. This is here basically to provide helpful
error messages more quickly before starting the action.
*/
func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *apiv1.ValidateArgumentTypeRequest) (*apiv1.ValidateArgumentTypeResponse, error) {
err := executor.TypeSafetyCheck("", req.Value, req.Type)
desc := ""
if err != nil {
desc = err.Error()
}
return &apiv1.ValidateArgumentTypeResponse{
Valid: err == nil,
Description: desc,
}, nil
}
func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *apiv1.WhoAmIRequest) (*apiv1.WhoAmIResponse, error) {
user := acl.UserFromContext(ctx, cfg)
res := &apiv1.WhoAmIResponse{
AuthenticatedUser: user.Username,
Usergroup: user.UsergroupLine,
Provider: user.Provider,
Sid: user.SID,
Acls: user.Acls,
}
return res, nil
}
func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *apiv1.SosReportRequest) (*httpbody.HttpBody, error) {
sos := installationinfo.GetSosReport()
if !cfg.InsecureAllowDumpSos {
log.Info(sos)
sos = "Your SOS Report has been logged to OliveTin logs.\n\nIf you are in a safe network, you can temporarily set `insecureAllowDumpSos: true` in your config.yaml, restart OliveTin, and refresh this page - it will put the output directly in the browser."
}
ret := &httpbody.HttpBody{
ContentType: "text/plain",
Data: []byte(sos),
}
return ret, nil
}
func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *apiv1.DumpVarsRequest) (*apiv1.DumpVarsResponse, error) {
res := &apiv1.DumpVarsResponse{}
if !cfg.InsecureAllowDumpVars {
res.Alert = "Dumping variables is not allowed by default because it is insecure."
return res, nil
}
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 res, nil
}
func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *apiv1.DumpPublicIdActionMapRequest) (*apiv1.DumpPublicIdActionMapResponse, error) {
res := &apiv1.DumpPublicIdActionMapResponse{}
res.Contents = make(map[string]*apiv1.ActionEntityPair)
if !cfg.InsecureAllowDumpActionMap {
res.Alert = "Dumping Public IDs is disallowed."
return res, nil
}
api.executor.MapActionIdToBindingLock.RLock()
for k, v := range api.executor.MapActionIdToBinding {
res.Contents[k] = &apiv1.ActionEntityPair{
ActionTitle: v.Action.Title,
EntityPrefix: v.EntityPrefix,
}
}
api.executor.MapActionIdToBindingLock.RUnlock()
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
return res, nil
}
func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *apiv1.GetReadyzRequest) (*apiv1.GetReadyzResponse, error) {
res := &apiv1.GetReadyzResponse{
Status: "OK",
}
return res, nil
}
// Start will start the GRPC API.
func Start(globalConfig *config.Config, ex *executor.Executor) {
cfg = globalConfig
log.WithFields(log.Fields{
"address": cfg.ListenAddressGrpcActions,
}).Info("Starting gRPC API")
lis, err := net.Listen("tcp", cfg.ListenAddressGrpcActions)
if err != nil {
log.Fatalf("Failed to listen - %v", err)
}
grpcServer := grpc.NewServer()
apiv1.RegisterOliveTinApiServiceServer(grpcServer, newServer(ex))
err = grpcServer.Serve(lis)
if err != nil {
log.Fatalf("Could not start gRPC Server - %v", err)
}
}
func newServer(ex *executor.Executor) *oliveTinAPI {
server := oliveTinAPI{}
server.executor = ex
return &server
}

View File

@ -2,20 +2,15 @@ package httpservers
import ( import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
) )
// StartServers will start 3 HTTP servers. The WebUI, the Rest API, and a proxy // StartServers will start 3 HTTP servers. The WebUI, the Rest API, and a proxy
// for both of them. // for both of them.
func StartServers(cfg *config.Config) { func StartServers(cfg *config.Config, ex *executor.Executor) {
go startWebUIServer(cfg)
if cfg.UseSingleHTTPFrontend {
go StartSingleHTTPFrontend(cfg)
}
if cfg.Prometheus.Enabled { if cfg.Prometheus.Enabled {
go StartPrometheus(cfg) go StartPrometheus(cfg)
} }
startRestAPIServer(cfg) StartSingleHTTPFrontend(cfg, ex)
} }

View File

@ -4,25 +4,17 @@ import (
"context" "context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/reflect/protoreflect"
"net/http" "net/http"
"strings" "strings"
apiv1 "github.com/OliveTin/OliveTin/gen/grpc/olivetin/api/v1" // apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
cors "github.com/OliveTin/OliveTin/internal/cors"
) )
var ( func parseHttpHeaderForAuth(cfg *config.Config, req *http.Request) (string, string) {
cfg *config.Config
)
func parseHttpHeaderForAuth(req *http.Request) (string, string) {
username, ok := req.Header[cfg.AuthHttpHeaderUsername] username, ok := req.Header[cfg.AuthHttpHeaderUsername]
if !ok { if !ok {
@ -49,34 +41,34 @@ func parseHttpHeaderForAuth(req *http.Request) (string, string) {
} }
//gocyclo:ignore //gocyclo:ignore
func parseRequestMetadata(ctx context.Context, req *http.Request) metadata.MD { func parseRequestMetadata(cfg *config.Config, ctx context.Context, req *http.Request) metadata.MD {
username := "" username := ""
usergroup := "" usergroup := ""
provider := "unknown" provider := "unknown"
sid := "" sid := ""
if cfg.AuthJwtHeader != "" { if cfg.AuthJwtHeader != "" {
username, usergroup = parseJwtHeader(req) username, usergroup = parseJwtHeader(cfg, req)
provider = "jwt-header" provider = "jwt-header"
} }
if cfg.AuthJwtCookieName != "" { if cfg.AuthJwtCookieName != "" {
username, usergroup = parseJwtCookie(req) username, usergroup = parseJwtCookie(cfg, req)
provider = "jwt-cookie" provider = "jwt-cookie"
} }
if cfg.AuthHttpHeaderUsername != "" && username == "" { if cfg.AuthHttpHeaderUsername != "" && username == "" {
username, usergroup = parseHttpHeaderForAuth(req) username, usergroup = parseHttpHeaderForAuth(cfg, req)
provider = "http-header" provider = "http-header"
} }
if len(cfg.AuthOAuth2Providers) > 0 && username == "" { // if len(cfg.AuthOAuth2Providers) > 0 && username == "" {
username, usergroup, sid = parseOAuth2Cookie(req) // username, usergroup, sid = parseOAuth2Cookie(req)
provider = "oauth2" // provider = "oauth2"
} // }
if cfg.AuthLocalUsers.Enabled && username == "" { if cfg.AuthLocalUsers.Enabled && username == "" {
username, usergroup, sid = parseLocalUserCookie(req) username, usergroup, sid = parseLocalUserCookie(cfg, req)
provider = "local" provider = "local"
} }
@ -92,12 +84,12 @@ func parseRequestMetadata(ctx context.Context, req *http.Request) metadata.MD {
return md return md
} }
func parseJwtHeader(req *http.Request) (string, string) { func parseJwtHeader(cfg *config.Config, req *http.Request) (string, string) {
// JWTs in the Authorization header are usually prefixed with "Bearer " which is not part of the JWT token. // JWTs in the Authorization header are usually prefixed with "Bearer " which is not part of the JWT token.
return parseJwt(strings.TrimPrefix(req.Header.Get(cfg.AuthJwtHeader), "Bearer ")) return parseJwt(cfg, strings.TrimPrefix(req.Header.Get(cfg.AuthJwtHeader), "Bearer "))
} }
func forwardResponseHandler(ctx context.Context, w http.ResponseWriter, msg protoreflect.ProtoMessage) error { func (h *OAuth2Handler) forwardResponseHandler(cfg *config.Config, ctx context.Context, w http.ResponseWriter, msg protoreflect.ProtoMessage) error {
md, ok := runtime.ServerMetadataFromContext(ctx) md, ok := runtime.ServerMetadataFromContext(ctx)
if !ok { if !ok {
@ -105,17 +97,17 @@ func forwardResponseHandler(ctx context.Context, w http.ResponseWriter, msg prot
return nil return nil
} }
forwardResponseHandlerLoginLocalUser(md.HeaderMD, w) forwardResponseHandlerLoginLocalUser(cfg, md.HeaderMD, w)
forwardResponseHandlerLogout(md.HeaderMD, w) h.forwardResponseHandlerLogout(cfg, md.HeaderMD, w)
return nil return nil
} }
func forwardResponseHandlerLogout(md metadata.MD, w http.ResponseWriter) { func (h *OAuth2Handler) forwardResponseHandlerLogout(cfg *config.Config, md metadata.MD, w http.ResponseWriter) {
if getMetadataKeyOrEmpty(md, "logout-provider") != "" { if getMetadataKeyOrEmpty(md, "logout-provider") != "" {
sid := getMetadataKeyOrEmpty(md, "logout-sid") sid := getMetadataKeyOrEmpty(md, "logout-sid")
delete(registeredStates, sid) delete(h.registeredStates, sid)
http.SetCookie( http.SetCookie(
w, w,
&http.Cookie{ &http.Cookie{
@ -127,7 +119,7 @@ func forwardResponseHandlerLogout(md metadata.MD, w http.ResponseWriter) {
}, },
) )
deleteLocalUserSession("local", sid) deleteLocalUserSession(cfg, "local", sid)
http.SetCookie( http.SetCookie(
w, w,
@ -156,52 +148,3 @@ func getMetadataKeyOrEmpty(md metadata.MD, key string) string {
return "" return ""
} }
func SetGlobalRestConfig(config *config.Config) {
cfg = config
}
func startRestAPIServer(globalConfig *config.Config) error {
cfg = globalConfig
loadUserSessions()
log.WithFields(log.Fields{
"address": cfg.ListenAddressRestActions,
}).Info("Starting REST API")
mux := newMux()
return http.ListenAndServe(cfg.ListenAddressRestActions, cors.AllowCors(mux))
}
func newMux() *runtime.ServeMux {
// The MarshalOptions set some important compatibility settings for the webui. See below.
mux := runtime.NewServeMux(
runtime.WithMetadata(parseRequestMetadata),
runtime.WithForwardResponseOption(forwardResponseHandler),
runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{
Marshaler: &runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{
UseProtoNames: false, // eg: canExec for js instead of can_exec from protobuf
EmitUnpopulated: true, // Emit empty fields so that javascript does not get "undefined" when accessing fields with empty values.
},
},
}),
)
ctx := context.Background()
opts := []grpc.DialOption{
grpc.WithTransportCredentials(
insecure.NewCredentials(),
),
}
err := apiv1.RegisterOliveTinApiServiceHandlerFromEndpoint(ctx, mux, cfg.ListenAddressGrpcActions, opts)
if err != nil {
log.Panicf("Could not register REST API Handler %v", err)
}
return mux
}

View File

@ -45,14 +45,14 @@ func registerSessionProvider(provider string) {
} }
} }
func deleteLocalUserSession(provider string, sid string) { func deleteLocalUserSession(cfg *config.Config, provider string, sid string) {
sessionStorageMutex.Lock() sessionStorageMutex.Lock()
deleteLocalUserSessionBatch(provider, sid) deleteLocalUserSessionBatch(provider, sid)
sessionStorageMutex.Unlock() sessionStorageMutex.Unlock()
saveUserSessions() saveUserSessions(cfg)
} }
func deleteLocalUserSessionBatch(provider string, sid string) { func deleteLocalUserSessionBatch(provider string, sid string) {
@ -68,7 +68,7 @@ func deleteLocalUserSessionBatch(provider string, sid string) {
delete(sessionStorage.Providers[provider].Sessions, sid) delete(sessionStorage.Providers[provider].Sessions, sid)
} }
func registerUserSession(provider string, sid string, username string) { func registerUserSession(cfg *config.Config, provider string, sid string, username string) {
sessionStorageMutex.Lock() sessionStorageMutex.Lock()
sessionStorage.Providers[provider].Sessions[sid] = &UserSession{ sessionStorage.Providers[provider].Sessions[sid] = &UserSession{
Username: username, Username: username,
@ -76,10 +76,10 @@ func registerUserSession(provider string, sid string, username string) {
} }
sessionStorageMutex.Unlock() sessionStorageMutex.Unlock()
saveUserSessions() saveUserSessions(cfg)
} }
func saveUserSessions() { func saveUserSessions(cfg *config.Config) {
sessionStorageMutex.Lock() sessionStorageMutex.Lock()
defer sessionStorageMutex.Unlock() defer sessionStorageMutex.Unlock()
@ -97,7 +97,7 @@ func saveUserSessions() {
filehelper.WriteFile(filename, out) filehelper.WriteFile(filename, out)
} }
func loadUserSessions() { func loadUserSessions(cfg *config.Config) {
registerSessionProviders() registerSessionProviders()
filename := filepath.Join(cfg.GetDir(), "sessions.db.yaml") filename := filepath.Join(cfg.GetDir(), "sessions.db.yaml")
@ -124,10 +124,10 @@ func loadUserSessions() {
return return
} }
deleteExpiredSessions() deleteExpiredSessions(cfg)
} }
func deleteExpiredSessions() { func deleteExpiredSessions(cfg *config.Config) {
sessionStorageMutex.Lock() sessionStorageMutex.Lock()
for provider, sessions := range sessionStorage.Providers { for provider, sessions := range sessionStorage.Providers {
@ -140,10 +140,10 @@ func deleteExpiredSessions() {
sessionStorageMutex.Unlock() sessionStorageMutex.Unlock()
saveUserSessions() saveUserSessions(cfg)
} }
func getUserFromSession(providerName string, sid string) *config.LocalUser { func getUserFromSession(cfg *config.Config, providerName string, sid string) *config.LocalUser {
provider, ok := sessionStorage.Providers[providerName] provider, ok := sessionStorage.Providers[providerName]
if !ok { if !ok {

View File

@ -11,6 +11,8 @@ import (
"os" "os"
"strings" "strings"
"github.com/OliveTin/OliveTin/internal/config"
// "github.com/coreos/go-oidc/v3/oidc" // "github.com/coreos/go-oidc/v3/oidc"
"github.com/MicahParks/keyfunc/v3" "github.com/MicahParks/keyfunc/v3"
"time" "time"
@ -23,7 +25,7 @@ var (
jwksVerifier keyfunc.Keyfunc jwksVerifier keyfunc.Keyfunc
) )
func initJwks() { func initJwks(cfg *config.Config) {
if jwksVerifier == nil { if jwksVerifier == nil {
var err error var err error
@ -43,7 +45,7 @@ func initJwks() {
} }
} }
func readLocalPublicKey() error { func readLocalPublicKey(cfg *config.Config) error {
if pubKeyBytes != nil { if pubKeyBytes != nil {
return nil // Already read. return nil // Already read.
} }
@ -62,14 +64,14 @@ func readLocalPublicKey() error {
return nil return nil
} }
func parseJwtTokenWithRemoteKey(jwtToken string) (*jwt.Token, error) { func parseJwtTokenWithRemoteKey(cfg *config.Config, jwtToken string) (*jwt.Token, error) {
initJwks() initJwks(cfg)
return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, jwt.WithAudience(cfg.AuthJwtAud)) return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, jwt.WithAudience(cfg.AuthJwtAud))
} }
func parseJwtTokenWithLocalKey(jwtString string) (*jwt.Token, error) { func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token, error) {
err := readLocalPublicKey() err := readLocalPublicKey(cfg)
if err != nil { if err != nil {
return nil, err return nil, err
@ -85,7 +87,7 @@ func parseJwtTokenWithLocalKey(jwtString string) (*jwt.Token, error) {
} }
// Hash-based Message Authentication Code // Hash-based Message Authentication Code
func parseJwtTokenWithHMAC(jwtString string) (*jwt.Token, error) { func parseJwtTokenWithHMAC(cfg *config.Config, jwtString string) (*jwt.Token, error) {
return jwt.Parse(jwtString, func(token *jwt.Token) (interface{}, error) { return jwt.Parse(jwtString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("parseJwt expected token algorithm HMAC but got: %v", token.Header["alg"]) return nil, fmt.Errorf("parseJwt expected token algorithm HMAC but got: %v", token.Header["alg"])
@ -95,20 +97,20 @@ func parseJwtTokenWithHMAC(jwtString string) (*jwt.Token, error) {
}) })
} }
func parseJwtToken(jwtString string) (*jwt.Token, error) { func parseJwtToken(cfg *config.Config, jwtString string) (*jwt.Token, error) {
if cfg.AuthJwtCertsURL != "" { if cfg.AuthJwtCertsURL != "" {
return parseJwtTokenWithRemoteKey(jwtString) return parseJwtTokenWithRemoteKey(cfg, jwtString)
} }
if cfg.AuthJwtPubKeyPath != "" { if cfg.AuthJwtPubKeyPath != "" {
return parseJwtTokenWithLocalKey(jwtString) return parseJwtTokenWithLocalKey(cfg, jwtString)
} }
return parseJwtTokenWithHMAC(jwtString) return parseJwtTokenWithHMAC(cfg, jwtString)
} }
func getClaimsFromJwtToken(jwtString string) (jwt.MapClaims, error) { func getClaimsFromJwtToken(cfg *config.Config, jwtString string) (jwt.MapClaims, error) {
token, err := parseJwtToken(jwtString) token, err := parseJwtToken(cfg, jwtString)
if err != nil { if err != nil {
log.Errorf("jwt parse failure: %v", err) log.Errorf("jwt parse failure: %v", err)
@ -130,7 +132,7 @@ func lookupClaimValueOrDefault(claims jwt.MapClaims, key string, def string) str
} }
} }
func parseJwtCookie(request *http.Request) (string, string) { func parseJwtCookie(cfg *config.Config, request *http.Request) (string, string) {
cookie, err := request.Cookie(cfg.AuthJwtCookieName) cookie, err := request.Cookie(cfg.AuthJwtCookieName)
if err != nil { if err != nil {
@ -138,11 +140,11 @@ func parseJwtCookie(request *http.Request) (string, string) {
return "", "" return "", ""
} }
return parseJwt(cookie.Value) return parseJwt(cfg, cookie.Value)
} }
func parseJwt(token string) (string, string) { func parseJwt(cfg *config.Config, token string) (string, string) {
claims, err := getClaimsFromJwtToken(token) claims, err := getClaimsFromJwtToken(cfg, token)
if err != nil { if err != nil {
log.Warnf("jwt claim error: %+v", err) log.Warnf("jwt claim error: %+v", err)

View File

@ -2,12 +2,13 @@ package httpservers
import ( import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"github.com/OliveTin/OliveTin/internal/config"
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
) )
func parseLocalUserCookie(req *http.Request) (string, string, string) { func parseLocalUserCookie(cfg *config.Config, req *http.Request) (string, string, string) {
cookie, err := req.Cookie("olivetin-sid-local") cookie, err := req.Cookie("olivetin-sid-local")
if err != nil { if err != nil {
@ -16,7 +17,7 @@ func parseLocalUserCookie(req *http.Request) (string, string, string) {
cookieValue := cookie.Value cookieValue := cookie.Value
user := getUserFromSession("local", cookieValue) user := getUserFromSession(cfg, "local", cookieValue)
if user == nil { if user == nil {
return "", "", "" return "", "", ""
@ -25,7 +26,7 @@ func parseLocalUserCookie(req *http.Request) (string, string, string) {
return user.Username, user.Usergroup, cookie.Value return user.Username, user.Usergroup, cookie.Value
} }
func forwardResponseHandlerLoginLocalUser(md metadata.MD, w http.ResponseWriter) error { func forwardResponseHandlerLoginLocalUser(cfg *config.Config, md metadata.MD, w http.ResponseWriter) error {
setUsername := getMetadataKeyOrEmpty(md, "set-username") setUsername := getMetadataKeyOrEmpty(md, "set-username")
if setUsername != "" { if setUsername != "" {
@ -36,7 +37,7 @@ func forwardResponseHandlerLoginLocalUser(md metadata.MD, w http.ResponseWriter)
} }
sid := uuid.NewString() sid := uuid.NewString()
registerUserSession("local", sid, user.Username) registerUserSession(cfg, "local", sid, user.Username)
http.SetCookie( http.SetCookie(
w, w,

View File

@ -17,25 +17,20 @@ import (
"time" "time"
) )
var ( type OAuth2Handler struct {
registeredStates = make(map[string]*oauth2State) cfg *config.Config
registeredProviders = make(map[string]*oauth2.Config) registeredStates map[string]*oauth2State
) registeredProviders map[string]*oauth2.Config
type oauth2State struct {
providerConfig *oauth2.Config
providerName string
Username string
Usergroup string
} }
func assignIfEmpty(target *string, value string) { func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
if *target == "" { h := &OAuth2Handler{
*target = value cfg: cfg,
} }
}
func oauth2Init(cfg *config.Config) { h.registeredStates = make(map[string]*oauth2State)
h.registeredProviders = make(map[string]*oauth2.Config)
for providerName, providerConfig := range cfg.AuthOAuth2Providers { for providerName, providerConfig := range cfg.AuthOAuth2Providers {
completeProviderConfig(providerName, providerConfig) completeProviderConfig(providerName, providerConfig)
@ -50,10 +45,25 @@ func oauth2Init(cfg *config.Config) {
RedirectURL: cfg.AuthOAuth2RedirectURL, RedirectURL: cfg.AuthOAuth2RedirectURL,
} }
registeredProviders[providerName] = newConfig h.registeredProviders[providerName] = newConfig
log.Debugf("Dumping newly registered provider: %v = %+v", providerName, providerConfig) log.Debugf("Dumping newly registered provider: %v = %+v", providerName, providerConfig)
} }
return h
}
type oauth2State struct {
providerConfig *oauth2.Config
providerName string
Username string
Usergroup string
}
func assignIfEmpty(target *string, value string) {
if *target == "" {
*target = value
}
} }
func completeProviderConfig(providerName string, providerConfig *config.OAuth2Provider) { func completeProviderConfig(providerName string, providerConfig *config.OAuth2Provider) {
@ -76,8 +86,8 @@ func completeProviderConfig(providerName string, providerConfig *config.OAuth2Pr
} }
} }
func getOAuth2Config(providerName string) (*oauth2.Config, error) { func (h *OAuth2Handler) getOAuth2Config(providerName string) (*oauth2.Config, error) {
config, ok := registeredProviders[providerName] config, ok := h.registeredProviders[providerName]
if !ok { if !ok {
return nil, fmt.Errorf("provider not found in config: %v", providerName) return nil, fmt.Errorf("provider not found in config: %v", providerName)
@ -96,7 +106,7 @@ func randString(nByte int) (string, error) {
return base64.URLEncoding.EncodeToString(b), nil return base64.URLEncoding.EncodeToString(b), nil
} }
func setOAuthCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) { func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
cookie := &http.Cookie{ cookie := &http.Cookie{
Name: name, Name: name,
Value: value, Value: value,
@ -109,7 +119,7 @@ func setOAuthCallbackCookie(w http.ResponseWriter, r *http.Request, name, value
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
} }
func handleOAuthLogin(w http.ResponseWriter, r *http.Request) { func (h *OAuth2Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
state, err := randString(16) state, err := randString(16)
if err != nil { if err != nil {
@ -118,7 +128,7 @@ func handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
} }
providerName := r.URL.Query().Get("provider") providerName := r.URL.Query().Get("provider")
provider, err := getOAuth2Config(providerName) provider, err := h.getOAuth2Config(providerName)
if err != nil { if err != nil {
log.Errorf("Failed to get provider config: %v %v", providerName, err) log.Errorf("Failed to get provider config: %v %v", providerName, err)
@ -126,20 +136,20 @@ func handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
registeredStates[state] = &oauth2State{ h.registeredStates[state] = &oauth2State{
providerConfig: provider, providerConfig: provider,
providerName: providerName, providerName: providerName,
Username: "", Username: "",
} }
setOAuthCallbackCookie(w, r, "olivetin-sid-oauth", state) h.setOAuthCallbackCookie(w, r, "olivetin-sid-oauth", state)
log.Infof("OAuth2 state: %v mapped to provider %v (found: %v), now redirecting", state, providerName, provider != nil) log.Infof("OAuth2 state: %v mapped to provider %v (found: %v), now redirecting", state, providerName, provider != nil)
http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound) http.Redirect(w, r, provider.AuthCodeURL(state), http.StatusFound)
} }
func checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, string, bool) { func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2State, string, bool) {
cookie, err := r.Cookie("olivetin-sid-oauth") cookie, err := r.Cookie("olivetin-sid-oauth")
state := cookie.Value state := cookie.Value
@ -157,7 +167,7 @@ func checkOAuthCallbackCookie(w http.ResponseWriter, r *http.Request) (*oauth2St
return nil, state, false return nil, state, false
} }
registeredState, ok := registeredStates[state] registeredState, ok := h.registeredStates[state]
if !ok { if !ok {
log.Errorf("State not found in server: %v", state) log.Errorf("State not found in server: %v", state)
@ -206,10 +216,10 @@ func getOAuthCertBundle(providerConfig *config.OAuth2Provider) *x509.CertPool {
return caCertPool return caCertPool
} }
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { func (h *OAuth2Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
log.Infof("OAuth2 Callback received") log.Infof("OAuth2 Callback received")
registeredState, state, ok := checkOAuthCallbackCookie(w, r) registeredState, state, ok := h.checkOAuthCallbackCookie(w, r)
if !ok { if !ok {
return return
@ -222,7 +232,7 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
"token-code": code, "token-code": code,
}).Debug("OAuth2 Token Code") }).Debug("OAuth2 Token Code")
providerConfig := cfg.AuthOAuth2Providers[registeredState.providerName] providerConfig := h.cfg.AuthOAuth2Providers[registeredState.providerName]
clientSettings := getOAuth2HttpClient(providerConfig) clientSettings := getOAuth2HttpClient(providerConfig)
@ -250,18 +260,18 @@ func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
Timeout: clientSettings.Timeout, Timeout: clientSettings.Timeout,
} }
userinfo := getUserInfo(userInfoClient, cfg.AuthOAuth2Providers[registeredState.providerName]) userinfo := getUserInfo(userInfoClient, h.cfg.AuthOAuth2Providers[registeredState.providerName])
registeredStates[state].Username = userinfo.Username h.registeredStates[state].Username = userinfo.Username
registeredStates[state].Usergroup = userinfo.Usergroup h.registeredStates[state].Usergroup = userinfo.Usergroup
for k, v := range registeredStates { for k, v := range h.registeredStates {
log.Debugf("states: %+v %+v", k, v) log.Debugf("states: %+v %+v", k, v)
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"state": state, "state": state,
"username": registeredStates[state].Username, "username": h.registeredStates[state].Username,
}).Info("OAuth2 login successful") }).Info("OAuth2 login successful")
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
@ -330,7 +340,7 @@ func getDataField(data map[string]any, field string) string {
return val.(string) return val.(string)
} }
func parseOAuth2Cookie(r *http.Request) (string, string, string) { func (h *OAuth2Handler) parseOAuth2Cookie(r *http.Request) (string, string, string) {
cookie, err := r.Cookie("olivetin-sid-oauth") cookie, err := r.Cookie("olivetin-sid-oauth")
if err != nil { if err != nil {
@ -342,7 +352,7 @@ func parseOAuth2Cookie(r *http.Request) (string, string, string) {
return "", "", "" return "", "", ""
} }
serverState, found := registeredStates[cookie.Value] serverState, found := h.registeredStates[cookie.Value]
if !found { if !found {
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

@ -10,11 +10,13 @@ away, and several other issues.
import ( import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/websocket" "github.com/OliveTin/OliveTin/internal/api"
"github.com/OliveTin/OliveTin/internal/executor"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"path"
) )
func logDebugRequest(cfg *config.Config, source string, r *http.Request) { func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
@ -31,42 +33,40 @@ func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
// StartSingleHTTPFrontend will create a reverse proxy that proxies the API // StartSingleHTTPFrontend will create a reverse proxy that proxies the API
// and webui internally. // and webui internally.
func StartSingleHTTPFrontend(cfg *config.Config) { func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"address": cfg.ListenAddressSingleHTTPFrontend, "address": cfg.ListenAddressSingleHTTPFrontend,
}).Info("Starting single HTTP frontend") }).Info("Starting single HTTP frontend")
apiURL, _ := url.Parse("http://" + cfg.ListenAddressRestActions)
apiProxy := httputil.NewSingleHostReverseProxy(apiURL)
webuiURL, _ := url.Parse("http://" + cfg.ListenAddressWebUI)
webuiProxy := httputil.NewSingleHostReverseProxy(webuiURL)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { apiPath, apiHandler := api.GetNewHandler(ex)
logDebugRequest(cfg, "api ", r)
apiProxy.ServeHTTP(w, r) log.Infof("API path is %s", apiPath)
})
mux.HandleFunc("/websocket", func(w http.ResponseWriter, r *http.Request) { mux.Handle("/api/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logDebugRequest(cfg, "ws ", r) fn := path.Base(r.URL.Path)
websocket.HandleWebsocket(w, r) r.URL.Path = apiPath + fn
})
mux.HandleFunc("/oauth/login", handleOAuthLogin) log.Infof("SingleFrontend HTTP API Req URL after rewrite: %v", r.URL.Path)
mux.HandleFunc("/oauth/callback", handleOAuthCallback) apiHandler.ServeHTTP(w, r)
}))
oauth2handler := NewOAuth2Handler(cfg)
mux.HandleFunc("/oauth/login", oauth2handler.handleOAuthLogin)
mux.HandleFunc("/oauth/callback", oauth2handler.handleOAuthCallback)
mux.HandleFunc("/readyz", handleReadyz) mux.HandleFunc("/readyz", handleReadyz)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { webuiServer := NewWebUIServer(cfg)
logDebugRequest(cfg, "ui ", r)
webuiProxy.ServeHTTP(w, r) mux.HandleFunc("/webUiSettings.json", webuiServer.generateWebUISettings)
}) mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
mux.Handle("/custom-webui/", webuiServer.handleCustomWebui())
mux.HandleFunc("/", webuiServer.handleWebui)
if cfg.Prometheus.Enabled { if cfg.Prometheus.Enabled {
promURL, _ := url.Parse("http://" + cfg.ListenAddressPrometheus) promURL, _ := url.Parse("http://" + cfg.ListenAddressPrometheus)
@ -79,8 +79,6 @@ func StartSingleHTTPFrontend(cfg *config.Config) {
}) })
} }
oauth2Init(cfg)
srv := &http.Server{ srv := &http.Server{
Addr: cfg.ListenAddressSingleHTTPFrontend, Addr: cfg.ListenAddressSingleHTTPFrontend,
Handler: mux, Handler: mux,

View File

@ -7,13 +7,20 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"path/filepath"
"github.com/jamesread/golure/pkg/dirs"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo" installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
sv "github.com/OliveTin/OliveTin/internal/stringvariables" sv "github.com/OliveTin/OliveTin/internal/stringvariables"
) )
type webUIServer struct {
cfg *config.Config
webuiDir string
}
var ( var (
customThemeCss []byte customThemeCss []byte
customThemeCssRead = false customThemeCssRead = false
@ -36,46 +43,65 @@ type webUISettings struct {
AdditionalLinks []*config.NavigationLink AdditionalLinks []*config.NavigationLink
} }
func findWebuiDir() string { func NewWebUIServer(cfg *config.Config) *webUIServer {
s := &webUIServer{
cfg: cfg,
}
s.webuiDir = s.findWebuiDir()
s.setupCustomWebuiDir()
return s
}
func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
//dirName := path.Dir(r.URL.Path)
// Mangle requests for any path like /logs or /config to load the webui index.html
if path.Ext(r.URL.Path) == "" && r.URL.Path != "/" {
log.Debugf("Mangling request for %s to /index.html", r.URL.Path)
http.ServeFile(w, r, path.Join(s.webuiDir, "index.html"))
} 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)
}
}
func (s *webUIServer) findWebuiDir() string {
directoriesToSearch := []string{ directoriesToSearch := []string{
cfg.WebUIDir, s.cfg.WebUIDir,
"../webui/", "../frontend/dist/",
"/usr/share/OliveTin/webui/", "../frontend/",
"/usr/share/OliveTin/frontend/",
"/var/www/OliveTin/", "/var/www/OliveTin/",
"/var/www/olivetin/", "/var/www/olivetin/",
"/etc/OliveTin/webui/", "/etc/OliveTin/frontend/",
} }
// Use a classic i := 0 style for loop here instead of range, as the dir, err := dirs.GetFirstExistingDirectory("webui", directoriesToSearch)
// search order must be deterministic - the order that the slice was defined in.
for i := 0; i < len(directoriesToSearch); i++ {
dir := directoriesToSearch[i]
absdir, _ := filepath.Abs(dir)
if _, err := os.Stat(absdir); !os.IsNotExist(err) {
log.WithFields(log.Fields{
"dir": absdir,
}).Infof("Found the webui directory")
sv.Set("internal.webuidir", absdir+" ("+dir+")")
return dir
}
}
if err != nil {
log.Warnf("Did not find the webui directory, you will probably get 404 errors.") log.Warnf("Did not find the webui directory, you will probably get 404 errors.")
return "./webui" // Should not exist return "./webui" // Should not exist
} }
func findCustomWebuiDir() string { log.Infof("Using webui directory: %s", dir)
dir := path.Join(cfg.GetDir(), "custom-webui")
return dir return dir
} }
func setupCustomWebuiDir() { func (s *webUIServer) findCustomWebuiDir() string {
dir := findCustomWebuiDir() dir := path.Join(s.cfg.GetDir(), "custom-webui")
return dir
}
func (s *webUIServer) setupCustomWebuiDir() {
dir := s.findCustomWebuiDir()
err := os.MkdirAll(path.Join(dir, "themes/"), 0775) err := os.MkdirAll(path.Join(dir, "themes/"), 0775)
@ -87,10 +113,10 @@ func setupCustomWebuiDir() {
} }
} }
func generateThemeCss(w http.ResponseWriter, r *http.Request) { func (s *webUIServer) generateThemeCss(w http.ResponseWriter, r *http.Request) {
themeCssFilename := path.Join(findCustomWebuiDir(), "themes", cfg.ThemeName, "theme.css") themeCssFilename := path.Join(s.findCustomWebuiDir(), "themes", s.cfg.ThemeName, "theme.css")
if !customThemeCssRead || cfg.ThemeCacheDisabled { if !customThemeCssRead || s.cfg.ThemeCacheDisabled {
customThemeCssRead = true customThemeCssRead = true
if _, err := os.Stat(themeCssFilename); err == nil { if _, err := os.Stat(themeCssFilename); err == nil {
@ -125,22 +151,24 @@ func buildPublicOAuth2ProvidersList(cfg *config.Config) []publicOAuth2Provider {
return publicProviders return publicProviders
} }
func generateWebUISettings(w http.ResponseWriter, r *http.Request) { func (s *webUIServer) generateWebUISettings(w http.ResponseWriter, r *http.Request) {
log.Infof("Generating webui settings for %s", r.RemoteAddr)
jsonRet, _ := json.Marshal(webUISettings{ jsonRet, _ := json.Marshal(webUISettings{
Rest: cfg.ExternalRestAddress + "/api/", Rest: s.cfg.ExternalRestAddress + "/api/",
ShowFooter: cfg.ShowFooter, ShowFooter: s.cfg.ShowFooter,
ShowNavigation: cfg.ShowNavigation, ShowNavigation: s.cfg.ShowNavigation,
ShowNewVersions: cfg.ShowNewVersions, ShowNewVersions: s.cfg.ShowNewVersions,
AvailableVersion: installationinfo.Runtime.AvailableVersion, AvailableVersion: installationinfo.Runtime.AvailableVersion,
CurrentVersion: installationinfo.Build.Version, CurrentVersion: installationinfo.Build.Version,
PageTitle: cfg.PageTitle, PageTitle: s.cfg.PageTitle,
SectionNavigationStyle: cfg.SectionNavigationStyle, SectionNavigationStyle: s.cfg.SectionNavigationStyle,
DefaultIconForBack: cfg.DefaultIconForBack, DefaultIconForBack: s.cfg.DefaultIconForBack,
EnableCustomJs: cfg.EnableCustomJs, EnableCustomJs: s.cfg.EnableCustomJs,
AuthLoginUrl: cfg.AuthLoginUrl, AuthLoginUrl: s.cfg.AuthLoginUrl,
AuthLocalLogin: cfg.AuthLocalUsers.Enabled, AuthLocalLogin: s.cfg.AuthLocalUsers.Enabled,
AuthOAuth2Providers: buildPublicOAuth2ProvidersList(cfg), AuthOAuth2Providers: buildPublicOAuth2ProvidersList(s.cfg),
AdditionalLinks: cfg.AdditionalNavigationLinks, AdditionalLinks: s.cfg.AdditionalNavigationLinks,
}) })
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
@ -151,36 +179,6 @@ func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
} }
} }
func startWebUIServer(cfg *config.Config) { func (s *webUIServer) handleCustomWebui() (http.Handler) {
log.WithFields(log.Fields{ return http.StripPrefix("/custom-webui/", http.FileServer(http.Dir(s.findCustomWebuiDir())))
"address": cfg.ListenAddressWebUI,
}).Info("Starting WebUI server")
setupCustomWebuiDir()
mux := http.NewServeMux()
mux.Handle("/custom-webui/", http.StripPrefix("/custom-webui/", http.FileServer(http.Dir(findCustomWebuiDir()))))
mux.HandleFunc("/theme.css", generateThemeCss)
mux.HandleFunc("/webUiSettings.json", generateWebUISettings)
webuiDir := findWebuiDir()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
dirName := path.Dir(r.URL.Path)
// Mangle requests for any path like /logs or /config to load the webui index.html
if path.Ext(r.URL.Path) == "" && r.URL.Path != "/" {
log.Debugf("Mangling request for %s to /index.html", r.URL.Path)
http.ServeFile(w, r, path.Join(webuiDir, "index.html"))
} else {
http.StripPrefix(dirName, http.FileServer(http.Dir(webuiDir))).ServeHTTP(w, r)
}
})
srv := &http.Server{
Addr: cfg.ListenAddressWebUI,
Handler: mux,
}
log.Fatal(srv.ListenAndServe())
} }

View File

@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"sync" "sync"
apiv1 "github.com/OliveTin/OliveTin/gen/grpc/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
"github.com/OliveTin/OliveTin/internal/executor" "github.com/OliveTin/OliveTin/internal/executor"
ws "github.com/gorilla/websocket" ws "github.com/gorilla/websocket"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"

View File

@ -7,7 +7,6 @@ import (
"github.com/OliveTin/OliveTin/internal/entityfiles" "github.com/OliveTin/OliveTin/internal/entityfiles"
"github.com/OliveTin/OliveTin/internal/executor" "github.com/OliveTin/OliveTin/internal/executor"
grpcapi "github.com/OliveTin/OliveTin/internal/grpcapi"
"github.com/OliveTin/OliveTin/internal/httpservers" "github.com/OliveTin/OliveTin/internal/httpservers"
"github.com/OliveTin/OliveTin/internal/installationinfo" "github.com/OliveTin/OliveTin/internal/installationinfo"
"github.com/OliveTin/OliveTin/internal/oncalendarfile" "github.com/OliveTin/OliveTin/internal/oncalendarfile"
@ -179,7 +178,5 @@ func main() {
go updatecheck.StartUpdateChecker(cfg) go updatecheck.StartUpdateChecker(cfg)
go grpcapi.Start(cfg, executor) httpservers.StartServers(cfg, executor)
httpservers.StartServers(cfg)
} }

View File

@ -7,8 +7,6 @@ import (
_ "github.com/bufbuild/buf/cmd/buf" _ "github.com/bufbuild/buf/cmd/buf"
_ "github.com/fzipp/gocyclo/cmd/gocyclo" _ "github.com/fzipp/gocyclo/cmd/gocyclo"
_ "github.com/go-critic/go-critic/cmd/gocritic" _ "github.com/go-critic/go-critic/cmd/gocritic"
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc" _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
_ "google.golang.org/protobuf/cmd/protoc-gen-go" _ "google.golang.org/protobuf/cmd/protoc-gen-go"
) )

6
var/entities/lights.yaml Normal file
View File

@ -0,0 +1,6 @@
- id: kitchen
name: Kitchen
turned_on: false
- id: living_room
name: Living Room
turned_on: false

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg8"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="OliveTinLogoMonocrome.svg"
inkscape:export-filename="/home/xconspirisist/sandbox/OliveTin/webui/OliveTinLogo.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="214.5"
inkscape:cy="130.5"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="1280"
inkscape:window-y="1104"
inkscape:window-maximized="1"
inkscape:showpageshadow="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect1402"
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:5.79097;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 78.150303,10.636649 0.06022,77.69053 c 0,0 -0.333558,7.417739 -26.071829,7.668294 -25.738273,0.250554 -27.812975,-7.668294 -27.812975,-7.668294 l 0.02958,-77.69053"
sodipodi:nodetypes="cczcc" />
<path
id="rect1469-7"
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 26.974,41.794388 23.923879,2.635333 24.529783,-2.635333 v 21.176145 c 0,0 -0.404835,4.427058 -24.431009,4.612696 C 26.97048,67.768868 26.974,62.970533 26.974,62.970533 Z"
sodipodi:nodetypes="cccczcc" />
<ellipse
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:4.00217;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1436"
ry="19.291208"
rx="13.695867"
cy="28.679092"
cx="71.078308"
transform="rotate(25.917116)" />
<ellipse
style="display:inline;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:5.79097;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1440"
cx="51.2528"
cy="10.636649"
rx="26.897503"
ry="6.525641" />
<path
id="path1436-3"
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:0.574412;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
inkscape:transform-center-x="-1.1166908"
inkscape:transform-center-y="0.49206772"
d="m 36.494243,8.7784525 c 1.841786,-0.1158042 2.469235,0.5295096 2.656071,1.4191125 0.186835,0.889603 -0.0974,1.788639 -1.939188,1.904443 -0.848904,0.05338 -2.422996,-0.462298 -3.064668,-0.677573 -0.750505,-0.251787 -1.565122,-0.327933 -1.665841,-0.807506 -0.09913,-0.472029 0.614422,-0.7401021 1.22232,-1.0768269 0.537767,-0.2978804 1.926784,-0.7072919 2.791306,-0.7616496 z"
sodipodi:nodetypes="sssssss" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

View File

@ -1,4 +0,0 @@
{
"extends": "@parcel/config-default",
"resolvers": ["parcel-resolver-ignore", "..."]
}

View File

@ -1,6 +0,0 @@
codestyle:
npm install
npx eslint --fix main.js js/*
npx stylelint style.css
.PHONY: codestyle

View File

@ -1,174 +0,0 @@
import './ExecutionButton.js'
import './ArgumentForm.js'
import { ExecutionFeedbackButton } from './ExecutionFeedbackButton.js'
class ActionButton extends ExecutionFeedbackButton {
constructDomFromTemplate () {
const tpl = document.getElementById('tplActionButton')
const content = tpl.content.cloneNode(true)
/*
* FIXME: Should probably be using a shadowdom here, but seem to
* get an error when combined with custom elements.
*/
this.appendChild(content)
this.btn = this.querySelector('button')
this.domTitle = this.btn.querySelector('.title')
this.domIcon = this.btn.querySelector('.icon')
}
constructFromJson (json) {
this.updateIterationTimestamp = 0
this.constructDomFromTemplate()
// Class attributes
this.updateFromJson(json)
this.actionId = json.id
// DOM Attributes
this.setAttribute('role', 'none')
this.setAttribute('id', 'actionButton-' + this.actionId)
if (!json.canExec) {
this.btn.disabled = true
}
this.btn.setAttribute('id', 'actionButtonInner-' + this.actionId)
this.btn.title = json.title
this.btn.onclick = () => {
if (json.arguments.length > 0) {
for (const oldArgumentForm of document.querySelectorAll('argument-form')) {
oldArgumentForm.remove()
}
this.updateUrlWithAction()
const frm = document.createElement('argument-form')
frm.setup(json, (args) => {
this.startAction(args)
})
document.body.appendChild(frm)
frm.querySelector('dialog').showModal()
} else {
this.startAction()
}
}
this.popupOnStart = json.popupOnStart
this.updateFromJson(json)
this.domTitle.innerText = this.btn.title
this.domIcon.innerHTML = this.unicodeIcon
}
updateFromJson (json) {
// Fields that should not be updated
//
// title - as the callback URL relies on it
if (json.icon === '') {
this.unicodeIcon = '&#x1f4a9'
} else {
this.unicodeIcon = unescape(json.icon)
}
this.domIcon.innerHTML = this.unicodeIcon
}
onExecStatusChanged () {
this.btn.disabled = false
setTimeout(() => {
this.updateDom(null, this.btn.title)
}, 2000)
}
getUniqueId () {
if (window.isSecureContext) {
return window.crypto.randomUUID()
} else {
return Date.now().toString()
}
}
startAction (actionArgs) {
this.btn.classList = [] // Removes old animation classes
if (actionArgs === undefined) {
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: this.actionId,
arguments: actionArgs,
uniqueTrackingId: this.getUniqueId()
}
this.onActionStarted(startActionArgs.uniqueTrackingId)
window.fetch(window.restBaseUrl + 'StartAction', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(startActionArgs)
}).then((res) => {
if (res.ok) {
return res.json()
} else {
throw new Error(res.statusText)
}
}
).then((json) => {
// The button used to wait for the action to finish, but now it is fire & forget
}).catch(err => {
throw err // We used to flash buttons red, but now hand to the global error handler
})
}
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', this.btn.title)
// Update the URL without reloading the page
window.history.replaceState({}, '', url.toString())
}
onActionStarted (executionTrackingId) {
if (this.popupOnStart === 'execution-button') {
const btnExecution = document.createElement('execution-button')
btnExecution.constructFromJson(executionTrackingId)
this.querySelector('.action-button-footer').hidden = false
this.querySelector('.action-button-footer').style.display = 'flex'
this.querySelector('.action-button-footer').prepend(btnExecution)
return
}
if (this.popupOnStart.includes('execution-dialog')) {
window.executionDialog.reset()
if (this.popupOnStart === 'execution-dialog-stdout-only') {
window.executionDialog.hideEverythingApartFromOutput()
}
window.executionDialog.executionTrackingId = executionTrackingId
window.executionDialog.show(this)
}
this.btn.disabled = true
}
}
window.customElements.define('action-button', ActionButton)

View File

@ -1,42 +0,0 @@
export class ActionStatusDisplay {
constructor (parentElement) {
this.exitCodeElement = document.createElement('span')
this.statusElement = document.createElement('span')
this.statusElement.innerText = 'unknown'
parentElement.innerText = ''
parentElement.appendChild(this.statusElement)
parentElement.appendChild(this.exitCodeElement)
}
getText () {
return this.statusElement.innerText
}
update (logEntry) {
this.statusElement.classList.remove(...this.statusElement.classList)
this.statusElement.classList.add('action-status')
if (logEntry.executionFinished) {
this.statusElement.innerText = 'Completed'
this.exitCodeElement.innerText = ' Exit code: ' + logEntry.exitCode
if (logEntry.exitCode === 0) {
this.statusElement.classList.add('action-success')
} else if (logEntry.blocked) {
this.statusElement.innerText = 'Blocked'
this.statusElement.classList.add('action-blocked')
this.exitCodeElement.innerText = ''
} else if (logEntry.timedOut) {
this.statusElement.innerText = 'Timed out'
this.statusElement.classList.add('action-timeout')
this.exitCodeElement.innerText = ''
} else {
this.statusElement.classList.add('action-nonzero-exit')
}
} else {
this.statusElement.innerText = 'Still running...'
this.exitCodeElement.innerText = ''
}
}
}

View File

@ -1,34 +0,0 @@
import { ExecutionFeedbackButton } from './ExecutionFeedbackButton.js'
class ExecutionButton extends ExecutionFeedbackButton {
constructFromJson (json) {
this.executionTrackingId = json
this.ellapsed = 0
this.appendChild(document.createElement('button'))
this.isWaiting = true
this.setAttribute('id', 'execution-' + json)
this.btn = this.querySelector('button')
this.btn.innerText = 'Executing...'
this.btn.onclick = () => {
this.show()
}
this.domTitle = this.btn
}
show () {
window.executionDialog.reset()
window.executionDialog.show()
window.executionDialog.fetchExecutionResult(this.executionTrackingId)
}
onExecStatusChanged () {
this.domTitle.innerText = this.ellapsed + 's'
this.btn.title = this.ellapsed + ' seconds'
}
}
window.customElements.define('execution-button', ExecutionButton)

View File

@ -1,237 +0,0 @@
import { ActionStatusDisplay } from './ActionStatusDisplay.js'
import { OutputTerminal } from './OutputTerminal.js'
// This ExecutionDialog is NOT a custom HTML element, but rather just picks up
// the <dialog /> element out of index.html and just re-uses that - as only
// one dialog can be shown at a time.
export class ExecutionDialog {
constructor () {
this.dlg = document.querySelector('dialog#execution-results-popup')
this.domIcon = document.getElementById('execution-dialog-icon')
this.domTitle = document.getElementById('execution-dialog-title')
this.domOutput = document.getElementById('execution-dialog-xterm')
this.domOutputHtml = document.getElementById('execution-dialog-output-html')
this.domOutputToggleBig = document.getElementById('execution-dialog-toggle-size')
this.domOutputToggleBig.onclick = () => {
this.toggleSize()
}
this.domBtnRerun = document.getElementById('execution-dialog-rerun-action')
this.domBtnKill = document.getElementById('execution-dialog-kill-action')
this.domDuration = document.getElementById('execution-dialog-duration')
this.domStatus = new ActionStatusDisplay(document.getElementById('execution-dialog-status'))
this.domExecutionBasics = document.getElementById('execution-dialog-basics')
this.domExecutionDetails = document.getElementById('execution-dialog-details')
window.terminal = new OutputTerminal()
window.terminal.open(this.domOutput)
window.terminal.resize(80, 24)
}
toggleSize () {
if (this.dlg.classList.contains('big')) {
this.dlg.classList.remove('big')
window.terminal.resize(80, 24)
} else {
this.dlg.classList.add('big')
window.terminal.fit()
}
}
async reset () {
this.executionSeconds = 0
this.executionTrackingId = 'notset'
this.dlg.classList.remove('big')
this.domOutputToggleBig.hidden = false
this.domIcon.innerText = ''
this.domTitle.innerText = 'Waiting for result... '
this.domDuration.innerText = ''
// window.terminal.close()
this.domBtnRerun.disabled = true
this.domBtnRerun.onclick = () => {}
this.domBtnKill.disabled = true
this.domBtnKill.onclick = () => {}
this.hideDetailsOnResult = false
this.domExecutionBasics.hidden = false
this.domExecutionDetails.hidden = true
await window.terminal.reset()
window.terminal.fit()
}
show (actionButton) {
if (typeof actionButton !== 'undefined' && actionButton != null) {
this.domIcon.innerText = actionButton.domIcon.innerText
}
this.domBtnKill.disabled = false
this.domBtnKill.onclick = () => {
this.killAction()
}
clearInterval(window.executionDialogTicker)
this.executionSeconds = 0
this.executionTick()
window.executionDialogTicker = setInterval(() => {
this.executionTick()
}, 1000)
if (this.dlg.open) {
this.dlg.close()
}
this.dlg.showModal()
}
rerunAction (actionId) {
const actionButton = document.getElementById('actionButton-' + actionId)
if (actionButton !== undefined) {
actionButton.btn.click()
}
this.dlg.close()
}
killAction () {
const killActionArgs = {
executionTrackingId: this.executionTrackingId
}
window.fetch(window.restBaseUrl + 'KillAction', {
cors: 'cors',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(killActionArgs)
}).then((res) => {
return res.json() // This isn't used by anything. UI is updated by OnExecutionFinished like normal.
}).catch(err => {
throw err
})
}
executionTick () {
this.executionSeconds++
this.updateDuration(null)
}
hideEverythingApartFromOutput () {
this.hideDetailsOnResult = true
this.domExecutionBasics.hidden = true
}
fetchExecutionResult (executionTrackingId) {
this.executionTrackingId = executionTrackingId
const executionStatusArgs = {
executionTrackingId: this.executionTrackingId
}
window.fetch(window.restBaseUrl + 'ExecutionStatus', {
cors: 'cors',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(executionStatusArgs)
}).then((res) => {
if (res.ok) {
return res.json()
} else if (res.status === 404) {
throw new Error('Execution not found: ' + executionTrackingId)
} else {
throw new Error(res.statusText)
}
}
).then((json) => {
this.renderExecutionResult(json)
}).catch(err => {
console.log(err)
this.renderError(err)
})
}
updateDuration (logEntry) {
if (logEntry == null) {
this.domDuration.innerHTML = this.executionSeconds + ' seconds'
} else if (!logEntry.executionStarted) {
this.domDuration.innerHTML = logEntry.datetimeStarted + ' (request time). Not executed.'
} else if (logEntry.executionStarted && !logEntry.executionFinished) {
this.domDuration.innerHTML = logEntry.datetimeStarted
} 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)
}
this.domDuration.innerHTML = logEntry.datetimeStarted + ' &rarr; ' + logEntry.datetimeFinished
if (delta !== '') {
this.domDuration.innerHTML += ' (' + delta + ')'
}
}
}
async renderExecutionResult (res) {
this.res = res
clearInterval(window.executionDialogTicker)
if ('type' in res && res.type === 'execution-dialog-output-html') {
this.domOutputHtml.hidden = false
this.domOutput.hidden = true
this.domOutputHtml.innerHTML = res.logEntry.output
this.domOutputHtml.hidden = false
this.hideDetailsonResult = true
} else {
this.domOutput.hidden = false
this.domOutputHtml.innerHTML = ''
this.domOutputHtml.hidden = true
}
if (this.hideDetailsOnResult) {
this.domExecutionDetails.hidden = true
}
this.executionTrackingId = res.logEntry.executionTrackingId
this.domBtnRerun.disabled = !res.logEntry.executionFinished
this.domBtnRerun.onclick = () => { this.rerunAction(res.logEntry.actionId) }
this.domBtnKill.disabled = !res.logEntry.canKill
this.domStatus.update(res.logEntry)
this.domIcon.innerHTML = res.logEntry.actionIcon
this.domTitle.innerText = res.logEntry.actionTitle
this.domTitle.title = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
this.updateDuration(res.logEntry)
await window.terminal.reset()
await window.terminal.write(res.logEntry.output, () => {
window.terminal.fit()
})
}
renderError (err) {
window.showBigError('execution-dlg-err', 'in the execution dialog', 'Failed to fetch execution result. ' + err, false)
}
}

View File

@ -1,37 +0,0 @@
export class NavigationBar {
constructor () {
this.navbar = document.getElementsByTagName('nav')[0]
this.mainLinks = document.getElementById('navigation-links')
this.supplementalLinks = document.getElementById('supplemental-links')
}
createLink (title, url, isSupplemental) {
const parent = (isSupplemental) ? this.supplementalLinks : this.mainLinks
const existsAlready = Array.from(parent.querySelectorAll('li')).some(el => el.title === title)
if (existsAlready) {
return
}
const linkA = document.createElement('a')
linkA.href = url
linkA.innerText = title
const navigationLi = document.createElement('li')
navigationLi.appendChild(linkA)
navigationLi.title = title
parent.appendChild(navigationLi)
}
refreshSectionPolicyLinks (policy) {
if (policy.showDiagnostics) {
this.createLink('Diagnostics', '/diagnostics', true)
}
if (policy.showLogList) {
this.createLink('Logs', '/logs', true)
}
}
}

View File

@ -1,82 +0,0 @@
import {
refreshServerConnectionLabel
} from './marshaller.js'
window.ws = null
export function checkWebsocketConnection () {
if (window.ws === null || window.ws.readyState === 3) {
reconnectWebsocket()
}
}
function reconnectWebsocket () {
window.websocketAvailable = false
const websocketConnectionUrl = new URL(window.location.toString())
websocketConnectionUrl.hash = ''
websocketConnectionUrl.pathname = '/websocket'
if (window.location.protocol === 'https:') {
websocketConnectionUrl.protocol = 'wss'
} else {
websocketConnectionUrl.protocol = 'ws'
}
window.websocketConnectionUrl = websocketConnectionUrl
const ws = window.ws = new WebSocket(websocketConnectionUrl.toString())
ws.addEventListener('open', websocketOnOpen)
ws.addEventListener('message', websocketOnMessage)
ws.addEventListener('error', websocketOnError)
ws.addEventListener('close', websocketOnClose)
}
function websocketOnOpen (evt) {
window.websocketAvailable = true
window.ws.send('monitor')
refreshServerConnectionLabel()
window.refreshLoop()
}
function websocketOnMessage (msg) {
// FIXME check msg status is OK
const j = JSON.parse(msg.data)
j.type = j.type.replace('olivetin.api.v1.', '')
const e = new Event(j.type)
e.payload = j.payload
switch (j.type) {
case 'EventOutputChunk':
case 'EventConfigChanged':
case 'EventExecutionFinished':
case 'EventExecutionStarted':
case 'EventEntityChanged':
window.dispatchEvent(e)
break
default:
window.showBigError('ws-unhandled-message', 'handling websocket message', 'Unhandled websocket message type from server: ' + j.type, true)
}
}
function websocketOnError (err) {
window.websocketAvailable = false
window.refreshLoop()
console.log('Websocket error is: ', err)
window.showBigError('websocket-connection', 'connecting to the websocket', 'This often means the connection was closed, sometimes this can happen due to reverse proxy timeouts. Sometimes your web browser can provide helpful diagnostic information in the web developer console. The reason given by your browser is:' + err, true)
refreshServerConnectionLabel()
}
function websocketOnClose () {
window.websocketAvailable = false
refreshServerConnectionLabel()
}

View File

@ -1,782 +0,0 @@
html, body {
display: flex;
flex-direction: column;
height: 100%;
}
body {
background-color: #dee3e7;
color: black;
font-family: sans-serif;
margin: 0;
padding: 0;
text-align: center;
display: flex;
flex-direction: column;
}
dialog {
box-shadow: 0 0 6px 0 #444;
max-width: 600px;
min-width: 60%;
text-align: left;
padding: 0;
}
dialog[open] {
display: flex;
flex-direction: column;
}
dialog.big {
max-width: 100vw;
width: 100vw;
height: 100vh;
max-height: 100dvh;
border: none;
margin: 0;
}
fieldset {
font-family: sans-serif;
display: grid;
grid-template-columns: repeat(auto-fit, 180px);
grid-auto-rows: 1fr;
grid-gap: 1em;
padding: 0;
text-align: center;
justify-content: center;
border: 0;
}
footer {
font-size: smaller;
}
footer span {
display: inline-block;
}
a {
color: black;
}
nav ul li a {
display: block;
padding: 0.5em 1em;
user-select: none;
text-decoration: none;
font-size: small;
}
a:focus {
background-color: black;
color: white;
outline: 1px solid black;
}
nav ul li a:hover {
background-color: #efefef;
color: black;
cursor: pointer;
border: 0;
outline: 0;
}
nav ul li a.selected {
background-color: #c4cdd4;
color: black;
outline: 0;
}
#sidebar-toggler-button {
height: 2em;
width: 2em;
text-align: center;
display: inline-grid;
place-items: center;
margin-left: 1em;
padding: 0;
}
.userinfo {
padding-right: 1em;
font-size: small;
}
.userinfo svg, .userinfo span {
vertical-align: middle;
}
nav {
left: -250px;
}
nav.topbar {
display: flex;
flex-direction: row;
place-content: end;
border-radius: .5em;
padding-right: 1em;
padding-left: 1em;
flex-grow: 1;
}
nav.sidebar {
background-color: white;
position: fixed;
width: 180px;
height: 100dvh;
top: 0;
transition: left 0.5s ease, visibility 0.5s ease;
box-shadow: 0 0 10px 0 #444;
z-index: -1;
flex-direction: column;
display: flex;
visibility: hidden;
}
nav.sidebar.shown {
visibility: visible;
left: 0;
}
.sidebar #navigation-links {
padding-top: 4em;
flex-grow: 1;
}
#supplemental-links {
flex-grow: 0;
}
h1 {
display: inline;
font-size: small;
padding-left: .5em;
flex-grow: 1;
margin: 0;
}
dialog h1 {
padding-left: 0;
}
nav ul {
margin: 0;
padding: 0;
}
nav.topbar ul {
display: inline-block;
}
nav.topbar ul li {
display: inline-block;
font-size: small;
}
nav.topbar ul li a {
border-radius: .5em;
}
nav.sidebar ul li {
list-style: none;
text-align: left;
border-bottom: 1px solid #ccc;
}
table {
background-color: white;
border-collapse: collapse;
width: 100%;
}
th,
td {
border-top: 1px solid #efefef;
text-align: left;
padding: 0.6em;
}
th:first-child {
width: 5%;
}
tr:hover td {
background-color: beige;
}
legend {
font-family: sans-serif;
padding-top: 1em;
text-align: center;
width: 100%;
padding-bottom: 1em;
font-weight: bold;
}
span.icon {
display: block;
font-size: 3em;
}
span.icon img {
width: 1em;
}
.action-header {
display: flex;
align-items: center;
}
.action-header span.icon,
tr.log-row span.icon {
display: inline-block;
padding-right: 0.2em;
vertical-align: middle;
}
.error {
background-color: salmon;
color: black;
}
.title.temporary-status-message {
color: gray;
}
h2 {
display: inline-block;
font-size: 1em;
margin-top: 0;
flex-grow: 1;
}
div.entity h2 {
grid-column: 1 / span all;
}
details {
display: inline-block;
flex-grow: 1;
}
/* General Buttons */
button {
transition: none;
font-weight: bold;
border-radius: .7em;
box-shadow: none;
font-size: 1em;
padding: 0.6em 1em;
border: 1px solid #ccc;
font-family: sans-serif;
color: black;
text-align: center;
background-color: white;
user-select: none;
}
action-button {
display: flex;
flex-direction: column;
}
action-button button {
font-size: .85em;
font-weight: normal;
flex-grow: 1;
width: 100%;
z-index: 1;
padding: 1em;
box-shadow: 0 0 6px 0 #aaa;
transition: background-color 1s ease, color .3s ease;
}
execution-button button {
margin-top: 0.2em;
margin-bottom: 0.2em;
}
button:focus {
outline: 1px solid black;
}
button:hover {
background-color: #ececec;
color: black;
cursor: pointer;
}
button:disabled {
color: #3c3c3c;
background-color: #cecece;
cursor: not-allowed;
text-decoration: line-through;
}
button[name="cancel"]:hover {
background-color: salmon;
color: black;
}
button[name="start"]:hover {
background-color: #aceaac;
color: black;
}
action-button button:hover {
box-shadow: 0 0 10px 0 #666;
}
span.title {
display: inline;
}
action-button details {
flex-grow: 1;
}
action-button details[open] {
margin-top: 0;
}
action-button details summary div {
display: inline-flex;
}
action-button details summary div span:first-child {
flex-grow: 1;
}
.action-button-footer {
text-align: left;
font-size: smaller;
overflow: auto;
}
execution-button {
display: inline-block;
margin-right: .2em;
margin-left: .2em;
margin-top: .2em;
}
.action-status {
padding: .4em;
border-radius: .4em;
}
.action-failed {
background-color: #e78284;
}
.action-success {
background-color: #a6d189;
color: black;
}
.action-nonzero-exit {
background-color: #ef9f76;
color: black;
}
.action-timeout {
background-color: #99d1db;
color: black;
}
.action-blocked {
background-color: #ca9ee6;
color: black;
}
img.logo {
width: 1em;
height: 1em;
vertical-align: middle;
}
main {
padding: 1em;
padding-top: 3.5em;
flex-grow: 1;
}
summary {
cursor: pointer;
}
form div.wrapper {
background-color: white;
text-align: left;
}
label {
text-align: right;
display: inline-block;
}
header {
text-align: left;
display: flex;
flex-direction: row;
z-index: 3;
align-items: center;
padding-bottom: .2em;
padding-top: 1em;
position: fixed;
background-color: #dee3e7;
width: 100vw;
}
input {
font-family: sans-serif;
padding: 0.6em;
border: 1px solid #ccc;
border-radius: .4em;
}
input:invalid {
outline: 2px solid red;
}
input[type="checkbox"] {
justify-self: baseline;
}
form .wrapper span.icon {
display: inline-block;
vertical-align: middle;
}
div.arguments {
display: grid;
grid-template-columns: max-content auto auto; /* We don't want the label or the description to wrap, and the input to take up the rest of the space */
grid-template-rows: auto;
grid-gap: 1em;
align-items: center;
}
p.argument-wrapper {
display: flex;
}
div.buttons {
display: flex;
justify-content: end;
gap: 1em;
}
input.invalid {
background-color: salmon;
}
#available-version {
background-color: #aceaac;
padding: 0.2em;
border-radius: 1em;
}
span.tag {
border-radius: 0.4em;
margin-top: .2em;
margin-right: .2em;
display: inline-block;
background-color: lightgray;
padding: .4em;
color: black;
}
span.annotation {
display: inline-block;
margin-top: .2em;
margin-right: .2em;
}
span.annotation-key {
padding: 0.4em;
border-radius: 0.4em 0 0 .4em;
display: inline-block;
background-color: lightgray;
color: #666;
}
span.annotation-value {
padding: 0.4em;
border-radius: 0 .4em .4em 0;
display: inline-block;
background-color: lightgray;
color: black;
}
.box-shadow {
box-shadow: 0 0 5px 0 #444;
background-color: #fff;
text-align: left;
}
.border-radius {
border-radius: .7em;
}
.box-shadow p {
padding: .6em;
margin: 0;
}
div.toolbar {
padding: .4em;
text-align: left;
background-color: #fff;
display: flex;
flex-direction: row;
}
div.display {
border: 1px solid #666;
box-shadow: 0 0 6px 0 #aaa;
border-radius: .7em;
display: flex;
align-items: center;
place-content: center center;
flex-direction: column;
font-size: small;
}
#execution-dialog-xterm {
flex-grow: 1;
}
.padded-content {
padding: 1em;
}
.padded-content-sides {
padding-left: 1em;
padding-right: 1em;
}
.ta-left {
text-align: left;
}
.xterm {
padding: 1em;
}
.flex-col {
display: flex;
flex-direction: column;
}
label.input-with-icons {
border: 1px solid #ccc;
display: flex;
flex-direction: row;
align-items: center;
}
label.input-with-icons input {
border: 0;
padding: .6em;
flex-grow: 1;
}
label.input-with-icons svg {
font-size: 1.5em;
}
label.input-with-icons button {
border: 0;
background-color: transparent;
padding-top: 0;
padding-bottom: 0;
box-shadow: none;
transition: color .3s ease;
}
label.input-with-icons button:disabled {
display: none;
}
#content-login hr {
border: 0;
border-top: 1px dashed #ccc;
}
#content-login button {
width: 100%;
grid-column: 1 / span 3;
margin-bottom: 1em;
}
#content-login button:last-child {
margin-bottom: 0;
}
#content-login ul {
list-style: none;
padding: 0;
}
#content-login form {
width: auto;
place-self: center;
}
.oauth2-icon {
font-size: 1.8em;
vertical-align: middle;
}
@media screen and (width <= 600px) {
fieldset {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
label {
text-align: left;
margin-bottom: .6em;
font-weight: bold;
}
p.argument-wrapper {
flex-direction: column;
}
div.arguments {
grid-template-columns: auto;
}
dialog {
border-left: 0;
border-right: 0;
margin-left: 0;
margin-right: 0;
width: 100vw;
max-width: 100vw;
}
.xterm {
margin-left: 0;
margin-right: 0;
width: fit-content;
}
}
@media (prefers-color-scheme: dark) {
body, header {
background-color: #333;
color: white;
}
.box-shadow {
box-shadow: 0 0 6px 0 #625c5c;
background-color: #222;
}
dialog {
background-color: #222;
color: white;
}
form div.wrapper {
background-color: #222;
}
label.input-with-icons {
border: 1px solid #666;
background-color: #383838;
}
input {
background-color: #383838;
color: white;
border: 1px solid #666;
}
button {
border: 1px solid #666;
background-color: #222;
color: white;
}
action-button button {
box-shadow: 0 0 6px 0 #444;
}
button:focus {
outline: 2px solid #72B7F4;
}
button:disabled {
background-color: black;
}
button:hover {
background-color: #444;
color: white;
}
footer {
color: #bbb;
}
a, a:visited {
color: #bbb;
}
a:focus {
background-color: #72B7F4;
color: black;
outline: 1px solid #72B7F4;
}
nav.sidebar {
background-color: #111;
color: white;
}
nav.sidebar ul li {
border-bottom: 1px solid #3e3e3e;
}
nav ul li a:hover {
background-color: #1b5988;
color: white;
}
nav ul li a.selected {
background-color: #0c3351;
color: white;
}
table,
td,
th {
border-top: 1px solid gray;
}
td,
tr {
background-color: #222;
color: white;
}
tr:hover td {
background-color: #1b5988;
}
div.toolbar {
background-color: black;
}
div.display {
box-shadow: 0 0 6px 0 #444;
}
}