Beta (#824)
This commit is contained in:
commit
d5b0276b69
|
|
@ -10,6 +10,7 @@ on:
|
|||
branches:
|
||||
- main
|
||||
- next
|
||||
- beta
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
branches:
|
||||
- name: main
|
||||
#branches:
|
||||
# - name: main
|
||||
# range: '3000.x.x'
|
||||
|
||||
# - name: release/2k
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ actions:
|
|||
popupOnStart: execution-button
|
||||
maxRate:
|
||||
- limit: 3
|
||||
duration: 5m
|
||||
duration: 1m
|
||||
|
||||
# You are not limited to operating system commands, and of course you can run
|
||||
# your own scripts. Here `maxConcurrent` stops the script running multiple
|
||||
|
|
@ -124,7 +124,10 @@ actions:
|
|||
#
|
||||
# Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html
|
||||
- title: Get OliveTin Theme
|
||||
shell: olivetin-get-theme {{ themeGitRepo }} {{ themeFolderName }}
|
||||
exec:
|
||||
- "olivetin-get-theme"
|
||||
- "{{ themeGitRepo }}"
|
||||
- "{{ themeFolderName }}"
|
||||
icon: theme
|
||||
arguments:
|
||||
- name: themeGitRepo
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
<title>OliveTin</title>
|
||||
|
||||
<link rel = "stylesheet" type = "text/css" href = "/theme.css" />
|
||||
<link rel = "stylesheet" href = "node_modules/@xterm/xterm/css/xterm.css" />
|
||||
|
||||
<link rel = "shortcut icon" type = "image/png" href = "OliveTinLogo.png" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
|
||||
import { rateLimits } from '../resources/vue/stores/rateLimits.js'
|
||||
|
||||
export function initWebsocket () {
|
||||
window.addEventListener('EventOutputChunk', onOutputChunk)
|
||||
window.addEventListener('EventExecutionStarted', onExecutionChanged)
|
||||
window.addEventListener('EventExecutionFinished', onExecutionChanged)
|
||||
|
||||
reconnectWebsocket()
|
||||
}
|
||||
|
|
@ -40,7 +43,6 @@ function handleEvent (msg) {
|
|||
break
|
||||
case 'EventExecutionFinished':
|
||||
case 'EventExecutionStarted':
|
||||
buttonResults[msg.event.value.logEntry.executionTrackingId] = msg.event.value.logEntry
|
||||
window.dispatchEvent(j)
|
||||
break
|
||||
default:
|
||||
|
|
@ -59,3 +61,19 @@ function onOutputChunk (evt) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onExecutionChanged (evt) {
|
||||
buttonResults[evt.payload.logEntry.executionTrackingId] = evt.payload.logEntry
|
||||
|
||||
const logEntry = evt.payload.logEntry
|
||||
|
||||
// Update rate limit store from logEntry if rate limit expiry datetime is provided
|
||||
if (logEntry && logEntry.datetimeRateLimitExpires && logEntry.bindingId) {
|
||||
// Parse datetime string "2006-01-02 15:04:05" and convert to Unix timestamp
|
||||
const date = new Date(logEntry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z')
|
||||
rateLimits[logEntry.bindingId] = date.getTime() / 1000
|
||||
} else if (logEntry && logEntry.bindingId) {
|
||||
// Clear rate limit if not set
|
||||
rateLimits[logEntry.bindingId] = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -11,17 +11,18 @@
|
|||
"dependencies": {
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
"@connectrpc/connect-web": "^2.1.1",
|
||||
"@hugeicons/core-free-icons": "^3.1.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/vue": "^1.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"iconify-icon": "^3.0.2",
|
||||
"picocrank": "^1.10.0",
|
||||
"picocrank": "^1.13.0",
|
||||
"standard": "^17.1.2",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vue-i18n": "^11.2.7",
|
||||
"vite": "^7.3.1",
|
||||
"vue": "^3.5.26",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -855,9 +856,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@hugeicons/core-free-icons": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.0.tgz",
|
||||
"integrity": "sha512-DVIdHcPxJ8MyaXaGe3appbaB5z4DWit5RLn8vTy3hsKTrpFEm4QftixfbcHXRqLpOMAoMa2+UXk35qNJ4ZWIsg==",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.1.tgz",
|
||||
"integrity": "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hugeicons/vue": {
|
||||
|
|
@ -911,13 +912,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.7.tgz",
|
||||
"integrity": "sha512-+Ra9I/LAzXDnmv/IrTO03WMCiLya7pHRmGJvNl9fKwx/W4REJ0xaMk2PxCRqnxcBsX443amEMdebQ3R1geiuIw==",
|
||||
"version": "11.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
|
||||
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "11.2.7",
|
||||
"@intlify/shared": "11.2.7"
|
||||
"@intlify/message-compiler": "11.2.8",
|
||||
"@intlify/shared": "11.2.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
|
|
@ -927,12 +928,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.7.tgz",
|
||||
"integrity": "sha512-TFamC+GzJAotAFwUNvbtRVBgvuSn2nCwKNresmPUHv3IIVMmXJt7QQJj/DORI1h8hs46ZF6L0Fs2xBohSOE4iQ==",
|
||||
"version": "11.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
|
||||
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.2.7",
|
||||
"@intlify/shared": "11.2.8",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -943,9 +944,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.7.tgz",
|
||||
"integrity": "sha512-uvlkvc/0uQ4FDlHQZccpUnmcOwNcaI3i+69ck2YJ+GqM35AoVbuS63b+YfirV4G0SZh64Ij2UMcFRMmB4nr95w==",
|
||||
"version": "11.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
|
||||
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
|
|
@ -2961,9 +2962,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/femtocrank": {
|
||||
"version": "2.4.12",
|
||||
"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.4.12.tgz",
|
||||
"integrity": "sha512-X2a4WVG1ADGjQcULUyH2FJ4njJNZobfP+iPO1MpAEtWRyVEDxps6dmktbOb3igoyCxNObF0T0uKwUC7zV21C0A==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/femtocrank/-/femtocrank-2.5.0.tgz",
|
||||
"integrity": "sha512-plV1HNS/fUzohWJ349kuCBZ3TCfXz7V4F/sY2lVbVWtGXUV+aHxLG6IddAMEf64k2LJ8j0KVrj+nIIKepFaKvg==",
|
||||
"license": "AGPL-3.0"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
|
|
@ -4607,17 +4608,17 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picocrank": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.10.0.tgz",
|
||||
"integrity": "sha512-rpuFopcko5jKuQpnqpvN0Umg9M2qmDI6Z/tVzbnT9PSplwJmtw9wTjgDHyQXujoBBULyurRqtIeQKDPNFLlHOQ==",
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.13.0.tgz",
|
||||
"integrity": "sha512-kfUU2KVZnHyZ3/s+vM2tw25G+rK//eMjhF7cuXNQm0Ar/o5YvPiLBx8uxCrNNNvhGqb+jQK1Jbn41KO6rAR8Lw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^3.1.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/vue": "^1.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"femtocrank": "^2.4.12",
|
||||
"femtocrank": "^2.5.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
|
|
@ -6122,9 +6123,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
|
|
@ -6246,13 +6247,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.7.tgz",
|
||||
"integrity": "sha512-LPv8bAY5OA0UvFEXl4vBQOBqJzRrlExy92tWgRuwW7tbykHf7CH71G2Y4TM2OwGcIS4+hyqKHS2EVBqaYwPY9Q==",
|
||||
"version": "11.2.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
|
||||
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.2.7",
|
||||
"@intlify/shared": "11.2.7",
|
||||
"@intlify/core-base": "11.2.8",
|
||||
"@intlify/shared": "11.2.8",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -24,17 +24,18 @@
|
|||
"dependencies": {
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
"@connectrpc/connect-web": "^2.1.1",
|
||||
"@hugeicons/core-free-icons": "^3.1.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/vue": "^1.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"iconify-icon": "^3.0.2",
|
||||
"picocrank": "^1.10.0",
|
||||
"picocrank": "^1.13.0",
|
||||
"standard": "^17.1.2",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vue-i18n": "^11.2.7",
|
||||
"vite": "^7.3.1",
|
||||
"vue": "^3.5.26",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
|
|||
* @generated from field: int32 timeout = 8;
|
||||
*/
|
||||
timeout: number;
|
||||
|
||||
/**
|
||||
* Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||
*
|
||||
* @generated from field: string datetime_rate_limit_expires = 9;
|
||||
*/
|
||||
datetimeRateLimitExpires: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -99,6 +106,11 @@ export declare type ActionArgument = Message<"olivetin.api.v1.ActionArgument"> &
|
|||
* @generated from field: map<string, string> suggestions = 7;
|
||||
*/
|
||||
suggestions: { [key: string]: string };
|
||||
|
||||
/**
|
||||
* @generated from field: string suggestions_browser_key = 8;
|
||||
*/
|
||||
suggestionsBrowserKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -476,6 +488,13 @@ export declare type GetLogsRequest = Message<"olivetin.api.v1.GetLogsRequest"> &
|
|||
* @generated from field: int64 start_offset = 1;
|
||||
*/
|
||||
startOffset: bigint;
|
||||
|
||||
/**
|
||||
* Optional date filter in YYYY-MM-DD format
|
||||
*
|
||||
* @generated from field: string date_filter = 2;
|
||||
*/
|
||||
dateFilter: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -543,11 +562,6 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
|
|||
*/
|
||||
datetimeFinished: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string action_id = 13;
|
||||
*/
|
||||
actionId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: bool execution_started = 14;
|
||||
*/
|
||||
|
|
@ -572,6 +586,20 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & {
|
|||
* @generated from field: bool can_kill = 18;
|
||||
*/
|
||||
canKill: boolean;
|
||||
|
||||
/**
|
||||
* Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||
*
|
||||
* @generated from field: string datetime_rate_limit_expires = 19;
|
||||
*/
|
||||
datetimeRateLimitExpires: string;
|
||||
|
||||
/**
|
||||
* Binding ID for matching rate limits to action buttons
|
||||
*
|
||||
* @generated from field: string binding_id = 20;
|
||||
*/
|
||||
bindingId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -904,9 +932,9 @@ export declare type DumpVarsResponse = Message<"olivetin.api.v1.DumpVarsResponse
|
|||
export declare const DumpVarsResponseSchema: GenMessage<DumpVarsResponse>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.ActionEntityPair
|
||||
* @generated from message olivetin.api.v1.DebugBinding
|
||||
*/
|
||||
export declare type ActionEntityPair = Message<"olivetin.api.v1.ActionEntityPair"> & {
|
||||
export declare type DebugBinding = Message<"olivetin.api.v1.DebugBinding"> & {
|
||||
/**
|
||||
* @generated from field: string action_title = 1;
|
||||
*/
|
||||
|
|
@ -919,10 +947,10 @@ export declare type ActionEntityPair = Message<"olivetin.api.v1.ActionEntityPair
|
|||
};
|
||||
|
||||
/**
|
||||
* Describes the message olivetin.api.v1.ActionEntityPair.
|
||||
* Use `create(ActionEntityPairSchema)` to create a new message.
|
||||
* Describes the message olivetin.api.v1.DebugBinding.
|
||||
* Use `create(DebugBindingSchema)` to create a new message.
|
||||
*/
|
||||
export declare const ActionEntityPairSchema: GenMessage<ActionEntityPair>;
|
||||
export declare const DebugBindingSchema: GenMessage<DebugBinding>;
|
||||
|
||||
/**
|
||||
* @generated from message olivetin.api.v1.DumpPublicIdActionMapRequest
|
||||
|
|
@ -946,9 +974,9 @@ export declare type DumpPublicIdActionMapResponse = Message<"olivetin.api.v1.Dum
|
|||
alert: string;
|
||||
|
||||
/**
|
||||
* @generated from field: map<string, olivetin.api.v1.ActionEntityPair> contents = 2;
|
||||
* @generated from field: map<string, olivetin.api.v1.DebugBinding> contents = 2;
|
||||
*/
|
||||
contents: { [key: string]: ActionEntityPair };
|
||||
contents: { [key: string]: DebugBinding };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1423,6 +1451,13 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
|
|||
* @generated from field: bool login_required = 23;
|
||||
*/
|
||||
loginRequired: boolean;
|
||||
|
||||
/**
|
||||
* List of available theme names
|
||||
*
|
||||
* @generated from field: repeated string available_themes = 24;
|
||||
*/
|
||||
availableThemes: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div :id="`actionButton-${actionId}`" role="none" class="action-button">
|
||||
<button :id="`actionButtonInner-${actionId}`" :title="title" :disabled="!canExec || isDisabled"
|
||||
:class="buttonClasses" @click="handleClick">
|
||||
<div :id="`actionButton-${bindingId}`" role="none" class="action-button">
|
||||
<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
|
||||
:class="combinedClasses" @click="handleClick">
|
||||
|
||||
<div class="navigate-on-start-container">
|
||||
<div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
|
||||
|
|
@ -18,18 +18,19 @@
|
|||
<span class="icon" v-html="unicodeIcon"></span>
|
||||
<span class="title" aria-live="polite">{{ displayTitle }}
|
||||
</span>
|
||||
<span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ArgumentForm from './views/ArgumentForm.vue'
|
||||
import { buttonResults } from './stores/buttonResults'
|
||||
import { rateLimits } from './stores/rateLimits'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { HugeiconsIcon } from '@hugeicons/vue'
|
||||
import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { ref, watch, onMounted, inject } from 'vue'
|
||||
import { ref, watch, onMounted, onUnmounted, inject, computed } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const navigateOnStart = ref('')
|
||||
|
|
@ -38,10 +39,15 @@ const props = defineProps({
|
|||
actionData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
cssClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const actionId = ref('')
|
||||
const bindingId = ref('')
|
||||
const title = ref('')
|
||||
const canExec = ref(true)
|
||||
const popupOnStart = ref('')
|
||||
|
|
@ -54,9 +60,24 @@ const displayTitle = ref('')
|
|||
const isDisabled = ref(false)
|
||||
const showArgumentForm = ref(false)
|
||||
|
||||
// Rate limiting
|
||||
const rateLimitExpires = ref(0)
|
||||
const isRateLimited = ref(false)
|
||||
const rateLimitMessage = ref('')
|
||||
let rateLimitInterval = null
|
||||
|
||||
// Animation classes
|
||||
const buttonClasses = ref([])
|
||||
|
||||
// Combined classes including custom cssClass
|
||||
const combinedClasses = computed(() => {
|
||||
const classes = [...buttonClasses.value]
|
||||
if (props.cssClass) {
|
||||
classes.push(props.cssClass)
|
||||
}
|
||||
return classes
|
||||
})
|
||||
|
||||
// Timestamps
|
||||
const updateIterationTimestamp = ref(0)
|
||||
|
||||
|
|
@ -75,7 +96,7 @@ function constructFromJson(json) {
|
|||
|
||||
updateFromJson(json)
|
||||
|
||||
actionId.value = json.bindingId
|
||||
bindingId.value = json.bindingId
|
||||
title.value = json.title
|
||||
canExec.value = json.canExec
|
||||
popupOnStart.value = json.popupOnStart
|
||||
|
|
@ -89,6 +110,19 @@ function constructFromJson(json) {
|
|||
isDisabled.value = !json.canExec
|
||||
displayTitle.value = title.value
|
||||
unicodeIcon.value = getUnicodeIcon(json.icon)
|
||||
|
||||
// Initialize rate limit from action data (parse datetime string)
|
||||
if (json.datetimeRateLimitExpires) {
|
||||
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
|
||||
rateLimitExpires.value = date.getTime() / 1000
|
||||
} else {
|
||||
rateLimitExpires.value = 0
|
||||
}
|
||||
// Also initialize the store so the watch picks it up
|
||||
if (bindingId.value) {
|
||||
rateLimits[bindingId.value] = rateLimitExpires.value
|
||||
}
|
||||
updateRateLimitStatus()
|
||||
}
|
||||
|
||||
function updateFromJson(json) {
|
||||
|
|
@ -96,6 +130,55 @@ function updateFromJson(json) {
|
|||
// title - as the callback URL relies on it
|
||||
|
||||
unicodeIcon.value = getUnicodeIcon(json.icon)
|
||||
|
||||
// Update rate limiting if changed (parse datetime string)
|
||||
if (json.datetimeRateLimitExpires) {
|
||||
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
|
||||
rateLimitExpires.value = date.getTime() / 1000
|
||||
updateRateLimitStatus()
|
||||
} else if (json.datetimeRateLimitExpires === '') {
|
||||
// Explicitly clear if empty string
|
||||
rateLimitExpires.value = 0
|
||||
updateRateLimitStatus()
|
||||
}
|
||||
}
|
||||
|
||||
function updateRateLimitStatus() {
|
||||
if (rateLimitExpires.value === 0) {
|
||||
isRateLimited.value = false
|
||||
rateLimitMessage.value = ''
|
||||
if (rateLimitInterval) {
|
||||
clearInterval(rateLimitInterval)
|
||||
rateLimitInterval = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const expires = rateLimitExpires.value
|
||||
|
||||
if (now >= expires) {
|
||||
// Rate limit has expired
|
||||
isRateLimited.value = false
|
||||
rateLimitMessage.value = ''
|
||||
rateLimitExpires.value = 0
|
||||
if (rateLimitInterval) {
|
||||
clearInterval(rateLimitInterval)
|
||||
rateLimitInterval = null
|
||||
}
|
||||
} else {
|
||||
// Still rate limited
|
||||
isRateLimited.value = true
|
||||
const secondsRemaining = expires - now
|
||||
rateLimitMessage.value = `Rate limited, available in ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}`
|
||||
|
||||
// Set up interval to update every second
|
||||
if (!rateLimitInterval) {
|
||||
rateLimitInterval = setInterval(() => {
|
||||
updateRateLimitStatus()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick() {
|
||||
|
|
@ -199,6 +282,30 @@ function onExecStatusChanged() {
|
|||
|
||||
onMounted(() => {
|
||||
constructFromJson(props.actionData)
|
||||
|
||||
// Watch the central rate limit store for updates to this button's bindingId
|
||||
// Watch the entire rateLimits object to ensure reactivity with dynamic keys
|
||||
watch(
|
||||
rateLimits,
|
||||
() => {
|
||||
const id = bindingId.value
|
||||
if (id && rateLimits[id] !== undefined) {
|
||||
const newExpires = rateLimits[id]
|
||||
if (newExpires !== rateLimitExpires.value) {
|
||||
rateLimitExpires.value = newExpires
|
||||
updateRateLimitStatus()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rateLimitInterval) {
|
||||
clearInterval(rateLimitInterval)
|
||||
rateLimitInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
|
|
@ -211,7 +318,9 @@ watch(
|
|||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
|
||||
@layer components {
|
||||
.action-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -256,6 +365,13 @@ watch(
|
|||
padding: 0.2em;
|
||||
}
|
||||
|
||||
.action-button button .rate-limit-message {
|
||||
font-size: 0.75em;
|
||||
color: #856404;
|
||||
padding: 0.2em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.action-button button.action-timeout {
|
||||
background: #fff3cd;
|
||||
|
|
@ -308,5 +424,5 @@ watch(
|
|||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<Header :title="pageTitle" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="showNavigation">
|
||||
<Header :title="pageTitle" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="sidebarEnabled" :topBarEnabled="topbarEnabled" :navigation="navigation">
|
||||
<template #toolbar>
|
||||
<div id="banner" v-if="bannerMessage" :style="bannerCss">
|
||||
<p>{{ bannerMessage }}</p>
|
||||
|
|
@ -19,8 +19,8 @@
|
|||
</Header>
|
||||
|
||||
<div id="layout">
|
||||
<Navigation ref="navigation" v-if="showNavigation">
|
||||
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation" />
|
||||
<Navigation ref="navigation">
|
||||
<Sidebar ref="sidebar" id = "mainnav" v-if="sidebarEnabled && showNavigation"/>
|
||||
</Navigation>
|
||||
|
||||
<div id="content" initial-martial-complete="{{ hasLoaded }}">
|
||||
|
|
@ -46,6 +46,10 @@
|
|||
<a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
|
||||
</span>
|
||||
|
||||
<span v-if="availableThemes.length > 1">
|
||||
<a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a>
|
||||
</span>
|
||||
|
||||
<span>{{ t('connected') }}</span>
|
||||
</p>
|
||||
<p>
|
||||
|
|
@ -55,7 +59,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<dialog ref="languageDialog" class="language-dialog" @click="handleDialogClick">
|
||||
<dialog ref="languageDialog" class="language-dialog" @click="handleLanguageDialogClick">
|
||||
<div class="dialog-content" @click.stop>
|
||||
<h2>{{ t('language-dialog.title') }}</h2>
|
||||
<select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
|
||||
|
|
@ -73,6 +77,21 @@
|
|||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog ref="themeDialog" class="theme-dialog" @click="handleThemeDialogClick">
|
||||
<div class="dialog-content" @click.stop>
|
||||
<h2>{{ t('theme-dialog.title') }}</h2>
|
||||
<select v-model="selectedTheme" @change="changeTheme" class="language-select">
|
||||
<option value="">{{ t('theme-dialog.default') }}</option>
|
||||
<option v-for="theme in availableThemes" :key="theme" :value="theme">
|
||||
{{ theme }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="closeThemeDialog">{{ t('theme-dialog.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -108,6 +127,7 @@ const showNavigation = ref(true)
|
|||
const showLogs = ref(true)
|
||||
const showDiagnostics = ref(true)
|
||||
const showLoginLink = ref(true)
|
||||
const sectionNavigationStyle = ref('sidebar')
|
||||
|
||||
const languageDialog = ref(null)
|
||||
const browserLanguages = ref([])
|
||||
|
|
@ -116,6 +136,12 @@ const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.g
|
|||
const languagePreference = ref(initialLanguagePreference || 'auto')
|
||||
const selectedLanguage = ref(languagePreference.value)
|
||||
|
||||
const themeDialog = ref(null)
|
||||
const availableThemes = ref([])
|
||||
const initialThemePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-theme') : null
|
||||
const themePreference = ref(initialThemePreference || '')
|
||||
const selectedTheme = ref(themePreference.value)
|
||||
|
||||
// Available languages with display names
|
||||
const availableLanguages = {
|
||||
'auto': 'Browser Language',
|
||||
|
|
@ -135,6 +161,23 @@ const currentLanguageName = computed(() => {
|
|||
return availableLanguages[languagePreference.value] || languagePreference.value
|
||||
})
|
||||
|
||||
// Computed property to get current theme display name
|
||||
const currentThemeName = computed(() => {
|
||||
if (!themePreference.value || themePreference.value === '') {
|
||||
return t('theme-dialog.default')
|
||||
}
|
||||
return themePreference.value
|
||||
})
|
||||
|
||||
// Computed properties for navigation style
|
||||
const topbarEnabled = computed(() => {
|
||||
return sectionNavigationStyle.value === 'topbar'
|
||||
})
|
||||
|
||||
const sidebarEnabled = computed(() => {
|
||||
return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
|
||||
})
|
||||
|
||||
function normalizeBrowserLanguage() {
|
||||
const available = Object.keys(combinedTranslations.messages || {})
|
||||
|
||||
|
|
@ -180,12 +223,17 @@ function updateHeaderFromInit() {
|
|||
showNavigation.value = window.initResponse.showNavigation
|
||||
showLogs.value = window.initResponse.showLogList
|
||||
showDiagnostics.value = window.initResponse.showDiagnostics
|
||||
sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
|
||||
availableThemes.value = window.initResponse.availableThemes || []
|
||||
|
||||
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
|
||||
showLoginLink.value = false
|
||||
}
|
||||
|
||||
applyStyleMods()
|
||||
|
||||
renderNavigation()
|
||||
applyTheme()
|
||||
|
||||
if (window.initResponse.loginRequired) {
|
||||
router.push('/login')
|
||||
|
|
@ -269,13 +317,75 @@ function changeLanguage() {
|
|||
closeLanguageDialog()
|
||||
}
|
||||
|
||||
function handleDialogClick(event) {
|
||||
function handleLanguageDialogClick(event) {
|
||||
// Close dialog when clicking on the backdrop
|
||||
if (event.target === languageDialog.value) {
|
||||
closeLanguageDialog()
|
||||
}
|
||||
}
|
||||
|
||||
function openThemeDialog() {
|
||||
selectedTheme.value = themePreference.value || ''
|
||||
|
||||
if (themeDialog.value) {
|
||||
themeDialog.value.showModal()
|
||||
}
|
||||
}
|
||||
|
||||
function closeThemeDialog() {
|
||||
if (themeDialog.value) {
|
||||
themeDialog.value.close()
|
||||
}
|
||||
}
|
||||
|
||||
function changeTheme() {
|
||||
if (!selectedTheme.value || selectedTheme.value === '') {
|
||||
localStorage.removeItem('olivetin-theme')
|
||||
themePreference.value = ''
|
||||
} else {
|
||||
localStorage.setItem('olivetin-theme', selectedTheme.value)
|
||||
themePreference.value = selectedTheme.value
|
||||
}
|
||||
|
||||
applyTheme()
|
||||
closeThemeDialog()
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
let themeStyle = document.getElementById('theme-style')
|
||||
|
||||
if (!themeStyle) {
|
||||
themeStyle = document.createElement('style')
|
||||
themeStyle.id = 'theme-style'
|
||||
themeStyle.type = 'text/css'
|
||||
document.head.appendChild(themeStyle)
|
||||
}
|
||||
|
||||
if (themePreference.value && themePreference.value !== '') {
|
||||
themeStyle.textContent = `@import url('/custom-webui/themes/${themePreference.value}/theme.css') layer(theme);`
|
||||
} else {
|
||||
themeStyle.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyleMods() {
|
||||
if (!window.initResponse || !window.initResponse.styleMods) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const styleMod of window.initResponse.styleMods) {
|
||||
if (styleMod) {
|
||||
document.body.classList.add(styleMod)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleThemeDialogClick(event) {
|
||||
if (event.target === themeDialog.value) {
|
||||
closeThemeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
window.updateHeaderFromInit = updateHeaderFromInit
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -285,6 +395,9 @@ onMounted(() => {
|
|||
// Initialize selected language from stored preference
|
||||
selectedLanguage.value = languagePreference.value
|
||||
|
||||
// Initialize selected theme from stored preference
|
||||
selectedTheme.value = themePreference.value || ''
|
||||
|
||||
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
|
||||
browserLanguages.value = navigator.languages
|
||||
}
|
||||
|
|
@ -305,7 +418,8 @@ onMounted(() => {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.language-dialog {
|
||||
.language-dialog,
|
||||
.theme-dialog {
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0;
|
||||
|
|
@ -313,7 +427,8 @@ onMounted(() => {
|
|||
width: 90%;
|
||||
}
|
||||
|
||||
.language-dialog::backdrop {
|
||||
.language-dialog::backdrop,
|
||||
.theme-dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
<span v-else>{{ component.title }}</span>
|
||||
</h2>
|
||||
|
||||
<fieldset>
|
||||
<fieldset :class="component.cssClass">
|
||||
<template v-for="subcomponent in component.contents">
|
||||
<DashboardComponent :component="subcomponent" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<ActionButton v-if="component.type == 'link'" :actionData="component.action" :key="component.title" />
|
||||
<ActionButton v-if="component.type == 'link'" :actionData="component.action" :cssClass="component.cssClass" :key="component.title" />
|
||||
|
||||
<DashboardComponentDirectory v-else-if="component.type == 'directory'" :component="component" />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<button @click="navigateToDirectory">
|
||||
<button @click="navigateToDirectory" :class="component.cssClass">
|
||||
<span class="icon" v-html="unicodeIcon"></span>
|
||||
<span class="title">{{ component.title }}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="display">
|
||||
<div class="display" :class="component.cssClass">
|
||||
<div v-html="component.title" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="mre-container">
|
||||
<div class="mre-container" :class="component.cssClass">
|
||||
<router-link
|
||||
v-if="executionTrackingId"
|
||||
:to="`/logs/${executionTrackingId}`"
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { buttonResults } from '../stores/buttonResults'
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
|
|
@ -23,7 +24,20 @@ const props = defineProps({
|
|||
|
||||
const output = ref('Waiting...')
|
||||
const executionTrackingId = ref(null)
|
||||
let eventListener = null
|
||||
let unwatchButtonResults = null
|
||||
|
||||
function updateFromLogEntry(logEntry) {
|
||||
if (logEntry) {
|
||||
if (logEntry.output !== undefined) {
|
||||
output.value = logEntry.output
|
||||
} else {
|
||||
output.value = 'No output available'
|
||||
}
|
||||
if (logEntry.executionTrackingId) {
|
||||
executionTrackingId.value = logEntry.executionTrackingId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMostRecentExecution() {
|
||||
if (!props.component.title) {
|
||||
|
|
@ -46,14 +60,7 @@ async function fetchMostRecentExecution() {
|
|||
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
|
||||
}
|
||||
updateFromLogEntry(result.logEntry)
|
||||
} else {
|
||||
output.value = 'No output available'
|
||||
executionTrackingId.value = null
|
||||
|
|
@ -70,32 +77,39 @@ async function fetchMostRecentExecution() {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
unwatchButtonResults = watch(
|
||||
buttonResults,
|
||||
() => {
|
||||
// Find the most recent finished execution for this bindingId
|
||||
const bindingId = props.component.title
|
||||
let mostRecent = null
|
||||
let mostRecentTime = null
|
||||
|
||||
for (const trackingId in buttonResults) {
|
||||
const logEntry = buttonResults[trackingId]
|
||||
if (logEntry && logEntry.bindingId === bindingId && logEntry.executionFinished) {
|
||||
const finishedTime = new Date(logEntry.datetimeFinished)
|
||||
if (!mostRecent || finishedTime > mostRecentTime) {
|
||||
mostRecent = logEntry
|
||||
mostRecentTime = finishedTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mostRecent) {
|
||||
updateFromLogEntry(mostRecent)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (eventListener) {
|
||||
window.removeEventListener('EventExecutionFinished', eventListener)
|
||||
if (unwatchButtonResults) {
|
||||
unwatchButtonResults()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ const routes = [
|
|||
icon: LeftToRightListDashIcon
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/logs/calendar',
|
||||
name: 'LogsCalendar',
|
||||
component: () => import('./views/LogsCalendarView.vue'),
|
||||
meta: {
|
||||
title: 'Logs Calendar',
|
||||
breadcrumb: [
|
||||
{ name: "Logs", href: "/logs" },
|
||||
{ name: "Calendar" },
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/entities',
|
||||
name: 'Entities',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { reactive } from 'vue'
|
||||
|
||||
// Store rate limit expiry times by bindingId
|
||||
// This allows all ActionButton components to reactively update when rate limits change
|
||||
export const rateLimits = reactive({})
|
||||
|
|
@ -12,10 +12,13 @@
|
|||
{{ formatLabel(arg.title) }}
|
||||
</label>
|
||||
|
||||
<datalist v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0" :id="`${arg.name}-choices`">
|
||||
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
|
||||
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
||||
{{ suggestion }}
|
||||
</option>
|
||||
<option v-for="(suggestion, index) in getBrowserSuggestions(arg)" :key="`browser-${index}`" :value="suggestion">
|
||||
{{ suggestion }}
|
||||
</option>
|
||||
</datalist>
|
||||
|
||||
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
||||
|
|
@ -28,7 +31,7 @@
|
|||
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
|
||||
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
|
||||
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
|
||||
:list="arg.suggestions ? `${arg.name}-choices` : undefined"
|
||||
:list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? `${arg.name}-choices` : undefined"
|
||||
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
|
||||
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
|
||||
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
|
||||
|
|
@ -313,6 +316,60 @@ function getUniqueId() {
|
|||
}
|
||||
}
|
||||
|
||||
function getBrowserSuggestions(arg) {
|
||||
if (!arg.suggestionsBrowserKey) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(`olivetin-suggestions-${arg.suggestionsBrowserKey}`)
|
||||
if (stored) {
|
||||
const suggestions = JSON.parse(stored)
|
||||
return Array.isArray(suggestions) ? suggestions : []
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load browser suggestions:', err)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function saveBrowserSuggestions() {
|
||||
for (const arg of actionArguments.value) {
|
||||
if (arg.suggestionsBrowserKey) {
|
||||
const value = argValues.value[arg.name]
|
||||
|
||||
// Only save non-empty values for non-checkbox/confirmation/password types
|
||||
if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') {
|
||||
try {
|
||||
const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
|
||||
const stored = localStorage.getItem(key)
|
||||
let suggestions = []
|
||||
|
||||
if (stored) {
|
||||
suggestions = JSON.parse(stored)
|
||||
if (!Array.isArray(suggestions)) {
|
||||
suggestions = []
|
||||
}
|
||||
}
|
||||
|
||||
// Add value if not already present
|
||||
if (!suggestions.includes(value)) {
|
||||
suggestions.unshift(value) // Add to beginning
|
||||
// Keep only the most recent 50 suggestions
|
||||
if (suggestions.length > 50) {
|
||||
suggestions = suggestions.slice(0, 50)
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(suggestions))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to save browser suggestions:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startAction(actionArgs) {
|
||||
const startActionArgs = {
|
||||
bindingId: props.bindingId,
|
||||
|
|
@ -356,6 +413,9 @@ async function handleSubmit(event) {
|
|||
const argvs = getArgumentValues()
|
||||
console.log('argument form has elements that passed validation')
|
||||
|
||||
// Save values to localStorage for arguments with suggestionsBrowserKey
|
||||
saveBrowserSuggestions()
|
||||
|
||||
try {
|
||||
const response = await startAction(argvs)
|
||||
router.push(`/logs/${response.executionTrackingId}`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<Section :title="t('logs.calendar-title')" :padding="false">
|
||||
<template #toolbar>
|
||||
<router-link to="/logs" class="button neutral">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"/>
|
||||
</svg>
|
||||
{{ t('logs.back-to-list') }}
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<div class="padding">
|
||||
<Calendar
|
||||
:events="calendarEvents"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:current-month="currentMonthIndex"
|
||||
:current-year="currentYear"
|
||||
@event-click="handleEventClick"
|
||||
@date-click="handleDayClick"
|
||||
@month-change="handleMonthChange"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Calendar from 'picocrank/vue/components/Calendar.vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const logs = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const currentMonthIndex = ref(new Date().getMonth())
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
|
||||
// Convert logs to calendar events format
|
||||
const calendarEvents = computed(() => {
|
||||
return logs.value
|
||||
.filter(log => {
|
||||
// Only include logs with valid start dates
|
||||
if (!log.datetimeStarted) return false
|
||||
const startDate = new Date(log.datetimeStarted)
|
||||
return !isNaN(startDate.getTime())
|
||||
})
|
||||
.map(log => {
|
||||
const startDate = new Date(log.datetimeStarted)
|
||||
let endDate = log.datetimeFinished ? new Date(log.datetimeFinished) : null
|
||||
|
||||
// Validate end date
|
||||
if (endDate && isNaN(endDate.getTime())) {
|
||||
endDate = null
|
||||
}
|
||||
|
||||
return {
|
||||
id: log.executionTrackingId,
|
||||
title: log.actionTitle || 'Untitled Action',
|
||||
date: startDate,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
actionIcon: log.actionIcon,
|
||||
user: log.user,
|
||||
tags: log.tags,
|
||||
logEntry: log
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function fetchLogs() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Currently fetches only the default page (startOffset: 0)
|
||||
// Multi-page fetching: loop through pages until no more logs or limit reached
|
||||
const allLogs = []
|
||||
let startOffset = BigInt(0)
|
||||
const maxLogs = 10000 // Reasonable limit to prevent excessive API calls
|
||||
const pageSize = 100 // Typical page size, will be updated from API response
|
||||
|
||||
while (allLogs.length < maxLogs) {
|
||||
const args = {
|
||||
"startOffset": startOffset,
|
||||
}
|
||||
|
||||
const response = await window.client.getLogs(args)
|
||||
const pageLogs = response.logs || []
|
||||
|
||||
// If no logs returned, we've reached the end
|
||||
if (pageLogs.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// Append logs from this page
|
||||
allLogs.push(...pageLogs)
|
||||
|
||||
// Update offset for next page
|
||||
const currentPageSize = Number(response.pageSize) || pageLogs.length
|
||||
startOffset += BigInt(currentPageSize)
|
||||
|
||||
// If we got fewer logs than the page size, we've reached the end
|
||||
if (pageLogs.length < currentPageSize) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logs.value = allLogs
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch logs:', err)
|
||||
error.value = 'Failed to load logs'
|
||||
window.showBigError('fetch-logs-calendar', 'getting logs for calendar', err, false)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEventClick(event) {
|
||||
// Navigate to the execution view when clicking on a calendar event
|
||||
if (event.id) {
|
||||
router.push(`/logs/${event.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDayClick(date) {
|
||||
// Navigate to logs list filtered by the selected date
|
||||
// Format date as YYYY-MM-DD using local date components to avoid timezone issues
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const dateString = `${year}-${month}-${day}`
|
||||
router.push({ path: '/logs', query: { date: dateString } })
|
||||
}
|
||||
|
||||
function handleMonthChange(month, year) {
|
||||
currentMonthIndex.value = month
|
||||
currentYear.value = year
|
||||
// Optionally fetch logs for the new month if needed
|
||||
// For now, we'll keep all logs loaded
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(div.calendar-header-nav) {
|
||||
background-color: var(--bg, #111);
|
||||
color: var(--text-color, #fff);
|
||||
border-color: var(--border-color, #333);
|
||||
}
|
||||
|
||||
:deep(div.calendar-header-nav h2.calendar-title) {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
<template>
|
||||
<Section :title="t('logs.title')" :padding="false">
|
||||
<template #toolbar>
|
||||
<router-link to="/logs/calendar" class="button neutral">
|
||||
{{ t('logs.calendar') }}
|
||||
</router-link>
|
||||
<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"
|
||||
|
|
@ -21,7 +24,20 @@
|
|||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('logs.timestamp') }}</th>
|
||||
<th>
|
||||
<div class="timestamp-header">
|
||||
<span>{{ t('logs.timestamp') }}</span>
|
||||
<span v-if="selectedDate" class="date-filter-indicator">
|
||||
<span class="date-filter-text">{{ formatDateFilter(selectedDate) }}</span>
|
||||
<button :title="t('logs.clear-date-filter')" @click="clearDateFilter" class="clear-date-button">
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>{{ t('logs.action') }}</th>
|
||||
<th>{{ t('logs.metadata') }}</th>
|
||||
<th>{{ t('logs.status') }}</th>
|
||||
|
|
@ -56,7 +72,14 @@
|
|||
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
|
||||
</div>
|
||||
|
||||
<div v-show="logs.length === 0" class="empty-state">
|
||||
<div v-show="selectedDate && filteredLogs.length === 0" class="empty-state">
|
||||
<p>{{ t('logs.no-logs-to-display') }} {{ formatDateFilter(selectedDate) }}.</p>
|
||||
<button @click="clearDateFilter" class="button neutral">
|
||||
{{ t('logs.clear-date-filter') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="logs.length === 0 && !selectedDate" class="empty-state">
|
||||
<p>{{ t('logs.no-logs-to-display') }}</p>
|
||||
<router-link to="/">{{ t('return-to-index') }}</router-link>
|
||||
</div>
|
||||
|
|
@ -64,27 +87,50 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Pagination from 'picocrank/vue/components/Pagination.vue'
|
||||
import Section from 'picocrank/vue/components/Section.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const logs = ref([])
|
||||
const searchText = ref('')
|
||||
const pageSize = ref(10)
|
||||
const currentPage = ref(1)
|
||||
const loading = ref(false)
|
||||
const totalCount = ref(0)
|
||||
const selectedDate = ref(null)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Read date query parameter from route
|
||||
function updateDateFromRoute() {
|
||||
const dateParam = route.query.date
|
||||
if (dateParam) {
|
||||
selectedDate.value = dateParam
|
||||
} else {
|
||||
selectedDate.value = null
|
||||
}
|
||||
// Re-fetch logs when date changes
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
// Watch for route changes to update date filter
|
||||
watch(() => route.query.date, () => {
|
||||
updateDateFromRoute()
|
||||
})
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
let result = logs.value
|
||||
|
||||
// Date filtering is now done server-side, so we only need to filter by search text
|
||||
if (searchText.value) {
|
||||
const searchLower = searchText.value.toLowerCase()
|
||||
result = logs.value.filter(log =>
|
||||
result = result.filter(log =>
|
||||
log.actionTitle.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
|
@ -106,6 +152,11 @@ async function fetchLogs() {
|
|||
"startOffset": BigInt(startOffset),
|
||||
}
|
||||
|
||||
// Add date filter if selected
|
||||
if (selectedDate.value) {
|
||||
args.dateFilter = selectedDate.value
|
||||
}
|
||||
|
||||
const response = await window.client.getLogs(args)
|
||||
|
||||
logs.value = response.logs
|
||||
|
|
@ -123,6 +174,24 @@ function clearSearch() {
|
|||
searchText.value = ''
|
||||
}
|
||||
|
||||
function clearDateFilter() {
|
||||
selectedDate.value = null
|
||||
// Remove date query parameter from URL
|
||||
const query = { ...route.query }
|
||||
delete query.date
|
||||
router.push({ path: route.path, query })
|
||||
}
|
||||
|
||||
function formatDateFilter(dateString) {
|
||||
// Format YYYY-MM-DD to a short format (e.g., "Jan 15, 2024")
|
||||
try {
|
||||
const date = new Date(dateString + 'T00:00:00')
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
} catch (err) {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return 'Unknown'
|
||||
try {
|
||||
|
|
@ -141,10 +210,11 @@ function handlePageChange(page) {
|
|||
function handlePageSizeChange(newPageSize) {
|
||||
pageSize.value = newPageSize
|
||||
currentPage.value = 1 // Reset to first page
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLogs()
|
||||
updateDateFromRoute()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -227,4 +297,47 @@ onMounted(() => {
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timestamp-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.date-filter-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
color: var(--text-secondary, #666);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-filter-text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.timestamp-header .clear-date-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.timestamp-header .clear-date-button:hover {
|
||||
opacity: 1;
|
||||
background: var(--hover-background, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.timestamp-header .clear-date-button svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@layer components, karma, theme;
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
|
|
@ -37,3 +39,27 @@ div.buttons button svg {
|
|||
section.small {
|
||||
border-radius: .4em;
|
||||
}
|
||||
|
||||
.sm-side-icons .action-button button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sm-imageicons-fullwidth .action-button button .icon img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sm-transparent-header header {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sm-transparent-header #sidebar-button {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sm-transparent-footer footer span {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
|
@ -18,6 +18,15 @@ export default defineConfig({
|
|||
target: 'http://localhost:1337',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/theme.css': {
|
||||
target: 'http://localhost:1337',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
"/custom-webui": {
|
||||
target: "http://localhost:1337",
|
||||
changeOrigin: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
/* Custom theme for integration testing */
|
||||
body {
|
||||
background-color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Add a distinctive style that we can test for */
|
||||
#content {
|
||||
border: 5px solid #4ecdc4;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { By } from 'selenium-webdriver'
|
||||
import {
|
||||
getRootAndWait,
|
||||
takeScreenshotOnFailure,
|
||||
} from '../../lib/elements.js'
|
||||
|
||||
describe('config: customTheme', function () {
|
||||
before(async function () {
|
||||
await runner.start('customTheme')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await runner.stop()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver)
|
||||
})
|
||||
|
||||
it('Custom theme CSS is loaded and accessible', async function () {
|
||||
await getRootAndWait()
|
||||
|
||||
// Fetch the theme CSS directly
|
||||
const themeCssUrl = runner.baseUrl() + 'theme.css'
|
||||
await webdriver.get(themeCssUrl)
|
||||
|
||||
// Wait for the page to load
|
||||
await webdriver.sleep(500)
|
||||
|
||||
// Get the page source (should be CSS content)
|
||||
const pageSource = await webdriver.getPageSource()
|
||||
|
||||
// Verify the theme CSS contains our custom styles
|
||||
expect(pageSource).to.include('background-color: #ff6b6b')
|
||||
expect(pageSource).to.include('border: 5px solid #4ecdc4')
|
||||
expect(pageSource).to.include('Custom theme for integration testing')
|
||||
})
|
||||
|
||||
it('Custom theme styles are applied to the page', async function () {
|
||||
await getRootAndWait()
|
||||
|
||||
// Wait a bit for CSS to be fully loaded
|
||||
await webdriver.sleep(500)
|
||||
|
||||
// Get computed background color of body
|
||||
const backgroundColor = await webdriver.executeScript(
|
||||
'return window.getComputedStyle(document.body).backgroundColor'
|
||||
)
|
||||
|
||||
// The background color should be rgb(255, 107, 107) which is #ff6b6b
|
||||
// Different browsers might return different formats, so we check for the RGB values
|
||||
expect(backgroundColor).to.include('255, 107, 107')
|
||||
|
||||
// Get computed border color of content element
|
||||
const borderColor = await webdriver.executeScript(
|
||||
'const el = document.getElementById("content"); return el ? window.getComputedStyle(el).borderColor : null'
|
||||
)
|
||||
|
||||
// The border color should be rgb(78, 205, 196) which is #4ecdc4
|
||||
// borderColor might return "rgb(78, 205, 196)" or similar format
|
||||
expect(borderColor).to.include('78, 205, 196')
|
||||
})
|
||||
|
||||
it('Theme CSS link is present in the HTML', async function () {
|
||||
await getRootAndWait()
|
||||
|
||||
// Find the theme.css link in the head
|
||||
const themeLink = await webdriver.findElement(By.css('link[href="/theme.css"]'))
|
||||
|
||||
expect(themeLink).to.not.be.null
|
||||
|
||||
// Verify it's a stylesheet
|
||||
const rel = await themeLink.getAttribute('rel')
|
||||
expect(rel).to.equal('stylesheet')
|
||||
|
||||
const type = await themeLink.getAttribute('type')
|
||||
expect(type).to.equal('text/css')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
#
|
||||
# Integration Test Config: EnabledExpression
|
||||
#
|
||||
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
logLevel: "DEBUG"
|
||||
checkForUpdates: false
|
||||
|
||||
actions:
|
||||
- title: Turn On Light
|
||||
shell: echo "Turning on light"
|
||||
icon: light
|
||||
entity: light
|
||||
enabledExpression: "{{ eq .CurrentEntity.powered_on false }}"
|
||||
|
||||
- title: Turn Off Light
|
||||
shell: echo "Turning off light"
|
||||
icon: light
|
||||
entity: light
|
||||
enabledExpression: "{{ eq .CurrentEntity.powered_on true }}"
|
||||
|
||||
- title: Always Enabled Action
|
||||
shell: echo "Always enabled"
|
||||
icon: check
|
||||
|
||||
entities:
|
||||
- file: entities/lights.yaml
|
||||
name: light
|
||||
|
||||
dashboards:
|
||||
- title: LightDashboard
|
||||
contents:
|
||||
- title: Light Controls
|
||||
type: fieldset
|
||||
entity: light
|
||||
contents:
|
||||
- title: Turn On Light
|
||||
- title: Turn Off Light
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { By, until, Condition } from 'selenium-webdriver'
|
||||
import {
|
||||
getRootAndWait,
|
||||
takeScreenshotOnFailure,
|
||||
} from '../../lib/elements.js'
|
||||
|
||||
describe('config: enabledExpression', function () {
|
||||
this.timeout(30000) // Increase timeout for async operations
|
||||
|
||||
before(async function () {
|
||||
await runner.start('enabledExpression')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await runner.stop()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver);
|
||||
});
|
||||
|
||||
it('Action with enabledExpression for lights enable the correct action', async function() {
|
||||
await getRootAndWait()
|
||||
|
||||
// Navigate to the Lights Dashboard
|
||||
// Use the path with space encoded as %20 - Vue Router should decode it
|
||||
await webdriver.get(runner.baseUrl() + '/dashboards/LightDashboard')
|
||||
|
||||
// Wait for the URL to change and the route to be processed
|
||||
await webdriver.wait(new Condition('wait for URL to contain dashboards', async function() {
|
||||
const url = await webdriver.getCurrentUrl()
|
||||
return url.includes('/dashboards/')
|
||||
}), 5000)
|
||||
|
||||
// Wait for dashboard to load by checking the loaded-dashboard attribute
|
||||
// The attribute should be set to the decoded title "LightDashboard"
|
||||
await webdriver.wait(new Condition('wait for loaded-dashboard', async function() {
|
||||
const body = await webdriver.findElement(By.tagName('body'))
|
||||
const attr = await body.getAttribute('loaded-dashboard')
|
||||
if (attr) {
|
||||
console.log('Current loaded-dashboard attribute:', attr)
|
||||
}
|
||||
// Accept either decoded or encoded version (component should decode, but handle both)
|
||||
return attr === 'LightDashboard'
|
||||
}), 10000)
|
||||
|
||||
// Verify we got the correct dashboard (prefer decoded, but accept encoded)
|
||||
const body = await webdriver.findElement(By.tagName('body'))
|
||||
const attr = await body.getAttribute('loaded-dashboard')
|
||||
if (attr !== 'LightDashboard') {
|
||||
const currentUrl = await webdriver.getCurrentUrl()
|
||||
throw new Error(`Dashboard not loaded correctly. Expected "LightDashboard", got "${attr}". Current URL: ${currentUrl}`)
|
||||
}
|
||||
|
||||
// Wait for dashboard content to appear - check for dashboard rows first
|
||||
await webdriver.wait(until.elementsLocated(By.css('.dashboard-row')), 5000)
|
||||
|
||||
// Debug: Check what's on the page
|
||||
const dashboardRows = await webdriver.findElements(By.css('.dashboard-row'))
|
||||
console.log(`Found ${dashboardRows.length} dashboard rows`)
|
||||
|
||||
for (let i = 0; i < dashboardRows.length; i++) {
|
||||
const row = dashboardRows[i]
|
||||
const h2Elements = await row.findElements(By.css('h2'))
|
||||
if (h2Elements.length > 0) {
|
||||
const h2Text = await h2Elements[0].getText()
|
||||
console.log(`Row ${i} h2: "${h2Text}"`)
|
||||
}
|
||||
const fieldsets = await row.findElements(By.css('fieldset'))
|
||||
console.log(`Row ${i} has ${fieldsets.length} fieldsets`)
|
||||
if (fieldsets.length > 0) {
|
||||
const buttons = await fieldsets[0].findElements(By.css('.action-button button'))
|
||||
console.log(`Row ${i} fieldset has ${buttons.length} buttons`)
|
||||
}
|
||||
}
|
||||
|
||||
// Find buttons by looking within entity fieldsets
|
||||
// Both rows have h2 title "Light Controls", so we identify them by which buttons are enabled
|
||||
// Living Room Light (powered_on: false) - Turn On should be enabled, Turn Off disabled
|
||||
// Bedroom Light (powered_on: true) - Turn Off should be enabled, Turn On disabled
|
||||
let turnOnButton = null
|
||||
let turnOffButton = null
|
||||
|
||||
for (const row of dashboardRows) {
|
||||
// Get the fieldset in this row
|
||||
const fieldsets = await row.findElements(By.css('fieldset'))
|
||||
if (fieldsets.length === 0) continue
|
||||
|
||||
const buttons = await fieldsets[0].findElements(By.css('.action-button button'))
|
||||
|
||||
// Check each button to identify which entity this row represents
|
||||
for (const btn of buttons) {
|
||||
const title = await btn.getAttribute('title')
|
||||
const disabled = await btn.getAttribute('disabled')
|
||||
const isEnabled = disabled === null
|
||||
|
||||
if (title === 'Turn On Light' && isEnabled) {
|
||||
// This is the Living Room Light row (Turn On is enabled because powered_on: false)
|
||||
turnOnButton = btn
|
||||
}
|
||||
|
||||
if (title === 'Turn Off Light' && isEnabled) {
|
||||
// This is the Bedroom Light row (Turn Off is enabled because powered_on: true)
|
||||
turnOffButton = btn
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(turnOnButton).to.not.be.null
|
||||
expect(turnOffButton).to.not.be.null
|
||||
|
||||
// Check that Turn On button is enabled (light is off)
|
||||
const turnOnDisabled = await turnOnButton.getAttribute('disabled')
|
||||
expect(turnOnDisabled).to.be.null
|
||||
|
||||
// Check that Turn Off button is enabled (light is on)
|
||||
const turnOffDisabled = await turnOffButton.getAttribute('disabled')
|
||||
expect(turnOffDisabled).to.be.null
|
||||
})
|
||||
|
||||
it('Action without enabledExpression is always enabled', async function() {
|
||||
await getRootAndWait()
|
||||
|
||||
// Navigate to actions view
|
||||
await webdriver.get(runner.baseUrl())
|
||||
|
||||
// Wait for action buttons
|
||||
await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000)
|
||||
|
||||
// Find "Always Enabled Action" button
|
||||
const actionButtons = await webdriver.findElements(By.css('.action-button button'))
|
||||
let alwaysEnabledButton = null
|
||||
|
||||
for (const btn of actionButtons) {
|
||||
const title = await btn.getAttribute('title')
|
||||
if (title === 'Always Enabled Action') {
|
||||
alwaysEnabledButton = btn
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(alwaysEnabledButton).to.not.be.null
|
||||
|
||||
// Check that it's enabled
|
||||
const disabled = await alwaysEnabledButton.getAttribute('disabled')
|
||||
expect(disabled).to.be.null
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
- name: "Living Room Light"
|
||||
powered_on: false
|
||||
|
||||
- name: "Bedroom Light"
|
||||
powered_on: true
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
logLevel: debug
|
||||
|
||||
insecureAllowDumpActionMap: true
|
||||
|
||||
actions:
|
||||
- title: Check status
|
||||
id: status_command
|
||||
|
|
|
|||
|
|
@ -59,8 +59,7 @@ describe('config: stdout-most-recent-execution', function () {
|
|||
})
|
||||
|
||||
it('stdout-most-recent-execution updates after action execution', async function () {
|
||||
this.timeout(30000) // Increase timeout for this test
|
||||
|
||||
this.timeout(45000)
|
||||
await getRootAndWait()
|
||||
|
||||
// Wait for the mre-output element
|
||||
|
|
@ -105,7 +104,7 @@ describe('config: stdout-most-recent-execution', function () {
|
|||
await statusButton.click()
|
||||
|
||||
// Wait a moment for the action to start
|
||||
await webdriver.sleep(500)
|
||||
await webdriver.sleep(2000)
|
||||
|
||||
// Wait for the output to update (the component listens to EventExecutionFinished events)
|
||||
// We'll wait for the output to change from the initial state
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
logLevel: "DEBUG"
|
||||
checkForUpdates: false
|
||||
|
||||
actions:
|
||||
- title: Test suggestionsBrowserKey
|
||||
shell: "echo 'Input value: {{ testInput }}, Second input: {{ testInput2 }}'"
|
||||
icon: ping
|
||||
arguments:
|
||||
- name: testInput
|
||||
title: Test Input
|
||||
description: "This input uses suggestionsBrowserKey"
|
||||
suggestionsBrowserKey: test-suggestions-key
|
||||
- name: testInput2
|
||||
title: Test Input 2
|
||||
description: "This input shares the same suggestionsBrowserKey"
|
||||
suggestionsBrowserKey: test-suggestions-key
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { By, Condition } from 'selenium-webdriver'
|
||||
import {
|
||||
getRootAndWait,
|
||||
getActionButton,
|
||||
takeScreenshotOnFailure,
|
||||
getTerminalBuffer,
|
||||
} from '../../lib/elements.js'
|
||||
|
||||
async function openArgumentForm() {
|
||||
await getRootAndWait()
|
||||
const btn = await getActionButton(webdriver, 'Test suggestionsBrowserKey')
|
||||
await btn.click()
|
||||
|
||||
await webdriver.wait(
|
||||
new Condition('wait for argument form page', async () => {
|
||||
const url = await webdriver.getCurrentUrl()
|
||||
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
||||
}),
|
||||
5000
|
||||
)
|
||||
}
|
||||
|
||||
async function getTestInput() {
|
||||
return await webdriver.findElement(By.id('testInput'))
|
||||
}
|
||||
|
||||
async function getTestInput2() {
|
||||
return await webdriver.findElement(By.id('testInput2'))
|
||||
}
|
||||
|
||||
async function getDatalistOptions(inputName = 'testInput') {
|
||||
return await webdriver.findElements(By.css(`datalist#${inputName}-choices option`))
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
|
||||
await submitButton.click()
|
||||
}
|
||||
|
||||
async function waitForLogsPage() {
|
||||
await webdriver.wait(
|
||||
new Condition('wait for logs page', async () => {
|
||||
const url = await webdriver.getCurrentUrl()
|
||||
return url.includes('/logs/') && !url.endsWith('/logs')
|
||||
}),
|
||||
5000
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForExecutionComplete() {
|
||||
await webdriver.wait(
|
||||
new Condition('wait for execution status', async () => {
|
||||
const statusElements = await webdriver.findElements(By.id('execution-dialog-status'))
|
||||
return statusElements.length > 0
|
||||
}),
|
||||
5000
|
||||
)
|
||||
|
||||
await webdriver.wait(
|
||||
new Condition('wait for execution to finish', async () => {
|
||||
try {
|
||||
const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
|
||||
const statusText = await statusElement.getText()
|
||||
return !statusText.includes('Executing')
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}),
|
||||
5000
|
||||
)
|
||||
|
||||
await webdriver.sleep(500)
|
||||
}
|
||||
|
||||
async function getLocalStorageItem(key) {
|
||||
return await webdriver.executeScript(`return localStorage.getItem('${key}')`)
|
||||
}
|
||||
|
||||
async function clearLocalStorage() {
|
||||
await webdriver.executeScript('return localStorage.clear()')
|
||||
}
|
||||
|
||||
describe('config: suggestionsBrowserKey', function () {
|
||||
before(async function () {
|
||||
await runner.start('suggestionsBrowserKey')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await runner.stop()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver)
|
||||
})
|
||||
|
||||
it('Input fields with suggestionsBrowserKey are rendered', async function () {
|
||||
await openArgumentForm()
|
||||
|
||||
const input1 = await getTestInput()
|
||||
expect(await input1.getTagName()).to.equal('input')
|
||||
expect(await input1.getAttribute('type')).to.equal('text')
|
||||
|
||||
const label1 = await webdriver.findElement(By.css('label[for="testInput"]'))
|
||||
expect(await label1.getText()).to.contain('Test Input')
|
||||
|
||||
const input2 = await getTestInput2()
|
||||
expect(await input2.getTagName()).to.equal('input')
|
||||
expect(await input2.getAttribute('type')).to.equal('text')
|
||||
|
||||
const label2 = await webdriver.findElement(By.css('label[for="testInput2"]'))
|
||||
expect(await label2.getText()).to.contain('Test Input 2')
|
||||
})
|
||||
|
||||
it('Submitting form saves value to localStorage', async function () {
|
||||
this.timeout(15000)
|
||||
|
||||
// Clear localStorage first
|
||||
await clearLocalStorage()
|
||||
|
||||
await openArgumentForm()
|
||||
|
||||
const input = await getTestInput()
|
||||
const testValue = 'test-value-123'
|
||||
await input.clear()
|
||||
await input.sendKeys(testValue)
|
||||
|
||||
await submitForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
|
||||
// Verify value was saved to localStorage
|
||||
const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
|
||||
expect(stored).to.not.be.null
|
||||
|
||||
const suggestions = JSON.parse(stored)
|
||||
expect(suggestions).to.be.an('array')
|
||||
expect(suggestions).to.include(testValue)
|
||||
})
|
||||
|
||||
it('Previously saved values appear in datalist', async function () {
|
||||
this.timeout(15000)
|
||||
|
||||
// First, save a value to localStorage
|
||||
const testValue = 'saved-suggestion-456'
|
||||
await webdriver.executeScript(`
|
||||
const key = 'olivetin-suggestions-test-suggestions-key';
|
||||
localStorage.setItem(key, JSON.stringify(['${testValue}']));
|
||||
`)
|
||||
|
||||
// Open the form
|
||||
await openArgumentForm()
|
||||
|
||||
// Check that datalist exists and contains the saved value
|
||||
const datalist = await webdriver.findElement(By.id('testInput-choices'))
|
||||
expect(datalist).to.not.be.null
|
||||
|
||||
const options = await getDatalistOptions()
|
||||
expect(options.length).to.be.greaterThan(0)
|
||||
|
||||
// Check if the saved value appears in the datalist
|
||||
let foundValue = false
|
||||
for (const option of options) {
|
||||
const value = await option.getAttribute('value')
|
||||
if (value === testValue) {
|
||||
foundValue = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(foundValue).to.be.true
|
||||
})
|
||||
|
||||
it('Multiple submissions accumulate suggestions', async function () {
|
||||
this.timeout(20000)
|
||||
|
||||
// Clear localStorage first
|
||||
await clearLocalStorage()
|
||||
|
||||
// Submit first value
|
||||
await openArgumentForm()
|
||||
const input1 = await getTestInput()
|
||||
await input1.clear()
|
||||
await input1.sendKeys('first-value')
|
||||
await submitForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
|
||||
// Submit second value
|
||||
await openArgumentForm()
|
||||
const input2 = await getTestInput()
|
||||
await input2.clear()
|
||||
await input2.sendKeys('second-value')
|
||||
await submitForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
|
||||
// Verify both values are in localStorage
|
||||
const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
|
||||
expect(stored).to.not.be.null
|
||||
|
||||
const suggestions = JSON.parse(stored)
|
||||
expect(suggestions).to.be.an('array')
|
||||
expect(suggestions).to.include('first-value')
|
||||
expect(suggestions).to.include('second-value')
|
||||
expect(suggestions[0]).to.equal('second-value') // Most recent should be first
|
||||
})
|
||||
|
||||
it('Empty values are not saved to localStorage', async function () {
|
||||
this.timeout(15000)
|
||||
|
||||
// Clear localStorage first
|
||||
await clearLocalStorage()
|
||||
|
||||
await openArgumentForm()
|
||||
|
||||
const input = await getTestInput()
|
||||
// Leave input empty (or clear it if it has a default)
|
||||
await input.clear()
|
||||
|
||||
await submitForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
|
||||
// Verify empty value was not saved - localStorage should be null or empty-equivalent
|
||||
const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key')
|
||||
// Should be null OR empty JSON array string ("[]") OR parse to empty array
|
||||
if (stored !== null) {
|
||||
const suggestions = JSON.parse(stored)
|
||||
expect(suggestions).to.be.an('array')
|
||||
expect(suggestions).to.have.length(0)
|
||||
}
|
||||
// If stored is null, that's also acceptable - no assertion needed
|
||||
})
|
||||
|
||||
it('Suggestions are shared across inputs with the same suggestionsBrowserKey', async function () {
|
||||
this.timeout(20000)
|
||||
|
||||
// Clear localStorage first
|
||||
await clearLocalStorage()
|
||||
|
||||
// Submit a value using the first input
|
||||
await openArgumentForm()
|
||||
const input1 = await getTestInput()
|
||||
await input1.clear()
|
||||
await input1.sendKeys('shared-value-from-input1')
|
||||
await submitForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
|
||||
// Open the form again and verify the value appears in both datalists
|
||||
await openArgumentForm()
|
||||
|
||||
// Check first input's datalist
|
||||
const datalist1 = await webdriver.findElement(By.id('testInput-choices'))
|
||||
expect(datalist1).to.not.be.null
|
||||
const options1 = await getDatalistOptions('testInput')
|
||||
let foundInInput1 = false
|
||||
for (const option of options1) {
|
||||
const value = await option.getAttribute('value')
|
||||
if (value === 'shared-value-from-input1') {
|
||||
foundInInput1 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(foundInInput1).to.be.true
|
||||
|
||||
// Check second input's datalist
|
||||
const datalist2 = await webdriver.findElement(By.id('testInput2-choices'))
|
||||
expect(datalist2).to.not.be.null
|
||||
const options2 = await getDatalistOptions('testInput2')
|
||||
let foundInInput2 = false
|
||||
for (const option of options2) {
|
||||
const value = await option.getAttribute('value')
|
||||
if (value === 'shared-value-from-input1') {
|
||||
foundInInput2 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(foundInInput2).to.be.true
|
||||
|
||||
// Now submit a value using the second input
|
||||
const input2 = await getTestInput2()
|
||||
await input2.clear()
|
||||
await input2.sendKeys('shared-value-from-input2')
|
||||
await submitForm()
|
||||
await waitForLogsPage()
|
||||
await waitForExecutionComplete()
|
||||
|
||||
// Verify both values appear in both datalists
|
||||
await openArgumentForm()
|
||||
|
||||
// Check that both values are in the first input's datalist
|
||||
const options1After = await getDatalistOptions('testInput')
|
||||
let foundValue1 = false
|
||||
let foundValue2 = false
|
||||
for (const option of options1After) {
|
||||
const value = await option.getAttribute('value')
|
||||
if (value === 'shared-value-from-input1') {
|
||||
foundValue1 = true
|
||||
}
|
||||
if (value === 'shared-value-from-input2') {
|
||||
foundValue2 = true
|
||||
}
|
||||
}
|
||||
expect(foundValue1).to.be.true
|
||||
expect(foundValue2).to.be.true
|
||||
|
||||
// Check that both values are in the second input's datalist
|
||||
const options2After = await getDatalistOptions('testInput2')
|
||||
foundValue1 = false
|
||||
foundValue2 = false
|
||||
for (const option of options2After) {
|
||||
const value = await option.getAttribute('value')
|
||||
if (value === 'shared-value-from-input1') {
|
||||
foundValue1 = true
|
||||
}
|
||||
if (value === 'shared-value-from-input2') {
|
||||
foundValue2 = true
|
||||
}
|
||||
}
|
||||
expect(foundValue1).to.be.true
|
||||
expect(foundValue2).to.be.true
|
||||
})
|
||||
})
|
||||
|
|
@ -4,14 +4,8 @@ listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
|||
logLevel: "DEBUG"
|
||||
checkForUpdates: false
|
||||
|
||||
themeName: "test-theme"
|
||||
|
||||
actions:
|
||||
- title: Test action
|
||||
shell: "echo 'Hello from themed OliveTin'"
|
||||
shell: "echo 'Hello from theme loading test'"
|
||||
icon: ping
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/* Theme One CSS */
|
||||
body {
|
||||
background-color: #theme-one;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/* Theme Two CSS */
|
||||
body {
|
||||
background-color: #theme-two;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
getRootAndWait,
|
||||
takeScreenshotOnFailure,
|
||||
} from '../../lib/elements.js'
|
||||
|
||||
describe('config: themeLoading', function () {
|
||||
before(async function () {
|
||||
await runner.start('themeLoading')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await runner.stop()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver)
|
||||
})
|
||||
|
||||
it('Available themes are discovered and returned in Init response', async function () {
|
||||
await getRootAndWait()
|
||||
|
||||
// Wait for initResponse to be available
|
||||
await webdriver.wait(async () => {
|
||||
const hasInitResponse = await webdriver.executeScript(
|
||||
'return typeof window.initResponse !== "undefined" && window.initResponse !== null'
|
||||
)
|
||||
return hasInitResponse
|
||||
}, 5000, 'Init response should be available')
|
||||
|
||||
// Get available themes from the Init response
|
||||
const availableThemes = await webdriver.executeScript(
|
||||
'return window.initResponse ? (window.initResponse.availableThemes || []) : []'
|
||||
)
|
||||
|
||||
// Verify themes array exists and is an array
|
||||
expect(availableThemes).to.be.an('array')
|
||||
|
||||
// Verify that themes with theme.css are discovered
|
||||
// theme-one and theme-two have theme.css, invalid-theme does not
|
||||
expect(availableThemes).to.include('theme-one')
|
||||
expect(availableThemes).to.include('theme-two')
|
||||
|
||||
// Verify that themes without theme.css are not included
|
||||
expect(availableThemes).to.not.include('invalid-theme')
|
||||
|
||||
// Verify themes are sorted alphabetically
|
||||
const sortedThemes = [...availableThemes].sort()
|
||||
expect(availableThemes).to.deep.equal(sortedThemes)
|
||||
})
|
||||
|
||||
it('Available themes list is accessible via JavaScript', async function () {
|
||||
await getRootAndWait()
|
||||
|
||||
// Wait for initResponse to be available
|
||||
await webdriver.wait(async () => {
|
||||
const hasInitResponse = await webdriver.executeScript(
|
||||
'return typeof window.initResponse !== "undefined" && window.initResponse !== null'
|
||||
)
|
||||
return hasInitResponse
|
||||
}, 5000, 'Init response should be available')
|
||||
|
||||
// Verify availableThemes is accessible
|
||||
const availableThemes = await webdriver.executeScript(
|
||||
'return window.initResponse ? (window.initResponse.availableThemes || []) : []'
|
||||
)
|
||||
|
||||
expect(availableThemes).to.be.an('array')
|
||||
expect(availableThemes.length).to.be.at.least(2)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -27,7 +27,11 @@
|
|||
"language-dialog.title": "Sprache auswählen",
|
||||
"login-button": "Login",
|
||||
"logs.action": "Aktion",
|
||||
"logs.back-to-list": "Zurück zur Liste",
|
||||
"logs.blocked": "Blockiert",
|
||||
"logs.calendar": "Kalender",
|
||||
"logs.calendar-title": "Protokoll-Kalender",
|
||||
"logs.clear-date-filter": "Datumsfilter löschen",
|
||||
"logs.clear-filter": "Suchfilter löschen",
|
||||
"logs.completed": "Abgeschlossen",
|
||||
"logs.exit-code": "Ausführungscode",
|
||||
|
|
@ -45,6 +49,9 @@
|
|||
"raise-issue": "Ein Problem melden auf GitHub",
|
||||
"return-to-index": "Zurück zur Startseite",
|
||||
"search-filter": "Filter aktuelle Seite",
|
||||
"theme-dialog.close": "Schließen",
|
||||
"theme-dialog.default": "Standard-Design",
|
||||
"theme-dialog.title": "Design auswählen",
|
||||
"welcome": "Willkommen bei OliveTin"
|
||||
},
|
||||
"en": {
|
||||
|
|
@ -73,7 +80,11 @@
|
|||
"language-dialog.title": "Select Language",
|
||||
"login-button": "Login",
|
||||
"logs.action": "Action",
|
||||
"logs.back-to-list": "Back to List",
|
||||
"logs.blocked": "Blocked",
|
||||
"logs.calendar": "Calendar",
|
||||
"logs.calendar-title": "Logs Calendar",
|
||||
"logs.clear-date-filter": "Clear date filter",
|
||||
"logs.clear-filter": "Clear search filter",
|
||||
"logs.completed": "Completed",
|
||||
"logs.exit-code": "Exit code",
|
||||
|
|
@ -91,6 +102,9 @@
|
|||
"raise-issue": "Raise an issue on GitHub",
|
||||
"return-to-index": "Return to index",
|
||||
"search-filter": "Filter current page",
|
||||
"theme-dialog.close": "Close",
|
||||
"theme-dialog.default": "Default Theme",
|
||||
"theme-dialog.title": "Select Theme",
|
||||
"welcome": "Welcome to OliveTin"
|
||||
},
|
||||
"es-ES": {
|
||||
|
|
@ -119,7 +133,11 @@
|
|||
"language-dialog.title": "Seleccionar idioma",
|
||||
"login-button": "Iniciar sesión",
|
||||
"logs.action": "Acción",
|
||||
"logs.back-to-list": "Volver a la Lista",
|
||||
"logs.blocked": "Bloqueado",
|
||||
"logs.calendar": "Calendario",
|
||||
"logs.calendar-title": "Calendario de Registros",
|
||||
"logs.clear-date-filter": "Limpiar filtro de fecha",
|
||||
"logs.clear-filter": "Limpiar filtro de búsqueda",
|
||||
"logs.completed": "Completado",
|
||||
"logs.exit-code": "Código de salida",
|
||||
|
|
@ -137,6 +155,9 @@
|
|||
"raise-issue": "Reportar un problema en GitHub",
|
||||
"return-to-index": "Volver a la página principal",
|
||||
"search-filter": "Filtrar página actual",
|
||||
"theme-dialog.close": "Cerrar",
|
||||
"theme-dialog.default": "Tema Predeterminado",
|
||||
"theme-dialog.title": "Seleccionar tema",
|
||||
"welcome": "Bienvenido a OliveTin"
|
||||
},
|
||||
"it-IT": {
|
||||
|
|
@ -165,7 +186,11 @@
|
|||
"language-dialog.title": "Seleziona lingua",
|
||||
"login-button": "Login",
|
||||
"logs.action": "Azione",
|
||||
"logs.back-to-list": "Torna all'Elenco",
|
||||
"logs.blocked": "Bloccato",
|
||||
"logs.calendar": "Calendario",
|
||||
"logs.calendar-title": "Calendario dei Registri",
|
||||
"logs.clear-date-filter": "Cancella filtro data",
|
||||
"logs.clear-filter": "Cancella filtro di ricerca",
|
||||
"logs.completed": "Completato",
|
||||
"logs.exit-code": "Codice di uscita",
|
||||
|
|
@ -183,6 +208,9 @@
|
|||
"raise-issue": "Segnala un problema su GitHub",
|
||||
"return-to-index": "Torna alla pagina principale",
|
||||
"search-filter": "Filtra la pagina corrente",
|
||||
"theme-dialog.close": "Chiudi",
|
||||
"theme-dialog.default": "Tema Predefinito",
|
||||
"theme-dialog.title": "Seleziona tema",
|
||||
"welcome": "Benvenuto in OliveTin"
|
||||
},
|
||||
"zh-Hans-CN": {
|
||||
|
|
@ -211,7 +239,11 @@
|
|||
"language-dialog.title": "选择语言",
|
||||
"login-button": "登录",
|
||||
"logs.action": "动作",
|
||||
"logs.back-to-list": "返回列表",
|
||||
"logs.blocked": "阻塞",
|
||||
"logs.calendar": "日历",
|
||||
"logs.calendar-title": "日志日历",
|
||||
"logs.clear-date-filter": "清除日期筛选器",
|
||||
"logs.clear-filter": "清除搜索筛选器",
|
||||
"logs.completed": "完成",
|
||||
"logs.exit-code": "退出代码",
|
||||
|
|
@ -229,6 +261,9 @@
|
|||
"raise-issue": "在 GitHub 上报告问题",
|
||||
"return-to-index": "返回首页",
|
||||
"search-filter": "过滤当前页面",
|
||||
"theme-dialog.close": "关闭",
|
||||
"theme-dialog.default": "默认主题",
|
||||
"theme-dialog.title": "选择主题",
|
||||
"welcome": "欢迎使用 OliveTin"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ translations:
|
|||
logs.exit-code: Ausführungscode
|
||||
logs.completed: Abgeschlossen
|
||||
logs.clear-filter: Suchfilter löschen
|
||||
logs.clear-date-filter: Datumsfilter löschen
|
||||
logs.calendar: Kalender
|
||||
logs.calendar-title: Protokoll-Kalender
|
||||
logs.back-to-list: Zurück zur Liste
|
||||
diagnostics.get-support: Unterstützung erhalten
|
||||
diagnostics.get-support-description: Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, einen sosreport von dieser Seite einzufügen.
|
||||
diagnostics.where-to-find-help: Wo Sie Hilfe finden
|
||||
|
|
@ -44,3 +48,6 @@ translations:
|
|||
language-dialog.browser-languages: Browser-Sprachen
|
||||
language-dialog.not-available: Nicht verfügbar
|
||||
language-dialog.close: Schließen
|
||||
theme-dialog.title: Design auswählen
|
||||
theme-dialog.default: Standard-Design
|
||||
theme-dialog.close: Schließen
|
||||
|
|
@ -21,6 +21,10 @@ translations:
|
|||
logs.exit-code: Exit code
|
||||
logs.completed: Completed
|
||||
logs.clear-filter: Clear search filter
|
||||
logs.clear-date-filter: Clear date filter
|
||||
logs.calendar: Calendar
|
||||
logs.calendar-title: Logs Calendar
|
||||
logs.back-to-list: Back to List
|
||||
diagnostics.get-support: Get support
|
||||
diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page.
|
||||
diagnostics.where-to-find-help: Where to find help
|
||||
|
|
@ -44,3 +48,6 @@ translations:
|
|||
language-dialog.browser-languages: Browser languages
|
||||
language-dialog.not-available: Not available
|
||||
language-dialog.close: Close
|
||||
theme-dialog.title: Select Theme
|
||||
theme-dialog.default: Default Theme
|
||||
theme-dialog.close: Close
|
||||
|
|
@ -21,6 +21,10 @@ translations:
|
|||
logs.exit-code: Código de salida
|
||||
logs.completed: Completado
|
||||
logs.clear-filter: Limpiar filtro de búsqueda
|
||||
logs.clear-date-filter: Limpiar filtro de fecha
|
||||
logs.calendar: Calendario
|
||||
logs.calendar-title: Calendario de Registros
|
||||
logs.back-to-list: Volver a la Lista
|
||||
diagnostics.get-support: Obtener soporte
|
||||
diagnostics.get-support-description: Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir un sosreport de esta página.
|
||||
diagnostics.where-to-find-help: Dónde encontrar ayuda
|
||||
|
|
@ -44,3 +48,6 @@ translations:
|
|||
language-dialog.browser-languages: Idiomas del navegador
|
||||
language-dialog.not-available: No disponible
|
||||
language-dialog.close: Cerrar
|
||||
theme-dialog.title: Seleccionar tema
|
||||
theme-dialog.default: Tema Predeterminado
|
||||
theme-dialog.close: Cerrar
|
||||
|
|
@ -21,6 +21,10 @@ translations:
|
|||
logs.exit-code: Codice di uscita
|
||||
logs.completed: Completato
|
||||
logs.clear-filter: Cancella filtro di ricerca
|
||||
logs.clear-date-filter: Cancella filtro data
|
||||
logs.calendar: Calendario
|
||||
logs.calendar-title: Calendario dei Registri
|
||||
logs.back-to-list: Torna all'Elenco
|
||||
diagnostics.get-support: Ottenere supporto
|
||||
diagnostics.get-support-description: Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere un sosreport da questa pagina.
|
||||
diagnostics.where-to-find-help: Dove trovare aiuto
|
||||
|
|
@ -44,3 +48,6 @@ translations:
|
|||
language-dialog.browser-languages: Lingue del browser
|
||||
language-dialog.not-available: Non disponibile
|
||||
language-dialog.close: Chiudi
|
||||
theme-dialog.title: Seleziona tema
|
||||
theme-dialog.default: Tema Predefinito
|
||||
theme-dialog.close: Chiudi
|
||||
|
|
@ -22,11 +22,18 @@ translations:
|
|||
language-dialog.browser-languages: 浏览器语言
|
||||
language-dialog.not-available: 不可用
|
||||
language-dialog.close: 关闭
|
||||
theme-dialog.title: 选择主题
|
||||
theme-dialog.default: 默认主题
|
||||
theme-dialog.close: 关闭
|
||||
logs.timed-out: 超时
|
||||
logs.blocked: 阻塞
|
||||
logs.exit-code: 退出代码
|
||||
logs.completed: 完成
|
||||
logs.clear-filter: 清除搜索筛选器
|
||||
logs.clear-date-filter: 清除日期筛选器
|
||||
logs.calendar: 日历
|
||||
logs.calendar-title: 日志日历
|
||||
logs.back-to-list: 返回列表
|
||||
diagnostics.get-support: 获取支持
|
||||
diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含 sosreport 将非常有帮助。
|
||||
diagnostics.where-to-find-help: 在哪里找到帮助
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ message Action {
|
|||
string popup_on_start = 6;
|
||||
int32 order = 7;
|
||||
int32 timeout = 8;
|
||||
string datetime_rate_limit_expires = 9; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||
}
|
||||
|
||||
message ActionArgument {
|
||||
|
|
@ -25,6 +26,7 @@ message ActionArgument {
|
|||
|
||||
string description = 6;
|
||||
map<string, string> suggestions = 7;
|
||||
string suggestions_browser_key = 8;
|
||||
}
|
||||
|
||||
message ActionArgumentChoice {
|
||||
|
|
@ -118,6 +120,7 @@ message StartActionByGetAndWaitResponse {
|
|||
|
||||
message GetLogsRequest{
|
||||
int64 start_offset = 1;
|
||||
string date_filter = 2; // Optional date filter in YYYY-MM-DD format
|
||||
};
|
||||
|
||||
message LogEntry {
|
||||
|
|
@ -132,12 +135,13 @@ message LogEntry {
|
|||
repeated string tags = 10;
|
||||
string execution_tracking_id = 11;
|
||||
string datetime_finished = 12;
|
||||
string action_id = 13;
|
||||
bool execution_started = 14;
|
||||
bool execution_finished = 15;
|
||||
bool blocked = 16;
|
||||
int64 datetime_index = 17;
|
||||
bool can_kill = 18;
|
||||
string datetime_rate_limit_expires = 19; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||
string binding_id = 20; // Binding ID for matching rate limits to action buttons
|
||||
}
|
||||
|
||||
message GetLogsResponse {
|
||||
|
|
@ -215,7 +219,7 @@ message DumpVarsResponse {
|
|||
map<string, string> contents = 2;
|
||||
}
|
||||
|
||||
message ActionEntityPair {
|
||||
message DebugBinding {
|
||||
string action_title = 1;
|
||||
string entity_prefix = 2;
|
||||
}
|
||||
|
|
@ -223,7 +227,7 @@ message ActionEntityPair {
|
|||
message DumpPublicIdActionMapRequest {}
|
||||
message DumpPublicIdActionMapResponse {
|
||||
string alert = 1;
|
||||
map<string, ActionEntityPair> contents = 2;
|
||||
map<string, DebugBinding> contents = 2;
|
||||
}
|
||||
|
||||
message GetReadyzRequest {}
|
||||
|
|
@ -328,6 +332,7 @@ message InitResponse {
|
|||
bool show_diagnostics = 21;
|
||||
bool show_log_list = 22;
|
||||
bool login_required = 23;
|
||||
repeated string available_themes = 24; // List of available theme names
|
||||
}
|
||||
|
||||
message AdditionalLink {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Action struct {
|
|||
PopupOnStart string `protobuf:"bytes,6,opt,name=popup_on_start,json=popupOnStart,proto3" json:"popup_on_start,omitempty"`
|
||||
Order int32 `protobuf:"varint,7,opt,name=order,proto3" json:"order,omitempty"`
|
||||
Timeout int32 `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"`
|
||||
DatetimeRateLimitExpires string `protobuf:"bytes,9,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -121,6 +122,13 @@ func (x *Action) GetTimeout() int32 {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (x *Action) GetDatetimeRateLimitExpires() string {
|
||||
if x != nil {
|
||||
return x.DatetimeRateLimitExpires
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ActionArgument struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
|
|
@ -130,6 +138,7 @@ type ActionArgument struct {
|
|||
Choices []*ActionArgumentChoice `protobuf:"bytes,5,rep,name=choices,proto3" json:"choices,omitempty"`
|
||||
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
|
||||
Suggestions map[string]string `protobuf:"bytes,7,rep,name=suggestions,proto3" json:"suggestions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
SuggestionsBrowserKey string `protobuf:"bytes,8,opt,name=suggestions_browser_key,json=suggestionsBrowserKey,proto3" json:"suggestions_browser_key,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -213,6 +222,13 @@ func (x *ActionArgument) GetSuggestions() map[string]string {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *ActionArgument) GetSuggestionsBrowserKey() string {
|
||||
if x != nil {
|
||||
return x.SuggestionsBrowserKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ActionArgumentChoice struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
|
||||
|
|
@ -1088,6 +1104,7 @@ func (x *StartActionByGetAndWaitResponse) GetLogEntry() *LogEntry {
|
|||
type GetLogsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
StartOffset int64 `protobuf:"varint,1,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"`
|
||||
DateFilter string `protobuf:"bytes,2,opt,name=date_filter,json=dateFilter,proto3" json:"date_filter,omitempty"` // Optional date filter in YYYY-MM-DD format
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -1129,6 +1146,13 @@ func (x *GetLogsRequest) GetStartOffset() int64 {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (x *GetLogsRequest) GetDateFilter() string {
|
||||
if x != nil {
|
||||
return x.DateFilter
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
DatetimeStarted string `protobuf:"bytes,1,opt,name=datetime_started,json=datetimeStarted,proto3" json:"datetime_started,omitempty"`
|
||||
|
|
@ -1142,12 +1166,13 @@ type LogEntry struct {
|
|||
Tags []string `protobuf:"bytes,10,rep,name=tags,proto3" json:"tags,omitempty"`
|
||||
ExecutionTrackingId string `protobuf:"bytes,11,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"`
|
||||
DatetimeFinished string `protobuf:"bytes,12,opt,name=datetime_finished,json=datetimeFinished,proto3" json:"datetime_finished,omitempty"`
|
||||
ActionId string `protobuf:"bytes,13,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"`
|
||||
ExecutionStarted bool `protobuf:"varint,14,opt,name=execution_started,json=executionStarted,proto3" json:"execution_started,omitempty"`
|
||||
ExecutionFinished bool `protobuf:"varint,15,opt,name=execution_finished,json=executionFinished,proto3" json:"execution_finished,omitempty"`
|
||||
Blocked bool `protobuf:"varint,16,opt,name=blocked,proto3" json:"blocked,omitempty"`
|
||||
DatetimeIndex int64 `protobuf:"varint,17,opt,name=datetime_index,json=datetimeIndex,proto3" json:"datetime_index,omitempty"`
|
||||
CanKill bool `protobuf:"varint,18,opt,name=can_kill,json=canKill,proto3" json:"can_kill,omitempty"`
|
||||
DatetimeRateLimitExpires string `protobuf:"bytes,19,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05"
|
||||
BindingId string `protobuf:"bytes,20,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` // Binding ID for matching rate limits to action buttons
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -1259,13 +1284,6 @@ func (x *LogEntry) GetDatetimeFinished() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *LogEntry) GetActionId() string {
|
||||
if x != nil {
|
||||
return x.ActionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LogEntry) GetExecutionStarted() bool {
|
||||
if x != nil {
|
||||
return x.ExecutionStarted
|
||||
|
|
@ -1301,6 +1319,20 @@ func (x *LogEntry) GetCanKill() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (x *LogEntry) GetDatetimeRateLimitExpires() string {
|
||||
if x != nil {
|
||||
return x.DatetimeRateLimitExpires
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LogEntry) GetBindingId() string {
|
||||
if x != nil {
|
||||
return x.BindingId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetLogsResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"`
|
||||
|
|
@ -2089,7 +2121,7 @@ func (x *DumpVarsResponse) GetContents() map[string]string {
|
|||
return nil
|
||||
}
|
||||
|
||||
type ActionEntityPair struct {
|
||||
type DebugBinding struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ActionTitle string `protobuf:"bytes,1,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"`
|
||||
EntityPrefix string `protobuf:"bytes,2,opt,name=entity_prefix,json=entityPrefix,proto3" json:"entity_prefix,omitempty"`
|
||||
|
|
@ -2097,20 +2129,20 @@ type ActionEntityPair struct {
|
|||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ActionEntityPair) Reset() {
|
||||
*x = ActionEntityPair{}
|
||||
func (x *DebugBinding) Reset() {
|
||||
*x = DebugBinding{}
|
||||
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ActionEntityPair) String() string {
|
||||
func (x *DebugBinding) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ActionEntityPair) ProtoMessage() {}
|
||||
func (*DebugBinding) ProtoMessage() {}
|
||||
|
||||
func (x *ActionEntityPair) ProtoReflect() protoreflect.Message {
|
||||
func (x *DebugBinding) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
|
|
@ -2122,19 +2154,19 @@ func (x *ActionEntityPair) ProtoReflect() protoreflect.Message {
|
|||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ActionEntityPair.ProtoReflect.Descriptor instead.
|
||||
func (*ActionEntityPair) Descriptor() ([]byte, []int) {
|
||||
// Deprecated: Use DebugBinding.ProtoReflect.Descriptor instead.
|
||||
func (*DebugBinding) Descriptor() ([]byte, []int) {
|
||||
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{35}
|
||||
}
|
||||
|
||||
func (x *ActionEntityPair) GetActionTitle() string {
|
||||
func (x *DebugBinding) GetActionTitle() string {
|
||||
if x != nil {
|
||||
return x.ActionTitle
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ActionEntityPair) GetEntityPrefix() string {
|
||||
func (x *DebugBinding) GetEntityPrefix() string {
|
||||
if x != nil {
|
||||
return x.EntityPrefix
|
||||
}
|
||||
|
|
@ -2180,7 +2212,7 @@ func (*DumpPublicIdActionMapRequest) Descriptor() ([]byte, []int) {
|
|||
type DumpPublicIdActionMapResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Alert string `protobuf:"bytes,1,opt,name=alert,proto3" json:"alert,omitempty"`
|
||||
Contents map[string]*ActionEntityPair `protobuf:"bytes,2,rep,name=contents,proto3" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
Contents map[string]*DebugBinding `protobuf:"bytes,2,rep,name=contents,proto3" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -2222,7 +2254,7 @@ func (x *DumpPublicIdActionMapResponse) GetAlert() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *DumpPublicIdActionMapResponse) GetContents() map[string]*ActionEntityPair {
|
||||
func (x *DumpPublicIdActionMapResponse) GetContents() map[string]*DebugBinding {
|
||||
if x != nil {
|
||||
return x.Contents
|
||||
}
|
||||
|
|
@ -3204,6 +3236,7 @@ type InitResponse struct {
|
|||
ShowDiagnostics bool `protobuf:"varint,21,opt,name=show_diagnostics,json=showDiagnostics,proto3" json:"show_diagnostics,omitempty"`
|
||||
ShowLogList bool `protobuf:"varint,22,opt,name=show_log_list,json=showLogList,proto3" json:"show_log_list,omitempty"`
|
||||
LoginRequired bool `protobuf:"varint,23,opt,name=login_required,json=loginRequired,proto3" json:"login_required,omitempty"`
|
||||
AvailableThemes []string `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -3399,6 +3432,13 @@ func (x *InitResponse) GetLoginRequired() bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (x *InitResponse) GetAvailableThemes() []string {
|
||||
if x != nil {
|
||||
return x.AvailableThemes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AdditionalLink struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
|
||||
|
|
@ -3839,7 +3879,7 @@ var File_olivetin_api_v1_olivetin_proto protoreflect.FileDescriptor
|
|||
|
||||
const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\x81\x02\n" +
|
||||
"\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\xc0\x02\n" +
|
||||
"\x06Action\x12\x1d\n" +
|
||||
"\n" +
|
||||
"binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" +
|
||||
|
|
@ -3849,7 +3889,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
|||
"\targuments\x18\x05 \x03(\v2\x1f.olivetin.api.v1.ActionArgumentR\targuments\x12$\n" +
|
||||
"\x0epopup_on_start\x18\x06 \x01(\tR\fpopupOnStart\x12\x14\n" +
|
||||
"\x05order\x18\a \x01(\x05R\x05order\x12\x18\n" +
|
||||
"\atimeout\x18\b \x01(\x05R\atimeout\"\xea\x02\n" +
|
||||
"\atimeout\x18\b \x01(\x05R\atimeout\x12=\n" +
|
||||
"\x1bdatetime_rate_limit_expires\x18\t \x01(\tR\x18datetimeRateLimitExpires\"\xa2\x03\n" +
|
||||
"\x0eActionArgument\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
|
||||
"\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
|
||||
|
|
@ -3857,7 +3898,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
|||
"\rdefault_value\x18\x04 \x01(\tR\fdefaultValue\x12?\n" +
|
||||
"\achoices\x18\x05 \x03(\v2%.olivetin.api.v1.ActionArgumentChoiceR\achoices\x12 \n" +
|
||||
"\vdescription\x18\x06 \x01(\tR\vdescription\x12R\n" +
|
||||
"\vsuggestions\x18\a \x03(\v20.olivetin.api.v1.ActionArgument.SuggestionsEntryR\vsuggestions\x1a>\n" +
|
||||
"\vsuggestions\x18\a \x03(\v20.olivetin.api.v1.ActionArgument.SuggestionsEntryR\vsuggestions\x126\n" +
|
||||
"\x17suggestions_browser_key\x18\b \x01(\tR\x15suggestionsBrowserKey\x1a>\n" +
|
||||
"\x10SuggestionsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" +
|
||||
|
|
@ -3922,9 +3964,11 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
|||
"\x1eStartActionByGetAndWaitRequest\x12\x1b\n" +
|
||||
"\taction_id\x18\x01 \x01(\tR\bactionId\"Y\n" +
|
||||
"\x1fStartActionByGetAndWaitResponse\x126\n" +
|
||||
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"3\n" +
|
||||
"\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"T\n" +
|
||||
"\x0eGetLogsRequest\x12!\n" +
|
||||
"\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\"\xc8\x04\n" +
|
||||
"\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\x12\x1f\n" +
|
||||
"\vdate_filter\x18\x02 \x01(\tR\n" +
|
||||
"dateFilter\"\x89\x05\n" +
|
||||
"\bLogEntry\x12)\n" +
|
||||
"\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" +
|
||||
"\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" +
|
||||
|
|
@ -3939,13 +3983,15 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
|||
"\x04tags\x18\n" +
|
||||
" \x03(\tR\x04tags\x122\n" +
|
||||
"\x15execution_tracking_id\x18\v \x01(\tR\x13executionTrackingId\x12+\n" +
|
||||
"\x11datetime_finished\x18\f \x01(\tR\x10datetimeFinished\x12\x1b\n" +
|
||||
"\taction_id\x18\r \x01(\tR\bactionId\x12+\n" +
|
||||
"\x11datetime_finished\x18\f \x01(\tR\x10datetimeFinished\x12+\n" +
|
||||
"\x11execution_started\x18\x0e \x01(\bR\x10executionStarted\x12-\n" +
|
||||
"\x12execution_finished\x18\x0f \x01(\bR\x11executionFinished\x12\x18\n" +
|
||||
"\ablocked\x18\x10 \x01(\bR\ablocked\x12%\n" +
|
||||
"\x0edatetime_index\x18\x11 \x01(\x03R\rdatetimeIndex\x12\x19\n" +
|
||||
"\bcan_kill\x18\x12 \x01(\bR\acanKill\"\xca\x01\n" +
|
||||
"\bcan_kill\x18\x12 \x01(\bR\acanKill\x12=\n" +
|
||||
"\x1bdatetime_rate_limit_expires\x18\x13 \x01(\tR\x18datetimeRateLimitExpires\x12\x1d\n" +
|
||||
"\n" +
|
||||
"binding_id\x18\x14 \x01(\tR\tbindingId\"\xca\x01\n" +
|
||||
"\x0fGetLogsResponse\x12-\n" +
|
||||
"\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" +
|
||||
"\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" +
|
||||
|
|
@ -3997,17 +4043,17 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
|||
"\bcontents\x18\x02 \x03(\v2/.olivetin.api.v1.DumpVarsResponse.ContentsEntryR\bcontents\x1a;\n" +
|
||||
"\rContentsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"Z\n" +
|
||||
"\x10ActionEntityPair\x12!\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"V\n" +
|
||||
"\fDebugBinding\x12!\n" +
|
||||
"\faction_title\x18\x01 \x01(\tR\vactionTitle\x12#\n" +
|
||||
"\rentity_prefix\x18\x02 \x01(\tR\fentityPrefix\"\x1e\n" +
|
||||
"\x1cDumpPublicIdActionMapRequest\"\xef\x01\n" +
|
||||
"\x1cDumpPublicIdActionMapRequest\"\xeb\x01\n" +
|
||||
"\x1dDumpPublicIdActionMapResponse\x12\x14\n" +
|
||||
"\x05alert\x18\x01 \x01(\tR\x05alert\x12X\n" +
|
||||
"\bcontents\x18\x02 \x03(\v2<.olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntryR\bcontents\x1a^\n" +
|
||||
"\bcontents\x18\x02 \x03(\v2<.olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntryR\bcontents\x1aZ\n" +
|
||||
"\rContentsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x127\n" +
|
||||
"\x05value\x18\x02 \x01(\v2!.olivetin.api.v1.ActionEntityPairR\x05value:\x028\x01\"\x12\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x123\n" +
|
||||
"\x05value\x18\x02 \x01(\v2\x1d.olivetin.api.v1.DebugBindingR\x05value:\x028\x01\"\x12\n" +
|
||||
"\x10GetReadyzRequest\"+\n" +
|
||||
"\x11GetReadyzResponse\x12\x16\n" +
|
||||
"\x06status\x18\x01 \x01(\tR\x06status\"\x14\n" +
|
||||
|
|
@ -4050,7 +4096,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
|||
"\x16GetDiagnosticsResponse\x12 \n" +
|
||||
"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
|
||||
"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\"\r\n" +
|
||||
"\vInitRequest\"\xa2\b\n" +
|
||||
"\vInitRequest\"\xcd\b\n" +
|
||||
"\fInitResponse\x12\x1e\n" +
|
||||
"\n" +
|
||||
"showFooter\x18\x01 \x01(\bR\n" +
|
||||
|
|
@ -4078,7 +4124,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
|
|||
"banner_css\x18\x14 \x01(\tR\tbannerCss\x12)\n" +
|
||||
"\x10show_diagnostics\x18\x15 \x01(\bR\x0fshowDiagnostics\x12\"\n" +
|
||||
"\rshow_log_list\x18\x16 \x01(\bR\vshowLogList\x12%\n" +
|
||||
"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\"8\n" +
|
||||
"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" +
|
||||
"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\"8\n" +
|
||||
"\x0eAdditionalLink\x12\x14\n" +
|
||||
"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
|
||||
"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
|
||||
|
|
@ -4181,7 +4228,7 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
|
|||
(*SosReportResponse)(nil), // 32: olivetin.api.v1.SosReportResponse
|
||||
(*DumpVarsRequest)(nil), // 33: olivetin.api.v1.DumpVarsRequest
|
||||
(*DumpVarsResponse)(nil), // 34: olivetin.api.v1.DumpVarsResponse
|
||||
(*ActionEntityPair)(nil), // 35: olivetin.api.v1.ActionEntityPair
|
||||
(*DebugBinding)(nil), // 35: olivetin.api.v1.DebugBinding
|
||||
(*DumpPublicIdActionMapRequest)(nil), // 36: olivetin.api.v1.DumpPublicIdActionMapRequest
|
||||
(*DumpPublicIdActionMapResponse)(nil), // 37: olivetin.api.v1.DumpPublicIdActionMapResponse
|
||||
(*GetReadyzRequest)(nil), // 38: olivetin.api.v1.GetReadyzRequest
|
||||
|
|
@ -4250,7 +4297,7 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
|
|||
0, // 27: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
|
||||
65, // 28: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
|
||||
3, // 29: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
|
||||
35, // 30: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.ActionEntityPair
|
||||
35, // 30: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
|
||||
6, // 31: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
|
||||
9, // 32: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
|
||||
12, // 33: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,14 +10,15 @@ require (
|
|||
connectrpc.com/connect v1.19.1
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/MicahParks/keyfunc/v3 v3.7.0
|
||||
github.com/PaesslerAG/jsonpath v0.1.1
|
||||
github.com/alexedwards/argon2id v1.0.0
|
||||
github.com/bufbuild/buf v1.61.0
|
||||
github.com/bufbuild/buf v1.63.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/fzipp/gocyclo v0.6.0
|
||||
github.com/go-critic/go-critic v0.14.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c
|
||||
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.0
|
||||
github.com/knadh/koanf/providers/env v1.1.0
|
||||
github.com/knadh/koanf/providers/file v1.2.1
|
||||
|
|
@ -51,13 +52,14 @@ require (
|
|||
buf.build/go/spdx v0.2.0 // indirect
|
||||
buf.build/go/standard v0.1.0 // indirect
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
connectrpc.com/otelconnect v0.8.0 // indirect
|
||||
connectrpc.com/otelconnect v0.9.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/MicahParks/jwkset v0.11.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/PaesslerAG/gval v1.2.4 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e // indirect
|
||||
github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 // indirect
|
||||
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
|
|
@ -112,7 +114,7 @@ require (
|
|||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
|
||||
github.com/quasilyte/gogrep v0.5.0 // indirect
|
||||
|
|
@ -125,6 +127,7 @@ require (
|
|||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/segmentio/encoding v0.5.3 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.1 // indirect
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
|
|||
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
connectrpc.com/otelconnect v0.8.0 h1:a4qrN4H8aEE2jAoCxheZYYfEjXMgVPyL9OzPQLBEFXU=
|
||||
connectrpc.com/otelconnect v0.8.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc=
|
||||
connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA=
|
||||
connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
|
|
@ -42,6 +44,13 @@ github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3
|
|||
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
|
||||
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
|
||||
github.com/PaesslerAG/gval v1.2.4 h1:rhX7MpjJlcxYwL2eTTYIOBUyEKZ+A96T9vQySWkVUiU=
|
||||
github.com/PaesslerAG/gval v1.2.4/go.mod h1:XRFLwvmkTEdYziLdaCeCa5ImcGVrfQbeNUbVR+C6xac=
|
||||
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
|
||||
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
|
|
@ -54,8 +63,12 @@ github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo
|
|||
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
|
||||
github.com/bufbuild/buf v1.61.0 h1:JPaK/RM2eoheyzznW+1LxaFgN6xjBCi8s25q2kUbH9A=
|
||||
github.com/bufbuild/buf v1.61.0/go.mod h1:Xs3leBmxjL5tTnSVYfNwNXHXD1k5et3fR/tJyIyQl4s=
|
||||
github.com/bufbuild/buf v1.63.0 h1:vMIRozWqYcOU992FqcGgAp8LjoWVTzr52qEolUT+xi4=
|
||||
github.com/bufbuild/buf v1.63.0/go.mod h1:IWF+TIxwmk4DeyDmguN8WhxKFKQitcX2WnP+RlJlDiY=
|
||||
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e h1:LQA+1MyiPkolGHJGC2GMDC5Xu+0RDVH6jGMKech7Exs=
|
||||
github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI=
|
||||
github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 h1:cQYwUyAzyMmYr7AyJU1C6pVCpUrJJBkmx7UunZosxxs=
|
||||
github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI=
|
||||
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU=
|
||||
github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
|
|
@ -152,6 +165,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM=
|
||||
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
|
||||
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac h1:JQ6AC9tf2xhwTxMY9nuIeOPM7Cj0BDeCNgKPrNgQvtQ=
|
||||
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
|
||||
github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ=
|
||||
github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8=
|
||||
github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88=
|
||||
|
|
@ -216,6 +231,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
|
|||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4=
|
||||
|
|
@ -242,12 +259,17 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
|
|||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
|
||||
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
||||
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
|
|
@ -293,6 +315,7 @@ go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
|||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
|
|
@ -305,6 +328,7 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6
|
|||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package api
|
|||
import (
|
||||
ctx "context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
|
@ -16,6 +18,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||
auth "github.com/OliveTin/OliveTin/internal/auth"
|
||||
|
|
@ -275,11 +278,23 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re
|
|||
}
|
||||
}
|
||||
|
||||
func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
|
||||
if logEntry.Binding == nil || logEntry.Binding.Action == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
expiryUnix := api.executor.GetTimeUntilAvailable(logEntry.Binding)
|
||||
if expiryUnix <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
|
||||
pble := &apiv1.LogEntry{
|
||||
ActionTitle: logEntry.ActionTitle,
|
||||
ActionIcon: logEntry.ActionIcon,
|
||||
ActionId: logEntry.ActionId,
|
||||
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
|
||||
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
|
||||
DatetimeIndex: logEntry.Index,
|
||||
|
|
@ -292,6 +307,8 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry
|
|||
ExecutionStarted: logEntry.ExecutionStarted,
|
||||
ExecutionFinished: logEntry.ExecutionFinished,
|
||||
User: logEntry.Username,
|
||||
BindingId: logEntry.Binding.ID,
|
||||
DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
|
||||
}
|
||||
|
||||
if !pble.ExecutionFinished {
|
||||
|
|
@ -311,10 +328,20 @@ func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string
|
|||
return logEntry
|
||||
}
|
||||
|
||||
func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
|
||||
// This is the actual action ID, not the binding ID.
|
||||
func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
|
||||
var ile *executor.InternalLogEntry
|
||||
|
||||
logs := api.executor.GetLogsByActionId(actionId)
|
||||
binding := api.executor.FindBindingByID(actionId)
|
||||
if binding == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logs := api.executor.GetLogsByBindingId(binding.ID)
|
||||
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
|
|
@ -341,7 +368,7 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
|
|||
ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId)
|
||||
|
||||
} else {
|
||||
ile = getMostRecentExecutionStatusById(api, req.Msg.ActionId)
|
||||
ile = getMostRecentExecutionStatusByActionId(api, req.Msg.ActionId)
|
||||
}
|
||||
|
||||
if ile == nil {
|
||||
|
|
@ -474,7 +501,11 @@ 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)
|
||||
dateFilter := ""
|
||||
if req.Msg.DateFilter != "" {
|
||||
dateFilter = req.Msg.DateFilter
|
||||
}
|
||||
logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize, dateFilter)
|
||||
for _, le := range logEntries {
|
||||
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
|
||||
}
|
||||
|
|
@ -539,7 +570,7 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
|
|||
return nil, err
|
||||
}
|
||||
|
||||
filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
|
||||
filtered := api.filterLogsByACL(api.executor.GetLogsByBindingId(req.Msg.ActionId), user)
|
||||
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
|
||||
if page.empty {
|
||||
return connect.NewResponse(buildEmptyPageResponse(page)), nil
|
||||
|
|
@ -688,7 +719,7 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum
|
|||
|
||||
func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
|
||||
res := &apiv1.DumpPublicIdActionMapResponse{}
|
||||
res.Contents = make(map[string]*apiv1.ActionEntityPair)
|
||||
res.Contents = make(map[string]*apiv1.DebugBinding)
|
||||
|
||||
if !api.cfg.InsecureAllowDumpActionMap {
|
||||
res.Alert = "Dumping Public IDs is disallowed."
|
||||
|
|
@ -696,16 +727,15 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Requ
|
|||
return connect.NewResponse(res), nil
|
||||
}
|
||||
|
||||
api.executor.MapActionIdToBindingLock.RLock()
|
||||
api.executor.MapActionBindingsLock.RLock()
|
||||
|
||||
for k, v := range api.executor.MapActionIdToBinding {
|
||||
res.Contents[k] = &apiv1.ActionEntityPair{
|
||||
for k, v := range api.executor.MapActionBindings {
|
||||
res.Contents[k] = &apiv1.DebugBinding{
|
||||
ActionTitle: v.Action.Title,
|
||||
EntityPrefix: "?",
|
||||
}
|
||||
}
|
||||
|
||||
api.executor.MapActionIdToBindingLock.RUnlock()
|
||||
api.executor.MapActionBindingsLock.RUnlock()
|
||||
|
||||
res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
|
||||
|
||||
|
|
@ -723,6 +753,10 @@ func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.Ge
|
|||
func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
|
||||
log.Debugf("EventStream: %v", req.Msg)
|
||||
|
||||
// Set X-Accel-Buffering header to disable nginx buffering for this stream
|
||||
// https://github.com/OliveTin/OliveTin/issues/765
|
||||
srv.ResponseHeader().Set("X-Accel-Buffering", "no")
|
||||
|
||||
user := auth.UserFromApiCall(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
|
|
@ -809,7 +843,7 @@ func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
|
|||
}
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
||||
func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
|
||||
toRemove := []*streamingClient{}
|
||||
|
||||
for _, client := range api.copyOfStreamingClients() {
|
||||
|
|
@ -817,7 +851,7 @@ func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
|
|||
case client.channel <- &apiv1.EventStreamResponse{
|
||||
Event: &apiv1.EventStreamResponse_ExecutionFinished{
|
||||
ExecutionFinished: &apiv1.EventExecutionFinished{
|
||||
LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
|
||||
LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
|
||||
},
|
||||
},
|
||||
}:
|
||||
|
|
@ -870,11 +904,62 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
|
|||
ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
|
||||
ShowLogList: user.EffectivePolicy.ShowLogList,
|
||||
LoginRequired: loginRequired,
|
||||
AvailableThemes: discoverAvailableThemes(api.cfg),
|
||||
}
|
||||
|
||||
return connect.NewResponse(res), nil
|
||||
}
|
||||
|
||||
// discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
|
||||
// A theme is considered available if it has a theme.css file.
|
||||
func discoverAvailableThemes(cfg *config.Config) []string {
|
||||
configDir := cfg.GetDir()
|
||||
if configDir == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
themesDir := path.Join(configDir, "custom-webui", "themes")
|
||||
entries, err := os.ReadDir(themesDir)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"themesDir": themesDir,
|
||||
"error": err,
|
||||
}).Tracef("Could not read themes directory")
|
||||
return []string{}
|
||||
}
|
||||
|
||||
themes := collectValidThemes(themesDir, entries)
|
||||
sort.Strings(themes)
|
||||
return themes
|
||||
}
|
||||
|
||||
// collectValidThemes collects theme names from directory entries that have a theme.css file.
|
||||
func collectValidThemes(themesDir string, entries []os.DirEntry) []string {
|
||||
var themes []string
|
||||
for _, entry := range entries {
|
||||
if themeName := getValidThemeName(themesDir, entry); themeName != "" {
|
||||
themes = append(themes, themeName)
|
||||
}
|
||||
}
|
||||
return themes
|
||||
}
|
||||
|
||||
// getValidThemeName returns the theme name if the entry is a valid theme directory with theme.css, otherwise returns empty string.
|
||||
func getValidThemeName(themesDir string, entry os.DirEntry) string {
|
||||
if !entry.IsDir() {
|
||||
return ""
|
||||
}
|
||||
|
||||
themeName := entry.Name()
|
||||
themeCssPath := path.Join(themesDir, themeName, "theme.css")
|
||||
|
||||
if _, err := os.Stat(themeCssPath); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return themeName
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
|
||||
var rootDashboards []string
|
||||
dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
acl "github.com/OliveTin/OliveTin/internal/acl"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
|
|
@ -22,10 +28,10 @@ func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action {
|
|||
}
|
||||
|
||||
func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action {
|
||||
rr.ex.MapActionIdToBindingLock.RLock()
|
||||
defer rr.ex.MapActionIdToBindingLock.RUnlock()
|
||||
rr.ex.MapActionBindingsLock.RLock()
|
||||
defer rr.ex.MapActionBindingsLock.RUnlock()
|
||||
|
||||
for _, binding := range rr.ex.MapActionIdToBinding {
|
||||
for _, binding := range rr.ex.MapActionBindings {
|
||||
if binding.Action.Title != title {
|
||||
continue
|
||||
}
|
||||
|
|
@ -55,17 +61,72 @@ func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePo
|
|||
return ret
|
||||
}
|
||||
|
||||
func evaluateEnabledExpression(action *config.Action, entity *entities.Entity) bool {
|
||||
if action.EnabledExpression == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
result := entities.ParseTemplateWith(action.EnabledExpression, entity)
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
if result == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if isTemplateError(result, action) {
|
||||
return false
|
||||
}
|
||||
|
||||
return evaluateResultValue(result)
|
||||
}
|
||||
|
||||
func isTemplateError(result string, action *config.Action) bool {
|
||||
if !strings.HasPrefix(result, "tpl ") || !strings.Contains(result, "error") {
|
||||
return false
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"actionTitle": action.Title,
|
||||
"enabledExpression": action.EnabledExpression,
|
||||
"result": result,
|
||||
}).Warn("enabledExpression template evaluation failed, treating as disabled")
|
||||
return true
|
||||
}
|
||||
|
||||
func evaluateResultValue(result string) bool {
|
||||
if strings.EqualFold(result, "true") {
|
||||
return true
|
||||
}
|
||||
|
||||
if num, err := strconv.Atoi(result); err == nil {
|
||||
return num != 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action {
|
||||
action := actionBinding.Action
|
||||
|
||||
aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action)
|
||||
enabledExprCanExec := evaluateEnabledExpression(action, actionBinding.Entity)
|
||||
|
||||
// Calculate rate limit expiry time
|
||||
expiryUnix := rr.ex.GetTimeUntilAvailable(actionBinding)
|
||||
datetimeRateLimitExpires := ""
|
||||
if expiryUnix > 0 {
|
||||
datetimeRateLimitExpires = time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
btn := apiv1.Action{
|
||||
BindingId: actionBinding.ID,
|
||||
Title: entities.ParseTemplateWith(action.Title, actionBinding.Entity),
|
||||
Icon: entities.ParseTemplateWith(action.Icon, actionBinding.Entity),
|
||||
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
|
||||
CanExec: aclCanExec && enabledExprCanExec,
|
||||
PopupOnStart: action.PopupOnStart,
|
||||
Order: int32(actionBinding.ConfigOrder),
|
||||
Timeout: int32(action.Timeout),
|
||||
DatetimeRateLimitExpires: datetimeRateLimitExpires,
|
||||
}
|
||||
|
||||
for _, cfgArg := range action.Arguments {
|
||||
|
|
@ -77,6 +138,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
|
|||
DefaultValue: cfgArg.Default,
|
||||
Choices: buildChoices(cfgArg),
|
||||
Suggestions: cfgArg.Suggestions,
|
||||
SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey,
|
||||
}
|
||||
|
||||
btn.Arguments = append(btn.Arguments, &pbArg)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
|
||||
apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
|
||||
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/entities"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
|
|
@ -20,7 +21,7 @@ import (
|
|||
"path"
|
||||
)
|
||||
|
||||
func getNewTestServerAndClient(t *testing.T, injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
|
||||
func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
|
||||
ex := executor.DefaultExecutor(injectedConfig)
|
||||
ex.RebuildActionMap()
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ func TestGetActionsAndStart(t *testing.T) {
|
|||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
|
||||
conn, client := getNewTestServerAndClient(t, cfg)
|
||||
conn, client := getNewTestServerAndClient(cfg)
|
||||
|
||||
respInit, errInit := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
|
||||
respGetReady, errReady := client.GetReadyz(context.Background(), connect.NewRequest(&apiv1.GetReadyzRequest{}))
|
||||
|
|
@ -98,7 +99,7 @@ func TestGetActionsAndStart(t *testing.T) {
|
|||
func TestGetEntities(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
|
||||
ts, client := getNewTestServerAndClient(t, cfg)
|
||||
ts, client := getNewTestServerAndClient(cfg)
|
||||
defer ts.Close()
|
||||
|
||||
setupTestEntities()
|
||||
|
|
@ -173,3 +174,164 @@ func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceCli
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEnabledExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
entity *entities.Entity
|
||||
expectedResult bool
|
||||
}{
|
||||
{
|
||||
name: "empty expression returns true",
|
||||
expression: "",
|
||||
entity: nil,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "literal true returns true",
|
||||
expression: "true",
|
||||
entity: nil,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "literal True returns true (case insensitive)",
|
||||
expression: "True",
|
||||
entity: nil,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "literal 1 returns true",
|
||||
expression: "1",
|
||||
entity: nil,
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "literal false returns false",
|
||||
expression: "false",
|
||||
entity: nil,
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "literal 0 returns false",
|
||||
expression: "0",
|
||||
entity: nil,
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "empty result returns false",
|
||||
expression: "{{ .NonExistent }}",
|
||||
entity: nil,
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "expression with CurrentEntity true",
|
||||
expression: "{{ eq .CurrentEntity.powered_on true }}",
|
||||
entity: &entities.Entity{Data: map[string]any{"powered_on": true}},
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "expression with CurrentEntity false",
|
||||
expression: "{{ eq .CurrentEntity.powered_on true }}",
|
||||
entity: &entities.Entity{Data: map[string]any{"powered_on": false}},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "expression with CurrentEntity integer 1",
|
||||
expression: "{{ .CurrentEntity.status }}",
|
||||
entity: &entities.Entity{Data: map[string]any{"status": 1}},
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "expression with CurrentEntity integer 0",
|
||||
expression: "{{ .CurrentEntity.status }}",
|
||||
entity: &entities.Entity{Data: map[string]any{"status": 0}},
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "template parse error returns false",
|
||||
expression: "{{ invalid syntax }}",
|
||||
entity: nil,
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "template exec error returns false",
|
||||
expression: "{{ .CurrentEntity.nonexistent }}",
|
||||
entity: nil,
|
||||
expectedResult: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action := &config.Action{
|
||||
EnabledExpression: tt.expression,
|
||||
}
|
||||
result := evaluateEnabledExpression(action, tt.entity)
|
||||
assert.Equal(t, tt.expectedResult, result, "evaluateEnabledExpression should return expected result")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildActionWithEnabledExpression(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.DefaultPermissions.Exec = true
|
||||
|
||||
action := &config.Action{
|
||||
Title: "Test Action",
|
||||
Shell: "echo test",
|
||||
EnabledExpression: "{{ eq .CurrentEntity.enabled true }}",
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, action)
|
||||
|
||||
ex := executor.DefaultExecutor(cfg)
|
||||
ex.RebuildActionMap()
|
||||
|
||||
binding := findBindingByTitle(ex, "Test Action")
|
||||
assert.NotNil(t, binding, "Binding should be found")
|
||||
|
||||
rr := &DashboardRenderRequest{
|
||||
AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "testuser"},
|
||||
cfg: cfg,
|
||||
ex: ex,
|
||||
}
|
||||
|
||||
testWithEntity(t, binding, rr, true, true, "Action should be executable when entity.enabled is true")
|
||||
testWithEntity(t, binding, rr, false, false, "Action should not be executable when entity.enabled is false")
|
||||
|
||||
bindingNoExpr := findBindingByTitle(ex, "Test Action No Expression")
|
||||
if bindingNoExpr == nil {
|
||||
actionNoExpression := &config.Action{
|
||||
Title: "Test Action No Expression",
|
||||
Shell: "echo test",
|
||||
}
|
||||
cfg.Actions = append(cfg.Actions, actionNoExpression)
|
||||
ex.RebuildActionMap()
|
||||
bindingNoExpr = findBindingByTitle(ex, "Test Action No Expression")
|
||||
}
|
||||
|
||||
actionResult := buildAction(bindingNoExpr, rr)
|
||||
assert.True(t, actionResult.CanExec, "Action without enabledExpression should be executable")
|
||||
}
|
||||
|
||||
func findBindingByTitle(ex *executor.Executor, title string) *executor.ActionBinding {
|
||||
ex.MapActionBindingsLock.RLock()
|
||||
defer ex.MapActionBindingsLock.RUnlock()
|
||||
|
||||
for _, b := range ex.MapActionBindings {
|
||||
if b.Action.Title == title {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *DashboardRenderRequest, enabled bool, expectedCanExec bool, message string) {
|
||||
binding.Entity = &entities.Entity{
|
||||
UniqueKey: "test-entity",
|
||||
Data: map[string]any{"enabled": enabled},
|
||||
}
|
||||
|
||||
actionResult := buildAction(binding, rr)
|
||||
assert.Equal(t, expectedCanExec, actionResult.CanExec, message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
|
|||
Contents: make([]*apiv1.DashboardComponent, 0),
|
||||
}
|
||||
|
||||
for _, binding := range rr.ex.MapActionIdToBinding {
|
||||
for _, binding := range rr.ex.MapActionBindings {
|
||||
if binding.Action.Hidden {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ type Action struct {
|
|||
ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
|
||||
ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
|
||||
ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
|
||||
ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"`
|
||||
Triggers []string `koanf:"triggers"`
|
||||
MaxConcurrent int `koanf:"maxConcurrent"`
|
||||
MaxRate []RateSpec `koanf:"maxRate"`
|
||||
Arguments []ActionArgument `koanf:"arguments"`
|
||||
PopupOnStart string `koanf:"popupOnStart"`
|
||||
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
|
||||
EnabledExpression string `koanf:"enabledExpression"`
|
||||
}
|
||||
|
||||
// ActionArgument objects appear on Actions.
|
||||
|
|
@ -41,6 +43,7 @@ type ActionArgument struct {
|
|||
Entity string `koanf:"entity"`
|
||||
RejectNull bool `koanf:"rejectNull"`
|
||||
Suggestions map[string]string `koanf:"suggestions"`
|
||||
SuggestionsBrowserKey string `koanf:"suggestionsBrowserKey"`
|
||||
}
|
||||
|
||||
// ActionArgumentChoice represents a predefined choice for an argument.
|
||||
|
|
@ -55,6 +58,18 @@ type RateSpec struct {
|
|||
Duration string `koanf:"duration"`
|
||||
}
|
||||
|
||||
// WebhookConfig defines configuration for generic webhook triggers.
|
||||
type WebhookConfig struct {
|
||||
Secret string `koanf:"secret"` // Optional: secret for signature verification
|
||||
AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none"
|
||||
AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature")
|
||||
MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers
|
||||
MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath")
|
||||
MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters
|
||||
Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions
|
||||
Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr")
|
||||
}
|
||||
|
||||
// Entity represents a "thing" that can have multiple actions associated with it.
|
||||
// for example, a media player with a start and stop action.
|
||||
type EntityFile struct {
|
||||
|
|
|
|||
|
|
@ -58,5 +58,8 @@ func (cfg *Config) SetDir(dir string) {
|
|||
}
|
||||
|
||||
func (cfg *Config) GetDir() string {
|
||||
if len(cfg.sourceFiles) == 0 {
|
||||
return ""
|
||||
}
|
||||
return cfg.sourceFiles[len(cfg.sourceFiles)-1]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,12 +49,12 @@ type ActionBinding struct {
|
|||
type Executor struct {
|
||||
logs map[string]*InternalLogEntry
|
||||
logsTrackingIdsByDate []string
|
||||
LogsByActionId map[string][]*InternalLogEntry
|
||||
LogsByBindingId map[string][]*InternalLogEntry
|
||||
|
||||
logmutex sync.RWMutex
|
||||
|
||||
MapActionIdToBinding map[string]*ActionBinding
|
||||
MapActionIdToBindingLock sync.RWMutex
|
||||
MapActionBindings map[string]*ActionBinding
|
||||
MapActionBindingsLock sync.RWMutex
|
||||
|
||||
Cfg *config.Config
|
||||
|
||||
|
|
@ -86,7 +86,6 @@ type ExecutionRequest struct {
|
|||
// easily serializable.
|
||||
type InternalLogEntry struct {
|
||||
Binding *ActionBinding
|
||||
BindingID string
|
||||
DatetimeStarted time.Time
|
||||
DatetimeFinished time.Time
|
||||
Output string
|
||||
|
|
@ -110,7 +109,6 @@ type InternalLogEntry struct {
|
|||
*/
|
||||
ActionTitle string
|
||||
ActionIcon string
|
||||
ActionId string
|
||||
}
|
||||
|
||||
type executorStepFunc func(*ExecutionRequest) bool
|
||||
|
|
@ -122,8 +120,8 @@ func DefaultExecutor(cfg *config.Config) *Executor {
|
|||
e.Cfg = cfg
|
||||
e.logs = make(map[string]*InternalLogEntry)
|
||||
e.logsTrackingIdsByDate = make([]string, 0)
|
||||
e.LogsByActionId = make(map[string][]*InternalLogEntry)
|
||||
e.MapActionIdToBinding = make(map[string]*ActionBinding)
|
||||
e.LogsByBindingId = make(map[string][]*InternalLogEntry)
|
||||
e.MapActionBindings = make(map[string]*ActionBinding)
|
||||
|
||||
e.chainOfCommand = []executorStepFunc{
|
||||
stepRequestAction,
|
||||
|
|
@ -226,29 +224,25 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
|
|||
return trackingIds, pagingResult
|
||||
}
|
||||
|
||||
// isValidLogEntryForACL checks if a log entry has all required fields for ACL checking.
|
||||
func isValidLogEntryForACL(entry *InternalLogEntry) bool {
|
||||
return entry != nil && entry.Binding != nil && entry.Binding.Action != nil
|
||||
}
|
||||
|
||||
// isLogEntryAllowedByACL checks if a log entry is allowed to be viewed by the user.
|
||||
func isLogEntryAllowedByACL(cfg *config.Config, user *authpublic.AuthenticatedUser, entry *InternalLogEntry) bool {
|
||||
return acl.IsAllowedLogs(cfg, user, entry.Binding.Action)
|
||||
}
|
||||
|
||||
func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.AuthenticatedUser) []*InternalLogEntry {
|
||||
func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.AuthenticatedUser, dateFilter string) []*InternalLogEntry {
|
||||
e.logmutex.RLock()
|
||||
defer e.logmutex.RUnlock()
|
||||
|
||||
filtered := make([]*InternalLogEntry, 0, len(e.logsTrackingIdsByDate))
|
||||
filterDate, hasDateFilter := parseDateFilter(dateFilter)
|
||||
|
||||
for _, trackingId := range e.logsTrackingIdsByDate {
|
||||
entry := e.logs[trackingId]
|
||||
|
||||
if !isValidLogEntryForACL(entry) {
|
||||
continue
|
||||
}
|
||||
if isLogEntryAllowedByACL(cfg, user, entry) {
|
||||
if shouldIncludeLogEntry(cfg, user, entry, filterDate, hasDateFilter) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +250,48 @@ func (e *Executor) filterLogsByACL(cfg *config.Config, user *authpublic.Authenti
|
|||
return filtered
|
||||
}
|
||||
|
||||
// parseDateFilter parses the date filter string and returns filter information.
|
||||
func parseDateFilter(dateFilter string) (filterDate time.Time, hasDateFilter bool) {
|
||||
if dateFilter == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
parsedDate, err := time.Parse("2006-01-02", dateFilter)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"dateFilter": dateFilter,
|
||||
"error": err,
|
||||
}).Errorf("Failed to parse date filter, expected format YYYY-MM-DD")
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
return parsedDate, true
|
||||
}
|
||||
|
||||
// shouldIncludeLogEntry determines if a log entry should be included based on ACL and date filter.
|
||||
func shouldIncludeLogEntry(cfg *config.Config, user *authpublic.AuthenticatedUser, entry *InternalLogEntry, filterDate time.Time, hasDateFilter bool) bool {
|
||||
if !isValidLogEntryForACL(entry) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !isLogEntryAllowedByACL(cfg, user, entry) {
|
||||
return false
|
||||
}
|
||||
|
||||
return matchesDateFilter(entry, filterDate, hasDateFilter)
|
||||
}
|
||||
|
||||
// matchesDateFilter checks if the log entry matches the date filter.
|
||||
func matchesDateFilter(entry *InternalLogEntry, filterDate time.Time, hasDateFilter bool) bool {
|
||||
if !hasDateFilter {
|
||||
return true
|
||||
}
|
||||
|
||||
entryDate := entry.DatetimeStarted.UTC().Truncate(24 * time.Hour)
|
||||
filterDateUTC := filterDate.UTC().Truncate(24 * time.Hour)
|
||||
return entryDate.Equal(filterDateUTC)
|
||||
}
|
||||
|
||||
// paginateFilteredLogs applies pagination to a filtered list of logs and returns
|
||||
// the paginated results along with pagination metadata.
|
||||
func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
|
||||
|
|
@ -282,8 +318,9 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC
|
|||
|
||||
// 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 *authpublic.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
|
||||
filtered := e.filterLogsByACL(cfg, user)
|
||||
// dateFilter is optional and should be in YYYY-MM-DD format. If empty, no date filtering is applied.
|
||||
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.AuthenticatedUser, startOffset int64, pageCount int64, dateFilter string) ([]*InternalLogEntry, *PagingResult) {
|
||||
filtered := e.filterLogsByACL(cfg, user, dateFilter)
|
||||
return paginateFilteredLogs(filtered, startOffset, pageCount)
|
||||
}
|
||||
|
||||
|
|
@ -297,10 +334,10 @@ func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
|
|||
return entry, found
|
||||
}
|
||||
|
||||
func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
|
||||
func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry {
|
||||
e.logmutex.RLock()
|
||||
|
||||
logs, found := e.LogsByActionId[actionId]
|
||||
logs, found := e.LogsByBindingId[bindingId]
|
||||
|
||||
e.logmutex.RUnlock()
|
||||
|
||||
|
|
@ -311,6 +348,122 @@ func (e *Executor) GetLogsByActionId(actionId string) []*InternalLogEntry {
|
|||
return logs
|
||||
}
|
||||
|
||||
// shouldCountExecution checks if a log entry should be counted for rate limiting.
|
||||
func shouldCountExecution(logEntry *InternalLogEntry, windowStart time.Time) bool {
|
||||
return !logEntry.Blocked && logEntry.DatetimeStarted.After(windowStart)
|
||||
}
|
||||
|
||||
// updateOldestExecution updates the oldest execution time if this entry is older.
|
||||
func updateOldestExecution(oldestExecutionTime **time.Time, logEntry *InternalLogEntry) {
|
||||
if *oldestExecutionTime == nil {
|
||||
*oldestExecutionTime = &logEntry.DatetimeStarted
|
||||
} else if logEntry.DatetimeStarted.Before(**oldestExecutionTime) {
|
||||
*oldestExecutionTime = &logEntry.DatetimeStarted
|
||||
}
|
||||
}
|
||||
|
||||
// findOldestExecutionInWindow finds the oldest execution within the time window and counts executions.
|
||||
// Returns the count of executions and the oldest execution time, or nil if none found.
|
||||
func findOldestExecutionInWindow(logs []*InternalLogEntry, windowStart time.Time) (int, *time.Time) {
|
||||
executions := 0
|
||||
var oldestExecutionTime *time.Time
|
||||
|
||||
for _, logEntry := range logs {
|
||||
if !shouldCountExecution(logEntry, windowStart) {
|
||||
continue
|
||||
}
|
||||
|
||||
executions++
|
||||
updateOldestExecution(&oldestExecutionTime, logEntry)
|
||||
}
|
||||
|
||||
return executions, oldestExecutionTime
|
||||
}
|
||||
|
||||
// calculateExpiryTime calculates when the oldest execution will fall outside the rate limit window.
|
||||
func calculateExpiryTime(oldestExecutionTime time.Time, duration time.Duration, now time.Time) time.Time {
|
||||
expiryTime := oldestExecutionTime.Add(duration)
|
||||
if !expiryTime.After(now) {
|
||||
return time.Time{}
|
||||
}
|
||||
return expiryTime
|
||||
}
|
||||
|
||||
// updateMaxExpiryTime updates maxExpiryTime if expiryTime is later.
|
||||
func updateMaxExpiryTime(maxExpiryTime *time.Time, expiryTime time.Time) {
|
||||
if expiryTime.IsZero() {
|
||||
return
|
||||
}
|
||||
|
||||
if maxExpiryTime.IsZero() || expiryTime.After(*maxExpiryTime) {
|
||||
*maxExpiryTime = expiryTime
|
||||
}
|
||||
}
|
||||
|
||||
// calculateExpiryForRate calculates the expiry time for a single rate limit rule.
|
||||
// Returns the expiry time if the rate limit is exceeded, or zero time if not.
|
||||
func calculateExpiryForRate(rate config.RateSpec, logs []*InternalLogEntry, now time.Time) time.Time {
|
||||
duration := parseDuration(rate)
|
||||
if duration <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
windowStart := now.Add(-duration)
|
||||
executions, oldestExecutionTime := findOldestExecutionInWindow(logs, windowStart)
|
||||
|
||||
if executions < rate.Limit || oldestExecutionTime == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return calculateExpiryTime(*oldestExecutionTime, duration, now)
|
||||
}
|
||||
|
||||
// getLogsForBinding retrieves logs for a binding ID.
|
||||
func (e *Executor) getLogsForBinding(bindingId string) []*InternalLogEntry {
|
||||
e.logmutex.RLock()
|
||||
logs, found := e.LogsByBindingId[bindingId]
|
||||
e.logmutex.RUnlock()
|
||||
|
||||
if !found || len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
// calculateMaxExpiryTimeFromRates calculates the maximum expiry time across all rate limit rules.
|
||||
func calculateMaxExpiryTimeFromRates(rates []config.RateSpec, logs []*InternalLogEntry, now time.Time) time.Time {
|
||||
var maxExpiryTime time.Time
|
||||
|
||||
for _, rate := range rates {
|
||||
expiryTime := calculateExpiryForRate(rate, logs, now)
|
||||
updateMaxExpiryTime(&maxExpiryTime, expiryTime)
|
||||
}
|
||||
|
||||
return maxExpiryTime
|
||||
}
|
||||
|
||||
// GetTimeUntilAvailable calculates when an action will be available again based on rate limits.
|
||||
// Returns the Unix timestamp in seconds when the rate limit expires, or 0 if the action is available now.
|
||||
func (e *Executor) GetTimeUntilAvailable(binding *ActionBinding) int64 {
|
||||
if len(binding.Action.MaxRate) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
logs := e.getLogsForBinding(binding.ID)
|
||||
if logs == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
maxExpiryTime := calculateMaxExpiryTimeFromRates(binding.Action.MaxRate, logs, time.Now())
|
||||
|
||||
if maxExpiryTime.IsZero() {
|
||||
return 0
|
||||
}
|
||||
|
||||
return maxExpiryTime.Unix()
|
||||
}
|
||||
|
||||
func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) {
|
||||
e.logmutex.Lock()
|
||||
|
||||
|
|
@ -337,7 +490,6 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
|
|||
ExitCode: DefaultExitCodeNotExecuted,
|
||||
ExecutionStarted: false,
|
||||
ExecutionFinished: false,
|
||||
ActionId: "",
|
||||
ActionTitle: "notfound",
|
||||
ActionIcon: "💩",
|
||||
Username: req.AuthenticatedUser.Username,
|
||||
|
|
@ -374,6 +526,11 @@ func (e *Executor) execChain(req *ExecutionRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
// Ensure DatetimeFinished is set even if execution was blocked early
|
||||
if req.logEntry.DatetimeFinished.IsZero() {
|
||||
req.logEntry.DatetimeFinished = time.Now()
|
||||
}
|
||||
|
||||
req.logEntry.ExecutionFinished = true
|
||||
|
||||
// This isn't a step, because we want to notify all listeners, irrespective
|
||||
|
|
@ -386,7 +543,7 @@ func getConcurrentCount(req *ExecutionRequest) int {
|
|||
|
||||
req.executor.logmutex.RLock()
|
||||
|
||||
for _, log := range req.executor.GetLogsByActionId(req.Binding.Action.ID) {
|
||||
for _, log := range req.executor.GetLogsByBindingId(req.Binding.ID) {
|
||||
if !log.ExecutionFinished {
|
||||
concurrentCount += 1
|
||||
}
|
||||
|
|
@ -436,7 +593,7 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int {
|
|||
|
||||
then := time.Now().Add(-duration)
|
||||
|
||||
for _, logEntry := range req.executor.GetLogsByActionId(req.Binding.Action.ID) {
|
||||
for _, logEntry := range req.executor.GetLogsByBindingId(req.Binding.ID) {
|
||||
// FIXME
|
||||
/*
|
||||
if logEntry.EntityPrefix != req.EntityPrefix {
|
||||
|
|
@ -573,16 +730,15 @@ func stepRequestAction(req *ExecutionRequest) bool {
|
|||
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
|
||||
req.logEntry.ActionId = req.Binding.Action.ID
|
||||
req.logEntry.Tags = req.Tags
|
||||
|
||||
req.executor.logmutex.Lock()
|
||||
|
||||
if _, containsKey := req.executor.LogsByActionId[req.Binding.Action.ID]; !containsKey {
|
||||
req.executor.LogsByActionId[req.Binding.Action.ID] = make([]*InternalLogEntry, 0)
|
||||
if _, containsKey := req.executor.LogsByBindingId[req.Binding.ID]; !containsKey {
|
||||
req.executor.LogsByBindingId[req.Binding.ID] = make([]*InternalLogEntry, 0)
|
||||
}
|
||||
|
||||
req.executor.LogsByActionId[req.Binding.Action.ID] = append(req.executor.LogsByActionId[req.Binding.Action.ID], req.logEntry)
|
||||
req.executor.LogsByBindingId[req.Binding.ID] = append(req.executor.LogsByBindingId[req.Binding.ID], req.logEntry)
|
||||
|
||||
req.executor.logmutex.Unlock()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import (
|
|||
)
|
||||
|
||||
func (e *Executor) FindBindingByID(id string) *ActionBinding {
|
||||
e.MapActionIdToBindingLock.RLock()
|
||||
pair, found := e.MapActionIdToBinding[id]
|
||||
e.MapActionIdToBindingLock.RUnlock()
|
||||
e.MapActionBindingsLock.RLock()
|
||||
pair, found := e.MapActionBindings[id]
|
||||
e.MapActionBindingsLock.RUnlock()
|
||||
|
||||
if !found {
|
||||
return nil
|
||||
|
|
@ -23,11 +23,11 @@ func (e *Executor) FindBindingByID(id string) *ActionBinding {
|
|||
}
|
||||
|
||||
func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding {
|
||||
e.MapActionIdToBindingLock.RLock()
|
||||
e.MapActionBindingsLock.RLock()
|
||||
|
||||
defer e.MapActionIdToBindingLock.RUnlock()
|
||||
defer e.MapActionBindingsLock.RUnlock()
|
||||
|
||||
for _, binding := range e.MapActionIdToBinding {
|
||||
for _, binding := range e.MapActionBindings {
|
||||
if binding.Action == action && binding.Entity == nil {
|
||||
return binding
|
||||
}
|
||||
|
|
@ -42,9 +42,9 @@ type RebuildActionMapRequest struct {
|
|||
}
|
||||
|
||||
func (e *Executor) RebuildActionMap() {
|
||||
e.MapActionIdToBindingLock.Lock()
|
||||
e.MapActionBindingsLock.Lock()
|
||||
|
||||
clear(e.MapActionIdToBinding)
|
||||
clear(e.MapActionBindings)
|
||||
|
||||
req := &RebuildActionMapRequest{
|
||||
Cfg: e.Cfg,
|
||||
|
|
@ -65,7 +65,7 @@ func (e *Executor) RebuildActionMap() {
|
|||
}
|
||||
}
|
||||
|
||||
e.MapActionIdToBindingLock.Unlock()
|
||||
e.MapActionBindingsLock.Unlock()
|
||||
|
||||
for _, l := range e.listeners {
|
||||
l.OnActionMapRebuilt()
|
||||
|
|
@ -100,10 +100,10 @@ func recurseDashboardForActionTitles(component *config.DashboardComponent, req *
|
|||
}
|
||||
|
||||
func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) {
|
||||
actionId := hashActionToID(action, "")
|
||||
bindingId := generateActionBindingId(action, "")
|
||||
|
||||
e.MapActionIdToBinding[actionId] = &ActionBinding{
|
||||
ID: actionId,
|
||||
e.MapActionBindings[bindingId] = &ActionBinding{
|
||||
ID: bindingId,
|
||||
Action: action,
|
||||
Entity: nil,
|
||||
ConfigOrder: configOrder,
|
||||
|
|
@ -118,9 +118,9 @@ func registerActionsFromEntities(e *Executor, configOrder int, entityTitle strin
|
|||
}
|
||||
|
||||
func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, ent *entities.Entity, req *RebuildActionMapRequest) {
|
||||
virtualActionId := hashActionToID(tpl, ent.UniqueKey)
|
||||
virtualActionId := generateActionBindingId(tpl, ent.UniqueKey)
|
||||
|
||||
e.MapActionIdToBinding[virtualActionId] = &ActionBinding{
|
||||
e.MapActionBindings[virtualActionId] = &ActionBinding{
|
||||
ID: virtualActionId,
|
||||
Action: tpl,
|
||||
Entity: ent,
|
||||
|
|
@ -129,7 +129,7 @@ func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action,
|
|||
}
|
||||
}
|
||||
|
||||
func hashActionToID(action *config.Action, entityPrefix string) string {
|
||||
func generateActionBindingId(action *config.Action, entityPrefix string) string {
|
||||
if action.ID != "" && entityPrefix == "" {
|
||||
return action.ID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"github.com/OliveTin/OliveTin/internal/auth/otoauth2"
|
||||
config "github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
"github.com/OliveTin/OliveTin/internal/webhooks"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
|
@ -72,6 +73,10 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
|
|||
|
||||
mux.HandleFunc("/readyz", handleReadyz)
|
||||
|
||||
webhookHandler := webhooks.NewWebhookHandler(cfg, ex)
|
||||
mux.HandleFunc("/webhooks", webhookHandler.HandleWebhook)
|
||||
mux.HandleFunc("/webhooks/", webhookHandler.HandleWebhook)
|
||||
|
||||
webuiServer := NewWebUIServer(cfg)
|
||||
|
||||
mux.HandleFunc("/theme.css", webuiServer.generateThemeCss)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/OliveTin/OliveTin/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type AuthVerifier struct {
|
||||
config config.WebhookConfig
|
||||
}
|
||||
|
||||
func NewAuthVerifier(cfg config.WebhookConfig) *AuthVerifier {
|
||||
return &AuthVerifier{config: cfg}
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) Verify(r *http.Request, payload []byte) bool {
|
||||
verifier := v.getVerifier()
|
||||
if verifier == nil {
|
||||
return v.handleUnknownAuthType()
|
||||
}
|
||||
return verifier(r, payload)
|
||||
}
|
||||
|
||||
type authVerifierFunc func(*http.Request, []byte) bool
|
||||
|
||||
func (v *AuthVerifier) getVerifier() authVerifierFunc {
|
||||
if v.config.AuthType == "" || v.config.AuthType == "none" {
|
||||
return func(_ *http.Request, _ []byte) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
verifierMap := v.buildVerifierMap()
|
||||
if verifier, ok := verifierMap[v.config.AuthType]; ok {
|
||||
return verifier
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) buildVerifierMap() map[string]authVerifierFunc {
|
||||
return map[string]authVerifierFunc{
|
||||
"hmac-sha256": v.verifyHMAC256,
|
||||
"hmac-sha1": v.verifyHMAC1,
|
||||
"bearer": func(r *http.Request, _ []byte) bool {
|
||||
return v.verifyBearer(r)
|
||||
},
|
||||
"basic": func(r *http.Request, _ []byte) bool {
|
||||
return v.verifyBasic(r)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) handleUnknownAuthType() bool {
|
||||
log.WithFields(log.Fields{
|
||||
"authType": v.config.AuthType,
|
||||
}).Warnf("Unknown auth type, rejecting")
|
||||
return false
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) verifyHMAC256(r *http.Request, payload []byte) bool {
|
||||
if v.config.Secret == "" {
|
||||
log.Warnf("HMAC-SHA256 auth requires secret")
|
||||
return false
|
||||
}
|
||||
|
||||
headerName := v.config.AuthHeader
|
||||
if headerName == "" {
|
||||
headerName = "X-Webhook-Signature"
|
||||
}
|
||||
|
||||
signature := r.Header.Get(headerName)
|
||||
if signature == "" {
|
||||
log.Debugf("Missing signature header: %s", headerName)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedSig := strings.TrimPrefix(signature, "sha256=")
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(v.config.Secret))
|
||||
mac.Write(payload)
|
||||
computedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(expectedSig), []byte(computedSig))
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) verifyHMAC1(r *http.Request, payload []byte) bool {
|
||||
if v.config.Secret == "" {
|
||||
log.Warnf("HMAC-SHA1 auth requires secret")
|
||||
return false
|
||||
}
|
||||
|
||||
headerName := v.config.AuthHeader
|
||||
if headerName == "" {
|
||||
headerName = "X-Webhook-Signature"
|
||||
}
|
||||
|
||||
signature := r.Header.Get(headerName)
|
||||
if signature == "" {
|
||||
log.Debugf("Missing signature header: %s", headerName)
|
||||
return false
|
||||
}
|
||||
|
||||
expectedSig := strings.TrimPrefix(signature, "sha1=")
|
||||
|
||||
mac := hmac.New(sha1.New, []byte(v.config.Secret))
|
||||
mac.Write(payload)
|
||||
computedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(expectedSig), []byte(computedSig))
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) verifyBearer(r *http.Request) bool {
|
||||
if v.config.Secret == "" {
|
||||
log.Warnf("Bearer auth requires secret")
|
||||
return false
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Debugf("Missing or invalid Bearer token")
|
||||
return false
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
tokenBytes := []byte(token)
|
||||
secretBytes := []byte(v.config.Secret)
|
||||
return len(tokenBytes) == len(secretBytes) && subtle.ConstantTimeCompare(tokenBytes, secretBytes) == 1
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) verifyBasic(r *http.Request) bool {
|
||||
if v.config.Secret == "" {
|
||||
log.Warnf("Basic auth requires secret")
|
||||
return false
|
||||
}
|
||||
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
log.Debugf("Missing Basic auth header")
|
||||
return false
|
||||
}
|
||||
|
||||
return v.verifyBasicCredentials(username, password)
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) verifyBasicCredentials(username, password string) bool {
|
||||
parts := strings.SplitN(v.config.Secret, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return v.verifyBasicWithUsername(username, password, parts[0], parts[1])
|
||||
}
|
||||
return v.verifyBasicPasswordOnly(password)
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) verifyBasicWithUsername(username, password, expectedUsername, expectedPassword string) bool {
|
||||
usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername))
|
||||
passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword))
|
||||
return usernameMatch == 1 && passwordMatch == 1
|
||||
}
|
||||
|
||||
func (v *AuthVerifier) verifyBasicPasswordOnly(password string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(v.config.Secret)) == 1
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"github.com/OliveTin/OliveTin/internal/config"
|
||||
)
|
||||
|
||||
// ApplyGitHubTemplate applies GitHub-specific template configurations
|
||||
// This allows users to use simple template names instead of configuring everything manually
|
||||
func ApplyGitHubTemplate(cfg *config.WebhookConfig, template string) {
|
||||
applier := getTemplateApplier(template)
|
||||
if applier != nil {
|
||||
applier(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
type templateApplier func(*config.WebhookConfig)
|
||||
|
||||
func getTemplateApplier(template string) templateApplier {
|
||||
templateMap := map[string]templateApplier{
|
||||
"github-push": applyGitHubPushTemplate,
|
||||
"github-pr": applyGitHubPRTemplate,
|
||||
"github-pull-request": applyGitHubPRTemplate,
|
||||
"github-release": applyGitHubReleaseTemplate,
|
||||
"github-workflow": applyGitHubWorkflowTemplate,
|
||||
}
|
||||
return templateMap[template]
|
||||
}
|
||||
|
||||
func setDefaultAuth(cfg *config.WebhookConfig) {
|
||||
if cfg.AuthHeader == "" {
|
||||
cfg.AuthHeader = "X-Hub-Signature-256"
|
||||
}
|
||||
if cfg.AuthType == "" {
|
||||
cfg.AuthType = "hmac-sha256"
|
||||
}
|
||||
}
|
||||
|
||||
func ensureMatchHeaders(cfg *config.WebhookConfig) {
|
||||
if len(cfg.MatchHeaders) == 0 {
|
||||
cfg.MatchHeaders = make(map[string]string)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureExtract(cfg *config.WebhookConfig) {
|
||||
if len(cfg.Extract) == 0 {
|
||||
cfg.Extract = make(map[string]string)
|
||||
}
|
||||
}
|
||||
|
||||
func setExtractIfMissing(cfg *config.WebhookConfig, key, value string) {
|
||||
if _, exists := cfg.Extract[key]; !exists {
|
||||
cfg.Extract[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func setMatchHeaderIfMissing(cfg *config.WebhookConfig, key, value string) {
|
||||
if _, exists := cfg.MatchHeaders[key]; !exists {
|
||||
cfg.MatchHeaders[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
|
||||
setDefaultAuth(cfg)
|
||||
ensureMatchHeaders(cfg)
|
||||
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "push")
|
||||
ensureExtract(cfg)
|
||||
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||
setExtractIfMissing(cfg, "git_ref", "$.ref")
|
||||
setExtractIfMissing(cfg, "git_commit", "$.head_commit.id")
|
||||
setExtractIfMissing(cfg, "git_branch", "$.ref")
|
||||
setExtractIfMissing(cfg, "git_message", "$.head_commit.message")
|
||||
setExtractIfMissing(cfg, "git_author", "$.head_commit.author.name")
|
||||
}
|
||||
|
||||
func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
|
||||
setDefaultAuth(cfg)
|
||||
ensureMatchHeaders(cfg)
|
||||
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "pull_request")
|
||||
ensureExtract(cfg)
|
||||
setExtractIfMissing(cfg, "pr_number", "$.number")
|
||||
setExtractIfMissing(cfg, "pr_title", "$.pull_request.title")
|
||||
setExtractIfMissing(cfg, "pr_author", "$.pull_request.user.login")
|
||||
setExtractIfMissing(cfg, "pr_action", "$.action")
|
||||
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||
setExtractIfMissing(cfg, "pr_state", "$.pull_request.state")
|
||||
setExtractIfMissing(cfg, "pr_head_sha", "$.pull_request.head.sha")
|
||||
}
|
||||
|
||||
func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
|
||||
setDefaultAuth(cfg)
|
||||
ensureMatchHeaders(cfg)
|
||||
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "release")
|
||||
ensureExtract(cfg)
|
||||
setExtractIfMissing(cfg, "release_action", "$.action")
|
||||
setExtractIfMissing(cfg, "release_tag", "$.release.tag_name")
|
||||
setExtractIfMissing(cfg, "release_name", "$.release.name")
|
||||
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||
setExtractIfMissing(cfg, "release_author", "$.release.author.login")
|
||||
}
|
||||
|
||||
func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) {
|
||||
setDefaultAuth(cfg)
|
||||
ensureMatchHeaders(cfg)
|
||||
setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "workflow_run")
|
||||
ensureExtract(cfg)
|
||||
setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name")
|
||||
setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status")
|
||||
setExtractIfMissing(cfg, "workflow_conclusion", "$.workflow_run.conclusion")
|
||||
setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
|
||||
setExtractIfMissing(cfg, "git_commit", "$.workflow_run.head_sha")
|
||||
setExtractIfMissing(cfg, "git_branch", "$.workflow_run.head_branch")
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/OliveTin/OliveTin/internal/auth"
|
||||
"github.com/OliveTin/OliveTin/internal/config"
|
||||
"github.com/OliveTin/OliveTin/internal/executor"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type ActionWebhookConfig struct {
|
||||
Action *config.Action
|
||||
Config config.WebhookConfig
|
||||
}
|
||||
|
||||
type WebhookHandler struct {
|
||||
cfg *config.Config
|
||||
executor *executor.Executor
|
||||
}
|
||||
|
||||
func NewWebhookHandler(cfg *config.Config, ex *executor.Executor) *WebhookHandler {
|
||||
return &WebhookHandler{
|
||||
cfg: cfg,
|
||||
executor: ex,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := h.readPayload(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
matchingActions := h.findMatchingActions(r, payload)
|
||||
if len(matchingActions) == 0 {
|
||||
h.writeOKResponse(w, "no matching webhook actions")
|
||||
return
|
||||
}
|
||||
|
||||
processed := h.processMatchingActions(matchingActions, r, payload)
|
||||
log.WithFields(log.Fields{
|
||||
"matched": len(matchingActions),
|
||||
"processed": processed,
|
||||
}).Infof("Webhook processed")
|
||||
|
||||
h.writeOKResponse(w, "webhook actions")
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) readPayload(r *http.Request) ([]byte, error) {
|
||||
maxSize := int64(1024 * 1024)
|
||||
payload, err := io.ReadAll(io.LimitReader(r.Body, maxSize))
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"error": err,
|
||||
}).Warnf("Failed to read webhook payload")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) writeOKResponse(w http.ResponseWriter, context string) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte("OK")); err != nil {
|
||||
log.WithError(err).Warnf("Failed to write response for %s", context)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) processMatchingActions(matchingActions []ActionWebhookConfig, r *http.Request, payload []byte) int {
|
||||
processed := 0
|
||||
for _, actionConfig := range matchingActions {
|
||||
if h.processWebhook(actionConfig, r, payload) {
|
||||
processed++
|
||||
}
|
||||
}
|
||||
return processed
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) findMatchingActions(r *http.Request, payload []byte) []ActionWebhookConfig {
|
||||
var matches []ActionWebhookConfig
|
||||
|
||||
for _, action := range h.cfg.Actions {
|
||||
matches = append(matches, h.findMatchingWebhooksForAction(action, r, payload)...)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) findMatchingWebhooksForAction(action *config.Action, r *http.Request, payload []byte) []ActionWebhookConfig {
|
||||
var matches []ActionWebhookConfig
|
||||
|
||||
for _, webhookConfig := range action.ExecOnWebhook {
|
||||
webhookConfigCopy := webhookConfig
|
||||
|
||||
if webhookConfigCopy.Template != "" {
|
||||
ApplyGitHubTemplate(&webhookConfigCopy, webhookConfigCopy.Template)
|
||||
}
|
||||
|
||||
matcher := NewWebhookMatcher(webhookConfigCopy, r, payload)
|
||||
if matcher.Matches() {
|
||||
matches = append(matches, ActionWebhookConfig{
|
||||
Action: action,
|
||||
Config: webhookConfigCopy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *http.Request, payload []byte) bool {
|
||||
verifier := NewAuthVerifier(actionConfig.Config)
|
||||
if !verifier.Verify(r, payload) {
|
||||
log.WithFields(log.Fields{
|
||||
"actionTitle": actionConfig.Action.Title,
|
||||
"authType": actionConfig.Config.AuthType,
|
||||
}).Warnf("Webhook authentication failed")
|
||||
return false
|
||||
}
|
||||
|
||||
matcher := NewWebhookMatcher(actionConfig.Config, r, payload)
|
||||
|
||||
args, err := matcher.ExtractArguments()
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"actionTitle": actionConfig.Action.Title,
|
||||
"error": err,
|
||||
}).Warnf("Failed to extract webhook arguments")
|
||||
return false
|
||||
}
|
||||
|
||||
h.executeAction(actionConfig.Action, args)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) {
|
||||
binding := h.executor.FindBindingWithNoEntity(action)
|
||||
if binding == nil {
|
||||
log.WithFields(log.Fields{
|
||||
"actionTitle": action.Title,
|
||||
}).Warnf("Action binding not found, skipping execution")
|
||||
return
|
||||
}
|
||||
|
||||
req := &executor.ExecutionRequest{
|
||||
Binding: binding,
|
||||
Cfg: h.cfg,
|
||||
Tags: []string{"webhook"},
|
||||
Arguments: args,
|
||||
AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"),
|
||||
}
|
||||
|
||||
h.executor.ExecRequest(req)
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/PaesslerAG/jsonpath"
|
||||
)
|
||||
|
||||
type JSONMatcher struct {
|
||||
payload interface{}
|
||||
}
|
||||
|
||||
func NewJSONMatcher(payload []byte) (*JSONMatcher, error) {
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(payload, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &JSONMatcher{payload: data}, nil
|
||||
}
|
||||
|
||||
func (m *JSONMatcher) MatchPath(pathExpr string, expectedValue string) (bool, error) {
|
||||
value, err := jsonpath.Get(pathExpr, m.payload)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// For string values, compare directly without marshaling
|
||||
if strValue, ok := value.(string); ok {
|
||||
return strValue == expectedValue, nil
|
||||
}
|
||||
|
||||
// For non-string values, marshal to JSON for consistent string representation
|
||||
jsonBytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to marshal extracted value: %w", err)
|
||||
}
|
||||
|
||||
valueStr := string(jsonBytes)
|
||||
return valueStr == expectedValue, nil
|
||||
}
|
||||
|
||||
func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) {
|
||||
value, err := jsonpath.Get(pathExpr, m.payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Marshal to JSON for consistent string representation
|
||||
jsonBytes, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal extracted value: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *JSONMatcher) GetPayload() interface{} {
|
||||
return m.payload
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/OliveTin/OliveTin/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type WebhookMatcher struct {
|
||||
config config.WebhookConfig
|
||||
req *http.Request
|
||||
bodyBytes []byte
|
||||
}
|
||||
|
||||
func NewWebhookMatcher(cfg config.WebhookConfig, r *http.Request, bodyBytes []byte) *WebhookMatcher {
|
||||
return &WebhookMatcher{
|
||||
config: cfg,
|
||||
req: r,
|
||||
bodyBytes: bodyBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) Matches() bool {
|
||||
if !m.matchHeaders() {
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.matchQuery() {
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.matchPath() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) matchHeaders() bool {
|
||||
if len(m.config.MatchHeaders) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for key, expectedValue := range m.config.MatchHeaders {
|
||||
actualValue := m.req.Header.Get(key)
|
||||
if !m.compareValues(actualValue, expectedValue) {
|
||||
log.WithFields(log.Fields{
|
||||
"header": key,
|
||||
"expected": expectedValue,
|
||||
"actual": actualValue,
|
||||
}).Debugf("Header mismatch")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) matchQuery() bool {
|
||||
if len(m.config.MatchQuery) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
query := m.req.URL.Query()
|
||||
for key, expectedValue := range m.config.MatchQuery {
|
||||
actualValue := query.Get(key)
|
||||
if !m.compareValues(actualValue, expectedValue) {
|
||||
log.WithFields(log.Fields{
|
||||
"query": key,
|
||||
"expected": expectedValue,
|
||||
"actual": actualValue,
|
||||
}).Debugf("Query parameter mismatch")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) matchPath() bool {
|
||||
if m.config.MatchPath == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
jsonPath, expectedValue := m.parseMatchPath()
|
||||
matcher, err := NewJSONMatcher(m.bodyBytes)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"error": err,
|
||||
}).Debugf("Failed to create JSON matcher")
|
||||
return false
|
||||
}
|
||||
|
||||
return m.matchPathValue(matcher, jsonPath, expectedValue)
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) parseMatchPath() (string, string) {
|
||||
parts := strings.SplitN(m.config.MatchPath, "=", 2)
|
||||
jsonPath := parts[0]
|
||||
expectedValue := ""
|
||||
if len(parts) == 2 {
|
||||
expectedValue = parts[1]
|
||||
}
|
||||
return jsonPath, expectedValue
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) matchPathValue(matcher *JSONMatcher, jsonPath, expectedValue string) bool {
|
||||
if expectedValue == "" {
|
||||
_, err := matcher.ExtractValue(jsonPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
matches, err := matcher.MatchPath(jsonPath, expectedValue)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"jsonPath": jsonPath,
|
||||
"error": err,
|
||||
}).Debugf("Failed to match JSONPath")
|
||||
return false
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) compareValues(actual, expected string) bool {
|
||||
if strings.HasPrefix(expected, "regex:") {
|
||||
pattern := strings.TrimPrefix(expected, "regex:")
|
||||
matched, err := regexp.MatchString(pattern, actual)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"pattern": pattern,
|
||||
"error": err,
|
||||
}).Warnf("Invalid regex pattern")
|
||||
return false
|
||||
}
|
||||
return matched
|
||||
}
|
||||
return actual == expected
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) {
|
||||
matcher, err := NewJSONMatcher(m.bodyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := m.extractJSONPathValues(matcher)
|
||||
m.addWebhookMetadata(args)
|
||||
m.addWebhookHeaders(args)
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) extractJSONPathValues(matcher *JSONMatcher) map[string]string {
|
||||
args := make(map[string]string)
|
||||
|
||||
for argName, jsonPath := range m.config.Extract {
|
||||
value, err := matcher.ExtractValue(jsonPath)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"argName": argName,
|
||||
"jsonPath": jsonPath,
|
||||
"error": err,
|
||||
}).Debugf("Failed to extract value")
|
||||
continue
|
||||
}
|
||||
args[argName] = value
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) addWebhookMetadata(args map[string]string) {
|
||||
args["webhook_method"] = m.req.Method
|
||||
args["webhook_path"] = m.req.URL.Path
|
||||
args["webhook_query"] = m.req.URL.RawQuery
|
||||
}
|
||||
|
||||
func (m *WebhookMatcher) addWebhookHeaders(args map[string]string) {
|
||||
for key, values := range m.req.Header {
|
||||
if len(values) > 0 {
|
||||
args["webhook_header_"+strings.ToLower(key)] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue