James Read 8 месяцев назад
Родитель
Сommit
2a6d9e4f68

+ 1 - 0
.gitignore

@@ -16,3 +16,4 @@ integration-tests/screenshots/
 webui/
 server.log
 OliveTin
+integration-tests/configs/authRequireGuestsToLogin/sessions.yaml

+ 2 - 2
AGENTS.md

@@ -26,7 +26,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 ### Test Notes and Gotchas
 - The top-level Makefile does not expose `unittests`; use `cd service && make unittests`.
 - Connect RPC API must be mounted correctly; in tests, create the handler via `GetNewHandler(ex)` and serve under `/api/`.
-- Frontend “ready” state: the app sets `document.body` attribute `initial-marshal-complete="true"` when loaded. Integration helpers wait for this before selecting elements.
+- Frontend “ready” state: the app sets `document.body` attribute `loaded-dashboard="<name>"` when loading a dashboard. Integration helpers that test dashboard functionality  wait for this before selecting elements. Certain conditions enforcing login will mean that this attribute is not set until a user is logged in.
 - Modern UI uses Vue components:
   - Action buttons are rendered as `.action-button button`.
   - Logs and Diagnostics are Vue router links available via `/logs` and `/diagnostics`.
@@ -64,6 +64,6 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 ### Troubleshooting
 - API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
 - Executor panics: check for nil `Binding/Action` and add guards in step functions.
-- Integration timeouts: wait for `initial-marshal-complete` and use selectors matching the Vue UI.
+- Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI.
 
 

+ 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

+ 75 - 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
  */
@@ -1316,6 +1378,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
    * @generated from field: bool show_log_list = 22;
    */
   showLogList: boolean;
+
+  /**
+   * @generated from field: bool login_required = 23;
+   */
+  loginRequired: boolean;
 };
 
 /**
@@ -1570,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
    */

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 10 - 0
frontend/resources/vue/App.vue

@@ -59,6 +59,7 @@
 
 <script setup>
 import { ref, onMounted } from 'vue';
+import { useRouter } from 'vue-router';
 import Sidebar from 'picocrank/vue/components/Sidebar.vue';
 import Header from 'picocrank/vue/components/Header.vue';
 import { HugeiconsIcon } from '@hugeicons/vue'
@@ -67,6 +68,8 @@ import { UserCircle02Icon } from '@hugeicons/core-free-icons'
 import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
 import logoUrl from '../../OliveTinLogo.png';
 
+const router = useRouter();
+
 const sidebar = ref(null);
 const username = ref('guest');
 const isLoggedIn = ref(false);
@@ -107,7 +110,14 @@ async function requestInit() {
     try {
         const initResponse = await window.client.init({})
 
+        // Store init response first so the login view can read options (e.g., authLocalLogin)
         window.initResponse = initResponse
+        
+        // Check if login is required and redirect if so (after storing initResponse)
+        if (initResponse.loginRequired) {
+            router.push('/login')
+            return
+        }
         window.initError = false
         window.initErrorMessage = ''
         window.initCompleted = true

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

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

@@ -0,0 +1,389 @@
+<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),
+      "pageSize": BigInt(Number(pageSize.value)),
+    }
+
+    const response = await window.client.getActionLogs(args)
+
+    logs.value = response.logs
+    const serverPageSize = Number(response.pageSize)
+    if (Number.isFinite(serverPageSize) && serverPageSize > 0) {
+      pageSize.value = serverPageSize
+    }
+    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>

+ 37 - 9
frontend/resources/vue/views/UserControlPanel.vue

@@ -13,6 +13,10 @@
         <dd v-if="userProvider !== 'system'">{{ userProvider }}</dd>
         <dt v-if="usergroup">Group</dt>
         <dd v-if="usergroup">{{ usergroup }}</dd>
+        <dt v-if="acls && acls.length > 0">Matched ACLs</dt>
+        <dd v-if="acls && acls.length > 0">
+          <span class="acl-tag" v-for="(acl, idx) in acls" :key="`acl-${idx}`">{{ acl }}</span>
+        </dd>
       </dl>
 
       <div class="user-actions">
@@ -38,6 +42,7 @@ const username = ref('guest')
 const userProvider = ref('system')
 const usergroup = ref('')
 const loggingOut = ref(false)
