This commit is contained in:
James Read 2026-03-09 10:20:40 +00:00 committed by GitHub
commit b03af0e2ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 578 additions and 62 deletions

View File

@ -13,6 +13,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
- **Frontend (Vue 3)**: `frontend/` (served by the service) - **Frontend (Vue 3)**: `frontend/` (served by the service)
- **Integration tests**: `integration-tests/` - **Integration tests**: `integration-tests/`
- **Protos/Generated**: `proto/`, `service/gen/...` - **Protos/Generated**: `proto/`, `service/gen/...`
- **Specs**: `specs/` — Markdown specs that define how code should behave in human-readable form. When changing behavior in a spec-covered area, keep implementation and tests aligned with the spec; do not reference code or symbols in specs (English only).
### How to Run ### How to Run
- Run the server (dev): - Run the server (dev):
@ -62,11 +63,10 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
### Contributing Checklist ### Contributing Checklist
- Review the contributing guidelines at `CONTRIBUTING.adoc`. - Review the contributing guidelines at `CONTRIBUTING.adoc`.
- Review the AI guidance in `AI.md`. - Review the AI guidance in `AI.md`.
- Review the pull request template at `.github/PULL_REQUEST_TEMPLATE.md`. - Review the pull request template at `.github/PULL_REQUEST_TEMPLATE.md`.
- When changing behaviour covered by a spec in `specs/`, ensure implementation and tests match the spec.
### Troubleshooting ### Troubleshooting
- API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL. - API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
- Executor panics: check for nil `Binding/Action` and add guards in step functions. - Executor panics: check for nil `Binding/Action` and add guards in step functions.
- Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI. - Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI.

View File

