Sfoglia il codice sorgente

Merge branch 'next' into fix-require-guests-login

James Read 8 mesi fa
parent
commit
430aab638b

+ 2 - 1
frontend/js/OutputTerminal.js

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

+ 70 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -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
  */
@@ -1575,6 +1637,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
    */

+ 59 - 45
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js

@@ -157,320 +157,334 @@ export const LogEntrySchema = /*@__PURE__*/
 export const GetLogsResponseSchema = /*@__PURE__*/
   messageDesc(file_olivetin_api_v1_olivetin, 20);
 
+/**
+ * Describes the message olivetin.api.v1.GetActionLogsRequest.
+ * Use `create(GetActionLogsRequestSchema)` to create a new message.
+ */
+export const GetActionLogsRequestSchema = /*@__PURE__*/
+  messageDesc(file_olivetin_api_v1_olivetin, 21);
+
+/**
+ * Describes the message olivetin.api.v1.GetActionLogsResponse.
+ * Use `create(GetActionLogsResponseSchema)` to create a new message.
+ */
+export const GetActionLogsResponseSchema = /*@__PURE__*/
+  messageDesc(file_olivetin_api_v1_olivetin, 22);
+
 /**
  * Describes the message olivetin.api.v1.ValidateArgumentTypeRequest.
  * Use `create(ValidateArgumentTypeRequestSchema)` to create a new message.
  */
 export const ValidateArgumentTypeRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 21);
+  messageDesc(file_olivetin_api_v1_olivetin, 23);
 
 /**
  * Describes the message olivetin.api.v1.ValidateArgumentTypeResponse.
  * Use `create(ValidateArgumentTypeResponseSchema)` to create a new message.
  */
 export const ValidateArgumentTypeResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 22);
+  messageDesc(file_olivetin_api_v1_olivetin, 24);
 
 /**
  * Describes the message olivetin.api.v1.WatchExecutionRequest.
  * Use `create(WatchExecutionRequestSchema)` to create a new message.
  */
 export const WatchExecutionRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 23);
+  messageDesc(file_olivetin_api_v1_olivetin, 25);
 
 /**
  * Describes the message olivetin.api.v1.WatchExecutionUpdate.
  * Use `create(WatchExecutionUpdateSchema)` to create a new message.
  */
 export const WatchExecutionUpdateSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 24);
+  messageDesc(file_olivetin_api_v1_olivetin, 26);
 
 /**
  * Describes the message olivetin.api.v1.ExecutionStatusRequest.
  * Use `create(ExecutionStatusRequestSchema)` to create a new message.
  */
 export const ExecutionStatusRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 25);
+  messageDesc(file_olivetin_api_v1_olivetin, 27);
 
 /**
  * Describes the message olivetin.api.v1.ExecutionStatusResponse.
  * Use `create(ExecutionStatusResponseSchema)` to create a new message.
  */
 export const ExecutionStatusResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 26);
+  messageDesc(file_olivetin_api_v1_olivetin, 28);
 
 /**
  * Describes the message olivetin.api.v1.WhoAmIRequest.
  * Use `create(WhoAmIRequestSchema)` to create a new message.
  */
 export const WhoAmIRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 27);
+  messageDesc(file_olivetin_api_v1_olivetin, 29);
 
 /**
  * Describes the message olivetin.api.v1.WhoAmIResponse.
  * Use `create(WhoAmIResponseSchema)` to create a new message.
  */
 export const WhoAmIResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 28);
+  messageDesc(file_olivetin_api_v1_olivetin, 30);
 
 /**
  * Describes the message olivetin.api.v1.SosReportRequest.
  * Use `create(SosReportRequestSchema)` to create a new message.
  */
 export const SosReportRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 29);
+  messageDesc(file_olivetin_api_v1_olivetin, 31);
 
 /**
  * Describes the message olivetin.api.v1.SosReportResponse.
  * Use `create(SosReportResponseSchema)` to create a new message.
  */
 export const SosReportResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 30);