+const acls = ref([])
 
 function updateUserInfo() {
   if (window.initResponse) {
@@ -48,6 +53,20 @@ function updateUserInfo() {
   }
 }
 
+async function fetchWhoAmI() {
+  try {
+    const res = await window.client.whoAmI({})
+    acls.value = res.acls || []
+    // Update usergroup from authoritative WhoAmI response
+    if (res.usergroup) {
+      usergroup.value = res.usergroup
+    }
+  } catch (e) {
+    console.warn('Failed to fetch WhoAmI for ACLs', e)
+    acls.value = []
+  }
+}
+
 async function handleLogout() {
   loggingOut.value = true
   
@@ -70,8 +89,12 @@ async function handleLogout() {
       console.error('Failed to reinitialize after logout:', initErr)
     }
     
-    // Redirect to home page
-    router.push('/')
+    // Redirect based on init response: if login is required, go to login page
+    if (window.initResponse && window.initResponse.loginRequired) {
+      router.push('/login')
+    } else {
+      router.push('/')
+    }
   } catch (err) {
     console.error('Logout error:', err)
   } finally {
@@ -83,14 +106,9 @@ let watchInterval = null
 
 onMounted(() => {
   updateUserInfo()
+  fetchWhoAmI()
   
-  // Watch for changes to init response
-  watchInterval = setInterval(() => {
-    if (window.initResponse) {
-      updateUserInfo()
-    }
-  }, 1000)
-})
+ })
 
 onUnmounted(() => {
   if (watchInterval) {
@@ -124,6 +142,16 @@ section {
   gap: 1rem;
 }
 
+.acl-tag {
+  display: inline-block;
+  background: var(--section-background);
+  border: 1px solid var(--border-color);
+  border-radius: 0.25rem;
+  padding: 0.1rem 0.4rem;
+  margin: 0 0.25rem 0.25rem 0;
+  font-size: 0.85rem;
+}
+
 .button {
   padding: 0.75rem 1.5rem;
   border-radius: 4px;

+ 26 - 3
integration-tests/configs/authRequireGuestsToLogin/config.yaml

@@ -14,9 +14,32 @@ authRequireGuestsToLogin: true
 authLocalUsers:
   enabled: true
   users:
-    - username: "testuser"
-      usergroup: "admin"
-      password: "testpass123"
+    - username: "alice"
+      usergroup: "admins"
+      password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
+
+    - username: "bob"
+      usergroup: "users"
+      password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
+
+accessControlLists:
+  - name: "admin"
+    matchUsergroups: ["admins"]
+    addToEveryAction: true
+    permissions:
+      view: true
+      exec: true
+      logs: true
+      kill: true
+
+  - name: "users"
+    matchUsergroups: ["users"]
+    addToEveryAction: true
+    permissions:
+      view: true
+      exec: false
+      logs: false
+      kill: false
 
 # Simple actions for testing
 actions:

+ 9 - 29
integration-tests/test/authRequireGuestsToLogin.mjs

@@ -21,39 +21,19 @@ describe('config: authRequireGuestsToLogin', function () {
     takeScreenshotOnFailure(this.currentTest, webdriver);
   });
 
-  it('Server starts successfully with authRequireGuestsToLogin enabled', async function () {
-    await webdriver.get(runner.baseUrl())
-    await webdriver.wait(until.titleContains('OliveTin'), 10000)
-    const title = await webdriver.getTitle()
-    expect(title).to.contain('OliveTin')
-    console.log('✓ Server started successfully with authRequireGuestsToLogin enabled')
-  })
+  it('Guest is redirected to login', async function () {
+    // Don't use getRootAndWait here because we want to test the redirect, and getRootAndWait waits for the dashboard to load
 
-  it('Guest user is blocked from accessing the web UI', async function () {
     await webdriver.get(runner.baseUrl())
-    
-    // Wait for the page to finish loading
-    await webdriver.wait(until.elementLocated(By.css('body')), 10000)
-    await new Promise(resolve => setTimeout(resolve, 3000))
-    
-    // The page should redirect or show an error because guest is not allowed
-    // We can't directly test the API from Selenium, but we can verify the page behavior
-    const currentUrl = await webdriver.getCurrentUrl()
-    console.log('Current URL:', currentUrl)
-    
-    // At minimum, we verify the server responds
-    const pageText = await webdriver.findElement(By.tagName('body')).getText()
-    console.log('✓ Page loaded, guest behavior verified')
-  })
 
-  it('Authenticated user can login and access the dashboard', async function () {
-    await webdriver.get(runner.baseUrl())
+    await webdriver.wait(until.urlContains('/login'), 10000)
     
-    // Check if there's a login link or login page
-    // This is a simplified test since we can't easily test the full auth flow from Selenium
-    const bodyText = await webdriver.findElement(By.tagName('body')).getText()
-    console.log('Page content preview:', bodyText.substring(0, 200))
-    console.log('✓ Authenticated user flow verified')
+    // Verify login UI elements are present
+    const loginElements = await webdriver.findElements(By.css('form.local-login-form, .login-oauth2, .login-disabled'))
+    expect(loginElements.length).to.be.greaterThan(0)
+
+    console.log('✓ Login page loaded correctly')
+
   })
 })
 

+ 17 - 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;
@@ -305,6 +319,7 @@ message InitResponse {
     string banner_css = 20;
 	bool show_diagnostics = 21;
 	bool show_log_list = 22;
+	bool login_required = 23;
 }
 
 message AdditionalLink {
@@ -366,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"))
 }

Разница между файлами не показана из-за своего большого размера
+ 230 - 94
service/gen/olivetin/api/v1/olivetin.pb.go


+ 58 - 50
service/internal/acl/acl.go

@@ -57,6 +57,9 @@ func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string {
 	} else {
 		ret = strings.Fields(u.UsergroupLine)
 	}
+
+	log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep)
+
 	return ret
 }
 
@@ -196,61 +199,66 @@ func getHeaderKeyOrEmpty(headers http.Header, key string) string {
 
 // UserFromContext tries to find a user from a Connect RPC context
 func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *AuthenticatedUser {
-	var ret *AuthenticatedUser
-
-	if req != nil {
-		ret = &AuthenticatedUser{}
-		// Only trust headers if explicitly configured
-		if cfg.AuthHttpHeaderUsername != "" {
-			ret.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername)
-		}
-
-		if cfg.AuthHttpHeaderUserGroup != "" {
-			ret.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup)
-		}
-		// Optional provider header; otherwise infer below
-		prov := getHeaderKeyOrEmpty(req.Header(), "provider")
-		if prov != "" {
-			ret.Provider = prov
-		}
-
-		// If no username from headers, fall back to local session cookie
-		if ret.Username == "" {
-			// Build a minimal http.Request to parse cookies from headers
-			dummy := &http.Request{Header: req.Header()}
-			if c, err := dummy.Cookie("olivetin-sid-local"); err == nil && c != nil && c.Value != "" {
-				if sess := auth.GetUserSession("local", c.Value); sess != nil {
-					if u := cfg.FindUserByUsername(sess.Username); u != nil {
-						ret.Username = u.Username
-						ret.UsergroupLine = u.Usergroup
-						ret.Provider = "local"
-						ret.SID = c.Value
-					} else {
-						log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config")
-					}
-				} else {
-					log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session")
-				}
-			}
-		}
-
-		if ret.Username != "" {
-			buildUserAcls(cfg, ret)
-		}
+	user := userFromHeaders(req, cfg)
+	if user.Username == "" {
+		user = userFromLocalSession(req, cfg, user)
 	}
-
-	if ret == nil || ret.Username == "" {
-		ret = UserGuest(cfg)
+	if user.Username == "" {
+		user = *UserGuest(cfg)
+	} else {
+		buildUserAcls(cfg, &user)
 	}
-
 	log.WithFields(log.Fields{
-		"username":      ret.Username,
-		"usergroupLine": ret.UsergroupLine,
-		"provider":      ret.Provider,
-		"acls":          ret.Acls,
+		"username":      user.Username,
+		"usergroupLine": user.UsergroupLine,
+		"provider":      user.Provider,
+		"acls":          user.Acls,
 	}).Debugf("UserFromContext")
+	return &user
+}
 
-	return ret
+//gocyclo:ignore
+func userFromHeaders[T any](req *connect.Request[T], cfg *config.Config) AuthenticatedUser {
+	var u AuthenticatedUser
+	if req == nil {
+		return u
+	}
+	if cfg.AuthHttpHeaderUsername != "" {
+		u.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername)
+	}
+	if cfg.AuthHttpHeaderUserGroup != "" {
+		u.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup)
+	}
+	if prov := getHeaderKeyOrEmpty(req.Header(), "provider"); prov != "" {
+		u.Provider = prov
+	}
+	return u
+}
+
+//gocyclo:ignore
+func userFromLocalSession[T any](req *connect.Request[T], cfg *config.Config, u AuthenticatedUser) AuthenticatedUser {
+	if req == nil || u.Username != "" {
+		return u
+	}
+	dummy := &http.Request{Header: req.Header()}
+	c, err := dummy.Cookie("olivetin-sid-local")
+	if err != nil || c == nil || c.Value == "" {
+		return u
+	}
+	sess := auth.GetUserSession("local", c.Value)
+	if sess == nil {
+		log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session")
+		return u
+	}
+	if cfgUser := cfg.FindUserByUsername(sess.Username); cfgUser != nil {
+		u.Username = cfgUser.Username
+		u.UsergroupLine = cfgUser.Usergroup
+		u.Provider = "local"
+		u.SID = c.Value
+		return u
+	}
+	log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config")
+	return u
 }
 
 func UserGuest(cfg *config.Config) *AuthenticatedUser {

+ 158 - 26
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,
@@ -432,25 +453,96 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
 	}
 
 	ret := &apiv1.GetLogsResponse{}
+	logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
+	for _, le := range logEntries {
+		ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
+	}
+	ret.CountRemaining = paging.CountRemaining
+	ret.PageSize = paging.PageSize
+	ret.TotalCount = paging.TotalCount
+	ret.StartOffset = paging.StartOffset
+	return connect.NewResponse(ret), nil
+}
 
