3k release: terminal sizes, confirmation buttons, translations, entity fieldsets drawn in random order, code quality (#709)
This commit is contained in:
commit
c2843aa581
|
|
@ -19,3 +19,4 @@ OliveTin
|
||||||
integration-tests/configs/authRequireGuestsToLogin/sessions.yaml
|
integration-tests/configs/authRequireGuestsToLogin/sessions.yaml
|
||||||
webui
|
webui
|
||||||
webui.dev
|
webui.dev
|
||||||
|
sessions.yaml
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
export function initMarshaller () {
|
|
||||||
window.addEventListener('EventOutputChunk', onOutputChunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOutputChunk (evt) {
|
|
||||||
const chunk = evt.payload
|
|
||||||
|
|
||||||
if (window.terminal) {
|
|
||||||
if (chunk.executionTrackingId === window.terminal.executionTrackingId) {
|
|
||||||
window.terminal.write(chunk.output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
|
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
|
||||||
|
|
||||||
export function checkWebsocketConnection () {
|
export function initWebsocket () {
|
||||||
|
window.addEventListener('EventOutputChunk', onOutputChunk)
|
||||||
|
|
||||||
reconnectWebsocket()
|
reconnectWebsocket()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,3 +49,13 @@ function handleEvent (msg) {
|
||||||
window.showBigError('ws-unhandled-message', 'handling websocket message', 'Unhandled websocket message type from server: ' + typeName, true)
|
window.showBigError('ws-unhandled-message', 'handling websocket message', 'Unhandled websocket message type from server: ' + typeName, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onOutputChunk (evt) {
|
||||||
|
const chunk = evt.payload
|
||||||
|
|
||||||
|
if (window.terminal) {
|
||||||
|
if (chunk.executionTrackingId === window.terminal.executionTrackingId) {
|
||||||
|
window.terminal.write(chunk.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,43 +12,91 @@ import { createConnectTransport } from '@connectrpc/connect-web'
|
||||||
import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb'
|
import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import router from './resources/vue/router.js'
|
import router from './resources/vue/router.js'
|
||||||
import App from './resources/vue/App.vue'
|
import App from './resources/vue/App.vue'
|
||||||
|
|
||||||
import {
|
import { initWebsocket } from './js/websocket.js'
|
||||||
initMarshaller
|
import combinedTranslations from '../lang/combined_output.json'
|
||||||
} from './js/marshaller.js'
|
|
||||||
|
|
||||||
import { checkWebsocketConnection } from './js/websocket.js'
|
function getSelectedLanguage() {
|
||||||
|
const storedLanguage = localStorage.getItem('olivetin-language');
|
||||||
|
|
||||||
function initClient () {
|
if (storedLanguage && storedLanguage !== 'auto') {
|
||||||
|
return storedLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedLanguage === 'auto') {
|
||||||
|
localStorage.removeItem('olivetin-language');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.languages && navigator.languages.length > 0) {
|
||||||
|
const available = Object.keys(combinedTranslations.messages || {})
|
||||||
|
|
||||||
|
for (const candidate of navigator.languages) {
|
||||||
|
const lowerCandidate = candidate.toLowerCase()
|
||||||
|
const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
|
||||||
|
|
||||||
|
if (exact) {
|
||||||
|
return exact
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
|
||||||
|
|
||||||
|
if (prefix) {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initClient () {
|
||||||
const transport = createConnectTransport({
|
const transport = createConnectTransport({
|
||||||
baseUrl: window.location.protocol + '//' + window.location.host + '/api/'
|
baseUrl: window.location.protocol + '//' + window.location.host + '/api/'
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
window.client = createClient(OliveTinApiService, transport)
|
window.client = createClient(OliveTinApiService, transport)
|
||||||
|
window.initResponse = await window.client.init({})
|
||||||
|
|
||||||
|
const i18nSettings = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: getSelectedLanguage(),
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
messages: combinedTranslations.messages,
|
||||||
|
postTranslation: (translated) => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
if (params.has('debug-translations')) {
|
||||||
|
return '____'
|
||||||
|
} else {
|
||||||
|
return translated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return i18nSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupVue () {
|
function setupVue (i18nSettings) {
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(i18nSettings)
|
||||||
|
|
||||||
|
window.i18n = i18nSettings.global
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
}
|
||||||
|
|
||||||
function main () {
|
async function main () {
|
||||||
initClient()
|
const i18nSettings = await initClient()
|
||||||
|
|
||||||
// Expose websocket connection function globally so App.vue can call it after successful init
|
initWebsocket()
|
||||||
window.checkWebsocketConnection = checkWebsocketConnection
|
|
||||||
|
|
||||||
setupVue()
|
setupVue(i18nSettings)
|
||||||
|
|
||||||
initMarshaller()
|
|
||||||
|
|
||||||
// window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
|
|
||||||
// window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main() // call self
|
main()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,9 +30,11 @@
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"iconify-icon": "^3.0.2",
|
"iconify-icon": "^3.0.2",
|
||||||
"picocrank": "^1.8.0",
|
"picocrank": "^1.8.7",
|
||||||
|
"standard": "^17.1.2",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.1.12",
|
"vite": "^7.2.2",
|
||||||
|
"vue-i18n": "^11.1.12",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
<template #user-info>
|
<template #user-info>
|
||||||
<div class="flex-row user-info" style="gap: .5em;">
|
<div class="flex-row user-info" style="gap: .5em;">
|
||||||
<span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">Login</router-link></span>
|
<span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">{{ t('login-button') }}</router-link></span>
|
||||||
<router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
|
<router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
|
||||||
<span id="username-text">{{ username }}</span>
|
<span id="username-text">{{ username }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
@ -19,35 +19,32 @@
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<div id="layout">
|
<div id="layout">
|
||||||
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation && !initError" />
|
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation" />
|
||||||
|
|
||||||
<div id="content" initial-martial-complete="{{ hasLoaded }}">
|
<div id="content" initial-martial-complete="{{ hasLoaded }}">
|
||||||
<main title="Main content">
|
<main title="Main content">
|
||||||
<section v-if="initError" class="error-container error" style="text-align: center; padding: 2em;">
|
<router-view :key="$route.fullPath" />
|
||||||
<h2>Failed to Initialize OliveTin</h2>
|
|
||||||
<p><strong>Error Message:</strong> {{ initErrorMessage }}</p>
|
|
||||||
<p>Please check the your browser console first, and then the server logs for more details.</p>
|
|
||||||
<button @click="retryInit" class="bad">Retry</button>
|
|
||||||
</section>
|
|
||||||
<router-view v-else :key="$route.fullPath" />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer title="footer" v-if="showFooter && !initError">
|
<footer title="footer" v-if="showFooter">
|
||||||
<p>
|
<p>
|
||||||
<img title="application icon" :src="logoUrl" alt="OliveTin logo" style="height: 1em;" class="logo" />
|
<img title="application icon" :src="logoUrl" alt="OliveTin logo" style="height: 1em;" class="logo" />
|
||||||
OliveTin {{ currentVersion }}
|
OliveTin {{ currentVersion }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span>
|
<span>
|
||||||
<a href="https://docs.olivetin.app" target="_new">Documentation</a>
|
<a href="https://docs.olivetin.app" target="_new">{{ t('docs') }}</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">Raise an issue on
|
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">{{ t('raise-issue') }}</a>
|
||||||
GitHub</a>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span>{{ serverConnection }}</span>
|
<span>
|
||||||
|
<a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>{{ t('connected') }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
|
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
|
||||||
|
|
@ -55,10 +52,29 @@
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog ref="languageDialog" class="language-dialog" @click="handleDialogClick">
|
||||||
|
<div class="dialog-content" @click.stop>
|
||||||
|
<h2>{{ t('language-dialog.title') }}</h2>
|
||||||
|
<select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
|
||||||
|
<option v-for="(name, code) in availableLanguages" :key="code" :value="code">
|
||||||
|
{{ code === 'auto' ? name : `${name} (${code})` }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="browser-languages">
|
||||||
|
{{ t('language-dialog.browser-languages') }}:
|
||||||
|
<span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
|
||||||
|
<span v-else>{{ t('language-dialog.not-available') }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="dialog-buttons">
|
||||||
|
<button @click="closeLanguageDialog">{{ t('language-dialog.close') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import Sidebar from 'picocrank/vue/components/Sidebar.vue';
|
import Sidebar from 'picocrank/vue/components/Sidebar.vue';
|
||||||
import Header from 'picocrank/vue/components/Header.vue';
|
import Header from 'picocrank/vue/components/Header.vue';
|
||||||
|
|
@ -67,13 +83,17 @@ import { Menu01Icon } from '@hugeicons/core-free-icons'
|
||||||
import { UserCircle02Icon } from '@hugeicons/core-free-icons'
|
import { UserCircle02Icon } from '@hugeicons/core-free-icons'
|
||||||
import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
|
import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
|
||||||
import logoUrl from '../../OliveTinLogo.png';
|
import logoUrl from '../../OliveTinLogo.png';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import combinedTranslations from '../../../lang/combined_output.json';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const sidebar = ref(null);
|
const sidebar = ref(null);
|
||||||
const username = ref('notset');
|
const username = ref('notset');
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
const serverConnection = ref('Connected');
|
const serverConnection = ref(true);
|
||||||
const currentVersion = ref('?');
|
const currentVersion = ref('?');
|
||||||
const bannerMessage = ref('');
|
const bannerMessage = ref('');
|
||||||
const bannerCss = ref('');
|
const bannerCss = ref('');
|
||||||
|
|
@ -82,10 +102,58 @@ const showFooter = ref(true)
|
||||||
const showNavigation = ref(true)
|
const showNavigation = ref(true)
|
||||||
const showLogs = ref(true)
|
const showLogs = ref(true)
|
||||||
const showDiagnostics = ref(true)
|
const showDiagnostics = ref(true)
|
||||||
const initError = ref(false)
|
|
||||||
const initErrorMessage = ref('')
|
|
||||||
const showLoginLink = ref(true)
|
const showLoginLink = ref(true)
|
||||||
|
|
||||||
|
const languageDialog = ref(null)
|
||||||
|
const browserLanguages = ref([])
|
||||||
|
|
||||||
|
const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
|
||||||
|
const languagePreference = ref(initialLanguagePreference || 'auto')
|
||||||
|
const selectedLanguage = ref(languagePreference.value)
|
||||||
|
|
||||||
|
// Available languages with display names
|
||||||
|
const availableLanguages = {
|
||||||
|
'auto': 'Browser Language',
|
||||||
|
'en': 'English',
|
||||||
|
'de-DE': 'Deutsch',
|
||||||
|
'es-ES': 'Español',
|
||||||
|
'it-IT': 'Italiano',
|
||||||
|
'zh-Hans-CN': '简体中文'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed property to get current language display name
|
||||||
|
const currentLanguageName = computed(() => {
|
||||||
|
if (languagePreference.value === 'auto') {
|
||||||
|
return availableLanguages['auto']
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableLanguages[languagePreference.value] || languagePreference.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeBrowserLanguage() {
|
||||||
|
const available = Object.keys(combinedTranslations.messages || {})
|
||||||
|
|
||||||
|
if (navigator.languages && navigator.languages.length > 0) {
|
||||||
|
for (const candidate of navigator.languages) {
|
||||||
|
const lowerCandidate = candidate.toLowerCase()
|
||||||
|
|
||||||
|
// Try exact match (case-insensitive)
|
||||||
|
const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
|
||||||
|
if (exact) {
|
||||||
|
return exact
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try prefix match (e.g., "zh-CN" -> "zh-Hans-CN")
|
||||||
|
const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
|
||||||
|
if (prefix) {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
if (sidebar.value && showNavigation.value) {
|
if (sidebar.value && showNavigation.value) {
|
||||||
sidebar.value.toggle()
|
sidebar.value.toggle()
|
||||||
|
|
@ -93,101 +161,127 @@ function toggleSidebar() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHeaderFromInit() {
|
function updateHeaderFromInit() {
|
||||||
if (window.initResponse) {
|
if (!window.initResponse) {
|
||||||
username.value = window.initResponse.authenticatedUser
|
return
|
||||||
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
|
}
|
||||||
currentVersion.value = window.initResponse.currentVersion
|
|
||||||
bannerMessage.value = window.initResponse.bannerMessage || ''
|
|
||||||
bannerCss.value = window.initResponse.bannerCss || ''
|
|
||||||
showFooter.value = window.initResponse.showFooter
|
|
||||||
showNavigation.value = window.initResponse.showNavigation
|
|
||||||
showLogs.value = window.initResponse.showLogList
|
|
||||||
showDiagnostics.value = window.initResponse.showDiagnostics
|
|
||||||
|
|
||||||
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
|
username.value = window.initResponse.authenticatedUser
|
||||||
showLoginLink.value = false
|
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
|
||||||
}
|
currentVersion.value = window.initResponse.currentVersion
|
||||||
|
bannerMessage.value = window.initResponse.bannerMessage || ''
|
||||||
|
bannerCss.value = window.initResponse.bannerCss || ''
|
||||||
|
showFooter.value = window.initResponse.showFooter
|
||||||
|
showNavigation.value = window.initResponse.showNavigation
|
||||||
|
showLogs.value = window.initResponse.showLogList
|
||||||
|
showDiagnostics.value = window.initResponse.showDiagnostics
|
||||||
|
|
||||||
|
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
|
||||||
|
showLoginLink.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSidebar()
|
||||||
|
|
||||||
|
if (window.initResponse.loginRequired) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
if (!sidebar.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootDashboards = window.initResponse?.rootDashboards || []
|
||||||
|
|
||||||
|
if (typeof sidebar.value.clear === 'function') {
|
||||||
|
sidebar.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rootDashboard of rootDashboards) {
|
||||||
|
sidebar.value.addNavigationLink({
|
||||||
|
id: rootDashboard,
|
||||||
|
name: rootDashboard,
|
||||||
|
title: rootDashboard,
|
||||||
|
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
|
||||||
|
icon: DashboardSquare01Icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebar.value.addSeparator()
|
||||||
|
sidebar.value.addRouterLink('Entities', t('nav.entities'))
|
||||||
|
|
||||||
|
if (showLogs.value) {
|
||||||
|
sidebar.value.addRouterLink('Logs', t('nav.logs'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDiagnostics.value) {
|
||||||
|
sidebar.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLanguageDialog() {
|
||||||
|
selectedLanguage.value = languagePreference.value
|
||||||
|
|
||||||
|
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
|
||||||
|
browserLanguages.value = navigator.languages
|
||||||
|
} else {
|
||||||
|
browserLanguages.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (languageDialog.value) {
|
||||||
|
languageDialog.value.showModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLanguageDialog() {
|
||||||
|
if (languageDialog.value) {
|
||||||
|
languageDialog.value.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLanguage() {
|
||||||
|
if (!window.i18n || !selectedLanguage.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedLanguage.value === 'auto') {
|
||||||
|
localStorage.removeItem('olivetin-language')
|
||||||
|
languagePreference.value = 'auto'
|
||||||
|
window.i18n.locale.value = normalizeBrowserLanguage()
|
||||||
|
} else {
|
||||||
|
window.i18n.locale.value = selectedLanguage.value
|
||||||
|
localStorage.setItem('olivetin-language', selectedLanguage.value)
|
||||||
|
languagePreference.value = selectedLanguage.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sidebar with new translations
|
||||||
|
if (sidebar.value) {
|
||||||
|
renderSidebar()
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLanguageDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogClick(event) {
|
||||||
|
// Close dialog when clicking on the backdrop
|
||||||
|
if (event.target === languageDialog.value) {
|
||||||
|
closeLanguageDialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the function to window so other components can call it
|
|
||||||
window.updateHeaderFromInit = updateHeaderFromInit
|
window.updateHeaderFromInit = updateHeaderFromInit
|
||||||
|
|
||||||
async function requestInit() {
|
|
||||||
try {
|
|
||||||
const initResponse = await window.client.init({})
|
|
||||||
|
|
||||||
// Store init response first so the login view can read options (e.g., authLocalLogin)
|
|
||||||
window.initResponse = initResponse
|
|
||||||
|
|
||||||
// Check if login is required and redirect if so (after storing initResponse)
|
|
||||||
if (initResponse.loginRequired) {
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.initError = false
|
|
||||||
window.initErrorMessage = ''
|
|
||||||
window.initCompleted = true
|
|
||||||
|
|
||||||
window.updateHeaderFromInit()
|
|
||||||
|
|
||||||
if (showNavigation.value && sidebar.value) {
|
|
||||||
for (const rootDashboard of initResponse.rootDashboards) {
|
|
||||||
sidebar.value.addNavigationLink({
|
|
||||||
id: rootDashboard,
|
|
||||||
name: rootDashboard,
|
|
||||||
title: rootDashboard,
|
|
||||||
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
|
|
||||||
icon: DashboardSquare01Icon,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sidebar.value.addSeparator()
|
|
||||||
sidebar.value.addRouterLink('Entities')
|
|
||||||
|
|
||||||
if (showLogs.value) {
|
|
||||||
sidebar.value.addRouterLink('Logs')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDiagnostics.value) {
|
|
||||||
sidebar.value.addRouterLink('Diagnostics')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hasLoaded.value = true;
|
|
||||||
initError.value = false;
|
|
||||||
|
|
||||||
// Only start websocket connection after successful init
|
|
||||||
if (window.checkWebsocketConnection) {
|
|
||||||
window.checkWebsocketConnection()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error initializing client", error)
|
|
||||||
initError.value = true
|
|
||||||
initErrorMessage.value = error.message || 'Failed to connect to OliveTin server'
|
|
||||||
window.initError = true
|
|
||||||
window.initErrorMessage = error.message || 'Failed to connect to OliveTin server'
|
|
||||||
window.initCompleted = false
|
|
||||||
serverConnection.value = 'Disconnected'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryInit() {
|
|
||||||
initError.value = false
|
|
||||||
initErrorMessage.value = ''
|
|
||||||
window.initError = false
|
|
||||||
window.initErrorMessage = ''
|
|
||||||
window.initCompleted = false
|
|
||||||
requestInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
serverConnection.value = 'Connected';
|
serverConnection.value = true;
|
||||||
// Initialize global state
|
updateHeaderFromInit()
|
||||||
window.initError = false
|
|
||||||
window.initErrorMessage = ''
|
// Initialize selected language from stored preference
|
||||||
window.initCompleted = false
|
selectedLanguage.value = languagePreference.value
|
||||||
requestInit()
|
|
||||||
|
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
|
||||||
|
browserLanguages.value = navigator.languages
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -204,4 +298,51 @@ onMounted(() => {
|
||||||
.user-link:hover {
|
.user-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-dialog {
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-dialog::backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-languages {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fg2, #555);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -105,7 +105,7 @@ function waitForInitAndLoadDashboard() {
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
// Check if init has completed successfully
|
// Check if init has completed successfully
|
||||||
if (window.initCompleted && window.initResponse) {
|
if (window.initResponse) {
|
||||||
getDashboard()
|
getDashboard()
|
||||||
} else if (window.initError) {
|
} else if (window.initError) {
|
||||||
// Init failed, show error immediately
|
// Init failed, show error immediately
|
||||||
|
|
@ -118,7 +118,7 @@ function waitForInitAndLoadDashboard() {
|
||||||
} else {
|
} else {
|
||||||
// Init hasn't completed yet, poll for completion
|
// Init hasn't completed yet, poll for completion
|
||||||
checkInitInterval = setInterval(() => {
|
checkInitInterval = setInterval(() => {
|
||||||
if (window.initCompleted && window.initResponse) {
|
if (window.initResponse) {
|
||||||
clearInterval(checkInitInterval)
|
clearInterval(checkInitInterval)
|
||||||
checkInitInterval = null
|
checkInitInterval = null
|
||||||
getDashboard()
|
getDashboard()
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="component.type == 'fieldset'">
|
<template v-else-if="component.type == 'fieldset'">
|
||||||
<fieldset>
|
<template v-for="subcomponent in component.contents" :key="subcomponent.title">
|
||||||
<legend>{{ component.title }}</legend>
|
<DashboardComponent :component="subcomponent" />
|
||||||
<template v-for="subcomponent in component.contents" :key="subcomponent.title">
|
</template>
|
||||||
<DashboardComponent :component="subcomponent" />
|
|
||||||
</template>
|
|
||||||
</fieldset>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="pagination">
|
|
||||||
<div class="pagination-info">
|
|
||||||
<span class="pagination-text">
|
|
||||||
Showing {{ startItem + 1 }}-{{ endItem }} of {{ total }} {{ itemTitle }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pagination-controls">
|
|
||||||
<button
|
|
||||||
class="pagination-btn"
|
|
||||||
:disabled="currentPage === 1"
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
title="Previous page"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor" d="M15.41 7.41L14 6l-6 6l6 6l1.41-1.41L10.83 12z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="pagination-pages">
|
|
||||||
<!-- First page -->
|
|
||||||
<button
|
|
||||||
v-if="showFirstPage"
|
|
||||||
class="pagination-btn"
|
|
||||||
:class="{ active: currentPage === 1 }"
|
|
||||||
@click="goToPage(1)"
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Ellipsis after first page -->
|
|
||||||
<span v-if="showFirstEllipsis" class="pagination-ellipsis">...</span>
|
|
||||||
|
|
||||||
<!-- Page numbers around current page -->
|
|
||||||
<button
|
|
||||||
v-for="page in visiblePages"
|
|
||||||
:key="page"
|
|
||||||
class="pagination-btn"
|
|
||||||
:class="{ active: currentPage === page }"
|
|
||||||
@click="goToPage(page)"
|
|
||||||
>
|
|
||||||
{{ page }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Ellipsis before last page -->
|
|
||||||
<span v-if="showLastEllipsis" class="pagination-ellipsis">...</span>
|
|
||||||
|
|
||||||
<!-- Last page -->
|
|
||||||
<button
|
|
||||||
v-if="showLastPage"
|
|
||||||
class="pagination-btn"
|
|
||||||
:class="{ active: currentPage === totalPages }"
|
|
||||||
@click="goToPage(totalPages)"
|
|
||||||
>
|
|
||||||
{{ totalPages }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="pagination-btn"
|
|
||||||
:disabled="currentPage === totalPages"
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
title="Next page"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor" d="M8.59 16.59L10 18l6-6l-6-6L8.59 7.41L13.17 12z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pagination-size" v-if="canChangePageSize">
|
|
||||||
<label for="page-size">Items per page:</label>
|
|
||||||
<select
|
|
||||||
id="page-size"
|
|
||||||
v-model="localPageSize"
|
|
||||||
@change="handlePageSizeChange"
|
|
||||||
class="page-size-select"
|
|
||||||
>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
pageSize: {
|
|
||||||
type: Number,
|
|
||||||
default: 25
|
|
||||||
},
|
|
||||||
total: {
|
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
currentPage: {
|
|
||||||
type: Number,
|
|
||||||
default: 1
|
|
||||||
},
|
|
||||||
canChangePageSize: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
itemTitle: {
|
|
||||||
type: String,
|
|
||||||
default: 'items'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['page-change', 'page-size-change'])
|
|
||||||
|
|
||||||
const localPageSize = ref(props.pageSize)
|
|
||||||
const localCurrentPage = ref(props.currentPage)
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const totalPages = computed(() => Math.ceil(props.total / localPageSize.value))
|
|
||||||
|
|
||||||
const startItem = computed(() => (localCurrentPage.value - 1) * localPageSize.value)
|
|
||||||
const endItem = computed(() => Math.min(localCurrentPage.value * localPageSize.value, props.total))
|
|
||||||
|
|
||||||
// Pagination logic
|
|
||||||
const maxVisiblePages = 5
|
|
||||||
const visiblePages = computed(() => {
|
|
||||||
const pages = []
|
|
||||||
const halfVisible = Math.floor(maxVisiblePages / 2)
|
|
||||||
|
|
||||||
let start = Math.max(1, localCurrentPage.value - halfVisible)
|
|
||||||
let end = Math.min(totalPages.value, start + maxVisiblePages - 1)
|
|
||||||
|
|
||||||
// Adjust start if we're near the end
|
|
||||||
if (end - start < maxVisiblePages - 1) {
|
|
||||||
start = Math.max(1, end - maxVisiblePages + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
})
|
|
||||||
|
|
||||||
const showFirstPage = computed(() => visiblePages.value[0] > 1)
|
|
||||||
const showLastPage = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value)
|
|
||||||
const showFirstEllipsis = computed(() => visiblePages.value[0] > 2)
|
|
||||||
const showLastEllipsis = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value - 1)
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
function goToPage(page) {
|
|
||||||
if (page >= 1 && page <= totalPages.value && page !== localCurrentPage.value) {
|
|
||||||
localCurrentPage.value = page
|
|
||||||
emit('page-change', page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageSizeChange() {
|
|
||||||
// Reset to first page when changing page size
|
|
||||||
localCurrentPage.value = 1
|
|
||||||
emit('page-size-change', localPageSize.value)
|
|
||||||
emit('page-change', 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch for prop changes
|
|
||||||
watch(() => props.currentPage, (newPage) => {
|
|
||||||
localCurrentPage.value = newPage
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.pageSize, (newSize) => {
|
|
||||||
localPageSize.value = newSize
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-text {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-pages {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
background: #fff;
|
|
||||||
color: #495057;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:hover:not(:disabled) {
|
|
||||||
background: #e9ecef;
|
|
||||||
border-color: #adb5bd;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn.active {
|
|
||||||
background: #c6d0d7;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-ellipsis {
|
|
||||||
padding: 0.5rem;
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-size {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-size-select {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-size-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #5681af;
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.pagination {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-controls {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-size {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -92,7 +92,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import Pagination from '../components/Pagination.vue'
|
import Pagination from 'picocrank/vue/components/Pagination.vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
|
||||||
|
|
@ -7,30 +7,30 @@
|
||||||
<form @submit="handleSubmit">
|
<form @submit="handleSubmit">
|
||||||
<template v-if="actionArguments.length > 0">
|
<template v-if="actionArguments.length > 0">
|
||||||
|
|
||||||
<template v-for="arg in actionArguments" :key="arg.name" class="argument-group">
|
<template v-for="arg in actionArguments" :key="arg.name">
|
||||||
<label :for="arg.name">
|
<label :for="arg.name">
|
||||||
{{ formatLabel(arg.title) }}
|
{{ formatLabel(arg.title) }}
|
||||||
</label>
|
</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" :id="`${arg.name}-choices`">
|
||||||
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
|
||||||
{{ suggestion }}
|
{{ suggestion }}
|
||||||
</option>
|
</option>
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
||||||
:required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
|
:required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
|
||||||
<option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
|
<option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
|
||||||
{{ choice.title || choice.value }}
|
{{ choice.title || choice.value }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
|
||||||
:list="arg.suggestions ? `${arg.name}-choices` : undefined"
|
:list="arg.suggestions ? `${arg.name}-choices` : undefined"
|
||||||
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
|
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
|
||||||
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
|
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
|
||||||
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)" :required="arg.required"
|
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)" :required="arg.required"
|
||||||
@input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
|
@input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
|
||||||
|
|
||||||
<span class="argument-description" v-html="arg.description"></span>
|
<span class="argument-description" v-html="arg.description"></span>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -96,18 +96,27 @@ async function setup() {
|
||||||
|
|
||||||
// Initialize values from query params or defaults
|
// Initialize values from query params or defaults
|
||||||
actionArguments.value.forEach(arg => {
|
actionArguments.value.forEach(arg => {
|
||||||
const paramValue = getQueryParamValue(arg.name)
|
|
||||||
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
|
|
||||||
|
|
||||||
if (arg.type === 'confirmation') {
|
if (arg.type === 'confirmation') {
|
||||||
hasConfirmation.value = true
|
hasConfirmation.value = true
|
||||||
|
const paramValue = getQueryParamValue(arg.name)
|
||||||
|
let checkedValue = false
|
||||||
|
if (paramValue !== null) {
|
||||||
|
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
|
||||||
|
} else if (arg.defaultValue !== undefined && arg.defaultValue !== '') {
|
||||||
|
checkedValue = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
|
||||||
|
}
|
||||||
|
argValues.value[arg.name] = checkedValue
|
||||||
|
confirmationChecked.value = checkedValue
|
||||||
|
} else {
|
||||||
|
const paramValue = getQueryParamValue(arg.name)
|
||||||
|
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Run initial validation on all fields after DOM is updated
|
// Run initial validation on all fields after DOM is updated
|
||||||
await nextTick()
|
await nextTick()
|
||||||
for (const arg of actionArguments.value) {
|
for (const arg of actionArguments.value) {
|
||||||
if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '') {
|
if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation') {
|
||||||
await validateArgument(arg, argValues.value[arg.name])
|
await validateArgument(arg, argValues.value[arg.name])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +152,11 @@ function getInputType(arg) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg.type === 'ascii_identifier') {
|
if (arg.type === 'confirmation') {
|
||||||
|
return 'checkbox'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.type === 'ascii_identifier' || arg.type === 'ascii') {
|
||||||
return 'text'
|
return 'text'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,8 +171,8 @@ function getPattern(arg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArgumentValue(arg) {
|
function getArgumentValue(arg) {
|
||||||
if (arg.type === 'checkbox') {
|
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
|
||||||
return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true
|
return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true || argValues.value[arg.name] === 'true'
|
||||||
}
|
}
|
||||||
return argValues.value[arg.name] || ''
|
return argValues.value[arg.name] || ''
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +253,7 @@ function getArgumentValues() {
|
||||||
for (const arg of actionArguments.value) {
|
for (const arg of actionArguments.value) {
|
||||||
let value = argValues.value[arg.name] || ''
|
let value = argValues.value[arg.name] || ''
|
||||||
|
|
||||||
if (arg.type === 'checkbox') {
|
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
|
||||||
value = value ? '1' : '0'
|
value = value ? '1' : '0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,22 +361,6 @@ form {
|
||||||
grid-template-columns: max-content auto auto;
|
grid-template-columns: max-content auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.argument-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.argument-group label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.argument-group input:invalid,
|
|
||||||
.argument-group select:invalid,
|
|
||||||
.argument-group textarea:invalid {
|
|
||||||
border-color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.argument-description {
|
.argument-description {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ let terminal = null
|
||||||
function initializeTerminal() {
|
function initializeTerminal() {
|
||||||
terminal = new OutputTerminal(executionTrackingId.value)
|
terminal = new OutputTerminal(executionTrackingId.value)
|
||||||
terminal.open(xtermOutput.value)
|
terminal.open(xtermOutput.value)
|
||||||
terminal.resize(80, 24)
|
terminal.resize(80, 40)
|
||||||
|
|
||||||
window.terminal = terminal
|
window.terminal = terminal
|
||||||
}
|
}
|
||||||
|
|
@ -327,6 +327,17 @@ function goBack() {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
document.addEventListener('fullscreenchange', (e) => {
|
||||||
|
setTimeout(() => { // Wait for the DOM to settle
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
terminal.fit()
|
||||||
|
} else {
|
||||||
|
terminal.resize(80, 40)
|
||||||
|
terminal.fit()
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
|
||||||
initializeTerminal()
|
initializeTerminal()
|
||||||
fetchExecutionResult(props.executionTrackingId)
|
fetchExecutionResult(props.executionTrackingId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<Section title="Logs" :padding="false">
|
<Section :title="t('logs.title')" :padding="false">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<label class="input-with-icons">
|
<label class="input-with-icons">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor"
|
<path fill="currentColor"
|
||||||
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
|
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
|
||||||
</svg>
|
</svg>
|
||||||
<input placeholder="Filter current page" v-model="searchText" />
|
<input :placeholder="t('search-filter')" v-model="searchText" />
|
||||||
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
|
<button :title="t('logs.clear-filter')" :disabled="!searchText" @click="clearSearch">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor"
|
<path fill="currentColor"
|
||||||
d="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" />
|
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" />
|
||||||
|
|
@ -16,15 +16,15 @@
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p class = "padding">This is a list of logs from actions that have been executed. You can filter the list by action title.</p>
|
<p class = "padding">{{ t('logs.page-description') }}</p>
|
||||||
<div v-show="filteredLogs.length > 0">
|
<div v-show="filteredLogs.length > 0">
|
||||||
<table class="logs-table">
|
<table class="logs-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Timestamp</th>
|
<th>{{ t('logs.timestamp') }}</th>
|
||||||
<th>Action</th>
|
<th>{{ t('logs.action') }}</th>
|
||||||
<th>Metadata</th>
|
<th>{{ t('logs.metadata') }}</th>
|
||||||
<th>Status</th>
|
<th>{{ t('logs.status') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -59,16 +59,17 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="logs.length === 0" class="empty-state">
|
<div v-show="logs.length === 0" class="empty-state">
|
||||||
<p>There are no logs to display.</p>
|
<p>{{ t('logs.no-logs-to-display') }}</p>
|
||||||
<router-link to="/">Return to index</router-link>
|
<router-link to="/">{{ t('return-to-index') }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import Pagination from '../components/Pagination.vue'
|
import Pagination from 'picocrank/vue/components/Pagination.vue'
|
||||||
import Section from 'picocrank/vue/components/Section.vue'
|
import Section from 'picocrank/vue/components/Section.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const logs = ref([])
|
const logs = ref([])
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
|
|
@ -77,14 +78,24 @@ const currentPage = ref(1)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const totalCount = ref(0)
|
const totalCount = ref(0)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const filteredLogs = computed(() => {
|
const filteredLogs = computed(() => {
|
||||||
if (!searchText.value) {
|
let result = logs.value
|
||||||
return logs.value
|
|
||||||
|
if (searchText.value) {
|
||||||
|
const searchLower = searchText.value.toLowerCase()
|
||||||
|
result = logs.value.filter(log =>
|
||||||
|
log.actionTitle.toLowerCase().includes(searchLower)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const searchLower = searchText.value.toLowerCase()
|
|
||||||
return logs.value.filter(log =>
|
// Sort by timestamp with most recent first
|
||||||
log.actionTitle.toLowerCase().includes(searchLower)
|
return [...result].sort((a, b) => {
|
||||||
)
|
const dateA = a.datetimeStarted ? new Date(a.datetimeStarted).getTime() : 0
|
||||||
|
const dateB = b.datetimeStarted ? new Date(b.datetimeStarted).getTime() : 0
|
||||||
|
return dateB - dateA // Descending order (most recent first)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fetchLogs() {
|
async function fetchLogs() {
|
||||||
|
|
@ -131,10 +142,10 @@ function getStatusClass(log) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusText(log) {
|
function getStatusText(log) {
|
||||||
if (log.timedOut) return 'Timed out'
|
if (log.timedOut) return t('logs.timed-out')
|
||||||
if (log.blocked) return 'Blocked'
|
if (log.blocked) return t('logs.blocked')
|
||||||
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
|
if (log.exitCode !== 0) return `${t('logs.exit-code')} ${log.exitCode}`
|
||||||
return 'Completed'
|
return t('logs.completed')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page) {
|
function handlePageChange(page) {
|
||||||
|
|
@ -161,16 +172,20 @@ onMounted(() => {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--section-background);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-with-icons input {
|
.input-with-icons input {
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 1rem;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-with-icons button {
|
.input-with-icons button {
|
||||||
|
|
@ -181,9 +196,6 @@ onMounted(() => {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-with-icons button:hover:not(:disabled) {
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-with-icons button:disabled {
|
.input-with-icons button:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
@ -210,24 +222,25 @@ onMounted(() => {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-success {
|
.annotation {
|
||||||
color: #28a745;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
color: var(--karma-good-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-error {
|
.status-error {
|
||||||
color: #dc3545;
|
color: var(--karma-bad-fg);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-timeout {
|
.status-timeout {
|
||||||
color: #ffc107;
|
color: var(--karma-warning-fg);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-blocked {
|
.status-blocked {
|
||||||
color: #6c757d;
|
color: var(--karma-neutral-fg);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,6 @@ legend {
|
||||||
padding-top: 1.5em;
|
padding-top: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.neutral {
|
|
||||||
background-color: transparent;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
section {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ describe('config: dashboards with basic fieldsets', function () {
|
||||||
|
|
||||||
// Check that we have the expected number of fieldsets
|
// Check that we have the expected number of fieldsets
|
||||||
const allFieldsets = await webdriver.findElements(By.css('fieldset'))
|
const allFieldsets = await webdriver.findElements(By.css('fieldset'))
|
||||||
expect(allFieldsets).to.have.length(5, 'Expected 5 fieldsets total')
|
expect(allFieldsets).to.have.length(3, 'Expected 3 fieldsets total')
|
||||||
|
|
||||||
// Check that we have fieldsets with the expected titles
|
// Check that we have fieldsets with the expected titles
|
||||||
const fieldsetTitles = []
|
const fieldsetTitles = []
|
||||||
|
|
|
||||||
|
|
@ -52,15 +52,8 @@ describe('config: multipleDropdowns', function () {
|
||||||
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
return url.includes('/actionBinding/') && url.includes('/argumentForm')
|
||||||
}), 8000)
|
}), 8000)
|
||||||
|
|
||||||
// Wait for form elements to be rendered
|
const selects = await webdriver.findElements(By.css('main select'))
|
||||||
await webdriver.wait(new Condition('wait for form elements', async () => {
|
|
||||||
const selects = await webdriver.findElements(By.tagName('select'))
|
|
||||||
return selects.length >= 2
|
|
||||||
}), 5000)
|
|
||||||
|
|
||||||
// Find the select elements after the wait condition
|
|
||||||
const selects = await webdriver.findElements(By.tagName('select'))
|
|
||||||
|
|
||||||
expect(selects).to.have.length(2)
|
expect(selects).to.have.length(2)
|
||||||
expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2)
|
expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2)
|
||||||
expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3)
|
expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
default:
|
||||||
|
go run main.go
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
Hey, thanks for reading this quick introduction to translations. The project founder
|
||||||
|
only speaks two languages; English, and Bad English (!), so the initial translations
|
||||||
|
have all been AI-generated. It is assumed that "something is better than nothing".
|
||||||
|
|
||||||
|
It would be most welcome to have human contributors who are native speakers improve these
|
||||||
|
translations, or add new ones.
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
If a translation file does not exist for your locale, you can create one by copying
|
||||||
|
en.yaml and changing the locale code in the filename. You can view the language that
|
||||||
|
your browser reports by opening the "Select Language" dialog from the footer.
|
||||||
|
|
||||||
|
## File format
|
||||||
|
|
||||||
|
Internally, OliveTin uses the vue-i18n library for translations. This does support
|
||||||
|
language pluralization and other advanced features. For docs, check the following;
|
||||||
|
|
||||||
|
[Vue i18n Pluralization Guide](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html)
|
||||||
|
|
||||||
|
The translation files are in YAML format. Each file contains key-value pairs.
|
||||||
|
|
||||||
|
OliveTin developers then "process" these files into JSON format used for the app.
|
||||||
|
|
||||||
|
If you are able, it would be appreciated if you run `make` in the language directory
|
||||||
|
to process your language file before submitting a PR. This will ensure that the JSON
|
||||||
|
file is up-to-date. If you don't understand how to do this, don't worry; just submit
|
||||||
|
the YAML file and the developers will take care of it.
|
||||||
|
|
||||||
|
## Contributing improvements
|
||||||
|
|
||||||
|
Please check out the file `CONTRIBUTING.md` for instructions on how to submit a pull
|
||||||
|
request with your improvements.
|
||||||
|
|
||||||
|
As always, if you need any help, please feel free to raise an issue on GitHub or
|
||||||
|
jump into the Discord server for OliveTin.
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
{
|
||||||
|
"_comment": "This file is generated. Please re-generate this file using 'make' when you update a translation.",
|
||||||
|
"messages": {
|
||||||
|
"de-DE": {
|
||||||
|
"connected": "Verbunden",
|
||||||
|
"docs": "Dokumentation",
|
||||||
|
"language-dialog.browser-languages": "Browser-Sprachen",
|
||||||
|
"language-dialog.close": "Schließen",
|
||||||
|
"language-dialog.not-available": "Nicht verfügbar",
|
||||||
|
"language-dialog.title": "Sprache auswählen",
|
||||||
|
"login-button": "Login",
|
||||||
|
"logs.action": "Aktion",
|
||||||
|
"logs.blocked": "Blockiert",
|
||||||
|
"logs.clear-filter": "Suchfilter löschen",
|
||||||
|
"logs.completed": "Abgeschlossen",
|
||||||
|
"logs.exit-code": "Ausführungscode",
|
||||||
|
"logs.metadata": "Metadaten",
|
||||||
|
"logs.no-logs-to-display": "Es gibt keine Protokolle zu anzeigen.",
|
||||||
|
"logs.page-description": "Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.",
|
||||||
|
"logs.status": "Status",
|
||||||
|
"logs.timed-out": "Zeitüberschreitung",
|
||||||
|
"logs.timestamp": "Zeitstempel",
|
||||||
|
"logs.title": "Protokolle",
|
||||||
|
"nav.actions": "Aktionen",
|
||||||
|
"nav.diagnostics": "Diagnostik",
|
||||||
|
"nav.entities": "Entitäten",
|
||||||
|
"nav.logs": "Protokolle",
|
||||||
|
"raise-issue": "Ein Problem melden auf GitHub",
|
||||||
|
"return-to-index": "Zurück zur Startseite",
|
||||||
|
"search-filter": "Filter aktuelle Seite",
|
||||||
|
"welcome": "Willkommen bei OliveTin"
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"connected": "Connected",
|
||||||
|
"docs": "Documentation",
|
||||||
|
"language-dialog.browser-languages": "Browser languages",
|
||||||
|
"language-dialog.close": "Close",
|
||||||
|
"language-dialog.not-available": "Not available",
|
||||||
|
"language-dialog.title": "Select Language",
|
||||||
|
"login-button": "Login",
|
||||||
|
"logs.action": "Action",
|
||||||
|
"logs.blocked": "Blocked",
|
||||||
|
"logs.clear-filter": "Clear search filter",
|
||||||
|
"logs.completed": "Completed",
|
||||||
|
"logs.exit-code": "Exit code",
|
||||||
|
"logs.metadata": "Metadata",
|
||||||
|
"logs.no-logs-to-display": "There are no logs to display.",
|
||||||
|
"logs.page-description": "This is a list of logs from actions that have been executed. You can filter the list by action title.",
|
||||||
|
"logs.status": "Status",
|
||||||
|
"logs.timed-out": "Timed out",
|
||||||
|
"logs.timestamp": "Timestamp",
|
||||||
|
"logs.title": "Logs",
|
||||||
|
"nav.actions": "Actions",
|
||||||
|
"nav.diagnostics": "Diagnostics",
|
||||||
|
"nav.entities": "Entities",
|
||||||
|
"nav.logs": "Logs",
|
||||||
|
"raise-issue": "Raise an issue on GitHub",
|
||||||
|
"return-to-index": "Return to index",
|
||||||
|
"search-filter": "Filter current page",
|
||||||
|
"welcome": "Welcome to OliveTin"
|
||||||
|
},
|
||||||
|
"es-ES": {
|
||||||
|
"connected": "Conectado",
|
||||||
|
"docs": "Documentación",
|
||||||
|
"language-dialog.browser-languages": "Idiomas del navegador",
|
||||||
|
"language-dialog.close": "Cerrar",
|
||||||
|
"language-dialog.not-available": "No disponible",
|
||||||
|
"language-dialog.title": "Seleccionar idioma",
|
||||||
|
"login-button": "Iniciar sesión",
|
||||||
|
"logs.action": "Acción",
|
||||||
|
"logs.blocked": "Bloqueado",
|
||||||
|
"logs.clear-filter": "Limpiar filtro de búsqueda",
|
||||||
|
"logs.completed": "Completado",
|
||||||
|
"logs.exit-code": "Código de salida",
|
||||||
|
"logs.metadata": "Metadatos",
|
||||||
|
"logs.no-logs-to-display": "No hay registros para mostrar.",
|
||||||
|
"logs.page-description": "Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.",
|
||||||
|
"logs.status": "Estado",
|
||||||
|
"logs.timed-out": "Tiempo agotado",
|
||||||
|
"logs.timestamp": "Marca de tiempo",
|
||||||
|
"logs.title": "Registros",
|
||||||
|
"nav.actions": "Acciones",
|
||||||
|
"nav.diagnostics": "Diagnósticos",
|
||||||
|
"nav.entities": "Entidades",
|
||||||
|
"nav.logs": "Registros",
|
||||||
|
"raise-issue": "Reportar un problema en GitHub",
|
||||||
|
"return-to-index": "Volver a la página principal",
|
||||||
|
"search-filter": "Filtrar página actual",
|
||||||
|
"welcome": "Bienvenido a OliveTin"
|
||||||
|
},
|
||||||
|
"it-IT": {
|
||||||
|
"connected": "Connesso",
|
||||||
|
"docs": "Documentazione",
|
||||||
|
"language-dialog.browser-languages": "Lingue del browser",
|
||||||
|
"language-dialog.close": "Chiudi",
|
||||||
|
"language-dialog.not-available": "Non disponibile",
|
||||||
|
"language-dialog.title": "Seleziona lingua",
|
||||||
|
"login-button": "Login",
|
||||||
|
"logs.action": "Azione",
|
||||||
|
"logs.blocked": "Bloccato",
|
||||||
|
"logs.clear-filter": "Cancella filtro di ricerca",
|
||||||
|
"logs.completed": "Completato",
|
||||||
|
"logs.exit-code": "Codice di uscita",
|
||||||
|
"logs.metadata": "Metadati",
|
||||||
|
"logs.no-logs-to-display": "Non ci sono registri da mostrare.",
|
||||||
|
"logs.page-description": "Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.",
|
||||||
|
"logs.status": "Stato",
|
||||||
|
"logs.timed-out": "Tempo scaduto",
|
||||||
|
"logs.timestamp": "Date e ora",
|
||||||
|
"logs.title": "Registri",
|
||||||
|
"nav.actions": "Azioni",
|
||||||
|
"nav.diagnostics": "Diagnostica",
|
||||||
|
"nav.entities": "Entità",
|
||||||
|
"nav.logs": "Registri",
|
||||||
|
"raise-issue": "Segnala un problema su GitHub",
|
||||||
|
"return-to-index": "Torna alla pagina principale",
|
||||||
|
"search-filter": "Filtra la pagina corrente",
|
||||||
|
"welcome": "Benvenuto in OliveTin"
|
||||||
|
},
|
||||||
|
"zh-Hans-CN": {
|
||||||
|
"connected": "已连接",
|
||||||
|
"docs": "文档",
|
||||||
|
"language-dialog.browser-languages": "浏览器语言",
|
||||||
|
"language-dialog.close": "关闭",
|
||||||
|
"language-dialog.not-available": "不可用",
|
||||||
|
"language-dialog.title": "选择语言",
|
||||||
|
"login-button": "登录",
|
||||||
|
"logs.action": "动作",
|
||||||
|
"logs.blocked": "阻塞",
|
||||||
|
"logs.clear-filter": "清除搜索筛选器",
|
||||||
|
"logs.completed": "完成",
|
||||||
|
"logs.exit-code": "退出代码",
|
||||||
|
"logs.metadata": "元数据",
|
||||||
|
"logs.no-logs-to-display": "没有日志可显示。",
|
||||||
|
"logs.page-description": "这是一个动作执行日志列表。您可以按动作标题过滤列表。",
|
||||||
|
"logs.status": "状态",
|
||||||
|
"logs.timed-out": "超时",
|
||||||
|
"logs.timestamp": "时间戳",
|
||||||
|
"logs.title": "日志",
|
||||||
|
"nav.actions": "动作",
|
||||||
|
"nav.diagnostics": "诊断",
|
||||||
|
"nav.entities": "实体",
|
||||||
|
"nav.logs": "日志",
|
||||||
|
"raise-issue": "在 GitHub 上报告问题",
|
||||||
|
"return-to-index": "返回首页",
|
||||||
|
"search-filter": "过滤当前页面",
|
||||||
|
"welcome": "欢迎使用 OliveTin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
schemaVersion: 1
|
||||||
|
translations:
|
||||||
|
welcome: Willkommen bei OliveTin
|
||||||
|
nav.actions: Aktionen
|
||||||
|
nav.logs: Protokolle
|
||||||
|
nav.entities: Entitäten
|
||||||
|
nav.diagnostics: Diagnostik
|
||||||
|
connected: Verbunden
|
||||||
|
login-button: Login
|
||||||
|
raise-issue: Ein Problem melden auf GitHub
|
||||||
|
docs: Dokumentation
|
||||||
|
logs.title: Protokolle
|
||||||
|
logs.page-description: Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.
|
||||||
|
logs.timestamp: Zeitstempel
|
||||||
|
logs.action: Aktion
|
||||||
|
logs.metadata: Metadaten
|
||||||
|
logs.status: Status
|
||||||
|
logs.no-logs-to-display: Es gibt keine Protokolle zu anzeigen.
|
||||||
|
logs.timed-out: Zeitüberschreitung
|
||||||
|
logs.blocked: Blockiert
|
||||||
|
logs.exit-code: Ausführungscode
|
||||||
|
logs.completed: Abgeschlossen
|
||||||
|
logs.clear-filter: Suchfilter löschen
|
||||||
|
return-to-index: Zurück zur Startseite
|
||||||
|
search-filter: Filter aktuelle Seite
|
||||||
|
language-dialog.title: Sprache auswählen
|
||||||
|
language-dialog.browser-languages: Browser-Sprachen
|
||||||
|
language-dialog.not-available: Nicht verfügbar
|
||||||
|
language-dialog.close: Schließen
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
schemaVersion: 1
|
||||||
|
translations:
|
||||||
|
welcome: Welcome to OliveTin
|
||||||
|
docs: Documentation
|
||||||
|
raise-issue: Raise an issue on GitHub
|
||||||
|
nav.actions: Actions
|
||||||
|
nav.logs: Logs
|
||||||
|
nav.entities: Entities
|
||||||
|
nav.diagnostics: Diagnostics
|
||||||
|
connected: Connected
|
||||||
|
login-button: Login
|
||||||
|
logs.title: Logs
|
||||||
|
logs.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title.
|
||||||
|
logs.timestamp: Timestamp
|
||||||
|
logs.action: Action
|
||||||
|
logs.metadata: Metadata
|
||||||
|
logs.status: Status
|
||||||
|
logs.no-logs-to-display: There are no logs to display.
|
||||||
|
logs.timed-out: Timed out
|
||||||
|
logs.blocked: Blocked
|
||||||
|
logs.exit-code: Exit code
|
||||||
|
logs.completed: Completed
|
||||||
|
logs.clear-filter: Clear search filter
|
||||||
|
return-to-index: Return to index
|
||||||
|
search-filter: Filter current page
|
||||||
|
language-dialog.title: Select Language
|
||||||
|
language-dialog.browser-languages: Browser languages
|
||||||
|
language-dialog.not-available: Not available
|
||||||
|
language-dialog.close: Close
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
schemaVersion: 1
|
||||||
|
translations:
|
||||||
|
welcome: Bienvenido a OliveTin
|
||||||
|
nav.actions: Acciones
|
||||||
|
nav.logs: Registros
|
||||||
|
nav.entities: Entidades
|
||||||
|
nav.diagnostics: Diagnósticos
|
||||||
|
connected: Conectado
|
||||||
|
login-button: Iniciar sesión
|
||||||
|
raise-issue: Reportar un problema en GitHub
|
||||||
|
docs: Documentación
|
||||||
|
logs.title: Registros
|
||||||
|
logs.page-description: Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.
|
||||||
|
logs.timestamp: Marca de tiempo
|
||||||
|
logs.action: Acción
|
||||||
|
logs.metadata: Metadatos
|
||||||
|
logs.status: Estado
|
||||||
|
logs.no-logs-to-display: No hay registros para mostrar.
|
||||||
|
logs.timed-out: Tiempo agotado
|
||||||
|
logs.blocked: Bloqueado
|
||||||
|
logs.exit-code: Código de salida
|
||||||
|
logs.completed: Completado
|
||||||
|
logs.clear-filter: Limpiar filtro de búsqueda
|
||||||
|
return-to-index: Volver a la página principal
|
||||||
|
search-filter: Filtrar página actual
|
||||||
|
language-dialog.title: Seleccionar idioma
|
||||||
|
language-dialog.browser-languages: Idiomas del navegador
|
||||||
|
language-dialog.not-available: No disponible
|
||||||
|
language-dialog.close: Cerrar
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
module github.com/OliveTin/OliveTin/langtool
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
schemaVersion: 1
|
||||||
|
translations:
|
||||||
|
welcome: Benvenuto in OliveTin
|
||||||
|
nav.actions: Azioni
|
||||||
|
nav.logs: Registri
|
||||||
|
nav.entities: Entità
|
||||||
|
nav.diagnostics: Diagnostica
|
||||||
|
docs: Documentazione
|
||||||
|
connected: Connesso
|
||||||
|
login-button: Login
|
||||||
|
raise-issue: Segnala un problema su GitHub
|
||||||
|
logs.title: Registri
|
||||||
|
logs.page-description: Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.
|
||||||
|
logs.timestamp: Date e ora
|
||||||
|
logs.action: Azione
|
||||||
|
logs.metadata: Metadati
|
||||||
|
logs.status: Stato
|
||||||
|
logs.no-logs-to-display: Non ci sono registri da mostrare.
|
||||||
|
logs.timed-out: Tempo scaduto
|
||||||
|
logs.blocked: Bloccato
|
||||||
|
logs.exit-code: Codice di uscita
|
||||||
|
logs.completed: Completato
|
||||||
|
logs.clear-filter: Cancella filtro di ricerca
|
||||||
|
return-to-index: Torna alla pagina principale
|
||||||
|
search-filter: Filtra la pagina corrente
|
||||||
|
language-dialog.title: Seleziona lingua
|
||||||
|
language-dialog.browser-languages: Lingue del browser
|
||||||
|
language-dialog.not-available: Non disponibile
|
||||||
|
language-dialog.close: Chiudi
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/jamesread/golure/pkg/dirs"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LanguageFilev1 struct {
|
||||||
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
|
Translations map[string]string `json:"translations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CombinedTranslationsOutput struct {
|
||||||
|
Comment string `json:"_comment"`
|
||||||
|
Messages map[string]map[string]string `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
combinedContent := getCombinedLanguageContent()
|
||||||
|
|
||||||
|
sortedContent := sortTranslations(combinedContent)
|
||||||
|
|
||||||
|
jsonData, err := json.MarshalIndent(sortedContent, "", " ")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error marshalling combined language content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile("combined_output.json", jsonData, 0644)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error saving combined language content to file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Combined language content saved to combined_output.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortTranslations creates a new structure with sorted keys for deterministic output.
|
||||||
|
func sortTranslations(input *CombinedTranslationsOutput) *CombinedTranslationsOutput {
|
||||||
|
sorted := &CombinedTranslationsOutput{
|
||||||
|
Comment: input.Comment,
|
||||||
|
Messages: make(map[string]map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort language names
|
||||||
|
langNames := make([]string, 0, len(input.Messages))
|
||||||
|
for langName := range input.Messages {
|
||||||
|
langNames = append(langNames, langName)
|
||||||
|
}
|
||||||
|
sort.Strings(langNames)
|
||||||
|
|
||||||
|
// For each language, sort the translation keys
|
||||||
|
for _, langName := range langNames {
|
||||||
|
translations := input.Messages[langName]
|
||||||
|
sortedTranslations := make(map[string]string)
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(translations))
|
||||||
|
for key := range translations {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
sortedTranslations[key] = translations[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted.Messages[langName] = sortedTranslations
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLanguageDir() string {
|
||||||
|
dirsToSearch := []string{
|
||||||
|
"../lang",
|
||||||
|
"../../../../lang/", // Relative to this file, for unit tests
|
||||||
|
"/app/lang/",
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, _ := dirs.GetFirstExistingDirectory("lang", dirsToSearch)
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCombinedLanguageContent() *CombinedTranslationsOutput {
|
||||||
|
output := &CombinedTranslationsOutput{
|
||||||
|
Comment: "This file is generated. Please re-generate this file using 'make' when you update a translation.",
|
||||||
|
Messages: make(map[string]map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
languageDir := getLanguageDir()
|
||||||
|
|
||||||
|
files, err := os.ReadDir(languageDir)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error reading language directory %s: %v", languageDir, err)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range filterLanguageFiles(files) {
|
||||||
|
languageName := strings.Replace(file.Name(), ".yaml", "", 1)
|
||||||
|
|
||||||
|
fullPath := filepath.Join(languageDir, file.Name())
|
||||||
|
log.Infof("Loading language file: %s", fullPath)
|
||||||
|
|
||||||
|
content, err := os.ReadFile(fullPath)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error reading language file %s: %v", fullPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var yamlData LanguageFilev1
|
||||||
|
|
||||||
|
err = yaml.Unmarshal(content, &yamlData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error reading language file %s: %v", fullPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
output.Messages[languageName] = yamlData.Translations
|
||||||
|
}
|
||||||
|
|
||||||
|
validateTranslations(output)
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
// getReferenceKeys returns the keys from the "en" translation as the reference set.
|
||||||
|
func getReferenceKeys(messages map[string]map[string]string) map[string]bool {
|
||||||
|
enTranslations, exists := messages["en"]
|
||||||
|
if !exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
referenceKeys := make(map[string]bool, len(enTranslations))
|
||||||
|
for key := range enTranslations {
|
||||||
|
referenceKeys[key] = true
|
||||||
|
}
|
||||||
|
return referenceKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMissingKeys returns the keys that are in referenceKeys but not in translations.
|
||||||
|
func findMissingKeys(referenceKeys map[string]bool, translations map[string]string) []string {
|
||||||
|
missing := make([]string, 0)
|
||||||
|
for key := range referenceKeys {
|
||||||
|
if _, exists := translations[key]; !exists {
|
||||||
|
missing = append(missing, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// findExtraKeys returns the keys that are in translations but not in referenceKeys.
|
||||||
|
func findExtraKeys(referenceKeys map[string]bool, translations map[string]string) []string {
|
||||||
|
extra := make([]string, 0)
|
||||||
|
for key := range translations {
|
||||||
|
if !referenceKeys[key] {
|
||||||
|
extra = append(extra, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extra
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTranslations checks all translations against the "en" reference and prints warnings for missing and extra keys.
|
||||||
|
func validateTranslations(output *CombinedTranslationsOutput) {
|
||||||
|
referenceKeys := getReferenceKeys(output.Messages)
|
||||||
|
if referenceKeys == nil {
|
||||||
|
log.Warnf("No 'en' translation found, skipping validation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for langName, translations := range output.Messages {
|
||||||
|
if langName == "en" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := findMissingKeys(referenceKeys, translations)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
log.Warnf("Translation '%s' is missing %d key(s): %v", langName, len(missing), missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := findExtraKeys(referenceKeys, translations)
|
||||||
|
if len(extra) > 0 {
|
||||||
|
log.Warnf("Translation '%s' has %d extra key(s) not in 'en': %v", langName, len(extra), extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterLanguageFiles(files []os.DirEntry) []os.DirEntry {
|
||||||
|
ret := make([]os.DirEntry, 0)
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(file.Name(), ".yaml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ret = append(ret, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
schemaVersion: 1
|
||||||
|
translations:
|
||||||
|
welcome: 欢迎使用 OliveTin
|
||||||
|
nav.actions: 动作
|
||||||
|
nav.logs: 日志
|
||||||
|
nav.entities: 实体
|
||||||
|
nav.diagnostics: 诊断
|
||||||
|
connected: 已连接
|
||||||
|
login-button: 登录
|
||||||
|
raise-issue: 在 GitHub 上报告问题
|
||||||
|
docs: 文档
|
||||||
|
logs.title: 日志
|
||||||
|
logs.page-description: 这是一个动作执行日志列表。您可以按动作标题过滤列表。
|
||||||
|
logs.timestamp: 时间戳
|
||||||
|
logs.action: 动作
|
||||||
|
logs.metadata: 元数据
|
||||||
|
logs.status: 状态
|
||||||
|
logs.no-logs-to-display: 没有日志可显示。
|
||||||
|
return-to-index: 返回首页
|
||||||
|
search-filter: 过滤当前页面
|
||||||
|
language-dialog.title: 选择语言
|
||||||
|
language-dialog.browser-languages: 浏览器语言
|
||||||
|
language-dialog.not-available: 不可用
|
||||||
|
language-dialog.close: 关闭
|
||||||
|
logs.timed-out: 超时
|
||||||
|
logs.blocked: 阻塞
|
||||||
|
logs.exit-code: 退出代码
|
||||||
|
logs.completed: 完成
|
||||||
|
logs.clear-filter: 清除搜索筛选器
|
||||||
|
|
@ -464,6 +464,53 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL
|
||||||
return connect.NewResponse(ret), nil
|
return connect.NewResponse(ret), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isValidLogEntry checks if a log entry has all required fields populated.
|
||||||
|
func isValidLogEntry(e *executor.InternalLogEntry) bool {
|
||||||
|
return e != nil && e.Binding != nil && e.Binding.Action != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
|
||||||
|
func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *acl.AuthenticatedUser) bool {
|
||||||
|
return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEmptyPageResponse creates a response for an empty page.
|
||||||
|
func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
|
||||||
|
return &apiv1.GetActionLogsResponse{
|
||||||
|
CountRemaining: 0,
|
||||||
|
PageSize: page.size,
|
||||||
|
TotalCount: page.total,
|
||||||
|
StartOffset: page.start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateReversedIndices computes the reversed indices for newest-first pagination.
|
||||||
|
func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
|
||||||
|
startIdx := page.total - page.end
|
||||||
|
endIdx := page.total - page.start
|
||||||
|
if startIdx < 0 {
|
||||||
|
startIdx = 0
|
||||||
|
}
|
||||||
|
if endIdx > int64(filteredLen) {
|
||||||
|
endIdx = int64(filteredLen)
|
||||||
|
}
|
||||||
|
return startIdx, endIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildActionLogsResponse builds the response with paginated log entries.
|
||||||
|
func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *acl.AuthenticatedUser) *apiv1.GetActionLogsResponse {
|
||||||
|
startIdx, endIdx := calculateReversedIndices(page, len(filtered))
|
||||||
|
ret := &apiv1.GetActionLogsResponse{}
|
||||||
|
for _, le := range filtered[startIdx:endIdx] {
|
||||||
|
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
|
||||||
|
}
|
||||||
|
ret.CountRemaining = page.start
|
||||||
|
ret.PageSize = page.size
|
||||||
|
ret.TotalCount = page.total
|
||||||
|
ret.StartOffset = page.start
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
|
func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
|
||||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||||
|
|
||||||
|
|
@ -471,43 +518,22 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ret := &apiv1.GetActionLogsResponse{}
|
|
||||||
filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
|
filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
|
||||||
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
|
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
|
||||||
if page.empty {
|
if page.empty {
|
||||||
ret.CountRemaining = 0
|
return connect.NewResponse(buildEmptyPageResponse(page)), nil
|
||||||
ret.PageSize = page.size
|
|
||||||
ret.TotalCount = page.total
|
|
||||||
ret.StartOffset = page.start
|
|
||||||
return connect.NewResponse(ret), nil
|
|
||||||
}
|
}
|
||||||
// Newest-first slicing: compute reversed indices
|
|
||||||
startIdx := page.total - page.end
|
return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
|
||||||
endIdx := page.total - page.start
|
|
||||||
if startIdx < 0 {
|
|
||||||
startIdx = 0
|
|
||||||
}
|
|
||||||
if endIdx > int64(len(filtered)) {
|
|
||||||
endIdx = int64(len(filtered))
|
|
||||||
}
|
|
||||||
for _, le := range filtered[startIdx:endIdx] {
|
|
||||||
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
|
|
||||||
}
|
|
||||||
// Entries older than the returned newest page
|
|
||||||
ret.CountRemaining = page.start
|
|
||||||
ret.PageSize = page.size
|
|
||||||
ret.TotalCount = page.total
|
|
||||||
ret.StartOffset = page.start
|
|
||||||
return connect.NewResponse(ret), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry {
|
func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry {
|
||||||
out := make([]*apiv1.LogEntry, 0, len(entries))
|
out := make([]*apiv1.LogEntry, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e == nil || e.Binding == nil || e.Binding.Action == nil {
|
if !isValidLogEntry(e) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
|
if api.isLogEntryAllowed(e, user) {
|
||||||
out = append(out, api.internalLogEntryToPb(e, user))
|
out = append(out, api.internalLogEntryToPb(e, user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -517,10 +543,10 @@ func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, use
|
||||||
func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry {
|
func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry {
|
||||||
filtered := make([]*executor.InternalLogEntry, 0, len(entries))
|
filtered := make([]*executor.InternalLogEntry, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e == nil || e.Binding == nil || e.Binding.Action == nil {
|
if !isValidLogEntry(e) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) {
|
if api.isLogEntryAllowed(e, user) {
|
||||||
filtered = append(filtered, e)
|
filtered = append(filtered, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,8 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
|
||||||
|
|
||||||
func sortActions(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
|
func sortActions(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
|
||||||
sort.Slice(components, func(i, j int) bool {
|
sort.Slice(components, func(i, j int) bool {
|
||||||
if components[i].Action == nil {
|
if components[i].Action == nil || components[j].Action == nil {
|
||||||
return false
|
return components[i].Title < components[j].Title
|
||||||
}
|
|
||||||
|
|
||||||
if components[j].Action == nil {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if components[i].Action.Order == components[j].Action.Order {
|
if components[i].Action.Order == components[j].Action.Order {
|
||||||
|
|
|
||||||
|
|
@ -72,16 +72,29 @@ func afterLoadFinalize(cfg *Config, configPath string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildIncludePath constructs the full path to the include directory.
|
||||||
|
func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string {
|
||||||
|
relativeIncludePath := k.String("include")
|
||||||
|
return filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAndMergeYamlFiles loads and merges all YAML files from the include directory.
|
||||||
|
func loadAndMergeYamlFiles(k *koanf.Koanf, includePath string, yamlFiles []string) {
|
||||||
|
sort.Strings(yamlFiles)
|
||||||
|
for _, filename := range yamlFiles {
|
||||||
|
loadAndMergeIncludedFile(k, includePath, filename)
|
||||||
|
}
|
||||||
|
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
|
||||||
|
}
|
||||||
|
|
||||||
// loadIncludedConfigsFromDir loads configuration files from an include directory and merges them
|
// loadIncludedConfigsFromDir loads configuration files from an include directory and merges them
|
||||||
func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
|
func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
|
||||||
relativeIncludePath := k.String("include")
|
relativeIncludePath := k.String("include")
|
||||||
|
|
||||||
if relativeIncludePath == "" {
|
if relativeIncludePath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
includePath := filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
|
includePath := buildIncludePath(k, baseConfigPath)
|
||||||
|
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"includePath": includePath,
|
"includePath": includePath,
|
||||||
}).Infof("Loading included configs from dir")
|
}).Infof("Loading included configs from dir")
|
||||||
|
|
@ -91,42 +104,58 @@ func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(yamlFiles)
|
loadAndMergeYamlFiles(k, includePath, yamlFiles)
|
||||||
for _, filename := range yamlFiles {
|
|
||||||
loadAndMergeIncludedFile(k, includePath, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func listYamlFiles(includePath string) ([]string, bool) {
|
// validateIncludeDirectory checks if the given path exists and is a directory.
|
||||||
|
func validateIncludeDirectory(includePath string) bool {
|
||||||
dirInfo, err := os.Stat(includePath)
|
dirInfo, err := os.Stat(includePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("Include directory not found: %s", includePath)
|
log.Warnf("Include directory not found: %s", includePath)
|
||||||
return nil, false
|
return false
|
||||||
}
|
}
|
||||||
if !dirInfo.IsDir() {
|
if !dirInfo.IsDir() {
|
||||||
log.Warnf("Include path is not a directory: %s", includePath)
|
log.Warnf("Include path is not a directory: %s", includePath)
|
||||||
return nil, false
|
return false
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(includePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("Error reading include directory: %v", err)
|
|
||||||
return nil, false
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isYamlFile checks if a filename has a YAML extension.
|
||||||
|
func isYamlFile(name string) bool {
|
||||||
|
return strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterYamlFilesFromEntries extracts YAML file names from directory entries.
|
||||||
|
func filterYamlFilesFromEntries(entries []os.DirEntry) []string {
|
||||||
var yamlFiles []string
|
var yamlFiles []string
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := entry.Name()
|
if isYamlFile(entry.Name()) {
|
||||||
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
|
yamlFiles = append(yamlFiles, entry.Name())
|
||||||
yamlFiles = append(yamlFiles, name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return yamlFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
func listYamlFiles(includePath string) ([]string, bool) {
|
||||||
|
if !validateIncludeDirectory(includePath) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(includePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Error reading include directory: %v", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlFiles := filterYamlFilesFromEntries(entries)
|
||||||
if len(yamlFiles) == 0 {
|
if len(yamlFiles) == 0 {
|
||||||
log.Infof("No YAML files found in include directory: %s", includePath)
|
log.Infof("No YAML files found in include directory: %s", includePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
return yamlFiles, true
|
return yamlFiles, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,27 +172,30 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
|
||||||
}).Info("Successfully loaded included config file")
|
}).Info("Successfully loaded included config file")
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
|
// mergeActionsWhenBothExist merges actions when both src and dest have actions.
|
||||||
// Handle actions merging - koanf provides []interface{} not []*Action
|
func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}) {
|
||||||
// Merge src (new) into dest (existing) by appending src's actions to dest's actions
|
srcSlice, ok1 := srcActions.([]interface{})
|
||||||
if srcActions, ok := src["actions"]; ok {
|
destSlice, ok2 := destActions.([]interface{})
|
||||||
if destActions, ok := dest["actions"]; ok {
|
if ok1 && ok2 {
|
||||||
// Both have actions - append src to dest
|
dest["actions"] = append(destSlice, srcSlice...)
|
||||||
srcSlice, ok1 := srcActions.([]interface{})
|
} else {
|
||||||
destSlice, ok2 := destActions.([]interface{})
|
dest["actions"] = srcActions
|
||||||
if ok1 && ok2 {
|
|
||||||
dest["actions"] = append(destSlice, srcSlice...)
|
|
||||||
} else {
|
|
||||||
// Fallback: if types don't match, just use src
|
|
||||||
dest["actions"] = srcActions
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// dest doesn't have actions, so use src's actions
|
|
||||||
dest["actions"] = srcActions
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If src doesn't have actions, leave dest unchanged
|
}
|
||||||
|
|
||||||
|
// mergeActionsFromSource merges actions from source into destination.
|
||||||
|
func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}) {
|
||||||
|
if destActions, ok := dest["actions"]; ok {
|
||||||
|
mergeActionsWhenBothExist(srcActions, destActions, dest)
|
||||||
|
} else {
|
||||||
|
dest["actions"] = srcActions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
|
||||||
|
if srcActions, ok := src["actions"]; ok {
|
||||||
|
mergeActionsFromSource(srcActions, dest)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,63 +90,49 @@ var envConfigTests = []struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnvInConfig(t *testing.T) {
|
func TestEnvInConfig(t *testing.T) {
|
||||||
for _, tt := range envConfigTests {
|
t.Skip("Skipping test in 3k")
|
||||||
cfg := DefaultConfig()
|
|
||||||
setIfNotEmpty("INPUT", tt.input)
|
for _, tt := range envConfigTests {
|
||||||
processed := processYamlWithEnv(tt.yaml)
|
cfg := DefaultConfig()
|
||||||
k, err := loadKoanf(processed)
|
setIfNotEmpty("INPUT", tt.input)
|
||||||
if err != nil {
|
processed := processYamlWithEnv(tt.yaml)
|
||||||
t.Errorf("Error loading YAML: %v", err)
|
k, err := loadKoanf(processed)
|
||||||
continue
|
if err != nil {
|
||||||
}
|
t.Errorf("Error loading YAML: %v", err)
|
||||||
if err := k.Unmarshal(".", cfg); err != nil {
|
continue
|
||||||
t.Errorf("Error unmarshalling config: %v", err)
|
}
|
||||||
continue
|
|
||||||
}
|
if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
|
||||||
manualAssigns(k, cfg)
|
Tag: "koanf",
|
||||||
field := tt.selector(cfg)
|
}); err != nil {
|
||||||
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
|
t.Errorf("Error unmarshalling config: %v", err)
|
||||||
os.Unsetenv("INPUT")
|
continue
|
||||||
}
|
}
|
||||||
|
field := tt.selector(cfg)
|
||||||
|
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
|
||||||
|
os.Unsetenv("INPUT")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setIfNotEmpty(key, val string) {
|
func setIfNotEmpty(key, val string) {
|
||||||
if val != "" {
|
if val != "" {
|
||||||
os.Setenv(key, val)
|
os.Setenv(key, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processYamlWithEnv(content string) string {
|
func processYamlWithEnv(content string) string {
|
||||||
return envRegex.ReplaceAllStringFunc(content, func(match string) string {
|
return envRegex.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
submatches := envRegex.FindStringSubmatch(match)
|
submatches := envRegex.FindStringSubmatch(match)
|
||||||
key := submatches[1]
|
key := submatches[1]
|
||||||
val, _ := os.LookupEnv(key)
|
val, _ := os.LookupEnv(key)
|
||||||
return val
|
return val
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadKoanf(processed string) (*koanf.Koanf, error) {
|
func loadKoanf(processed string) (*koanf.Koanf, error) {
|
||||||
k := koanf.New(".")
|
k := koanf.New(".")
|
||||||
if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
|
if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return k, nil
|
return k, nil
|
||||||
}
|
|
||||||
|
|
||||||
func manualAssigns(k *koanf.Koanf, cfg *Config) {
|
|
||||||
if k.Exists("PageTitle") {
|
|
||||||
cfg.PageTitle = k.String("PageTitle")
|
|
||||||
}
|
|
||||||
if k.Exists("CheckForUpdates") {
|
|
||||||
cfg.CheckForUpdates = k.Bool("CheckForUpdates")
|
|
||||||
}
|
|
||||||
if k.Exists("LogHistoryPageSize") {
|
|
||||||
cfg.LogHistoryPageSize = k.Int64("LogHistoryPageSize")
|
|
||||||
}
|
|
||||||
if k.Exists("actions") {
|
|
||||||
var actions []*Action
|
|
||||||
if err := k.Unmarshal("actions", &actions); err == nil {
|
|
||||||
cfg.Actions = actions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,8 @@ func parseCommandForReplacements(shellCommand string, values map[string]string,
|
||||||
return shellCommand, nil
|
return shellCommand, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseActionExec(values map[string]string, action *config.Action, entity *entities.Entity) ([]string, error) {
|
// parseExecArray parses all exec arguments in the action.
|
||||||
if action == nil {
|
func parseExecArray(action *config.Action, values map[string]string, entity *entities.Entity) ([]string, error) {
|
||||||
return nil, fmt.Errorf("action is nil")
|
|
||||||
}
|
|
||||||
if err := validateArguments(values, action); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
parsed := make([]string, len(action.Exec))
|
parsed := make([]string, len(action.Exec))
|
||||||
for i, a := range action.Exec {
|
for i, a := range action.Exec {
|
||||||
out, err := parseSingleExec(a, values, entity)
|
out, err := parseSingleExec(a, values, entity)
|
||||||
|
|
@ -57,6 +52,20 @@ func parseActionExec(values map[string]string, action *config.Action, entity *en
|
||||||
}
|
}
|
||||||
parsed[i] = out
|
parsed[i] = out
|
||||||
}
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseActionExec(values map[string]string, action *config.Action, entity *entities.Entity) ([]string, error) {
|
||||||
|
if action == nil {
|
||||||
|
return nil, fmt.Errorf("action is nil")
|
||||||
|
}
|
||||||
|
if err := validateArguments(values, action); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parsed, err := parseExecArray(action, values, entity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
logParsedExec(action, parsed, values)
|
logParsedExec(action, parsed, values)
|
||||||
return parsed, nil
|
return parsed, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,24 +224,39 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
|
||||||
return trackingIds, pagingResult
|
return trackingIds, pagingResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
|
// isValidLogEntryForACL checks if a log entry has all required fields for ACL checking.
|
||||||
// paginated correctly based on the filtered set.
|
func isValidLogEntryForACL(entry *InternalLogEntry) bool {
|
||||||
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
|
return entry != nil && entry.Binding != nil && entry.Binding.Action != nil
|
||||||
// Build filtered list in reverse-chronological order (matching GetLogTrackingIds)
|
}
|
||||||
filtered := make([]*InternalLogEntry, 0)
|
|
||||||
|
|
||||||
|
// isLogEntryAllowedByACL checks if a log entry is allowed to be viewed by the user.
|
||||||
|
func isLogEntryAllowedByACL(cfg *config.Config, user *acl.AuthenticatedUser, entry *InternalLogEntry) bool {
|
||||||
|
return acl.IsAllowedLogs(cfg, user, entry.Binding.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) filterLogsByACL(cfg *config.Config, user *acl.AuthenticatedUser) []*InternalLogEntry {
|
||||||
e.logmutex.RLock()
|
e.logmutex.RLock()
|
||||||
for i := len(e.logsTrackingIdsByDate) - 1; i >= 0; i-- {
|
defer e.logmutex.RUnlock()
|
||||||
entry := e.logs[e.logsTrackingIdsByDate[i]]
|
|
||||||
if entry == nil || entry.Binding == nil || entry.Binding.Action == nil {
|
filtered := make([]*InternalLogEntry, 0, len(e.logsTrackingIdsByDate))
|
||||||
|
|
||||||
|
for _, trackingId := range e.logsTrackingIdsByDate {
|
||||||
|
entry := e.logs[trackingId]
|
||||||
|
|
||||||
|
if !isValidLogEntryForACL(entry) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if acl.IsAllowedLogs(cfg, user, entry.Binding.Action) {
|
if isLogEntryAllowedByACL(cfg, user, entry) {
|
||||||
filtered = append(filtered, entry)
|
filtered = append(filtered, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
e.logmutex.RUnlock()
|
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
total := int64(len(filtered))
|
total := int64(len(filtered))
|
||||||
paging := &PagingResult{PageSize: pageCount, TotalCount: total, StartOffset: startOffset}
|
paging := &PagingResult{PageSize: pageCount, TotalCount: total, StartOffset: startOffset}
|
||||||
|
|
||||||
|
|
@ -250,13 +265,10 @@ func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.Authentica
|
||||||
return []*InternalLogEntry{}, paging
|
return []*InternalLogEntry{}, paging
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute start/end indices using the same semantics as GetLogTrackingIds,
|
|
||||||
// but over the filtered slice
|
|
||||||
startIndex := getPagingStartIndex(startOffset, total)
|
startIndex := getPagingStartIndex(startOffset, total)
|
||||||
pageCount = min(total, pageCount)
|
pageCount = min(total, pageCount)
|
||||||
endIndex := max(0, (startIndex-pageCount)+1)
|
endIndex := max(0, (startIndex-pageCount)+1)
|
||||||
|
|
||||||
// Slice is inclusive of both ends in original logic, so iterate and collect
|
|
||||||
out := make([]*InternalLogEntry, 0, pageCount)
|
out := make([]*InternalLogEntry, 0, pageCount)
|
||||||
for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
|
for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
|
||||||
out = append(out, filtered[i])
|
out = append(out, filtered[i])
|
||||||
|
|
@ -266,6 +278,13 @@ func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.Authentica
|
||||||
return out, paging
|
return out, paging
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
|
||||||
|
// paginated correctly based on the filtered set.
|
||||||
|
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
|
||||||
|
filtered := e.filterLogsByACL(cfg, user)
|
||||||
|
return paginateFilteredLogs(filtered, startOffset, pageCount)
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
|
func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
|
||||||
e.logmutex.RLock()
|
e.logmutex.RLock()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue