feat: ActionDetailsView component, show timeout and logs for action. Fix output streaming.

This commit is contained in:
jamesread 2025-10-29 22:20:41 +00:00
parent f337e05eaf
commit 57390be16f
12 changed files with 1182 additions and 321 deletions

View File

@ -15,7 +15,8 @@ import { Mutex } from './Mutex.js'
* occour in sequential order. * occour in sequential order.
*/ */
export class OutputTerminal { export class OutputTerminal {
constructor () { constructor (executionTrackingId) {
this.executionTrackingId = executionTrackingId
this.writeMutex = new Mutex() this.writeMutex = new Mutex()
this.terminal = new Terminal({ this.terminal = new Terminal({
convertEol: true convertEol: true

View File

@ -48,6 +48,11 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
* @generated from field: int32 order = 7; * @generated from field: int32 order = 7;
*/ */
order: number; 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>; 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 * @generated from message olivetin.api.v1.ValidateArgumentTypeRequest
*/ */
@ -1570,6 +1632,14 @@ export declare const OliveTinApiService: GenService<{
input: typeof GetLogsRequestSchema; input: typeof GetLogsRequestSchema;
output: typeof GetLogsResponseSchema; 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 * @generated from rpc olivetin.api.v1.OliveTinApiService.ValidateArgumentType
*/ */

File diff suppressed because one or more lines are too long

View File

@ -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', path: '/diagnostics',
name: 'Diagnostics', name: 'Diagnostics',

View File

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

View File

@ -1,7 +1,13 @@
<template> <template>
<Section :title="'Execution Results: ' + title" id = "execution-results-popup"> <Section :title="'Execution Results: ' + title" id = "execution-results-popup">
<template #toolbar> <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"> <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" <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" /> 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> <span class="icon" role="img" v-html="icon" style = "align-self: start"></span>
</div> </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> <div ref="xtermOutput"></div>
<br /> <br />
@ -81,12 +94,15 @@ const duration = ref('')
const logEntry = ref(null) const logEntry = ref(null)
const canRerun = ref(false) const canRerun = ref(false)
const canKill = ref(false) const canKill = ref(false)
const actionId = ref('')
const notFound = ref(false)
const errorMessage = ref('')
let executionTicker = null let executionTicker = null
let terminal = null let terminal = null
function initializeTerminal() { function initializeTerminal() {
terminal = new OutputTerminal(executionTrackingId.value, this) terminal = new OutputTerminal(executionTrackingId.value)
terminal.open(xtermOutput.value) terminal.open(xtermOutput.value)
terminal.resize(80, 24) terminal.resize(80, 24)
@ -94,7 +110,19 @@ function initializeTerminal() {
} }
function toggleSize() { 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() { async function reset() {
@ -112,6 +140,8 @@ async function reset() {
canRerun.value = false canRerun.value = false
canKill.value = false canKill.value = false
logEntry.value = null logEntry.value = null
notFound.value = false
errorMessage.value = ''
if (terminal) { if (terminal) {
await terminal.reset() await terminal.reset()
@ -139,10 +169,23 @@ function show(actionButton) {
} }
async function rerunAction() { async function rerunAction() {
let startActionArgs = {} if (!logEntry.value || !logEntry.value.actionId) {
const res = await window.client.startAction(startActionArgs) 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}`) 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() { async function killAction() {
@ -177,6 +220,8 @@ async function fetchExecutionResult(executionTrackingIdParam) {
console.log("fetchExecutionResult", executionTrackingIdParam) console.log("fetchExecutionResult", executionTrackingIdParam)
executionTrackingId.value = executionTrackingIdParam executionTrackingId.value = executionTrackingIdParam
notFound.value = false
errorMessage.value = ''
const executionStatusArgs = { const executionStatusArgs = {
executionTrackingId: executionTrackingId.value executionTrackingId: executionTrackingId.value
@ -187,7 +232,13 @@ async function fetchExecutionResult(executionTrackingIdParam) {
await renderExecutionResult(logEntryResult) await renderExecutionResult(logEntryResult)
} catch (err) { } 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 throw err
} }
} }
@ -236,6 +287,7 @@ async function renderExecutionResult(res) {
icon.value = res.logEntry.actionIcon icon.value = res.logEntry.actionIcon
title.value = res.logEntry.actionTitle title.value = res.logEntry.actionTitle
titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
actionId.value = res.logEntry.actionId
updateDuration(res.logEntry) updateDuration(res.logEntry)
@ -308,3 +360,43 @@ defineExpose({
}) })
</script> </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>

View File

@ -12,6 +12,7 @@ message Action {
repeated ActionArgument arguments = 5; repeated ActionArgument arguments = 5;
string popup_on_start = 6; string popup_on_start = 6;
int32 order = 7; int32 order = 7;
int32 timeout = 8;
} }
message ActionArgument { message ActionArgument {
@ -141,6 +142,19 @@ message GetLogsResponse {
int64 start_offset = 5; 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 { message ValidateArgumentTypeRequest {
string value = 1; string value = 1;
string type = 2; string type = 2;
@ -366,6 +380,8 @@ service OliveTinApiService {
rpc ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) {} rpc ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) {}
rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {} rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {}
rpc GetActionLogs(GetActionLogsRequest) returns (GetActionLogsResponse) {}
rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {} rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {}

View File

@ -60,6 +60,9 @@ const (
// OliveTinApiServiceGetLogsProcedure is the fully-qualified name of the OliveTinApiService's // OliveTinApiServiceGetLogsProcedure is the fully-qualified name of the OliveTinApiService's
// GetLogs RPC. // GetLogs RPC.
OliveTinApiServiceGetLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetLogs" 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 // OliveTinApiServiceValidateArgumentTypeProcedure is the fully-qualified name of the
// OliveTinApiService's ValidateArgumentType RPC. // OliveTinApiService's ValidateArgumentType RPC.
OliveTinApiServiceValidateArgumentTypeProcedure = "/olivetin.api.v1.OliveTinApiService/ValidateArgumentType" 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) KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], 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) 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) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], 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) 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.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
connect.WithClientOptions(opts...), 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]( validateArgumentType: connect.NewClient[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse](
httpClient, httpClient,
baseURL+OliveTinApiServiceValidateArgumentTypeProcedure, baseURL+OliveTinApiServiceValidateArgumentTypeProcedure,
@ -303,6 +313,7 @@ type oliveTinApiServiceClient struct {
killAction *connect.Client[v1.KillActionRequest, v1.KillActionResponse] killAction *connect.Client[v1.KillActionRequest, v1.KillActionResponse]
executionStatus *connect.Client[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse] executionStatus *connect.Client[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse]
getLogs *connect.Client[v1.GetLogsRequest, v1.GetLogsResponse] getLogs *connect.Client[v1.GetLogsRequest, v1.GetLogsResponse]
getActionLogs *connect.Client[v1.GetActionLogsRequest, v1.GetActionLogsResponse]
validateArgumentType *connect.Client[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse] validateArgumentType *connect.Client[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse]
whoAmI *connect.Client[v1.WhoAmIRequest, v1.WhoAmIResponse] whoAmI *connect.Client[v1.WhoAmIRequest, v1.WhoAmIResponse]
sosReport *connect.Client[v1.SosReportRequest, v1.SosReportResponse] 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) 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. // ValidateArgumentType calls olivetin.api.v1.OliveTinApiService.ValidateArgumentType.
func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, req *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) { func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, req *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) {
return c.validateArgumentType.CallUnary(ctx, req) 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) KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], 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) 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) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], 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) 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.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
connect.WithHandlerOptions(opts...), connect.WithHandlerOptions(opts...),
) )
oliveTinApiServiceGetActionLogsHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetActionLogsProcedure,
svc.GetActionLogs,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceValidateArgumentTypeHandler := connect.NewUnaryHandler( oliveTinApiServiceValidateArgumentTypeHandler := connect.NewUnaryHandler(
OliveTinApiServiceValidateArgumentTypeProcedure, OliveTinApiServiceValidateArgumentTypeProcedure,
svc.ValidateArgumentType, svc.ValidateArgumentType,
@ -639,6 +662,8 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
oliveTinApiServiceExecutionStatusHandler.ServeHTTP(w, r) oliveTinApiServiceExecutionStatusHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetLogsProcedure: case OliveTinApiServiceGetLogsProcedure:
oliveTinApiServiceGetLogsHandler.ServeHTTP(w, r) oliveTinApiServiceGetLogsHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetActionLogsProcedure:
oliveTinApiServiceGetActionLogsHandler.ServeHTTP(w, r)
case OliveTinApiServiceValidateArgumentTypeProcedure: case OliveTinApiServiceValidateArgumentTypeProcedure:
oliveTinApiServiceValidateArgumentTypeHandler.ServeHTTP(w, r) oliveTinApiServiceValidateArgumentTypeHandler.ServeHTTP(w, r)
case OliveTinApiServiceWhoAmIProcedure: 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")) 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) { 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")) 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

View File

@ -365,6 +365,10 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a
binding := api.executor.FindBindingByID(req.Msg.BindingId) 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{ return connect.NewResponse(&apiv1.GetActionBindingResponse{
Action: buildAction(binding, &DashboardRenderRequest{ Action: buildAction(binding, &DashboardRenderRequest{
cfg: api.cfg, 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) logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
for _, logEntry := range logEntries { 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 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) pbLogEntry := api.internalLogEntryToPb(logEntry, user)
ret.Logs = append(ret.Logs, pbLogEntry) 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 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 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 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) log.Debugf("Sending event to client: %v", msg)
if err := srv.Send(msg); err != nil { if err := srv.Send(msg); err != nil {
log.Errorf("Error sending event to client: %v", err) 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 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() { func (api *oliveTinAPI) OnActionMapRebuilt() {
toRemove := []*connectedClients{}
for _, client := range api.connectedClients { for _, client := range api.connectedClients {
select { select {
case client.channel <- &apiv1.EventStreamResponse{ case client.channel <- &apiv1.EventStreamResponse{
@ -596,12 +677,19 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
}, },
}: }:
default: 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) { func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
toRemove := []*connectedClients{}
for _, client := range api.connectedClients { for _, client := range api.connectedClients {
select { select {
case client.channel <- &apiv1.EventStreamResponse{ case client.channel <- &apiv1.EventStreamResponse{
@ -612,12 +700,19 @@ func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
}, },
}: }:
default: 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) { func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
toRemove := []*connectedClients{}
for _, client := range api.connectedClients { for _, client := range api.connectedClients {
select { select {
case client.channel <- &apiv1.EventStreamResponse{ case client.channel <- &apiv1.EventStreamResponse{
@ -628,9 +723,14 @@ func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
}, },
}: }:
default: 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) { 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) { func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
toRemove := []*connectedClients{}
for _, client := range api.connectedClients { for _, client := range api.connectedClients {
select { select {
case client.channel <- &apiv1.EventStreamResponse{ case client.channel <- &apiv1.EventStreamResponse{
@ -745,9 +847,14 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
}, },
}: }:
default: 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) { func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {

View File

@ -46,6 +46,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action), CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
PopupOnStart: action.PopupOnStart, PopupOnStart: action.PopupOnStart,
Order: int32(actionBinding.ConfigOrder), Order: int32(actionBinding.ConfigOrder),
Timeout: int32(action.Timeout),
} }
for _, cfgArg := range action.Arguments { for _, cfgArg := range action.Arguments {

View File

@ -482,6 +482,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
return false return false
} }
req.logEntry.Binding = req.Binding
req.logEntry.ActionConfigTitle = req.Binding.Action.Title req.logEntry.ActionConfigTitle = req.Binding.Action.Title
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity) req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
req.logEntry.ActionIcon = req.Binding.Action.Icon req.logEntry.ActionIcon = req.Binding.Action.Icon