+  messageDesc(file_olivetin_api_v1_olivetin, 32);
 
 /**
  * Describes the message olivetin.api.v1.DumpVarsRequest.
  * Use `create(DumpVarsRequestSchema)` to create a new message.
  */
 export const DumpVarsRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 31);
+  messageDesc(file_olivetin_api_v1_olivetin, 33);
 
 /**
  * Describes the message olivetin.api.v1.DumpVarsResponse.
  * Use `create(DumpVarsResponseSchema)` to create a new message.
  */
 export const DumpVarsResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 32);
+  messageDesc(file_olivetin_api_v1_olivetin, 34);
 
 /**
  * Describes the message olivetin.api.v1.ActionEntityPair.
  * Use `create(ActionEntityPairSchema)` to create a new message.
  */
 export const ActionEntityPairSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 33);
+  messageDesc(file_olivetin_api_v1_olivetin, 35);
 
 /**
  * Describes the message olivetin.api.v1.DumpPublicIdActionMapRequest.
  * Use `create(DumpPublicIdActionMapRequestSchema)` to create a new message.
  */
 export const DumpPublicIdActionMapRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 34);
+  messageDesc(file_olivetin_api_v1_olivetin, 36);
 
 /**
  * Describes the message olivetin.api.v1.DumpPublicIdActionMapResponse.
  * Use `create(DumpPublicIdActionMapResponseSchema)` to create a new message.
  */
 export const DumpPublicIdActionMapResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 35);
+  messageDesc(file_olivetin_api_v1_olivetin, 37);
 
 /**
  * Describes the message olivetin.api.v1.GetReadyzRequest.
  * Use `create(GetReadyzRequestSchema)` to create a new message.
  */
 export const GetReadyzRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 36);
+  messageDesc(file_olivetin_api_v1_olivetin, 38);
 
 /**
  * Describes the message olivetin.api.v1.GetReadyzResponse.
  * Use `create(GetReadyzResponseSchema)` to create a new message.
  */
 export const GetReadyzResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 37);
+  messageDesc(file_olivetin_api_v1_olivetin, 39);
 
 /**
  * Describes the message olivetin.api.v1.EventStreamRequest.
  * Use `create(EventStreamRequestSchema)` to create a new message.
  */
 export const EventStreamRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 38);
+  messageDesc(file_olivetin_api_v1_olivetin, 40);
 
 /**
  * Describes the message olivetin.api.v1.EventStreamResponse.
  * Use `create(EventStreamResponseSchema)` to create a new message.
  */
 export const EventStreamResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 39);
+  messageDesc(file_olivetin_api_v1_olivetin, 41);
 
 /**
  * Describes the message olivetin.api.v1.EventOutputChunk.
  * Use `create(EventOutputChunkSchema)` to create a new message.
  */
 export const EventOutputChunkSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 40);
+  messageDesc(file_olivetin_api_v1_olivetin, 42);
 
 /**
  * Describes the message olivetin.api.v1.EventEntityChanged.
  * Use `create(EventEntityChangedSchema)` to create a new message.
  */
 export const EventEntityChangedSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 41);
+  messageDesc(file_olivetin_api_v1_olivetin, 43);
 
 /**
  * Describes the message olivetin.api.v1.EventConfigChanged.
  * Use `create(EventConfigChangedSchema)` to create a new message.
  */
 export const EventConfigChangedSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 42);
+  messageDesc(file_olivetin_api_v1_olivetin, 44);
 
 /**
  * Describes the message olivetin.api.v1.EventExecutionFinished.
  * Use `create(EventExecutionFinishedSchema)` to create a new message.
  */
 export const EventExecutionFinishedSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 43);
+  messageDesc(file_olivetin_api_v1_olivetin, 45);
 
 /**
  * Describes the message olivetin.api.v1.EventExecutionStarted.
  * Use `create(EventExecutionStartedSchema)` to create a new message.
  */
 export const EventExecutionStartedSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 44);
+  messageDesc(file_olivetin_api_v1_olivetin, 46);
 
 /**
  * Describes the message olivetin.api.v1.KillActionRequest.
  * Use `create(KillActionRequestSchema)` to create a new message.
  */
 export const KillActionRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 45);
+  messageDesc(file_olivetin_api_v1_olivetin, 47);
 
 /**
  * Describes the message olivetin.api.v1.KillActionResponse.
  * Use `create(KillActionResponseSchema)` to create a new message.
  */
 export const KillActionResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 46);
+  messageDesc(file_olivetin_api_v1_olivetin, 48);
 
 /**
  * Describes the message olivetin.api.v1.LocalUserLoginRequest.
  * Use `create(LocalUserLoginRequestSchema)` to create a new message.
  */
 export const LocalUserLoginRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 47);
+  messageDesc(file_olivetin_api_v1_olivetin, 49);
 
 /**
  * Describes the message olivetin.api.v1.LocalUserLoginResponse.
  * Use `create(LocalUserLoginResponseSchema)` to create a new message.
  */
 export const LocalUserLoginResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 48);
+  messageDesc(file_olivetin_api_v1_olivetin, 50);
 
 /**
  * Describes the message olivetin.api.v1.PasswordHashRequest.
  * Use `create(PasswordHashRequestSchema)` to create a new message.
  */
 export const PasswordHashRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 49);
+  messageDesc(file_olivetin_api_v1_olivetin, 51);
 
 /**
  * Describes the message olivetin.api.v1.PasswordHashResponse.
  * Use `create(PasswordHashResponseSchema)` to create a new message.
  */
 export const PasswordHashResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 50);
+  messageDesc(file_olivetin_api_v1_olivetin, 52);
 
 /**
  * Describes the message olivetin.api.v1.LogoutRequest.
  * Use `create(LogoutRequestSchema)` to create a new message.
  */
 export const LogoutRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 51);
+  messageDesc(file_olivetin_api_v1_olivetin, 53);
 
 /**
  * Describes the message olivetin.api.v1.LogoutResponse.
  * Use `create(LogoutResponseSchema)` to create a new message.
  */
 export const LogoutResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 52);
+  messageDesc(file_olivetin_api_v1_olivetin, 54);
 
 /**
  * Describes the message olivetin.api.v1.GetDiagnosticsRequest.
  * Use `create(GetDiagnosticsRequestSchema)` to create a new message.
  */
 export const GetDiagnosticsRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 53);
+  messageDesc(file_olivetin_api_v1_olivetin, 55);
 
 /**
  * Describes the message olivetin.api.v1.GetDiagnosticsResponse.
  * Use `create(GetDiagnosticsResponseSchema)` to create a new message.
  */
 export const GetDiagnosticsResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 54);
+  messageDesc(file_olivetin_api_v1_olivetin, 56);
 
 /**
  * Describes the message olivetin.api.v1.InitRequest.
  * Use `create(InitRequestSchema)` to create a new message.
  */
 export const InitRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 55);
+  messageDesc(file_olivetin_api_v1_olivetin, 57);
 
 /**
  * Describes the message olivetin.api.v1.InitResponse.
  * Use `create(InitResponseSchema)` to create a new message.
  */
 export const InitResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 56);
+  messageDesc(file_olivetin_api_v1_olivetin, 58);
 
 /**
  * Describes the message olivetin.api.v1.AdditionalLink.
  * Use `create(AdditionalLinkSchema)` to create a new message.
  */
 export const AdditionalLinkSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 57);
+  messageDesc(file_olivetin_api_v1_olivetin, 59);
 
 /**
  * Describes the message olivetin.api.v1.OAuth2Provider.
  * Use `create(OAuth2ProviderSchema)` to create a new message.
  */
 export const OAuth2ProviderSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 58);
+  messageDesc(file_olivetin_api_v1_olivetin, 60);
 
 /**
  * Describes the message olivetin.api.v1.GetActionBindingRequest.
  * Use `create(GetActionBindingRequestSchema)` to create a new message.
  */
 export const GetActionBindingRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 59);
+  messageDesc(file_olivetin_api_v1_olivetin, 61);
 
 /**
  * Describes the message olivetin.api.v1.GetActionBindingResponse.
  * Use `create(GetActionBindingResponseSchema)` to create a new message.
  */
 export const GetActionBindingResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 60);
+  messageDesc(file_olivetin_api_v1_olivetin, 62);
 
 /**
  * Describes the message olivetin.api.v1.GetEntitiesRequest.
  * Use `create(GetEntitiesRequestSchema)` to create a new message.
  */
 export const GetEntitiesRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 61);
+  messageDesc(file_olivetin_api_v1_olivetin, 63);
 
 /**
  * Describes the message olivetin.api.v1.GetEntitiesResponse.
  * Use `create(GetEntitiesResponseSchema)` to create a new message.
  */
 export const GetEntitiesResponseSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 62);
+  messageDesc(file_olivetin_api_v1_olivetin, 64);
 
 /**
  * Describes the message olivetin.api.v1.EntityDefinition.
  * Use `create(EntityDefinitionSchema)` to create a new message.
  */
 export const EntityDefinitionSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 63);
+  messageDesc(file_olivetin_api_v1_olivetin, 65);
 
 /**
  * Describes the message olivetin.api.v1.GetEntityRequest.
  * Use `create(GetEntityRequestSchema)` to create a new message.
  */
 export const GetEntityRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 64);
+  messageDesc(file_olivetin_api_v1_olivetin, 66);
 
 /**
  * Describes the message olivetin.api.v1.RestartActionRequest.
  * Use `create(RestartActionRequestSchema)` to create a new message.
  */
 export const RestartActionRequestSchema = /*@__PURE__*/