@ -1,5 +1,8 @@
import { buttonResults } from '../resources/vue/stores/buttonResults.js' import { buttonResults } from '../resources/vue/stores/buttonResults.js'
import { rateLimits } from '../resources/vue/stores/rateLimits.js' import { rateLimits } from '../resources/vue/stores/rateLimits.js'
import { connectionState } from '../resources/vue/stores/connectionState.js'
const RECONNECT_DELAY_MS = 10000
export function initWebsocket () { export function initWebsocket () {
window.addEventListener('EventOutputChunk', onOutputChunk) window.addEventListener('EventOutputChunk', onOutputChunk)
@ -16,9 +19,21 @@ async function reconnectWebsocket () {
return return
} }
connectionState.reconnecting = true
connectionState.connected = false
if (connectionState.disconnectedAt == null) {
connectionState.disconnectedAt = Date.now()
}
connectionState.nextReconnectAt = null
try { try {
window.websocketAvailable = true window.websocketAvailable = true
for await (const e of window.client.eventStream()) { const stream = window.client.eventStream()
connectionState.connected = true
connectionState.reconnecting = false
connectionState.nextReconnectAt = null
for await (const e of stream) {
connectionState.disconnectedAt = null
handleEvent(e) handleEvent(e)
} }
} catch (err) { } catch (err) {
@ -26,7 +41,13 @@ async function reconnectWebsocket () {
} }
window.websocketAvailable = false window.websocketAvailable = false
console.log('Reconnecting websocket...') connectionState.connected = false
connectionState.disconnectedAt = connectionState.disconnectedAt ?? Date.now()
connectionState.nextReconnectAt = Date.now() + RECONNECT_DELAY_MS
console.log('Reconnecting websocket in ' + RECONNECT_DELAY_MS + 'ms...')
setTimeout(() => {
reconnectWebsocket()
}, RECONNECT_DELAY_MS)
} }
function handleEvent (msg) { function handleEvent (msg) {

View File

@ -7,6 +7,7 @@
</template> </template>
<template #user-info> <template #user-info>
<ConnectionBanner />
<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">{{ t('login-button') }}</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">
@ -49,8 +50,6 @@
<span v-if="availableThemes.length > 1"> <span v-if="availableThemes.length > 1">
<a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a> <a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a>
</span> </span>
<span>{{ t('connected') }}</span>
</p> </p>
<p v-if="showVersionNumber"> <p v-if="showVersionNumber">
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a> <a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
@ -100,6 +99,7 @@ import { useRouter } from 'vue-router';
import Sidebar from 'picocrank/vue/components/Sidebar.vue'; import Sidebar from 'picocrank/vue/components/Sidebar.vue';
import Navigation from 'picocrank/vue/components/Navigation.vue'; import Navigation from 'picocrank/vue/components/Navigation.vue';
import Header from 'picocrank/vue/components/Header.vue'; import Header from 'picocrank/vue/components/Header.vue';
import ConnectionBanner from './components/ConnectionBanner.vue';
import { HugeiconsIcon } from '@hugeicons/vue' import { HugeiconsIcon } from '@hugeicons/vue'
import { Menu01Icon } from '@hugeicons/core-free-icons' import { Menu01Icon } from '@hugeicons/core-free-icons'
import { UserCircle02Icon } from '@hugeicons/core-free-icons' import { UserCircle02Icon } from '@hugeicons/core-free-icons'
@ -107,7 +107,6 @@ import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
import logoUrl from '../../OliveTinLogo.png'; import logoUrl from '../../OliveTinLogo.png';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import combinedTranslations from '../../../lang/combined_output.json'; import combinedTranslations from '../../../lang/combined_output.json';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
@ -116,7 +115,6 @@ const sidebar = ref(null);
const navigation = ref(null); const navigation = ref(null);
const username = ref('notset'); const username = ref('notset');
const isLoggedIn = ref(false); const isLoggedIn = ref(false);
const serverConnection = ref(true);
const currentVersion = ref('?'); const currentVersion = ref('?');
const pageTitle = ref('OliveTin'); const pageTitle = ref('OliveTin');
const bannerMessage = ref(''); const bannerMessage = ref('');
@ -404,7 +402,6 @@ function handleThemeDialogClick(event) {
window.updateHeaderFromInit = updateHeaderFromInit window.updateHeaderFromInit = updateHeaderFromInit
onMounted(() => { onMounted(() => {
serverConnection.value = true;
updateHeaderFromInit() updateHeaderFromInit()
// Initialize selected language from stored preference // Initialize selected language from stored preference

View File

@ -0,0 +1,84 @@
<template>
<span id="connection-banner" v-if="!connectionState.connected" class="inline-notification critical user-info-connection">
<span class="connection-banner-sr-only" role="status">{{ staticAnnouncement }}</span>
<span aria-hidden="true">{{ bannerText }}</span>
</span>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { connectionState } from '../stores/connectionState.js'
const { t } = useI18n()
function formatShortRelative(ms) {
if (ms < 0) return '0s'
const secs = Math.floor(ms / 1000)
const mins = Math.floor(secs / 60)
const hours = Math.floor(mins / 60)
if (hours > 0) return `${hours}h`
if (mins > 0) return `${mins}m`
return `${secs}s`
}
function formatShortTime(ts) {
if (ts == null) return '--:--'
return new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
const now = ref(Date.now())
let ticker = null
watch(() => connectionState.connected, (connected) => {
if (ticker) {
clearInterval(ticker)
ticker = null
}
if (!connected) {
now.value = Date.now()
ticker = setInterval(() => { now.value = Date.now() }, 1000)
}
}, { immediate: true })
onUnmounted(() => {
if (ticker) {
clearInterval(ticker)
ticker = null
}
})
const staticAnnouncement = computed(() => t('disconnected-banner-announcement'))
const bannerText = computed(() => {
const at = connectionState.disconnectedAt
const next = connectionState.nextReconnectAt
const n = now.value
const disconnectedSince = formatShortTime(at)
if (next != null && next > n) {
const reconnectIn = formatShortRelative(next - n)
return t('disconnected-banner', { disconnectedSince, reconnectIn })
}
return t('disconnected-banner-reconnecting', { disconnectedSince })
})
</script>
<style scoped>
#connection-banner.user-info-connection {
font-weight: 500;
}
.inline-notification {
border: 0;
margin: 0;
}
.connection-banner-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View File

@ -0,0 +1,8 @@
import { reactive } from 'vue'
export const connectionState = reactive({
connected: false,
reconnecting: false,
disconnectedAt: null,
nextReconnectAt: null
})

View File

@ -169,14 +169,15 @@ function show(actionButton) {
} }
async function rerunAction() { async function rerunAction() {
if (!logEntry.value || !logEntry.value.actionId) { const bindingId = logEntry.value?.bindingId
if (!logEntry.value || !bindingId) {
console.error('Cannot rerun: no action ID available') console.error('Cannot rerun: no action ID available')
return return
} }
try { try {
const startActionArgs = { const startActionArgs = {
"bindingId": logEntry.value.actionId, "bindingId": bindingId,
"arguments": [] "arguments": []
} }
@ -281,13 +282,13 @@ async function renderExecutionResult(res) {
} }
executionTrackingId.value = res.logEntry.executionTrackingId executionTrackingId.value = res.logEntry.executionTrackingId
canRerun.value = res.logEntry.executionFinished canRerun.value = res.logEntry.executionFinished && !!res.logEntry.bindingId
canKill.value = res.logEntry.canKill canKill.value = res.logEntry.canKill
icon.value = res.logEntry.actionIcon icon.value = res.logEntry.actionIcon
title.value = res.logEntry.actionTitle title.value = res.logEntry.actionTitle
titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId titleTooltip.value = 'Action ID: ' + res.logEntry.bindingId + '\nExecution ID: ' + res.logEntry.executionTrackingId
actionId.value = res.logEntry.actionId actionId.value = res.logEntry.bindingId
updateDuration(res.logEntry) updateDuration(res.logEntry)

View File

@ -2,8 +2,8 @@
import { describe, it, before, after } from 'mocha' import { describe, it, before, after } from 'mocha'
import { expect } from 'chai' import { expect } from 'chai'
import { By, until, Condition } from 'selenium-webdriver' import { By, until, Condition } from 'selenium-webdriver'
import { import {
getRootAndWait, getRootAndWait,
getActionButtons, getActionButtons,
takeScreenshotOnFailure, takeScreenshotOnFailure,
} from '../../lib/elements.js' } from '../../lib/elements.js'
@ -29,8 +29,8 @@ describe('config: entities', function () {
expect(buttons).to.not.be.null expect(buttons).to.not.be.null
expect(buttons).to.have.length(5) expect(buttons).to.have.length(5)
// Test INT with 10 numbers // Entity buttons are in numeric key order (0,1,2,3,4); first row is "INT with 10 numbers"
const buttonInt10 = await buttons[2] const buttonInt10 = await buttons[0]
expect(await buttonInt10.getAttribute('title')).to.be.equal('Test me INT with 10 numbers') expect(await buttonInt10.getAttribute('title')).to.be.equal('Test me INT with 10 numbers')
await buttonInt10.click() await buttonInt10.click()
@ -49,7 +49,7 @@ describe('config: entities', function () {
// Check that the execution completed successfully by looking at the status // Check that the execution completed successfully by looking at the status
const statusElement = await webdriver.findElement(By.id('execution-dialog-status')) const statusElement = await webdriver.findElement(By.id('execution-dialog-status'))
const statusText = await statusElement.getText() const statusText = await statusElement.getText()
// The status should indicate success (not "Executing..." or "Failed") // The status should indicate success (not "Executing..." or "Failed")
expect(statusText).to.not.include('Executing') expect(statusText).to.not.include('Executing')
expect(statusText).to.not.include('Failed') expect(statusText).to.not.include('Failed')

View File

@ -20,6 +20,10 @@
"diagnostics.unknown": "Unbekannt", "diagnostics.unknown": "Unbekannt",
"diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData", "diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData",
"diagnostics.where-to-find-help": "Wo Sie Hilfe finden", "diagnostics.where-to-find-help": "Wo Sie Hilfe finden",
"disconnected": "Getrennt",
"disconnected-banner": "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.",
"disconnected-banner-announcement": "Events-Websocket getrennt.",
"disconnected-banner-reconnecting": "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…",
"docs": "Dokumentation", "docs": "Dokumentation",
"language-dialog.browser-languages": "Browser-Sprachen", "language-dialog.browser-languages": "Browser-Sprachen",
"language-dialog.close": "Schließen", "language-dialog.close": "Schließen",
@ -47,6 +51,7 @@
"nav.entities": "Entitäten", "nav.entities": "Entitäten",
"nav.logs": "Protokolle", "nav.logs": "Protokolle",
"raise-issue": "Ein Problem melden auf GitHub", "raise-issue": "Ein Problem melden auf GitHub",
"reconnecting": "Verbinde erneut…",
"return-to-index": "Zurück zur Startseite", "return-to-index": "Zurück zur Startseite",
"search-filter": "Filter aktuelle Seite", "search-filter": "Filter aktuelle Seite",
"theme-dialog.close": "Schließen", "theme-dialog.close": "Schließen",
@ -73,6 +78,10 @@
"diagnostics.unknown": "Unknown", "diagnostics.unknown": "Unknown",
"diagnostics.useragent-data-error": "Error retrieving userAgentData", "diagnostics.useragent-data-error": "Error retrieving userAgentData",
"diagnostics.where-to-find-help": "Where to find help", "diagnostics.where-to-find-help": "Where to find help",
"disconnected": "Disconnected",
"disconnected-banner": "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}.",
"disconnected-banner-announcement": "Events websocket disconnected.",
"disconnected-banner-reconnecting": "Events websocket disconnected since {disconnectedSince}. Trying reconnect…",
"docs": "Documentation", "docs": "Documentation",
"language-dialog.browser-languages": "Browser languages", "language-dialog.browser-languages": "Browser languages",
"language-dialog.close": "Close", "language-dialog.close": "Close",
@ -100,6 +109,7 @@
"nav.entities": "Entities", "nav.entities": "Entities",
"nav.logs": "Logs", "nav.logs": "Logs",
"raise-issue": "Raise an issue on GitHub", "raise-issue": "Raise an issue on GitHub",
"reconnecting": "Reconnecting…",
"return-to-index": "Return to index", "return-to-index": "Return to index",
"search-filter": "Filter current page", "search-filter": "Filter current page",
"theme-dialog.close": "Close", "theme-dialog.close": "Close",
@ -126,6 +136,10 @@
"diagnostics.unknown": "Desconocido", "diagnostics.unknown": "Desconocido",
"diagnostics.useragent-data-error": "Error al recuperar userAgentData", "diagnostics.useragent-data-error": "Error al recuperar userAgentData",
"diagnostics.where-to-find-help": "Dónde encontrar ayuda", "diagnostics.where-to-find-help": "Dónde encontrar ayuda",
"disconnected": "Desconectado",
"disconnected-banner": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.",
"disconnected-banner-announcement": "Websocket de eventos desconectado.",
"disconnected-banner-reconnecting": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…",
"docs": "Documentación", "docs": "Documentación",
"language-dialog.browser-languages": "Idiomas del navegador", "language-dialog.browser-languages": "Idiomas del navegador",
"language-dialog.close": "Cerrar", "language-dialog.close": "Cerrar",
@ -153,6 +167,7 @@
"nav.entities": "Entidades", "nav.entities": "Entidades",
"nav.logs": "Registros", "nav.logs": "Registros",
"raise-issue": "Reportar un problema en GitHub", "raise-issue": "Reportar un problema en GitHub",
"reconnecting": "Reconectando…",
"return-to-index": "Volver a la página principal", "return-to-index": "Volver a la página principal",
"search-filter": "Filtrar página actual", "search-filter": "Filtrar página actual",
"theme-dialog.close": "Cerrar", "theme-dialog.close": "Cerrar",
@ -179,6 +194,10 @@
"diagnostics.unknown": "Sconosciuto", "diagnostics.unknown": "Sconosciuto",
"diagnostics.useragent-data-error": "Errore nel recupero di userAgentData", "diagnostics.useragent-data-error": "Errore nel recupero di userAgentData",
"diagnostics.where-to-find-help": "Dove trovare aiuto", "diagnostics.where-to-find-help": "Dove trovare aiuto",
"disconnected": "Disconnesso",
"disconnected-banner": "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.",
"disconnected-banner-announcement": "Websocket eventi disconnesso.",
"disconnected-banner-reconnecting": "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…",
"docs": "Documentazione", "docs": "Documentazione",
"language-dialog.browser-languages": "Lingue del browser", "language-dialog.browser-languages": "Lingue del browser",
"language-dialog.close": "Chiudi", "language-dialog.close": "Chiudi",
@ -206,6 +225,7 @@
"nav.entities": "Entità", "nav.entities": "Entità",
"nav.logs": "Registri", "nav.logs": "Registri",
"raise-issue": "Segnala un problema su GitHub", "raise-issue": "Segnala un problema su GitHub",
"reconnecting": "Riconnessione…",
"return-to-index": "Torna alla pagina principale", "return-to-index": "Torna alla pagina principale",
"search-filter": "Filtra la pagina corrente", "search-filter": "Filtra la pagina corrente",
"theme-dialog.close": "Chiudi", "theme-dialog.close": "Chiudi",
@ -232,6 +252,10 @@
"diagnostics.unknown": "未知", "diagnostics.unknown": "未知",
"diagnostics.useragent-data-error": "检索 userAgentData 时出错", "diagnostics.useragent-data-error": "检索 userAgentData 时出错",
"diagnostics.where-to-find-help": "在哪里找到帮助", "diagnostics.where-to-find-help": "在哪里找到帮助",
"disconnected": "已断开连接",
"disconnected-banner": "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。",
"disconnected-banner-announcement": "事件 WebSocket 已断开。",
"disconnected-banner-reconnecting": "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…",
"docs": "文档", "docs": "文档",
"language-dialog.browser-languages": "浏览器语言", "language-dialog.browser-languages": "浏览器语言",
"language-dialog.close": "关闭", "language-dialog.close": "关闭",
@ -259,6 +283,7 @@
"nav.entities": "实体", "nav.entities": "实体",
"nav.logs": "日志", "nav.logs": "日志",
"raise-issue": "在 GitHub 上报告问题", "raise-issue": "在 GitHub 上报告问题",
"reconnecting": "正在重新连接…",
"return-to-index": "返回首页", "return-to-index": "返回首页",
"search-filter": "过滤当前页面", "search-filter": "过滤当前页面",
"theme-dialog.close": "关闭", "theme-dialog.close": "关闭",
@ -267,4 +292,4 @@
"welcome": "欢迎使用 OliveTin" "welcome": "欢迎使用 OliveTin"
} }
} }
} }

View File

@ -6,6 +6,11 @@ translations:
nav.entities: Entitäten nav.entities: Entitäten
nav.diagnostics: Diagnostik nav.diagnostics: Diagnostik
connected: Verbunden connected: Verbunden
disconnected: Getrennt
reconnecting: Verbinde erneut…
disconnected-banner: "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}."
disconnected-banner-reconnecting: "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…"
disconnected-banner-announcement: Events-Websocket getrennt.
login-button: Login login-button: Login
raise-issue: Ein Problem melden auf GitHub raise-issue: Ein Problem melden auf GitHub
docs: Dokumentation docs: Dokumentation
@ -50,4 +55,4 @@ translations:
language-dialog.close: Schließen language-dialog.close: Schließen
theme-dialog.title: Design auswählen theme-dialog.title: Design auswählen
theme-dialog.default: Standard-Design theme-dialog.default: Standard-Design
theme-dialog.close: Schließen theme-dialog.close: Schließen

View File

@ -8,6 +8,11 @@ translations:
nav.entities: Entities nav.entities: Entities
nav.diagnostics: Diagnostics nav.diagnostics: Diagnostics
connected: Connected connected: Connected
disconnected: Disconnected
reconnecting: Reconnecting…
disconnected-banner: "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}."
disconnected-banner-reconnecting: "Events websocket disconnected since {disconnectedSince}. Trying reconnect…"
disconnected-banner-announcement: Events websocket disconnected.
login-button: Login login-button: Login
logs.title: Logs 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.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title.
@ -50,4 +55,4 @@ translations:
language-dialog.close: Close language-dialog.close: Close
theme-dialog.title: Select Theme theme-dialog.title: Select Theme
theme-dialog.default: Default Theme theme-dialog.default: Default Theme
theme-dialog.close: Close theme-dialog.close: Close

View File

@ -6,6 +6,11 @@ translations:
nav.entities: Entidades nav.entities: Entidades
nav.diagnostics: Diagnósticos nav.diagnostics: Diagnósticos
connected: Conectado connected: Conectado
disconnected: Desconectado
reconnecting: Reconectando…
disconnected-banner: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}."
disconnected-banner-reconnecting: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…"
disconnected-banner-announcement: Websocket de eventos desconectado.
login-button: Iniciar sesión login-button: Iniciar sesión
raise-issue: Reportar un problema en GitHub raise-issue: Reportar un problema en GitHub
docs: Documentación docs: Documentación
@ -50,4 +55,4 @@ translations:
language-dialog.close: Cerrar language-dialog.close: Cerrar
theme-dialog.title: Seleccionar tema theme-dialog.title: Seleccionar tema
theme-dialog.default: Tema Predeterminado theme-dialog.default: Tema Predeterminado
theme-dialog.close: Cerrar theme-dialog.close: Cerrar

View File

@ -7,6 +7,11 @@ translations:
nav.diagnostics: Diagnostica nav.diagnostics: Diagnostica
docs: Documentazione docs: Documentazione
connected: Connesso connected: Connesso
disconnected: Disconnesso
reconnecting: Riconnessione…
disconnected-banner: "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}."
disconnected-banner-reconnecting: "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…"
disconnected-banner-announcement: Websocket eventi disconnesso.
login-button: Login login-button: Login
raise-issue: Segnala un problema su GitHub raise-issue: Segnala un problema su GitHub
logs.title: Registri logs.title: Registri
@ -50,4 +55,4 @@ translations:
language-dialog.close: Chiudi language-dialog.close: Chiudi
theme-dialog.title: Seleziona tema theme-dialog.title: Seleziona tema
theme-dialog.default: Tema Predefinito theme-dialog.default: Tema Predefinito
theme-dialog.close: Chiudi theme-dialog.close: Chiudi

View File

@ -6,6 +6,11 @@ translations:
nav.entities: 实体 nav.entities: 实体
nav.diagnostics: 诊断 nav.diagnostics: 诊断
connected: 已连接 connected: 已连接
disconnected: 已断开连接
reconnecting: 正在重新连接…
disconnected-banner: "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。"
disconnected-banner-reconnecting: "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…"
disconnected-banner-announcement: 事件 WebSocket 已断开。
login-button: 登录 login-button: 登录
raise-issue: 在 GitHub 上报告问题 raise-issue: 在 GitHub 上报告问题
docs: 文档 docs: 文档
@ -50,4 +55,4 @@ translations:
diagnostics.copy-to-clipboard: 复制到剪贴板 diagnostics.copy-to-clipboard: 复制到剪贴板
diagnostics.copied: 已复制! diagnostics.copied: 已复制!
diagnostics.unknown: 未知 diagnostics.unknown: 未知
diagnostics.useragent-data-error: 检索 userAgentData 时出错 diagnostics.useragent-data-error: 检索 userAgentData 时出错

View File

@ -185,9 +185,7 @@ func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice {
func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string) []*apiv1.ActionArgumentChoice { func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string) []*apiv1.ActionArgumentChoice {
ret := []*apiv1.ActionArgumentChoice{} ret := []*apiv1.ActionArgumentChoice{}
entList := entities.GetEntityInstances(entityTitle) for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) {
for _, ent := range entList {
ret = append(ret, &apiv1.ActionArgumentChoice{ ret = append(ret, &apiv1.ActionArgumentChoice{
Value: tpl.ParseTemplateOfActionBeforeExec(firstChoice.Value, ent), Value: tpl.ParseTemplateOfActionBeforeExec(firstChoice.Value, ent),
Title: tpl.ParseTemplateOfActionBeforeExec(firstChoice.Title, ent), Title: tpl.ParseTemplateOfActionBeforeExec(firstChoice.Title, ent),

View File

@ -432,6 +432,77 @@ func TestViewPermissionAllowedSeesAction(t *testing.T) {
assert.Equal(t, "secret_action", resp.Action.BindingId) assert.Equal(t, "secret_action", resp.Action.BindingId)
} }
// TestViewPermissionExcludedFromCustomDashboard (issue #921) asserts that when a custom dashboard
// lists an action by title, users without view permission do not see that action (title or icon).
func TestViewPermissionExcludedFromCustomDashboard(t *testing.T) {
cfg, lowUser, _ := buildViewPermissionTestConfig(t)
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Custom",
Contents: []*config.DashboardComponent{
{Title: "Secret Action"},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
rr := &DashboardRenderRequest{
AuthenticatedUser: lowUser,
cfg: cfg,
ex: ex,
}
dashboard := findDashboardByTitle(rr, "Custom")
require.NotNil(t, dashboard)
db := buildDashboardFromConfig(dashboard, rr)
require.NotNil(t, db)
bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents)
assert.NotContains(t, bindingIdsInDashboard, "secret_action",
"user with view:false must not see action on custom dashboard; got bindingIds: %v", bindingIdsInDashboard)
assert.False(t, dashboardContentsContainForbiddenComponent(db.Contents, "Secret Action", "🔒"),
"user with view:false must not see Secret Action title or lock icon in custom dashboard")
}
// TestViewPermissionExcludedFromEntityDashboard (GHSA: view permission) asserts that when a dashboard
// has an entity fieldset listing an action, users without view permission do not see that action.
func TestViewPermissionExcludedFromEntityDashboard(t *testing.T) {
entities.ClearEntitiesOfType("vp_entity_test")
defer entities.ClearEntitiesOfType("vp_entity_test")
entities.AddEntity("vp_entity_test", "1", map[string]any{"title": "Test Entity"})
cfg, lowUser, _ := buildViewPermissionTestConfig(t)
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "WithEntity",
Contents: []*config.DashboardComponent{
{
Title: "Servers", Type: "fieldset", Entity: "vp_entity_test",
Contents: []*config.DashboardComponent{{Title: "Secret Action"}},
},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
rr := &DashboardRenderRequest{
AuthenticatedUser: lowUser,
cfg: cfg,
ex: ex,
}
dashboard := findDashboardByTitle(rr, "WithEntity")
require.NotNil(t, dashboard)
db := buildDashboardFromConfig(dashboard, rr)
require.NotNil(t, db)
bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents)
assert.NotContains(t, bindingIdsInDashboard, "secret_action",
"user with view:false must not see action in entity fieldset; got bindingIds: %v", bindingIdsInDashboard)
assert.False(t, dashboardContentsContainForbiddenComponent(db.Contents, "Secret Action", "🔒"),
"user with view:false must not see Secret Action title or lock icon in entity dashboard")
}
func bindingIdsInDashboardContents(contents []*apiv1.DashboardComponent) []string { func bindingIdsInDashboardContents(contents []*apiv1.DashboardComponent) []string {
var ids []string var ids []string
for _, c := range contents { for _, c := range contents {
@ -450,3 +521,54 @@ func bindingIdsFromComponent(c *apiv1.DashboardComponent) []string {
} }
return append(ids, bindingIdsInDashboardContents(c.Contents)...) return append(ids, bindingIdsInDashboardContents(c.Contents)...)
} }
func componentHasForbiddenTitleOrIcon(c *apiv1.DashboardComponent, forbiddenTitle, forbiddenIcon string) bool {
return c != nil && (c.Title == forbiddenTitle || c.Icon == forbiddenIcon)
}
func componentOrDescendantsContainForbidden(c *apiv1.DashboardComponent, forbiddenTitle, forbiddenIcon string) bool {
if c == nil {
return false
}
if componentHasForbiddenTitleOrIcon(c, forbiddenTitle, forbiddenIcon) {
return true
}
return dashboardContentsContainForbiddenComponent(c.Contents, forbiddenTitle, forbiddenIcon)
}
// dashboardContentsContainForbiddenComponent recursively walks contents and returns true if any
// component has Title == forbiddenTitle or Icon == forbiddenIcon.
func dashboardContentsContainForbiddenComponent(contents []*apiv1.DashboardComponent, forbiddenTitle, forbiddenIcon string) bool {
for _, c := range contents {
if componentOrDescendantsContainForbidden(c, forbiddenTitle, forbiddenIcon) {
return true
}
}
return false
}
func TestOrderTopLevelDashboardComponents_RegularFieldsetsPreserveConfigOrder(t *testing.T) {
zebra := &apiv1.DashboardComponent{Title: "Zebra", Type: "fieldset", EntityType: ""}
alpha := &apiv1.DashboardComponent{Title: "Alpha", Type: "fieldset", EntityType: ""}
root := &apiv1.DashboardComponent{Title: "Actions", Type: "fieldset", EntityType: ""}
components := []*apiv1.DashboardComponent{zebra, alpha, root}
out := orderTopLevelDashboardComponents(components, root)
require.Len(t, out, 3)
assert.Same(t, zebra, out[0], "first must be Zebra (config order)")
assert.Same(t, alpha, out[1], "second must be Alpha (config order)")
assert.Same(t, root, out[2], "third must be root Actions fieldset")
}
func TestOrderTopLevelDashboardComponents_SortablesSorted(t *testing.T) {
entityBeta := &apiv1.DashboardComponent{Title: "Beta", Type: "fieldset", EntityType: "server"}
entityAlpha := &apiv1.DashboardComponent{Title: "Alpha", Type: "fieldset", EntityType: "server"}
components := []*apiv1.DashboardComponent{entityBeta, entityAlpha}
out := orderTopLevelDashboardComponents(components, nil)
require.Len(t, out, 2)
assert.Equal(t, "Alpha", out[0].Title, "sortables ordered by title")
assert.Equal(t, "Beta", out[1].Title)
}

View File

@ -11,9 +11,8 @@ import (
func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent { func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent {
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.DashboardComponent, 0)
entities := entities.GetEntityInstances(entityTitle) orderedEntities := entities.GetEntityInstancesOrdered(entityTitle)
for _, ent := range orderedEntities {
for _, ent := range entities {
fs := buildEntityFieldset(tpl, ent, rr) fs := buildEntityFieldset(tpl, ent, rr)
if len(fs.Contents) > 0 { if len(fs.Contents) > 0 {
@ -30,7 +29,7 @@ func buildEntityFieldset(component *config.DashboardComponent, ent *entities.Ent
Type: "fieldset", Type: "fieldset",
Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(component.Contents, ent, component.Entity, rr)), Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(component.Contents, ent, component.Entity, rr)),
CssClass: tpl.ParseTemplateOfActionBeforeExec(component.CssClass, ent), CssClass: tpl.ParseTemplateOfActionBeforeExec(component.CssClass, ent),
Action: rr.findAction(component.Title), Action: rr.findActionForEntity(component.Title, ent),
EntityType: component.Entity, EntityType: component.Entity,
EntityKey: ent.UniqueKey, EntityKey: ent.UniqueKey,
} }
@ -83,8 +82,6 @@ func isLinkType(itemType string) bool {
} }
func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent { func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent {
clone.Type = "link"
clone.Title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, ent)
// Prefer an entity-specific action when available, but fall back to a // Prefer an entity-specific action when available, but fall back to a
// non-entity-scoped action with the same title. This allows inline actions // non-entity-scoped action with the same title. This allows inline actions
// defined inside entity dashboards to work without requiring an explicit // defined inside entity dashboards to work without requiring an explicit
@ -93,7 +90,11 @@ func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clo
if action == nil { if action == nil {
action = rr.findAction(subitem.Title) action = rr.findAction(subitem.Title)
} }
if action == nil {
return nil
}
clone.Type = "link"
clone.Title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, ent)
clone.Action = action clone.Action = action
return clone return clone
} }

View File

@ -2,6 +2,7 @@ package api
import ( import (
"sort" "sort"
"strconv"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
acl "github.com/OliveTin/OliveTin/internal/acl" acl "github.com/OliveTin/OliveTin/internal/acl"
@ -111,9 +112,10 @@ func buildDashboardFromConfig(dashboard *config.DashboardComponent, rr *Dashboar
} }
func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.Dashboard { func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.Dashboard {
contents, root := getDashboardComponentContentsWithEntity(dashboard, rr, entity)
return &apiv1.Dashboard{ return &apiv1.Dashboard{
Title: dashboard.Title, Title: dashboard.Title,
Contents: sortActions(removeNulls(getDashboardComponentContentsWithEntity(dashboard, rr, entity))), Contents: orderTopLevelDashboardComponents(removeNulls(contents), root),
} }
} }
@ -148,33 +150,54 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
continue continue
} }
fieldset.Contents = append(fieldset.Contents, &apiv1.DashboardComponent{ comp := &apiv1.DashboardComponent{
Type: "link", Type: "link",
Title: action.Title, Title: action.Title,
Icon: action.Icon, Icon: action.Icon,
Action: action, Action: action,
}) }
if binding.Entity != nil {
comp.EntityKey = binding.Entity.UniqueKey
}
fieldset.Contents = append(fieldset.Contents, comp)
} }
if len(fieldset.Contents) > 0 { if len(fieldset.Contents) > 0 {
fieldset.Contents = sortActions(fieldset.Contents) fieldset.Contents = sortDashboardComponents(fieldset.Contents)
db.Contents = append(db.Contents, fieldset) db.Contents = append(db.Contents, fieldset)
} }
return db return db
} }
func sortActions(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent { func entityKeyLess(a, b string) bool {
ai, errA := strconv.ParseInt(a, 10, 64)
bi, errB := strconv.ParseInt(b, 10, 64)
if errA == nil && errB == nil {
return ai < bi
}
return a < b
}
//gocyclo:ignore
func sortDashboardComponents(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 || components[j].Action == nil { if components[i].Action == nil || components[j].Action == nil {
if components[i].EntityKey != "" && components[j].EntityKey != "" &&
components[i].EntityKey != components[j].EntityKey {
return entityKeyLess(components[i].EntityKey, components[j].EntityKey)
}
return components[i].Title < components[j].Title return components[i].Title < components[j].Title
} }
if components[i].Action.Order == components[j].Action.Order { if components[i].Action.Order != components[j].Action.Order {
return components[i].Action.Title < components[j].Action.Title
} else {
return components[i].Action.Order < components[j].Action.Order return components[i].Action.Order < components[j].Action.Order
} }
if components[i].EntityKey != components[j].EntityKey {
return entityKeyLess(components[i].EntityKey, components[j].EntityKey)
}
return components[i].Action.Title < components[j].Action.Title
}) })
return components return components
@ -194,7 +217,58 @@ func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardCompo
return ret return ret
} }
func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) []*apiv1.DashboardComponent { func isNonEntityFieldset(component *apiv1.DashboardComponent) bool {
return component != nil && component.Type == "fieldset" && component.EntityType == ""
}
func isRegularFieldset(component *apiv1.DashboardComponent, root *apiv1.DashboardComponent) bool {
if !isNonEntityFieldset(component) {
return false
}
return root == nil || component != root
}
func partitionTopLevelComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) (regular, sortables []*apiv1.DashboardComponent, isRegular []bool) {
regular = make([]*apiv1.DashboardComponent, 0)
sortables = make([]*apiv1.DashboardComponent, 0)
isRegular = make([]bool, len(components))
for i, c := range components {
anchor := isRegularFieldset(c, root)
isRegular[i] = anchor
if anchor {
regular = append(regular, c)
} else {
sortables = append(sortables, c)
}
}
return regular, sortables, isRegular
}
func mergeOrderedTopLevelComponents(regular, sortables []*apiv1.DashboardComponent, isRegular []bool) []*apiv1.DashboardComponent {
out := make([]*apiv1.DashboardComponent, 0, len(isRegular))
regIdx, sortIdx := 0, 0
for _, anchor := range isRegular {
if anchor {
out = append(out, regular[regIdx])
regIdx++
} else {
out = append(out, sortables[sortIdx])
sortIdx++
}
}
return out
}
func orderTopLevelDashboardComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) []*apiv1.DashboardComponent {
if len(components) == 0 {
return components
}
regular, sortables, isRegular := partitionTopLevelComponents(components, root)
sortDashboardComponents(sortables)
return mergeOrderedTopLevelComponents(regular, sortables, isRegular)
}
func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) ([]*apiv1.DashboardComponent, *apiv1.DashboardComponent) {
ret := make([]*apiv1.DashboardComponent, 0) ret := make([]*apiv1.DashboardComponent, 0)
rootFieldset := createRootFieldset() rootFieldset := createRootFieldset()
@ -202,7 +276,11 @@ func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponen
processDashboardSubitemWithEntity(subitem, rr, &ret, rootFieldset, entity) processDashboardSubitemWithEntity(subitem, rr, &ret, rootFieldset, entity)
} }
return appendRootFieldsetIfNeeded(ret, rootFieldset) if len(rootFieldset.Contents) > 0 {
ret = append(ret, rootFieldset)
return ret, rootFieldset
}
return ret, nil
} }
func createRootFieldset() *apiv1.DashboardComponent { func createRootFieldset() *apiv1.DashboardComponent {
@ -213,31 +291,39 @@ func createRootFieldset() *apiv1.DashboardComponent {
} }
} }
func appendComponentIfNotNil(components *[]*apiv1.DashboardComponent, comp *apiv1.DashboardComponent) {
if comp != nil {
*components = append(*components, comp)
}
}
func getDashboardComponentOrNil(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent {
if len(subitem.Contents) == 0 && rr.findActionForEntity(subitem.Title, entity) == nil {
if !isAllowedType(subitem.Type) {
return nil
}
}
return buildDashboardComponentSimpleWithEntity(subitem, rr, entity)
}
func processDashboardSubitemWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent, entity *entities.Entity) { func processDashboardSubitemWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent, entity *entities.Entity) {
if subitem.Type != "fieldset" { if subitem.Type != "fieldset" {
rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimpleWithEntity(subitem, rr, entity)) appendComponentIfNotNil(&rootFieldset.Contents, getDashboardComponentOrNil(subitem, rr, entity))
return return
} }
if subitem.Entity != "" { if subitem.Entity != "" {
*ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...) *ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
} else { } else {
*ret = append(*ret, buildDashboardComponentSimpleWithEntity(subitem, rr, entity)) appendComponentIfNotNil(ret, getDashboardComponentOrNil(subitem, rr, entity))
} }
} }
func appendRootFieldsetIfNeeded(ret []*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent) []*apiv1.DashboardComponent {
if len(rootFieldset.Contents) > 0 {
ret = append(ret, rootFieldset)
}
return ret
}
func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent { func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent {
var contents []*apiv1.DashboardComponent var contents []*apiv1.DashboardComponent
if len(subitem.Contents) > 0 { if len(subitem.Contents) > 0 {
contents = getDashboardComponentContentsWithEntity(subitem, rr, entity) contents, _ = getDashboardComponentContentsWithEntity(subitem, rr, entity)
} }
action := rr.findActionForEntity(subitem.Title, entity) action := rr.findActionForEntity(subitem.Title, entity)

View File

@ -281,7 +281,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
config.Prometheus.Enabled = false config.Prometheus.Enabled = false
config.Prometheus.DefaultGoMetrics = false config.Prometheus.DefaultGoMetrics = false
config.Security.HeaderContentSecurityPolicy = true config.Security.HeaderContentSecurityPolicy = true
config.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'" config.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"
config.Security.HeaderXContentTypeOptions = true config.Security.HeaderXContentTypeOptions = true
config.Security.HeaderXFrameOptions = true config.Security.HeaderXFrameOptions = true
config.Security.XFrameOptions = "DENY" config.Security.XFrameOptions = "DENY"

View File

@ -194,7 +194,7 @@ func (cfg *Config) sanitizeSecurityHeadersCSP() {
if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" { if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" {
return return
} }
cfg.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'" cfg.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"
} }
func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() { func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {

View File

@ -1,8 +1,10 @@
package entities package entities
import ( import (
// "github.com/stretchr/testify/assert"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestLoadObjectPerLineJsonFile(t *testing.T) { func TestLoadObjectPerLineJsonFile(t *testing.T) {
@ -16,3 +18,44 @@ func TestLoadObjectPerLineJsonFile(t *testing.T) {
assert.Equal(t, "1234567890", GetEntity("testrow", "0"), "Value should match expected value") assert.Equal(t, "1234567890", GetEntity("testrow", "0"), "Value should match expected value")
*/ */
} }
func TestGetEntityInstancesOrdered_numericKeys(t *testing.T) {
ClearEntitiesOfType("order_test")
defer ClearEntitiesOfType("order_test")
AddEntity("order_test", "2", map[string]any{"title": "Second"})
AddEntity("order_test", "0", map[string]any{"title": "Zeroth"})
AddEntity("order_test", "10", map[string]any{"title": "Tenth"})
AddEntity("order_test", "1", map[string]any{"title": "First"})
ordered := GetEntityInstancesOrdered("order_test")
require.Len(t, ordered, 4, "should return 4 entities")
assert.Equal(t, "0", ordered[0].UniqueKey, "first key should be 0")
assert.Equal(t, "1", ordered[1].UniqueKey, "second key should be 1")
assert.Equal(t, "2", ordered[2].UniqueKey, "third key should be 2")
assert.Equal(t, "10", ordered[3].UniqueKey, "fourth key should be 10 (numeric order)")
}
func TestGetEntityInstancesOrdered_lexicographicKeys(t *testing.T) {
ClearEntitiesOfType("order_test_lex")
defer ClearEntitiesOfType("order_test_lex")
AddEntity("order_test_lex", "zebra", map[string]any{"title": "Z"})
AddEntity("order_test_lex", "alpha", map[string]any{"title": "A"})
AddEntity("order_test_lex", "beta", map[string]any{"title": "B"})
ordered := GetEntityInstancesOrdered("order_test_lex")
require.Len(t, ordered, 3, "should return 3 entities")
assert.Equal(t, "alpha", ordered[0].UniqueKey)
assert.Equal(t, "beta", ordered[1].UniqueKey)
assert.Equal(t, "zebra", ordered[2].UniqueKey)
}
func TestGetEntityInstancesOrdered_emptyOrMissing(t *testing.T) {
ordered := GetEntityInstancesOrdered("nonexistent_type")
assert.Nil(t, ordered)
ClearEntitiesOfType("empty_test")
ordered = GetEntityInstancesOrdered("empty_test")
assert.Nil(t, ordered)
}

View File

@ -10,6 +10,8 @@ package entities
*/ */
import ( import (
"sort"
"strconv"
"strings" "strings"
"sync" "sync"
) )
@ -64,6 +66,49 @@ func GetEntityInstances(entityName string) entityInstancesByKey {
return make(entityInstancesByKey, 0) return make(entityInstancesByKey, 0)
} }
func GetEntityInstancesOrdered(entityName string) []*Entity {
instances := GetEntityInstances(entityName)
if len(instances) == 0 {
return nil
}
keys := make([]string, 0, len(instances))
for key := range instances {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
return compareEntityKeys(keys[i], keys[j]) < 0
})
result := make([]*Entity, 0, len(keys))
for _, key := range keys {
result = append(result, instances[key])
}
return result
}
//gocyclo:ignore
func compareEntityKeys(a, b string) int {
ai, errA := strconv.ParseInt(a, 10, 64)
bi, errB := strconv.ParseInt(b, 10, 64)
if errA == nil && errB == nil {
if ai < bi {
return -1
}
if ai > bi {
return 1
}
return 0
}
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
func AddEntity(entityName string, entityKey string, data any) { func AddEntity(entityName string, entityKey string, data any) {
rwmutex.Lock() rwmutex.Lock()

View File

@ -20,6 +20,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path" "path"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -30,6 +31,14 @@ const (
MaxTriggerDepth = 10 MaxTriggerDepth = 10
) )
var validTrackingIDPattern = regexp.MustCompile(`^[a-fA-F0-9\-]+$`)
func isValidTrackingID(id string) bool {
const MaxTrackingIDLength = 36
return id != "" && len(id) <= MaxTrackingIDLength && validTrackingIDPattern.MatchString(id)
}
var ( var (
metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{ metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{
Name: "olivetin_actions_requested_count", Name: "olivetin_actions_requested_count",
@ -506,8 +515,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
} }
_, isDuplicate := e.GetLog(req.TrackingID) _, isDuplicate := e.GetLog(req.TrackingID)
if isDuplicate || !isValidTrackingID(req.TrackingID) {
if isDuplicate || req.TrackingID == "" {
req.TrackingID = uuid.NewString() req.TrackingID = uuid.NewString()
} }

View File

@ -146,7 +146,7 @@ func registerAction(e *Executor, configOrder int, action *config.Action, req *Re
} }
func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action, req *RebuildActionMapRequest) { func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action, req *RebuildActionMapRequest) {
for _, ent := range entities.GetEntityInstances(entityTitle) { for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) {
registerActionFromEntity(e, configOrder, tpl, ent, req) registerActionFromEntity(e, configOrder, tpl, ent, req)
} }
} }