-	logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
+func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
+	user := acl.UserFromContext(ctx, req, api.cfg)
 
-	for _, logEntry := range logEntries {
-		action := logEntry.Binding.Action
+	if err := api.checkDashboardAccess(user); err != nil {
+		return nil, err
+	}
 
-		if action == nil || acl.IsAllowedLogs(api.cfg, user, action) {
-			pbLogEntry := api.internalLogEntryToPb(logEntry, user)
+	ret := &apiv1.GetActionLogsResponse{}
+	filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
+	page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
+	if page.empty {
+		ret.CountRemaining = 0
+		ret.PageSize = page.size
+		ret.TotalCount = page.total
+		ret.StartOffset = page.start
+		return connect.NewResponse(ret), nil
+	}
+    // Newest-first slicing: compute reversed indices
+    startIdx := page.total - page.end
+    endIdx := page.total - page.start
+    if startIdx < 0 { startIdx = 0 }
+    if endIdx > int64(len(filtered)) { endIdx = int64(len(filtered)) }
+    for _, le := range filtered[startIdx:endIdx] {
+        ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
+    }
+    // Entries older than the returned newest page
+    ret.CountRemaining = page.start
+	ret.PageSize = page.size
+	ret.TotalCount = page.total
+	ret.StartOffset = page.start
+	return connect.NewResponse(ret), nil
+}
 
-			ret.Logs = append(ret.Logs, pbLogEntry)
+func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry {
+	out := make([]*apiv1.LogEntry, 0, len(entries))
+	for _, e := range entries {
+		if e == nil || e.Binding == nil || e.Binding.Action == nil {
+			continue
+		}
+		if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
+			out = append(out, api.internalLogEntryToPb(e, user))
 		}
 	}
+	return out
+}
 
-	ret.CountRemaining = pagingResult.CountRemaining
-	ret.PageSize = pagingResult.PageSize
-	ret.TotalCount = pagingResult.TotalCount
-	ret.StartOffset = pagingResult.StartOffset
+func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry {
+	filtered := make([]*executor.InternalLogEntry, 0, len(entries))
+	for _, e := range entries {
+		if e == nil || e.Binding == nil || e.Binding.Action == nil {
+			continue
+		}
+		if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
+			filtered = append(filtered, e)
+		}
+	}
+	return filtered
+}
 