-  messageDesc(file_olivetin_api_v1_olivetin, 65);
+  messageDesc(file_olivetin_api_v1_olivetin, 67);
 
 /**
  * @generated from service olivetin.api.v1.OliveTinApiService

+ 13 - 0
frontend/resources/vue/router.js

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

+ 385 - 0
frontend/resources/vue/views/ActionDetailsView.vue

@@ -0,0 +1,385 @@
+<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, watch } 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)
+    window.showBigError('fetch-action', 'getting action details', err, false)
+  }
+}
+
+function resetState() {
+  action.value = null
+  actionTitle.value = 'Action Details'
+  logs.value = []
+  totalCount.value = 0
+  currentPage.value = 1
+  searchText.value = ''
+  loading.value = true
+}
+
+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()
+})
+
+watch(
+  () => route.params.actionId,
+  () => {
+    resetState()
+    fetchAction()
+    fetchActionLogs()
+  },
+  { immediate: false }
+)
+</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>
+

+ 101 - 9
frontend/resources/vue/views/ExecutionView.vue

@@ -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
   }
 }
@@ -204,7 +255,7 @@ function updateDuration(logEntryParam) {
   } else {
 	let delta = ''
 	try {
-	  delta = (new Date(logEntry.value.datetimeStarted) - new Date(logEntry.value.datetimeStarted)) / 1000
+		  delta = (new Date(logEntry.value.datetimeFinished) - new Date(logEntry.value.datetimeStarted)) / 1000
 	  delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
 	} catch (e) {
 	  console.warn('Failed to calculate delta', e)
@@ -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>

+ 16 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -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 ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) {}
 
 	rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {}
+	
+	rpc GetActionLogs(GetActionLogsRequest) returns (GetActionLogsResponse) {}
 
 	rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {}
 

+ 29 - 0
service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go

@@ -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
+ 230 - 94
service/gen/olivetin/api/v1/olivetin.pb.go


+ 148 - 13
service/internal/api/api.go

@@ -14,6 +14,7 @@ import (
 
 	"fmt"
 	"net/http"
+	"sync"
 
 	acl "github.com/OliveTin/OliveTin/internal/acl"
 	auth "github.com/OliveTin/OliveTin/internal/auth"
@@ -28,10 +29,26 @@ type oliveTinAPI struct {
 	executor *executor.Executor
 	cfg      *config.Config
 
-	connectedClients []*connectedClients
+	// streamingClients is a set of currently connected clients.
+	// The empty struct value models set semantics (keys only) and keeps add/remove O(1).
+	// We use a map for efficient membership and deletion; ordering is not required.
+	streamingClients      map[*streamingClient]struct{}
+	streamingClientsMutex sync.RWMutex
 }
 
-type connectedClients struct {
+// This is used to avoid race conditions when iterating over the connectedClients map.
+// and holds the lock for as minimal time as possible to avoid blocking the API for too long.
+func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
+	api.streamingClientsMutex.RLock()
+	defer api.streamingClientsMutex.RUnlock()
+	clients := make([]*streamingClient, 0, len(api.streamingClients))
+	for client := range api.streamingClients {
+		clients = append(clients, client)
+	}
+	return clients
+}
+
+type streamingClient struct {
 	channel           chan *apiv1.EventStreamResponse
 	AuthenticatedUser *acl.AuthenticatedUser
 }
@@ -365,6 +382,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 +457,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 +479,75 @@ 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
+
+	// Validate and clamp offset to prevent out-of-bounds access
+	if startOffset < 0 {
+		startOffset = 0
+	}
+
+	// If offset is beyond available data, return empty result with correct metadata
+	if startOffset >= totalCount {
+		ret.CountRemaining = 0
+		ret.PageSize = pageSize
+		ret.TotalCount = totalCount
+		ret.StartOffset = startOffset
+		return connect.NewResponse(ret), nil
+	}
+
+	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
@@ -565,20 +660,25 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 		return err
 	}
 
-	client := &connectedClients{
+	client := &streamingClient{
 		channel:           make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
 		AuthenticatedUser: user,
 	}
 
 	log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username)
 
-	api.connectedClients = append(api.connectedClients, client)
+	api.streamingClientsMutex.Lock()
+	api.streamingClients[client] = struct{}{}
+	api.streamingClientsMutex.Unlock()
 
 	// 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)
+			// Remove disconnected client from the list
+			api.removeClient(client)
+			break
 		}
 	}
 
@@ -587,8 +687,16 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	return nil
 }
 
+func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
+	api.streamingClientsMutex.Lock()
+	delete(api.streamingClients, clientToRemove)
+	api.streamingClientsMutex.Unlock()
+}
+
 func (api *oliveTinAPI) OnActionMapRebuilt() {
-	for _, client := range api.connectedClients {
+	toRemove := []*streamingClient{}
+
+	for _, client := range api.copyOfStreamingClients() {
 		select {
 		case client.channel <- &apiv1.EventStreamResponse{
 			Event: &apiv1.EventStreamResponse_ConfigChanged{
@@ -596,13 +704,20 @@ 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) {
-	for _, client := range api.connectedClients {
+	toRemove := []*streamingClient{}
+
+	for _, client := range api.copyOfStreamingClients() {
 		select {
 		case client.channel <- &apiv1.EventStreamResponse{
 			Event: &apiv1.EventStreamResponse_ExecutionStarted{
@@ -612,13 +727,20 @@ 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) {
-	for _, client := range api.connectedClients {
+	toRemove := []*streamingClient{}
+
+	for _, client := range api.copyOfStreamingClients() {
 		select {
 		case client.channel <- &apiv1.EventStreamResponse{
 			Event: &apiv1.EventStreamResponse_ExecutionFinished{
@@ -628,9 +750,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) {
@@ -733,7 +860,9 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin
 }
 
 func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
-	for _, client := range api.connectedClients {
+	toRemove := []*streamingClient{}
+
+	for _, client := range api.copyOfStreamingClients() {
 		select {
 		case client.channel <- &apiv1.EventStreamResponse{
 			Event: &apiv1.EventStreamResponse_OutputChunk{
@@ -744,9 +873,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) {
@@ -863,6 +997,7 @@ func newServer(ex *executor.Executor) *oliveTinAPI {
 	server := oliveTinAPI{}
 	server.cfg = ex.Cfg
 	server.executor = ex
+	server.streamingClients = make(map[*streamingClient]struct{})
 
 	ex.AddListener(&server)
 	return &server

+ 1 - 0
service/internal/api/apiActions.go

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

+ 1 - 0
service/internal/executor/executor.go

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

Some files were not shown because too many files changed in this diff