feat: ActionDetailsView component, show timeout and logs for action. Fix output streaming.
This commit is contained in:
parent
f337e05eaf
commit
57390be16f
|
|
@ -15,7 +15,8 @@ import { Mutex } from './Mutex.js'
|
|||
* occour in sequential order.
|
||||
*/
|
||||
export class OutputTerminal {
|
||||
constructor () {
|
||||
constructor (executionTrackingId) {
|
||||
this.executionTrackingId = executionTrackingId
|
||||
this.writeMutex = new Mutex()
|
||||
this.terminal = new Terminal({
|
||||
convertEol: true
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
|
|||
* @generated from field: int32 order = 7;
|
||||
*/
|
||||
order: number;
|
||||
|
||||
/**
|
||||
* @generated from field: int32 timeout = 8;
|
||||
*/
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -581,6 +586,63 @@ export declare type GetLogsResponse = Message<"olivetin.api.v1.GetLogsResponse">
|
|||
*/
|
||||
export declare const GetLogsResponseSchema: GenMessage<GetLogsResponse>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.GetActionLogsRequest
|
||||
*/
|
||||
export declare type GetActionLogsRequest = Message<"olivetin.api.v1.GetActionLogsRequest"> & {
|
||||
/**
|
||||
* @generated from field: string action_id = 1;
|
||||
*/
|
||||
actionId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 start_offset = 2;
|
||||
*/
|
||||
startOffset: bigint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.GetActionLogsRequest.
|
||||
* Use `create(GetActionLogsRequestSchema)` to create a new message.
|
||||
*/
|
||||
export declare const GetActionLogsRequestSchema: GenMessage<GetActionLogsRequest>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.GetActionLogsResponse
|
||||
*/
|
||||
export declare type GetActionLogsResponse = Message<"olivetin.api.v1.GetActionLogsResponse"> & {
|
||||
/**
|
||||
* @generated from field: repeated olivetin.api.v1.LogEntry logs = 1;
|
||||
*/
|
||||
logs: LogEntry[];
|
||||
|
||||
/**
|
||||
* @generated from field: int64 count_remaining = 2;
|
||||
*/
|
||||
countRemaining: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 page_size = 3;
|
||||
*/
|
||||
pageSize: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 total_count = 4;
|
||||
*/
|
||||
totalCount: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: int64 start_offset = 5;
|
||||
*/
|
||||
startOffset: bigint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.GetActionLogsResponse.
|
||||
* Use `create(GetActionLogsResponseSchema)` to create a new message.
|
||||
*/
|
||||
export declare const GetActionLogsResponseSchema: GenMessage<GetActionLogsResponse>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.ValidateArgumentTypeRequest
|
||||
*/
|
||||
|
|
@ -1570,6 +1632,14 @@ export declare const OliveTinApiService: GenService<{
|
|||
input: typeof GetLogsRequestSchema;
|
||||
output: typeof GetLogsResponseSchema;
|
||||
},
|
||||
/**
|
||||
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetActionLogs
|
||||
*/
|
||||
getActionLogs: {
|
||||
methodKind: "unary";
|
||||
input: typeof GetActionLogsRequestSchema;
|
||||
output: typeof GetActionLogsResponseSchema;
|
||||
},
|
||||
/**
|
||||
* @generated from rpc olivetin.api.v1.OliveTinApiService.ValidateArgumentType
|
||||
*/
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -70,6 +70,19 @@ const routes = [
|
|||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/action/:actionId',
|
||||
name: 'ActionDetails',
|
||||
component: () => import('./views/ActionDetailsView.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
title: 'Action Details',
|
||||
breadcrumb: [
|
||||
{ name: "Actions", href: "/" },
|
||||
{ name: "Action Details" },
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/diagnostics',
|
||||
name: 'Diagnostics',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
<template>
|
||||
<Section :title="'Action Details: ' + actionTitle" :padding="false">
|
||||
<template #toolbar>
|
||||
<button v-if="action" @click="startAction" title="Start this action" class="button neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 6v12l8-6z" />
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class = "flex-row padding" v-if="action">
|
||||
<div class = "fg1">
|
||||
<dl>
|
||||
<dt>Title</dt>
|
||||
<dd>{{ action.title }}</dd>
|
||||
<dt>Timeout</dt>
|
||||
<dd>{{ action.timeout }} seconds</dd>
|
||||
</dl>
|
||||
<p v-if="action" class = "fg1">
|
||||
Execution history for this action. You can filter by execution tracking ID.
|
||||
</p>
|
||||
</div>
|
||||
<div style = "align-self: start; text-align: right;">
|
||||
<span class="icon" v-html="action.icon"></span>
|
||||
|
||||
<div class="filter-container">
|
||||
<label class="input-with-icons">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
|
||||
</svg>
|
||||
<input placeholder="Filter current page" v-model="searchText" />
|
||||
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor"
|
||||
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="filteredLogs.length > 0">
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Execution ID</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>
|
||||
<router-link :to="`/logs/${log.executionTrackingId}`">
|
||||
{{ log.executionTrackingId }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="tags">
|
||||
<span class="annotation">
|
||||
<span class="annotation-key">User:</span>
|
||||
<span class="annotation-val">{{ log.user }}</span>
|
||||
</span>
|
||||
<span v-if="log.tags && log.tags.length > 0" class="tag-list">
|
||||
<span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="exit-code">
|
||||
<span :class="getStatusClass(log) + ' annotation'">
|
||||
{{ getStatusText(log) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" @page-change="handlePageChange" class="padding"
|
||||
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
|
||||
</div>
|
||||
|
||||
<div v-show="logs.length === 0 && !loading" class="empty-state">
|
||||
<p>This action has no execution history.</p>
|
||||
<router-link to="/">Return to index</router-link>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Pagination from '../components/Pagination.vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const logs = ref([])
|
||||
const action = ref(null)
|
||||
const actionTitle = ref('Action Details')
|
||||
const searchText = ref('')
|
||||
const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
const loading = ref(false)
|
||||
const totalCount = ref(0)
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return logs.value
|
||||
}
|
||||
const searchLower = searchText.value.toLowerCase()
|
||||
return logs.value.filter(log =>
|
||||
log.executionTrackingId.toLowerCase().includes(searchLower) ||
|
||||
log.actionTitle.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
|
||||
async function fetchActionLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const actionId = route.params.actionId
|
||||
const startOffset = (currentPage.value - 1) * pageSize.value
|
||||
|
||||
const args = {
|
||||
"actionId": actionId,
|
||||
"startOffset": BigInt(startOffset),
|
||||
}
|
||||
|
||||
const response = await window.client.getActionLogs(args)
|
||||
|
||||
logs.value = response.logs
|
||||
pageSize.value = Number(response.pageSize) || 0
|
||||
totalCount.value = Number(response.totalCount) || 0
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch action logs:', err)
|
||||
window.showBigError('fetch-action-logs', 'getting action logs', err, false)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAction() {
|
||||
try {
|
||||
const actionId = route.params.actionId
|
||||
const args = {
|
||||
"bindingId": actionId
|
||||
}
|
||||
const response = await window.client.getActionBinding(args)
|
||||
action.value = response.action
|
||||
actionTitle.value = action.value?.title || 'Unknown Action'
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch action:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchText.value = ''
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return 'Unknown'
|
||||
try {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString()
|
||||
} catch (err) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(log) {
|
||||
if (log.timedOut) return 'status-timeout'
|
||||
if (log.blocked) return 'status-blocked'
|
||||
if (log.exitCode !== 0) return 'status-error'
|
||||
return 'status-success'
|
||||
}
|
||||
|
||||
function getStatusText(log) {
|
||||
if (log.timedOut) return 'Timed out'
|
||||
if (log.blocked) return 'Blocked'
|
||||
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
|
||||
return 'Completed'
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
currentPage.value = page
|
||||
fetchActionLogs()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(newPageSize) {
|
||||
pageSize.value = newPageSize
|
||||
currentPage.value = 1
|
||||
fetchActionLogs()
|
||||
}
|
||||
|
||||
async function startAction() {
|
||||
if (!action.value || !action.value.bindingId) {
|
||||
console.error('Cannot start action: no binding ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const args = {
|
||||
"bindingId": action.value.bindingId,
|
||||
"arguments": []
|
||||
}
|
||||
|
||||
const response = await window.client.startAction(args)
|
||||
router.push(`/logs/${response.executionTrackingId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to start action:', err)
|
||||
window.showBigError('start-action', 'starting action', err, false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAction()
|
||||
fetchActionLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
background-color: var(--section-background);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logs-table td {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.log-row:hover {
|
||||
background-color: var(--hover-background);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.input-with-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--section-background);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.input-with-icons input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-with-icons button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-with-icons button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.annotation {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.annotation-key {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.annotation-val {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: var(--accent-color);
|
||||
color: var(--accent-text);
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.exit-code .status-success {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.exit-code .status-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.exit-code .status-timeout {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.exit-code .status-blocked {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
<template>
|
||||
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
|
||||
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
|
||||
<template #toolbar>
|
||||
<button @click="toggleSize" title="Toggle dialog size">
|
||||
<router-link v-if="actionId" :to="`/action/${actionId}`" title="View all executions for this action" class="button neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
|
||||
</svg>
|
||||
Action Details
|
||||
</router-link>
|
||||
<button @click="toggleSize" title="Toggle dialog size" class = "neutral">
|
||||
<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" />
|
||||
|
|
@ -22,6 +28,13 @@
|
|||
<span class="icon" role="img" v-html="icon" style = "align-self: start"></span>
|
||||
</div>
|
||||
|
||||
<div v-if="notFound" class="error-message padded-content">
|
||||
<h3>Execution Not Found</h3>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<p>The execution with ID <code>{{ executionTrackingId }}</code> could not be found.</p>
|
||||
<router-link to="/logs">View all logs</router-link> or <router-link to="/">return to home</router-link>.
|
||||
</div>
|
||||
|
||||
<div ref="xtermOutput"></div>
|
||||
|
||||
<br />
|
||||
|
|
@ -81,12 +94,15 @@ const duration = ref('')
|
|||
const logEntry = ref(null)
|
||||
const canRerun = ref(false)
|
||||
const canKill = ref(false)
|
||||
const actionId = ref('')
|
||||
const notFound = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
let executionTicker = null
|
||||
let terminal = null
|
||||
|
||||
function initializeTerminal() {
|
||||
terminal = new OutputTerminal(executionTrackingId.value, this)
|
||||
terminal = new OutputTerminal(executionTrackingId.value)
|
||||
terminal.open(xtermOutput.value)
|
||||
terminal.resize(80, 24)
|
||||
|
||||
|
|
@ -94,7 +110,19 @@ function initializeTerminal() {
|
|||
}
|
||||
|
||||
function toggleSize() {
|
||||
terminal.fit()
|
||||
if (!xtermOutput.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (xtermOutput.value.requestFullscreen) {
|
||||
xtermOutput.value.requestFullscreen()
|
||||
} else if (xtermOutput.value.webkitRequestFullscreen) {
|
||||
xtermOutput.value.webkitRequestFullscreen()
|
||||
} else if (xtermOutput.value.mozRequestFullScreen) {
|
||||
xtermOutput.value.mozRequestFullScreen()
|
||||
} else if (xtermOutput.value.msRequestFullscreen) {
|
||||
xtermOutput.value.msRequestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
|
|
@ -112,6 +140,8 @@ async function reset() {
|
|||
canRerun.value = false
|
||||
canKill.value = false
|
||||
logEntry.value = null
|
||||
notFound.value = false
|
||||
errorMessage.value = ''
|
||||
|
||||
if (terminal) {
|
||||
await terminal.reset()
|
||||
|
|
@ -139,10 +169,23 @@ function show(actionButton) {
|
|||
}
|
||||
|
||||
async function rerunAction() {
|
||||
let startActionArgs = {}
|
||||
const res = await window.client.startAction(startActionArgs)
|
||||
if (!logEntry.value || !logEntry.value.actionId) {
|
||||
console.error('Cannot rerun: no action ID available')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const startActionArgs = {
|
||||
"bindingId": logEntry.value.actionId,
|
||||
"arguments": []
|
||||
}
|
||||
|
||||
const res = await window.client.startAction(startActionArgs)
|
||||
router.push(`/logs/${res.executionTrackingId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to rerun action:', err)
|
||||
window.showBigError('rerun-action', 'rerunning action', err, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function killAction() {
|
||||
|
|
@ -177,6 +220,8 @@ async function fetchExecutionResult(executionTrackingIdParam) {
|
|||
console.log("fetchExecutionResult", executionTrackingIdParam)
|
||||
|
||||
executionTrackingId.value = executionTrackingIdParam
|
||||
notFound.value = false
|
||||
errorMessage.value = ''
|
||||
|
||||
const executionStatusArgs = {
|
||||
executionTrackingId: executionTrackingId.value
|
||||
|
|
@ -187,7 +232,13 @@ async function fetchExecutionResult(executionTrackingIdParam) {
|
|||
|
||||
await renderExecutionResult(logEntryResult)
|
||||
} catch (err) {
|
||||
renderError(err)
|
||||
// Check if it's a "not found" error (404 or similar)
|
||||
if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
|
||||
notFound.value = true
|
||||
errorMessage.value = err.message || 'The execution could not be found in the system.'
|
||||
} else {
|
||||
renderError(err)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
|
@ -236,6 +287,7 @@ async function renderExecutionResult(res) {
|
|||
icon.value = res.logEntry.actionIcon
|
||||
title.value = res.logEntry.actionTitle
|
||||
titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
|
||||
actionId.value = res.logEntry.actionId
|
||||
|
||||
updateDuration(res.logEntry)
|
||||
|
||||
|
|
@ -308,3 +360,43 @@ defineExpose({
|
|||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-history-link {
|
||||
color: var(--link-color, #007bff);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c2c7;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error-message h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 0.5rem 0;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.error-message code {
|
||||
background-color: #f8d7da;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.125rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.error-message a {
|
||||
color: #721c24;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ message Action {
|
|||
repeated ActionArgument arguments = 5;
|
||||
string popup_on_start = 6;
|
||||
int32 order = 7;
|
||||
int32 timeout = 8;
|
||||
}
|
||||
|
||||
message ActionArgument {
|
||||
|
|
@ -141,6 +142,19 @@ message GetLogsResponse {
|
|||
int64 start_offset = 5;
|
||||
}
|
||||
|
||||
message GetActionLogsRequest {
|
||||
string action_id = 1;
|
||||
int64 start_offset = 2;
|
||||
}
|
||||
|
||||
message GetActionLogsResponse {
|
||||
repeated LogEntry logs = 1;
|
||||
int64 count_remaining = 2;
|
||||
int64 page_size = 3;
|
||||
int64 total_count = 4;
|
||||
int64 start_offset = 5;
|
||||
}
|
||||
|
||||
message ValidateArgumentTypeRequest {
|
||||
string value = 1;
|
||||
string type = 2;
|
||||
|
|
@ -367,6 +381,8 @@ service OliveTinApiService {
|
|||
|
||||
rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {}
|
||||
|
||||
rpc GetActionLogs(GetActionLogsRequest) returns (GetActionLogsResponse) {}
|
||||
|
||||
rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {}
|
||||
|
||||
rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse) {}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ const (
|
|||
// OliveTinApiServiceGetLogsProcedure is the fully-qualified name of the OliveTinApiService's
|
||||
// GetLogs RPC.
|
||||
OliveTinApiServiceGetLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetLogs"
|
||||
// OliveTinApiServiceGetActionLogsProcedure is the fully-qualified name of the OliveTinApiService's
|
||||
// GetActionLogs RPC.
|
||||
OliveTinApiServiceGetActionLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetActionLogs"
|
||||
// OliveTinApiServiceValidateArgumentTypeProcedure is the fully-qualified name of the
|
||||
// OliveTinApiService's ValidateArgumentType RPC.
|
||||
OliveTinApiServiceValidateArgumentTypeProcedure = "/olivetin.api.v1.OliveTinApiService/ValidateArgumentType"
|
||||
|
|
@ -117,6 +120,7 @@ type OliveTinApiServiceClient interface {
|
|||
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)
|
||||
GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], 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)
|
||||
|
|
@ -199,6 +203,12 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string,
|
|||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
getActionLogs: connect.NewClient[v1.GetActionLogsRequest, v1.GetActionLogsResponse](
|
||||
httpClient,
|
||||
baseURL+OliveTinApiServiceGetActionLogsProcedure,
|
||||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")),
|
||||
connect.WithClientOptions(opts...),
|
||||
),
|
||||
validateArgumentType: connect.NewClient[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse](
|
||||
httpClient,
|
||||
baseURL+OliveTinApiServiceValidateArgumentTypeProcedure,
|
||||
|
|
@ -303,6 +313,7 @@ type oliveTinApiServiceClient struct {
|
|||
killAction *connect.Client[v1.KillActionRequest, v1.KillActionResponse]
|
||||
executionStatus *connect.Client[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse]
|
||||
getLogs *connect.Client[v1.GetLogsRequest, v1.GetLogsResponse]
|
||||
getActionLogs *connect.Client[v1.GetActionLogsRequest, v1.GetActionLogsResponse]
|
||||
validateArgumentType *connect.Client[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse]
|
||||
whoAmI *connect.Client[v1.WhoAmIRequest, v1.WhoAmIResponse]
|
||||
sosReport *connect.Client[v1.SosReportRequest, v1.SosReportResponse]
|
||||
|
|
@ -365,6 +376,11 @@ func (c *oliveTinApiServiceClient) GetLogs(ctx context.Context, req *connect.Req
|
|||
return c.getLogs.CallUnary(ctx, req)
|
||||
}
|
||||
|
||||
// GetActionLogs calls olivetin.api.v1.OliveTinApiService.GetActionLogs.
|
||||
func (c *oliveTinApiServiceClient) GetActionLogs(ctx context.Context, req *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) {
|
||||
return c.getActionLogs.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)
|
||||
|
|
@ -451,6 +467,7 @@ type OliveTinApiServiceHandler interface {
|
|||
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)
|
||||
GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], 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)
|
||||
|
|
@ -529,6 +546,12 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
|
|||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
oliveTinApiServiceGetActionLogsHandler := connect.NewUnaryHandler(
|
||||
OliveTinApiServiceGetActionLogsProcedure,
|
||||
svc.GetActionLogs,
|
||||
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")),
|
||||
connect.WithHandlerOptions(opts...),
|
||||
)
|
||||
oliveTinApiServiceValidateArgumentTypeHandler := connect.NewUnaryHandler(
|
||||
OliveTinApiServiceValidateArgumentTypeProcedure,
|
||||
svc.ValidateArgumentType,
|
||||
|
|
@ -639,6 +662,8 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
|
|||
oliveTinApiServiceExecutionStatusHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceGetLogsProcedure:
|
||||
oliveTinApiServiceGetLogsHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceGetActionLogsProcedure:
|
||||
oliveTinApiServiceGetActionLogsHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceValidateArgumentTypeProcedure:
|
||||
oliveTinApiServiceValidateArgumentTypeHandler.ServeHTTP(w, r)
|
||||
case OliveTinApiServiceWhoAmIProcedure:
|
||||
|
|
@ -714,6 +739,10 @@ func (UnimplementedOliveTinApiServiceHandler) GetLogs(context.Context, *connect.
|
|||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetLogs is not implemented"))
|
||||
}
|
||||
|
||||
func (UnimplementedOliveTinApiServiceHandler) GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) {
|
||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetActionLogs 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"))
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -365,6 +365,10 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a
|
|||
|
||||
binding := api.executor.FindBindingByID(req.Msg.BindingId)
|
||||
|
||||
if binding == nil {
|
||||
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&apiv1.GetActionBindingResponse{
|
||||
Action: buildAction(binding, &DashboardRenderRequest{
|
||||
cfg: api.cfg,
|
||||
|
|
@ -436,9 +440,14 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
|
|||
logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
|
||||
|
||||
for _, logEntry := range logEntries {
|
||||
// Skip if binding is nil or action is nil
|
||||
if logEntry.Binding == nil || logEntry.Binding.Action == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
action := logEntry.Binding.Action
|
||||
|
||||
if action == nil || acl.IsAllowedLogs(api.cfg, user, action) {
|
||||
if acl.IsAllowedLogs(api.cfg, user, action) {
|
||||
pbLogEntry := api.internalLogEntryToPb(logEntry, user)
|
||||
|
||||
ret.Logs = append(ret.Logs, pbLogEntry)
|
||||
|
|
@ -453,6 +462,61 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
|
|||
return connect.NewResponse(ret), nil
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := &apiv1.GetActionLogsResponse{}
|
||||
|
||||
logs := api.executor.GetLogsByActionId(req.Msg.ActionId)
|
||||
|
||||
// Apply ACL filtering
|
||||
filteredLogs := make([]*executor.InternalLogEntry, 0)
|
||||
for _, logEntry := range logs {
|
||||
// Skip if binding is nil or action is nil
|
||||
if logEntry.Binding == nil || logEntry.Binding.Action == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
action := logEntry.Binding.Action
|
||||
if acl.IsAllowedLogs(api.cfg, user, action) {
|
||||
filteredLogs = append(filteredLogs, logEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
totalCount := int64(len(filteredLogs))
|
||||
pageSize := api.cfg.LogHistoryPageSize
|
||||
startOffset := req.Msg.StartOffset
|
||||
|
||||
startIdx := startOffset
|
||||
endIdx := startOffset + pageSize
|
||||
if endIdx > totalCount {
|
||||
endIdx = totalCount
|
||||
}
|
||||
|
||||
logEntries := filteredLogs[startIdx:endIdx]
|
||||
countRemaining := totalCount - endIdx
|
||||
if countRemaining < 0 {
|
||||
countRemaining = 0
|
||||
}
|
||||
|
||||
for _, logEntry := range logEntries {
|
||||
pbLogEntry := api.internalLogEntryToPb(logEntry, user)
|
||||
ret.Logs = append(ret.Logs, pbLogEntry)
|
||||
}
|
||||
|
||||
ret.CountRemaining = countRemaining
|
||||
ret.PageSize = pageSize
|
||||
ret.TotalCount = totalCount
|
||||
ret.StartOffset = startOffset
|
||||
|
||||
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
|
||||
|
|
@ -579,6 +643,9 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
|
|||
log.Debugf("Sending event to client: %v", msg)
|
||||
if err := srv.Send(msg); err != nil {
|
||||
log.Errorf("Error sending event to client: %v", err)
|
||||
// Remove disconnected client from the list
|
||||
api.removeClient(client)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +654,21 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
|
|||
return nil
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) removeClient(clientToRemove *connectedClients) {
|
||||
api.connectedClients = func() []*connectedClients {
|
||||
var filtered []*connectedClients
|
||||
for _, client := range api.connectedClients {
|
||||
if client != clientToRemove {
|
||||
filtered = append(filtered, client)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}()
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnActionMapRebuilt() {
|
||||
toRemove := []*connectedClients{}
|
||||
|
||||
for _, client := range api.connectedClients {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
|
|
@ -596,12 +677,19 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
|
|||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
|
||||
toRemove := []*connectedClients{}
|
||||
|
||||
for _, client := range api.connectedClients {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
|
|
@ -612,12 +700,19 @@ func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
|
|||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
||||
toRemove := []*connectedClients{}
|
||||
|
||||
for _, client := range api.connectedClients {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
|
|
@ -628,9 +723,14 @@ func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
|||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
|
||||
|
|
@ -734,6 +834,8 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin
|
|||
}
|
||||
|
||||
func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
|
||||
toRemove := []*connectedClients{}
|
||||
|
||||
for _, client := range api.connectedClients {
|
||||
select {
|
||||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
|
|
@ -745,9 +847,14 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
|
|||
},
|
||||
}:
|
||||
default:
|
||||
log.Warnf("EventStream: client channel is full, dropping message")
|
||||
log.Warnf("EventStream: client channel is full, removing client")
|
||||
toRemove = append(toRemove, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range toRemove {
|
||||
api.removeClient(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
|
|||
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
|
||||
PopupOnStart: action.PopupOnStart,
|
||||
Order: int32(actionBinding.ConfigOrder),
|
||||
Timeout: int32(action.Timeout),
|
||||
}
|
||||
|
||||
for _, cfgArg := range action.Arguments {
|
||||
|
|
|
|||
|
|
@ -482,6 +482,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
req.logEntry.Binding = req.Binding
|
||||
req.logEntry.ActionConfigTitle = req.Binding.Action.Title
|
||||
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
|
||||
req.logEntry.ActionIcon = req.Binding.Action.Icon
|
||||
|
|
|
|||
Loading…
Reference in New Issue