View File

@ -0,0 +1,52 @@
# Spec: Dashboard component ordering
This spec describes how dashboard components (fieldsets, entity fieldsets, actions, and other elements) are ordered in OliveTin. It documents the current behaviour so that it can be reasoned about and kept consistent.
---
## 1. Implementation
### 1.1 Two ways dashboards are built
Dashboards are built in two ways:
- **Default dashboard:** Used when there is no dashboard configuration. A single fieldset titled "Actions" is created and filled with actions that are not already on a configured dashboard.
- **Config dashboard:** Built from the dashboard configuration (e.g. under dashboards or dashboards.d). The structure is derived by walking the config tree, which produces a mix of fieldsets and a special root fieldset titled "Actions" that holds any loose items.
Ordering rules differ slightly between these two cases.
### 1.2 Top-level dashboard contents (config dashboards)
**Fieldsets without entities:** Fieldsets that are not tied to an entity type appear at the top level in **config order**. Their position in the dashboard matches the order in which they are defined in the config.
**Other top-level components:** All other top-level components (including the root "Actions" fieldset and entity fieldset groups) are **sorted** before being shown. Sort order:
1. If a component has no linked action, it is ordered by its title (alphabetically).
2. Otherwise, components are ordered first by the action's order value (lower values first).
3. If order values are equal, components are ordered by entity key: if both keys are whole numbers they are compared numerically; otherwise they are compared alphabetically.
4. If still equal, components are ordered by the action's title (alphabetically).
The root "Actions" fieldset is the single fieldset created by the build to hold loose items; it is identified by reference (not by position). When present it is added last to the list, then the sort is applied among that fieldset and entity-related components. So that fieldset can appear anywhere among those according to the rules above. When there are no loose items the root is not present, and the last component in the list is not treated as the root—so a fieldset without entities in the last position keeps config order. Regular fieldsets stay in config order and are not reordered.
### 1.3 Entity fieldsets (order of fieldsets per entity type)
When a fieldset in the config is tied to an entity type (e.g. "Server" or "Project"), one fieldset is built per entity instance. Those fieldsets are shown in **entity key order**.
**Entity key order:**
- If both keys are whole numbers: **numeric** order (e.g. 2 before 10).
- Otherwise: **alphabetical** (lexicographic) order.
So the order of entity fieldsets (e.g. one per server, one per project) is determined by this entity key order, not by config or insertion order.
### 1.4 Contents inside fieldsets
**Default dashboard:** The single "Actions" fieldset's contents are sorted. The same rules as for top-level components apply: order value first, then entity key (numeric then alphabetical), then action title.
**Config dashboards:** For all fieldsets (the root "Actions" fieldset, entity fieldsets, and regular fieldsets), the contents are **not** sorted. They keep the order from the config:
- **Root "Actions" fieldset:** Items appear in the order they are listed in the config (loose items that are not inside a fieldset).
- **Entity fieldset contents:** The order comes from the template's contents in the config.
- **Regular (non-entity) fieldset contents:** The order comes from the config, including for nested structure.
So within any config-defined fieldset, the order of actions and other child components is the **config order**.