Browse Source

Merge branch 'next' of github.com:OliveTin/OliveTin into next

jamesread 7 months ago
parent
commit
fe8ecf49eb

+ 3 - 0
frontend/resources/vue/components/DashboardComponent.vue

@@ -13,6 +13,8 @@
         <div v-html="component.title" />
     </div>
 
+    <DashboardComponentMostRecentExecution v-else-if="component.type == 'stdout-most-recent-execution'" :component="component" />
+
     <template v-else-if="component.type == 'fieldset'">
         <template v-for="subcomponent in component.contents" :key="subcomponent.title">
             <DashboardComponent :component="subcomponent" />
@@ -28,6 +30,7 @@
 
 <script setup>
 import ActionButton from '../ActionButton.vue'
+import DashboardComponentMostRecentExecution from './DashboardComponentMostRecentExecution.vue'
 
 const props = defineProps({
     component: {

+ 149 - 0
frontend/resources/vue/components/DashboardComponentMostRecentExecution.vue

@@ -0,0 +1,149 @@
+<template>
+  <div class="mre-container">   
+    <router-link 
+        v-if="executionTrackingId" 
+        :to="`/logs/${executionTrackingId}`" 
+        class="mre-link"
+    >
+        <pre class="mre-output">{{ output }}</pre>
+    </router-link>
+    <pre v-else class="mre-output fg-important">{{ output }}</pre>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+
+const props = defineProps({
+  component: {
+    type: Object,
+    required: true
+  }
+})
+
+const output = ref('Waiting...')
+const executionTrackingId = ref(null)
+let eventListener = null
+
+async function fetchMostRecentExecution() {
+  if (!props.component.title) {
+    output.value = 'Error: No action ID specified'
+    executionTrackingId.value = null
+    return
+  }
+
+  if (!window.client) {
+    output.value = 'Error: Client not initialized'
+    executionTrackingId.value = null
+    return
+  }
+
+  try {
+    const executionStatusArgs = {
+      actionId: props.component.title
+    }
+
+    const result = await window.client.executionStatus(executionStatusArgs)
+    
+    if (result.logEntry) {
+      if (result.logEntry.output !== undefined) {
+        output.value = result.logEntry.output
+      } else {
+        output.value = 'No output available'
+      }
+      if (result.logEntry.executionTrackingId) {
+        executionTrackingId.value = result.logEntry.executionTrackingId
+      }
+    } else {
+      output.value = 'No output available'
+      executionTrackingId.value = null
+    }
+  } catch (err) {
+    if (err.code === 'NotFound' || err.status === 404) {
+      output.value = 'No execution found'
+      executionTrackingId.value = null
+    } else {
+      output.value = 'Error: ' + (err.message || 'Failed to fetch execution')
+      console.error('Failed to fetch most recent execution:', err)
+      executionTrackingId.value = null
+    }
+  }
+}
+
+function handleExecutionFinished(event) {
+  // The dashboard component "title" field is used for lots of things
+  // and in this context for MreOutput it's just to refer to an actionId.
+  //
+  // So this is not a typo.
+  const logEntry = event.payload.logEntry
+  if (logEntry && logEntry.actionId === props.component.title) {
+    if (logEntry.output !== undefined) {
+      output.value = logEntry.output
+    }
+    if (logEntry.executionTrackingId) {
+      executionTrackingId.value = logEntry.executionTrackingId
+    }
+  }
+}
+
+onMounted(() => {
+  fetchMostRecentExecution()
+  
+  eventListener = (event) => handleExecutionFinished(event)
+  window.addEventListener('EventExecutionFinished', eventListener)
+})
+
+onBeforeUnmount(() => {
+  if (eventListener) {
+    window.removeEventListener('EventExecutionFinished', eventListener)
+  }
+})
+</script>
+
+<style scoped>
+.mre-container {
+  display: grid;
+  grid-column: span 2;
+}
+
+.mre-link {
+  text-decoration: none;
+  color: inherit;
+  display: grid;
+  cursor: pointer;
+  grid-column: span 2;
+}
+
+.mre-link:hover .mre-output {
+  border-color: #999;
+}
+
+.mre-output {
+  box-shadow: 0 0 .6em #aaa;
+  border: 1px dashed #ccc;
+  border-radius: .7em;
+  padding: 1em;
+  margin: 0;
+  min-height: 0;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  font-family: monospace;
+  font-size: 0.9em;
+  overflow-x: auto;
+  overflow-y: auto;
+  transition: border-color 0.2s ease;
+  max-height: 20em;
+}
+
+@media (prefers-color-scheme: dark) {
+  .mre-output {
+    border: 1px dashed #444;
+    box-shadow: 0 0 .6em #444;
+  }
+  
+  .mre-link:hover .mre-output {
+    border-color: #666;
+  }
+}
+</style>
+

+ 21 - 0
integration-tests/tests/stdoutMostRecentExecution/config.yaml

@@ -0,0 +1,21 @@
+logLevel: debug
+
+actions:
+  - title: Check status
+    id: status_command
+    shell: |
+      date
+
+    icon: poop
+
+dashboards:
+  - title: Test Dashboard
+    contents:
+      - title: Status Section
+        type: fieldset
+        contents:
+          - type: stdout-most-recent-execution
+            title: status_command
+
+          - title: Check status
+

+ 141 - 0
integration-tests/tests/stdoutMostRecentExecution/stdoutMostRecentExecution.mjs

@@ -0,0 +1,141 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By, Condition } from 'selenium-webdriver'
+import {
+  getRootAndWait,
+  getActionButtons,
+  takeScreenshotOnFailure,
+} from '../../lib/elements.js'
+
+describe('config: stdout-most-recent-execution', function () {
+  before(async function () {
+    await runner.start('stdoutMostRecentExecution')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('stdout-most-recent-execution component is rendered', async function () {
+    await getRootAndWait()
+
+    const title = await webdriver.getTitle()
+    expect(title).to.be.equal('Test Dashboard - OliveTin')
+
+    // Wait for the mre-output element to appear
+    await webdriver.wait(
+      new Condition('wait for mre-output element', async () => {
+        const elements = await webdriver.findElements(By.css('.mre-output'))
+        return elements.length > 0
+      }),
+      10000
+    )
+
+    const mreElements = await webdriver.findElements(By.css('.mre-output'))
+    expect(mreElements).to.have.length(1, 'Expected one stdout-most-recent-execution component')
+  })
+
+  it('stdout-most-recent-execution displays initial state', async function () {
+    await getRootAndWait()
+
+    await webdriver.wait(
+      new Condition('wait for mre-output element', async () => {
+        const elements = await webdriver.findElements(By.css('.mre-output'))
+        return elements.length > 0
+      }),
+      10000
+    )
+
+    const mreElement = await webdriver.findElement(By.css('.mre-output'))
+    const text = await mreElement.getText()
+
+    // Should show either "Waiting...", "No execution found", or actual output
+    expect(text).to.be.a('string')
+    expect(text.length).to.be.greaterThan(0)
+  })
+
+  it('stdout-most-recent-execution updates after action execution', async function () {
+    this.timeout(30000) // Increase timeout for this test
+
+    await getRootAndWait()
+
+    // Wait for the mre-output element
+    await webdriver.wait(
+      new Condition('wait for mre-output element', async () => {
+        const elements = await webdriver.findElements(By.css('.mre-output'))
+        return elements.length > 0
+      }),
+      10000
+    )
+
+    const mreElement = await webdriver.findElement(By.css('.mre-output'))
+    const initialText = await mreElement.getText()
+
+    // Find the "Check status" action button (button text is the action title, not ID)
+    await webdriver.wait(
+      new Condition('wait for Check status button', async () => {
+        const buttons = await webdriver.findElements(By.css('.action-button button'))
+        for (const btn of buttons) {
+          const text = await btn.getText()
+          if (text.includes('Check status')) {
+            return true
+          }
+        }
+        return false
+      }),
+      10000
+    )
+
+    const buttons = await webdriver.findElements(By.css('.action-button button'))
+    let statusButton = null
+    for (const btn of buttons) {
+      const text = await btn.getText()
+      if (text.includes('Check status')) {
+        statusButton = btn
+        break
+      }
+    }
+    expect(statusButton).to.not.be.null
+
+    // Click the button to execute the action
+    await statusButton.click()
+
+    // Wait a moment for the action to start
+    await webdriver.sleep(500)
+
+    // Wait for the output to update (the component listens to EventExecutionFinished events)
+    // We'll wait for the output to change from the initial state
+    await webdriver.wait(
+      new Condition('wait for output to update after execution', async () => {
+        try {
+          const mreElement = await webdriver.findElement(By.css('.mre-output'))
+          const newText = await mreElement.getText()
+          // Output should change from initial state and contain actual output
+          // (not "Waiting...", "No execution found", or the same as initialText)
+          const hasChanged = newText !== initialText
+          const hasValidOutput = newText && 
+                                 !newText.includes('Waiting...') && 
+                                 !newText.includes('No execution found') && 
+                                 !newText.includes('Error:') &&
+                                 newText.trim().length > 0
+          return hasChanged && hasValidOutput
+        } catch (e) {
+          return false
+        }
+      }),
+      20000
+    )
+
+    const updatedMreElement = await webdriver.findElement(By.css('.mre-output'))
+    const updatedText = await updatedMreElement.getText()
+
+    // The date command should produce output, so verify it's not empty and not an error state
+    expect(updatedText).to.not.include('Waiting...')
+    expect(updatedText).to.not.include('No execution found')
+    expect(updatedText.trim().length).to.be.greaterThan(0)
+  })
+})