feat: Add translations and language support (#708)

This commit is contained in:
James Read 2025-11-11 00:26:03 +00:00 committed by GitHub
commit 09ecc8d15c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 982 additions and 279 deletions

1
.gitignore vendored
View File

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

View File

@ -12,43 +12,84 @@ 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 combinedTranslations from '../lang/combined_output.json'
import { import {
initMarshaller initMarshaller
} from './js/marshaller.js' } from './js/marshaller.js'
import { checkWebsocketConnection } from './js/websocket.js' import { checkWebsocketConnection } from './js/websocket.js'
function initClient () { function getSelectedLanguage() {
const storedLanguage = localStorage.getItem('olivetin-language');
if (storedLanguage && storedLanguage !== 'auto') {
return storedLanguage;
}
if (storedLanguage === 'auto') {
localStorage.removeItem('olivetin-language');
}
if (navigator.languages && navigator.languages.length > 0) {
return navigator.languages[0];
}
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)
// Make i18n instance accessible globally for language switching
window.i18n = i18nSettings.global
app.mount('#app') app.mount('#app')
} }
function main () { async function main () {
initClient()
// Expose websocket connection function globally so App.vue can call it after successful init
window.checkWebsocketConnection = checkWebsocketConnection window.checkWebsocketConnection = checkWebsocketConnection
setupVue() const i18nSettings = await initClient()
setupVue(i18nSettings)
initMarshaller() initMarshaller()
// window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
// window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
} }
main() // call self main() // call self

View File

@ -17,9 +17,10 @@
"@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.1",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
"vite": "^7.1.12", "vite": "^7.1.12",
"vue-i18n": "^11.1.12",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
@ -732,6 +733,50 @@
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@intlify/core-base": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.1.12",
"@intlify/shared": "11.1.12"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.1.12",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -2362,9 +2407,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picocrank": { "node_modules/picocrank": {
"version": "1.8.0", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.8.0.tgz", "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.8.1.tgz",
"integrity": "sha512-YPGmXvw7vvjIcgrAe3io87kZDM+NUa+aiEYxk8CVqBzgI4koXeF+2VEGPHBwknZBBEbJfXsSdnxVwXrLKpWKfw==", "integrity": "sha512-g3JIVY8W5EVDGG+tG83Z0vzhMnj+J/RYkt6/ssdzZofR+6EWfFqE1+DQiyqb6rzjNuj0Y3xy3bpIprtzY9SQ6Q==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@hugeicons/core-free-icons": "^1.0.16", "@hugeicons/core-free-icons": "^1.0.16",
@ -3307,6 +3352,26 @@
} }
} }
}, },
"node_modules/vue-i18n": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.1.12",
"@intlify/shared": "11.1.12",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.6.3", "version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",

View File

@ -30,9 +30,10 @@
"@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.1",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
"vite": "^7.1.12", "vite": "^7.1.12",
"vue-i18n": "^11.1.12",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
} }
} }

View File

@ -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>{{ t('connected') }}</span>
<span>
<a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
</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">
{{ name }}
</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,16 @@ 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';
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 +101,46 @@ 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 getBrowserLanguage() {
if (navigator.languages && navigator.languages.length > 0) {
return navigator.languages[0]
}
if (navigator.language) {
return navigator.language
}
return 'en'
}
function toggleSidebar() { function toggleSidebar() {
if (sidebar.value && showNavigation.value) { if (sidebar.value && showNavigation.value) {
sidebar.value.toggle() sidebar.value.toggle()
@ -93,101 +148,129 @@ 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.checkWebsocketConnection) {
window.checkWebsocketConnection()
}
if (window.initResponse.loginRequired) {
router.push('/login')
return
}
}
function renderSidebar() {
if (!sidebar.value) {
return
}
if (typeof sidebar.value.clear === 'function') {
sidebar.value.clear()
}
for (const rootDashboard of window.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', 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 = getBrowserLanguage()
} 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 +287,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>

View File

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

View File

@ -1,12 +1,12 @@
<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="Clear search 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"
@ -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,8 +59,8 @@
</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>
@ -69,6 +69,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import Pagination from '../components/Pagination.vue' import Pagination from '../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,6 +78,8 @@ 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) { if (!searchText.value) {
return logs.value return logs.value
@ -131,10 +134,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) {

View File

@ -52,14 +52,7 @@ 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)

2
lang/Makefile Normal file
View File

@ -0,0 +1,2 @@
default:
go run main.go

36
lang/README.md Normal file
View File

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

View File

@ -0,0 +1 @@
{"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.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.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.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":"Status","logs.timed-out":"Tiempo agotado","logs.timestamp":"Timestamp","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.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":"Status","logs.timed-out":"Tempo scaduto","logs.timestamp":"Timestamp","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.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"}}}

28
lang/de-DE.yaml Normal file
View File

@ -0,0 +1,28 @@
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
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

28
lang/en.yaml Normal file
View File

@ -0,0 +1,28 @@
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
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

28
lang/es-ES.yaml Normal file
View File

@ -0,0 +1,28 @@
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: Timestamp
logs.action: Acción
logs.metadata: Metadatos
logs.status: Status
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
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

11
lang/go.mod Normal file
View File

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

20
lang/go.sum Normal file
View File

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

28
lang/it-IT.yaml Normal file
View File

@ -0,0 +1,28 @@
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: Timestamp
logs.action: Azione
logs.metadata: Metadati
logs.status: Status
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
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

187
lang/main.go Normal file
View File

@ -0,0 +1,187 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"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 {
Messages map[string]map[string]string `json:"messages"`
}
func main() {
combinedContent := getCombinedLanguageContent()
jsonData, err := json.Marshal(combinedContent)
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")
}
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{
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
}
func parseAcceptLanguages(headerLanguage string) []string {
acceptLanguages := make([]string, 0)
for _, lang := range strings.Split(headerLanguage, ",") {
lang = strings.TrimSpace(lang)
acceptLanguages = append(acceptLanguages, lang)
}
return acceptLanguages
}

28
lang/zh-Hans-CN.yaml Normal file
View File

@ -0,0 +1,28 @@
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: 完成

View File

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

View File

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

View File

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

View File

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

View File

@ -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) }
// 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)
}
// filterLogsByACL builds a filtered list of logs in reverse-chronological order
// that are visible to the user based on ACL rules.
func (e *Executor) filterLogsByACL(cfg *config.Config, user *acl.AuthenticatedUser) []*InternalLogEntry {
filtered := make([]*InternalLogEntry, 0) filtered := make([]*InternalLogEntry, 0)
e.logmutex.RLock() e.logmutex.RLock()
for i := len(e.logsTrackingIdsByDate) - 1; i >= 0; i-- { for i := len(e.logsTrackingIdsByDate) - 1; i >= 0; i-- {
entry := e.logs[e.logsTrackingIdsByDate[i]] entry := e.logs[e.logsTrackingIdsByDate[i]]
if entry == nil || entry.Binding == nil || entry.Binding.Action == nil { 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() 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()