-	return connect.NewResponse(ret), nil
+type pageInfo struct {
+	total int64
+	size  int64
+	start int64
+	end   int64
+	empty bool
+}
+
+func paginate(total int64, size int64, start int64) pageInfo {
+	if start < 0 {
+		start = 0
+	}
+	if start >= total {
+		return pageInfo{total: total, size: size, start: start, end: start, empty: true}
+	}
+	end := start + size
+	if end > total {
+		end = total
+	}
+	return pageInfo{total: total, size: size, start: start, end: end, empty: false}
 }
 
 /*
@@ -565,20 +657,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 +684,17 @@ 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()
+	close(clientToRemove.channel)
+}
+
 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 +702,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 +725,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 +748,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) {
@@ -645,9 +770,7 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
 func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
 	user := acl.UserFromContext(ctx, req, api.cfg)
 
-	if err := api.checkDashboardAccess(user); err != nil {
-		return nil, err
-	}
+	loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
 
 	res := &apiv1.InitResponse{
 		ShowFooter:                api.cfg.ShowFooter,
@@ -672,6 +795,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 		BannerCss:                 api.cfg.BannerCSS,
 		ShowDiagnostics:           user.EffectivePolicy.ShowDiagnostics,
 		ShowLogList:               user.EffectivePolicy.ShowLogList,
+		LoginRequired:             loginRequired,
 	}
 
 	return connect.NewResponse(res), nil
@@ -734,7 +858,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{
@@ -745,9 +871,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) {
@@ -864,6 +995,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 {

+ 8 - 19
service/internal/auth/sessions.go

@@ -88,34 +88,23 @@ func LoadUserSessions(cfg *config.Config) {
 	data, err := os.ReadFile(cfg.GetDir() + "/sessions.yaml")
 	if err != nil {
 		logrus.WithError(err).Warn("Failed to read sessions.yaml file")
-		// Initialize empty session storage if file doesn't exist
-		if sessionStorage == nil {
-			sessionStorage = &SessionStorage{
-				Providers: make(map[string]*SessionProvider),
-			}
-		}
+		ensureEmptySessionStorage()
 		return
 	}
 
-	err = yaml.Unmarshal(data, &sessionStorage)
-	if err != nil {
+	if err := yaml.Unmarshal(data, &sessionStorage); err != nil {
 		logrus.WithError(err).Error("Failed to unmarshal sessions.yaml")
-		// Initialize empty session storage if unmarshal fails
-		if sessionStorage == nil {
-			sessionStorage = &SessionStorage{
-				Providers: make(map[string]*SessionProvider),
-			}
-		}
+		ensureEmptySessionStorage()
 		return
 	}
 
-	// Ensure sessionStorage and Providers are properly initialized
+	ensureEmptySessionStorage()
+}
+
+func ensureEmptySessionStorage() {
 	if sessionStorage == nil {
-		sessionStorage = &SessionStorage{
-			Providers: make(map[string]*SessionProvider),
-		}
+		sessionStorage = &SessionStorage{Providers: make(map[string]*SessionProvider)}
 	}
-
 	if sessionStorage.Providers == nil {
 		sessionStorage.Providers = make(map[string]*SessionProvider)
 	}

+ 128 - 116
service/internal/config/config_reloader.go

@@ -48,51 +48,77 @@ func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) {
 func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
 	log.Infof("Appending cfg source: %s", configPath)
 
-	// Unmarshal config - koanf will handle mapstructure tags automatically
-	err := k.Unmarshal(".", cfg)
-	if err != nil {
-		log.Errorf("Error unmarshalling config: %v", err)
+	if !unmarshalRoot(k, cfg) {
 		return
 	}
 
-	// Fallback for complex nested structures that might not unmarshal correctly
-	// Only attempt manual unmarshaling if the automatic approach didn't populate the fields
-	if len(cfg.Actions) == 0 && k.Exists("actions") {
-		var actions []*Action
-		if err := k.Unmarshal("actions", &actions); err == nil {
-			cfg.Actions = actions
-			log.Debugf("Manually loaded %d actions", len(actions))
-		}
+	loadCollectionsFallbacks(k, cfg)
+
+	applyConfigOverrides(k, cfg)
+
+	afterLoadFinalize(cfg, configPath)
+}
+
+func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
+	if err := k.Unmarshal(".", cfg); err != nil {
+		log.Errorf("Error unmarshalling config: %v", err)
+		return false
 	}
+	return true
+}
 
-	if len(cfg.Dashboards) == 0 && k.Exists("dashboards") {
-		var dashboards []*DashboardComponent
-		if err := k.Unmarshal("dashboards", &dashboards); err == nil {
-			cfg.Dashboards = dashboards
-			log.Debugf("Manually loaded %d dashboards", len(dashboards))
-		}
+func loadCollectionsFallbacks(k *koanf.Koanf, cfg *Config) {
+	maybeUnmarshalActions(k, cfg)
+	maybeUnmarshalDashboards(k, cfg)
+	maybeUnmarshalEntities(k, cfg)
+	maybeUnmarshalAuthLocalUsers(k, cfg)
+}
+
+func maybeUnmarshalActions(k *koanf.Koanf, cfg *Config) {
+	if len(cfg.Actions) != 0 || !k.Exists("actions") {
+		return
 	}
+	var actions []*Action
+	if err := k.Unmarshal("actions", &actions); err == nil {
+		cfg.Actions = actions
+		log.Debugf("Manually loaded %d actions", len(actions))
+	}
+}
 
-	if len(cfg.Entities) == 0 && k.Exists("entities") {
-		var entities []*EntityFile
-		if err := k.Unmarshal("entities", &entities); err == nil {
-			cfg.Entities = entities
-			log.Debugf("Manually loaded %d entities", len(entities))
-		}
+func maybeUnmarshalDashboards(k *koanf.Koanf, cfg *Config) {
+	if len(cfg.Dashboards) != 0 || !k.Exists("dashboards") {
+		return
+	}
+	var dashboards []*DashboardComponent
+	if err := k.Unmarshal("dashboards", &dashboards); err == nil {
+		cfg.Dashboards = dashboards
+		log.Debugf("Manually loaded %d dashboards", len(dashboards))
 	}
+}
 
-	if len(cfg.AuthLocalUsers.Users) == 0 && k.Exists("authLocalUsers") {
-		var authLocalUsers AuthLocalUsersConfig
-		if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil {
-			cfg.AuthLocalUsers = authLocalUsers
-			log.Debugf("Manually loaded local auth config")
-		}
+func maybeUnmarshalEntities(k *koanf.Koanf, cfg *Config) {
+	if len(cfg.Entities) != 0 || !k.Exists("entities") {
+		return
 	}
+	var entities []*EntityFile
+	if err := k.Unmarshal("entities", &entities); err == nil {
+		cfg.Entities = entities
+		log.Debugf("Manually loaded %d entities", len(entities))
+	}
+}
 
-	// Map structure tags should handle these automatically, but we keep fallbacks
-	// for fields that might not unmarshal correctly
-	applyConfigOverrides(k, cfg)
+func maybeUnmarshalAuthLocalUsers(k *koanf.Koanf, cfg *Config) {
+	if len(cfg.AuthLocalUsers.Users) != 0 || !k.Exists("authLocalUsers") {
+		return
+	}
+	var authLocalUsers AuthLocalUsersConfig
+	if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil {
+		cfg.AuthLocalUsers = authLocalUsers
+		log.Debugf("Manually loaded local auth config")
+	}
+}
 
+func afterLoadFinalize(cfg *Config, configPath string) {
 	metricConfigReloadedCount.Inc()
 	metricConfigActionCount.Set(float64(len(cfg.Actions)))
 
@@ -135,128 +161,112 @@ func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) {
 		return
 	}
 
-	configDir := filepath.Dir(baseConfigPath)
-	includePath := filepath.Join(configDir, cfg.Include)
-
+	includePath := filepath.Join(filepath.Dir(baseConfigPath), cfg.Include)
 	log.Infof("Loading included configs from: %s", includePath)
 
-	// Check if the include directory exists
+	yamlFiles, ok := listYamlFiles(includePath)
+	if !ok || len(yamlFiles) == 0 {
+		return
+	}
+
+	sort.Strings(yamlFiles)
+	for _, filename := range yamlFiles {
+		loadAndMergeIncludedFile(cfg, includePath, filename)
+	}
+
+	log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
+	cfg.Sanitize()
+}
+
+func listYamlFiles(includePath string) ([]string, bool) {
 	dirInfo, err := os.Stat(includePath)
 	if err != nil {
 		log.Warnf("Include directory not found: %s", includePath)
-		return
+		return nil, false
 	}
-
 	if !dirInfo.IsDir() {
 		log.Warnf("Include path is not a directory: %s", includePath)
-		return
+		return nil, false
 	}
-
-	// Read all .yml files from the directory
 	entries, err := os.ReadDir(includePath)
 	if err != nil {
 		log.Errorf("Error reading include directory: %v", err)
-		return
+		return nil, false
 	}
-
-	// Filter and sort .yml files
 	var yamlFiles []string
 	for _, entry := range entries {
-		if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml")) {
-			yamlFiles = append(yamlFiles, entry.Name())
+		if entry.IsDir() {
+			continue
+		}
+		name := entry.Name()
+		if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
+			yamlFiles = append(yamlFiles, name)
 		}
 	}
-
 	if len(yamlFiles) == 0 {
 		log.Infof("No YAML files found in include directory: %s", includePath)
-		return
 	}
+	return yamlFiles, true
+}
 
-	// Sort files to ensure deterministic load order
-	sort.Strings(yamlFiles)
-
-	// Load each file and merge into config
-	for _, filename := range yamlFiles {
-		filePath := filepath.Join(includePath, filename)
-		log.Infof("Loading included config file: %s", filePath)
-
-		includeK := koanf.New(".")
-		f := file.Provider(filePath)
-
-		if err := includeK.Load(f, yaml.Parser()); err != nil {
-			log.Errorf("Error loading included config file %s: %v", filePath, err)
-			continue
-		}
-
-		// Unmarshal into a temporary config to process properly
-		tempCfg := &Config{}
-		if err := includeK.Unmarshal(".", tempCfg); err != nil {
-			log.Errorf("Error unmarshalling included config file %s: %v", filePath, err)
-			continue
-		}
-
-		// Apply the same manual loading workarounds as in AppendSource
-		if len(tempCfg.Actions) == 0 && includeK.Exists("actions") {
-			var actions []*Action
-			if err := includeK.Unmarshal("actions", &actions); err == nil {
-				tempCfg.Actions = actions
-				log.Debugf("Manually loaded %d actions from %s", len(actions), filename)
-			}
-		}
+func loadAndMergeIncludedFile(cfg *Config, includePath, filename string) {
+	filePath := filepath.Join(includePath, filename)
+	log.Infof("Loading included config file: %s", filePath)
 
-		// Merge the temp config into the main config
-		// Later files override earlier ones
-		mergeConfig(cfg, tempCfg)
+	includeK := koanf.New(".")
+	if err := includeK.Load(file.Provider(filePath), yaml.Parser()); err != nil {
+		log.Errorf("Error loading included config file %s: %v", filePath, err)
+		return
+	}
 
-		log.Infof("Successfully loaded and merged %s", filename)
+	tempCfg := &Config{}
+	if err := includeK.Unmarshal(".", tempCfg); err != nil {
+		log.Errorf("Error unmarshalling included config file %s: %v", filePath, err)
+		return
 	}
 
-	log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
+	loadCollectionsFallbacks(includeK, tempCfg)
 
-	// Sanitize the merged config
-	cfg.Sanitize()
+	mergeConfig(cfg, tempCfg)
+	log.Infof("Successfully loaded and merged %s", filename)
 }
 
 func mergeConfig(base *Config, overlay *Config) {
-	// Merge Actions - overlay appends to base
+	mergeSlices(base, overlay)
+	overrideSimple(base, overlay)
+	overrideNested(base, overlay)
+	overrideStrings(base, overlay)
+}
+
+func mergeSlices(base *Config, overlay *Config) {
 	if len(overlay.Actions) > 0 {
 		base.Actions = append(base.Actions, overlay.Actions...)
 	}
-
-	// Merge Dashboards - overlay appends to base
 	if len(overlay.Dashboards) > 0 {
 		base.Dashboards = append(base.Dashboards, overlay.Dashboards...)
 		log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards))
 	}
-
-	// Merge Entities - overlay appends to base
 	if len(overlay.Entities) > 0 {
 		base.Entities = append(base.Entities, overlay.Entities...)
 		log.Debugf("Merged %d entities from include", len(overlay.Entities))
 	}
-
-	// Merge AccessControlLists - overlay appends to base
 	if len(overlay.AccessControlLists) > 0 {
 		base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...)
 		log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists))
 	}
-
-	// Merge AuthLocalUsers.Users - overlay appends to base
 	if len(overlay.AuthLocalUsers.Users) > 0 {
 		base.AuthLocalUsers.Users = append(base.AuthLocalUsers.Users, overlay.AuthLocalUsers.Users...)
 		log.Debugf("Merged %d local users from include", len(overlay.AuthLocalUsers.Users))
 	}
-
-	// Merge slices by appending
 	if len(overlay.StyleMods) > 0 {
 		base.StyleMods = append(base.StyleMods, overlay.StyleMods...)
 	}
-
 	if len(overlay.AdditionalNavigationLinks) > 0 {
 		base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...)
 	}
+}
 
-	// Override simple fields (later files win)
+func overrideSimple(base *Config, overlay *Config) {
 	if overlay.LogLevel != "" {
 		base.LogLevel = overlay.LogLevel
 	}
@@ -278,28 +288,30 @@ func mergeConfig(base *Config, overlay *Config) {
 	if overlay.AuthRequireGuestsToLogin != base.AuthRequireGuestsToLogin {
 		base.AuthRequireGuestsToLogin = overlay.AuthRequireGuestsToLogin
 	}
-
-	// Override nested structs
-	if overlay.DefaultPolicy.ShowDiagnostics != base.DefaultPolicy.ShowDiagnostics {
-		base.DefaultPolicy.ShowDiagnostics = overlay.DefaultPolicy.ShowDiagnostics
-	}
-	if overlay.DefaultPolicy.ShowLogList != base.DefaultPolicy.ShowLogList {
-		base.DefaultPolicy.ShowLogList = overlay.DefaultPolicy.ShowLogList
+	if overlay.AuthLocalUsers.Enabled {
+		base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled
 	}
+}
 
-	if overlay.Prometheus.Enabled != base.Prometheus.Enabled {
-		base.Prometheus.Enabled = overlay.Prometheus.Enabled
+func overrideNested(base *Config, overlay *Config) {
+	// Only apply overrides when overlay explicitly enables the option.
+	// This mirrors the presence-check pattern used elsewhere to avoid
+	// unintentionally disabling an already-enabled base setting with a default false.
+	if overlay.DefaultPolicy.ShowDiagnostics {
+		base.DefaultPolicy.ShowDiagnostics = true
 	}
-	if overlay.Prometheus.DefaultGoMetrics != base.Prometheus.DefaultGoMetrics {
-		base.Prometheus.DefaultGoMetrics = overlay.Prometheus.DefaultGoMetrics
+	if overlay.DefaultPolicy.ShowLogList {
+		base.DefaultPolicy.ShowLogList = true
 	}
-
-	// Override AuthLocalUsers.Enabled if set
-	if overlay.AuthLocalUsers.Enabled {
-		base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled
+	if overlay.Prometheus.Enabled {
+		base.Prometheus.Enabled = true
 	}
+	if overlay.Prometheus.DefaultGoMetrics {
+		base.Prometheus.DefaultGoMetrics = true
+	}
+}
 
-	// Override string fields if non-empty
+func overrideStrings(base *Config, overlay *Config) {
 	overrideString(&base.BannerMessage, overlay.BannerMessage)
 	overrideString(&base.BannerCSS, overlay.BannerCSS)
 	overrideString(&base.LogLevel, overlay.LogLevel)

+ 55 - 47
service/internal/config/config_reloader_test.go

@@ -90,55 +90,63 @@ var envConfigTests = []struct {
 }
 
 func TestEnvInConfig(t *testing.T) {
-	for _, tt := range envConfigTests {
-		cfg := DefaultConfig()
-
-		if tt.input != "" {
-			os.Setenv("INPUT", tt.input)
-		}
-
-		// Process the YAML content to replace environment variables
-		processedYaml := envRegex.ReplaceAllStringFunc(tt.yaml, func(match string) string {
-			submatches := envRegex.FindStringSubmatch(match)
-			key := submatches[1]
-			val, _ := os.LookupEnv(key)
-			return val
-		})
-
-		k := koanf.New(".")
-		err := k.Load(rawbytes.Provider([]byte(processedYaml)), yaml.Parser())
-		if err != nil {
-			t.Errorf("Error loading YAML: %v", err)
-			continue
-		}
+    for _, tt := range envConfigTests {
+        cfg := DefaultConfig()
+        setIfNotEmpty("INPUT", tt.input)
+        processed := processYamlWithEnv(tt.yaml)
+        k, err := loadKoanf(processed)
+        if err != nil {
+            t.Errorf("Error loading YAML: %v", err)
+            continue
+        }
+        if err := k.Unmarshal(".", cfg); err != nil {
+            t.Errorf("Error unmarshalling config: %v", err)
+            continue
+        }
+        manualAssigns(k, cfg)
+        field := tt.selector(cfg)
+        assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
+        os.Unsetenv("INPUT")
+    }
+}
 
-		// Try default unmarshaling
-		err = k.Unmarshal(".", cfg)
-		if err != nil {
-			t.Errorf("Error unmarshalling config: %v", err)
-			continue
-		}
+func setIfNotEmpty(key, val string) {
+    if val != "" {
+        os.Setenv(key, val)
+    }
+}
 
-		// Manual field assignment for testing (since default unmarshaling has issues with field mapping)
-		if k.Exists("PageTitle") {
-			cfg.PageTitle = k.String("PageTitle")
-		}
-		if k.Exists("CheckForUpdates") {
-			cfg.CheckForUpdates = k.Bool("CheckForUpdates")
-		}
-		if k.Exists("LogHistoryPageSize") {
-			cfg.LogHistoryPageSize = k.Int64("LogHistoryPageSize")
-		}
-		if k.Exists("actions") {
-			var actions []*Action
-			if err := k.Unmarshal("actions", &actions); err == nil {
-				cfg.Actions = actions
-			}
-		}
+func processYamlWithEnv(content string) string {
+    return envRegex.ReplaceAllStringFunc(content, func(match string) string {
+        submatches := envRegex.FindStringSubmatch(match)
+        key := submatches[1]
+        val, _ := os.LookupEnv(key)
+        return val
+    })
+}
 
-		field := tt.selector(cfg)
-		assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
+func loadKoanf(processed string) (*koanf.Koanf, error) {
+    k := koanf.New(".")
+    if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
+        return nil, err
+    }
+    return k, nil
+}
 
-		os.Unsetenv("INPUT")
-	}
+func manualAssigns(k *koanf.Koanf, cfg *Config) {
+    if k.Exists("PageTitle") {
+        cfg.PageTitle = k.String("PageTitle")
+    }
+    if k.Exists("CheckForUpdates") {
+        cfg.CheckForUpdates = k.Bool("CheckForUpdates")
+    }
+    if k.Exists("LogHistoryPageSize") {
+        cfg.LogHistoryPageSize = k.Int64("LogHistoryPageSize")
+    }
+    if k.Exists("actions") {
+        var actions []*Action
+        if err := k.Unmarshal("actions", &actions); err == nil {
+            cfg.Actions = actions
+        }
+    }
 }

+ 28 - 22
service/internal/entities/entities.go

@@ -30,36 +30,42 @@ func AddListener(l func()) {
 }
 
 func SetupEntityFileWatchers(cfg *config.Config) {
-	configDir := cfg.GetDir()
+	baseDir := resolveEntitiesBaseDir(cfg.GetDir())
+	for i := range cfg.Entities { // #337 - iterate by key, not by value
+		ef := cfg.Entities[i]
+		watchAndLoadEntity(baseDir, ef)
+	}
+}
 
-	// Only use var directory if not in integration test mode
-	absConfigDir, _ := filepath.Abs(configDir)
-	if !strings.Contains(absConfigDir, "integration-tests") {
-		configDirVar := filepath.Join(configDir, "var") // for development purposes
+//gocyclo:ignore
+func resolveEntitiesBaseDir(configDir string) string {
+	absConfigDir, err := filepath.Abs(configDir)
 
-		if _, err := os.Stat(configDirVar); err == nil {
-			configDir = configDirVar
-		}
+	if err != nil {
+		log.Errorf("Error getting absolute path for %s: %v", configDir, err)
+		return configDir
 	}
 
-	for entityIndex := range cfg.Entities { // #337 - iterate by key, not by value
-		ef := cfg.Entities[entityIndex]
-		p := ef.File
-
-		if !filepath.IsAbs(p) {
-			p = filepath.Join(configDir, p)
+	if strings.Contains(absConfigDir, "integration-tests") {
+		return configDir
+	}
 
-			log.WithFields(log.Fields{
-				"entityFile": p,
-			}).Debugf("Adding config dir to entity file path")
-		}
+	devVar := filepath.Join(configDir, "var")
 
-		go filehelper.WatchFileWrite(p, func(filename string) {
-			loadEntityFile(p, ef.Name)
-		})
+	if _, err := os.Stat(devVar); err == nil {
+		return devVar
+	}
+	return absConfigDir
+}
 
-		loadEntityFile(p, ef.Name)
+func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
+	p := ef.File
+	if !filepath.IsAbs(p) {
+		p = filepath.Join(baseDir, p)
+		log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
 	}
+	go filehelper.WatchFileWrite(p, func(filename string) { loadEntityFile(p, ef.Name) })
+	loadEntityFile(p, ef.Name)
 }
 
 func loadEntityFile(filename string, entityname string) {

+ 30 - 35
service/internal/executor/arguments.go

@@ -46,42 +46,42 @@ func parseActionExec(values map[string]string, action *config.Action, entity *en
 	if action == nil {
 		return nil, fmt.Errorf("action is nil")
 	}
-
-	for _, arg := range action.Arguments {
-		argName := arg.Name
-		argValue := values[argName]
-
-		err := typecheckActionArgument(&arg, argValue, action)
-
+	if err := validateArguments(values, action); err != nil {
+		return nil, err
+	}
+	parsed := make([]string, len(action.Exec))
+	for i, a := range action.Exec {
+		out, err := parseSingleExec(a, values, entity)
 		if err != nil {
 			return nil, err
 		}
+		parsed[i] = out
+	}
+	logParsedExec(action, parsed, values)
+	return parsed, nil
+}
 
-		log.WithFields(log.Fields{
-			"name":  argName,
-			"value": argValue,
-		}).Debugf("Arg assigned")
+func parseSingleExec(a string, values map[string]string, entity *entities.Entity) (string, error) {
+	arg, err := parseCommandForReplacements(a, values, entity)
+	if err != nil {
+		return "", err
 	}
+	return entities.ParseTemplateWithArgs(arg, entity, values), nil
+}
 
-	parsedArgs := make([]string, len(action.Exec))
-	for i, arg := range action.Exec {
-		parsedArg, err := parseCommandForReplacements(arg, values, entity)
-		if err != nil {
-			return nil, err
+func validateArguments(values map[string]string, action *config.Action) error {
+	for _, arg := range action.Arguments {
+		if err := typecheckActionArgument(&arg, values[arg.Name], action); err != nil {
+			return err
 		}
-
-		parsedArg = entities.ParseTemplateWithArgs(parsedArg, entity, values)
-		parsedArgs[i] = parsedArg
+		log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned")
 	}
+	return nil
+}
 
-	redactedArgs := redactExecArgs(parsedArgs, action.Arguments, values)
-
-	log.WithFields(log.Fields{
-		"actionTitle": action.Title,
-		"cmd":         redactedArgs,
-	}).Infof("Action parse args - After (Exec)")
-
-	return parsedArgs, nil
+func logParsedExec(action *config.Action, parsed []string, values map[string]string) {
+	redacted := redactExecArgs(parsed, action.Arguments, values)
+	log.WithFields(log.Fields{"actionTitle": action.Title, "cmd": redacted}).Infof("Action parse args - After (Exec)")
 }
 
 func parseActionArguments(values map[string]string, action *config.Action, entity *entities.Entity) (string, error) {
@@ -298,17 +298,12 @@ func checkShellArgumentSafety(action *config.Action) error {
 	if action.Shell == "" {
 		return nil
 	}
-
-	unsafeTypes := []string{"url", "email", "raw_string_multiline", "very_dangerous_raw_string"}
-
+	unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}}
 	for _, arg := range action.Arguments {
-		for _, unsafeType := range unsafeTypes {
-			if arg.Type == unsafeType {
-				return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
-			}
+		if _, bad := unsafe[arg.Type]; bad {
+			return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
 		}
 	}
-
 	return nil
 }
 

+ 110 - 46
service/internal/executor/executor.go

@@ -224,6 +224,48 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
 	return trackingIds, pagingResult
 }
 
+// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
+// paginated correctly based on the filtered set.
+func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
+	// Build filtered list in reverse-chronological order (matching GetLogTrackingIds)
+	filtered := make([]*InternalLogEntry, 0)
+
+	e.logmutex.RLock()
+	for i := len(e.logsTrackingIdsByDate) - 1; i >= 0; i-- {
+		entry := e.logs[e.logsTrackingIdsByDate[i]]
+		if entry == nil || entry.Binding == nil || entry.Binding.Action == nil {
+			continue
+		}
+		if acl.IsAllowedLogs(cfg, user, entry.Binding.Action) {
+			filtered = append(filtered, entry)
+		}
+	}
+	e.logmutex.RUnlock()
+
+	total := int64(len(filtered))
+	paging := &PagingResult{PageSize: pageCount, TotalCount: total, StartOffset: startOffset}
+
+	if total == 0 {
+		paging.CountRemaining = 0
+		return []*InternalLogEntry{}, paging
+	}
+
+	// Compute start/end indices using the same semantics as GetLogTrackingIds,
+	// but over the filtered slice
+	startIndex := getPagingStartIndex(startOffset, total)
+	pageCount = min(total, pageCount)
+	endIndex := max(0, (startIndex-pageCount)+1)
+
+	// Slice is inclusive of both ends in original logic, so iterate and collect
+	out := make([]*InternalLogEntry, 0, pageCount)
+	for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
+		out = append(out, filtered[i])
+	}
+
+	paging.CountRemaining = endIndex
+	return out, paging
+}
+
 func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
 	e.logmutex.RLock()
 
@@ -427,49 +469,73 @@ func stepACLCheck(req *ExecutionRequest) bool {
 }
 
 func stepParseArgs(req *ExecutionRequest) bool {
-	var err error
+	ensureArgumentMap(req)
+	injectSystemArgs(req)
 
-	if req.Arguments == nil {
-		req.Arguments = make(map[string]string)
+	if !hasBindingAndAction(req) {
+		return fail(req, fmt.Errorf("cannot parse arguments: Binding or Action is nil"))
 	}
 
-	req.Arguments["ot_executionTrackingId"] = req.TrackingID
-	req.Arguments["ot_username"] = req.AuthenticatedUser.Username
-
 	mangleInvalidArgumentValues(req)
 
-	if req.Binding == nil || req.Binding.Action == nil {
-		err = fmt.Errorf("cannot parse arguments: Binding or Action is nil")
-		req.logEntry.Output = err.Error()
-		log.Warn(err.Error())
-		return false
+	if hasExec(req) {
+		return handleExecBranch(req)
+	} else {
+		return handleShellBranch(req)
 	}
+}
 
-	if len(req.Binding.Action.Exec) > 0 {
-		req.useDirectExec = true
-		req.execArgs, err = parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
-	} else {
-		req.useDirectExec = false
+func handleExecBranch(req *ExecutionRequest) bool {
+	args, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
 
-		err = checkShellArgumentSafety(req.Binding.Action)
-		if err != nil {
-			req.logEntry.Output = err.Error()
-			log.Warn(err.Error())
-			return false
-		}
+	if err != nil {
+		return fail(req, err)
+	}
+
+	req.useDirectExec = true
+	req.execArgs = args
+	return true
+}
 
-		req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Binding.Action, req.Binding.Entity)
+func handleShellBranch(req *ExecutionRequest) bool {
+	if err := checkShellArgumentSafety(req.Binding.Action); err != nil {
+		return fail(req, err)
 	}
 
+	cmd, err := parseActionArguments(req.Arguments, req.Binding.Action, req.Binding.Entity)
+
 	if err != nil {
-		req.logEntry.Output = err.Error()
+		return fail(req, err)
+	}
 
-		log.Warn(err.Error())
+	req.useDirectExec = false
+	req.finalParsedCommand = cmd
+	return true
+}
 
-		return false
+func ensureArgumentMap(req *ExecutionRequest) {
+	if req.Arguments == nil {
+		req.Arguments = make(map[string]string)
 	}
+}
 
-	return true
+func injectSystemArgs(req *ExecutionRequest) {
+	req.Arguments["ot_executionTrackingId"] = req.TrackingID
+	req.Arguments["ot_username"] = req.AuthenticatedUser.Username
+}
+
+func hasBindingAndAction(req *ExecutionRequest) bool {
+	return !(req.Binding == nil || req.Binding.Action == nil)
+}
+
+func hasExec(req *ExecutionRequest) bool {
+	return len(req.Binding.Action.Exec) > 0
+}
+
+func fail(req *ExecutionRequest, err error) bool {
+	req.logEntry.Output = err.Error()
+	log.Warn(err.Error())
+	return false
 }
 
 func stepRequestAction(req *ExecutionRequest) bool {
@@ -482,6 +548,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
@@ -585,34 +652,17 @@ func buildEnv(args map[string]string) []string {
 func stepExec(req *ExecutionRequest) bool {
 	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second)
 	defer cancel()
-
 	streamer := &OutputStreamer{Req: req}
-
-	var cmd *exec.Cmd
-	if req.useDirectExec {
-		cmd = wrapCommandDirect(ctx, req.execArgs)
-	} else {
-		cmd = wrapCommandInShell(ctx, req.finalParsedCommand)
-	}
-
+	cmd := buildCommand(ctx, req)
 	if cmd == nil {
 		req.logEntry.Output = "Cannot execute: no command arguments provided"
 		log.Warn("Cannot execute: no command arguments provided")
 		return false
 	}
-
-	cmd.Stdout = streamer
-	cmd.Stderr = streamer
-	cmd.Env = buildEnv(req.Arguments)
-
-	req.logEntry.ExecutionStarted = true
-
+	prepareCommand(cmd, streamer, req)
 	runerr := cmd.Start()
-
 	req.logEntry.Process = cmd.Process
-
 	waiterr := cmd.Wait()
-
 	req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
 	req.logEntry.Output = streamer.String()
 
@@ -642,6 +692,20 @@ func stepExec(req *ExecutionRequest) bool {
 	return true
 }
 
+func buildCommand(ctx context.Context, req *ExecutionRequest) *exec.Cmd {
+	if req.useDirectExec {
+		return wrapCommandDirect(ctx, req.execArgs)
+	}
+	return wrapCommandInShell(ctx, req.finalParsedCommand)
+}
+
+func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionRequest) {
+	cmd.Stdout = streamer
+	cmd.Stderr = streamer
+	cmd.Env = buildEnv(req.Arguments)
+	req.logEntry.ExecutionStarted = true
+}
+
 func stepExecAfter(req *ExecutionRequest) bool {
 	if req.Binding.Action.ShellAfterCompleted == "" {
 		return true

+ 0 - 44
service/internal/httpservers/restapi.go

@@ -1,44 +0,0 @@
-package httpservers
-
-import (
-	"net/http"
-	"strings"
-
-	log "github.com/sirupsen/logrus"
-
-	//	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
-
-	config "github.com/OliveTin/OliveTin/internal/config"
-)
-
-func parseHttpHeaderForAuth(cfg *config.Config, req *http.Request) (string, string) {
-	username, ok := req.Header[cfg.AuthHttpHeaderUsername]
-
-	if !ok {
-		log.Warnf("Config has AuthHttpHeaderUsername set to %v, but it was not found", cfg.AuthHttpHeaderUsername)
-
-		return "", ""
-	}
-
-	if cfg.AuthHttpHeaderUserGroup != "" {
-		usergroup, ok := req.Header[cfg.AuthHttpHeaderUserGroup]
-
-		if ok {
-			log.Debugf("HTTP Header Auth found a username and usergroup")
-
-			return username[0], usergroup[0]
-		} else {
-			log.Warnf("Config has AuthHttpHeaderUserGroup set to %v, but it was not found", cfg.AuthHttpHeaderUserGroup)
-		}
-	}
-
-	log.Debugf("HTTP Header Auth found a username, but usergroup is not being used")
-
-	return username[0], ""
-}
-
-//gocyclo:ignore
-func parseJwtHeader(cfg *config.Config, req *http.Request) (string, string) {
-	// JWTs in the Authorization header are usually prefixed with "Bearer " which is not part of the JWT token.
-	return parseJwt(cfg, strings.TrimPrefix(req.Header.Get(cfg.AuthJwtHeader), "Bearer "))
-}

+ 10 - 4
service/main.go

@@ -51,10 +51,16 @@ func init() {
 }
 
 func initLog() {
-	log.SetFormatter(&log.TextFormatter{
-		ForceQuote:       true,
-		DisableTimestamp: true,
-	})
+	logFormat := os.Getenv("OLIVETIN_LOG_FORMAT")
+
+	if logFormat == "json" {
+		log.SetFormatter(&log.JSONFormatter{})
+	} else {
+		log.SetFormatter(&log.TextFormatter{
+			ForceQuote:       true,
+			DisableTimestamp: true,
+		})
+	}
 
 	// Use debug this early on to catch details about startup errors. The
 	// default config will raise the log level later, if not set.

Некоторые файлы не были показаны из-за большого количества измененных файлов