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)
- **Integration tests**: `integration-tests/`
- **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
- 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
- Review the contributing guidelines at `CONTRIBUTING.adoc`.
- 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
- 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.
- 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 { rateLimits } from '../resources/vue/stores/rateLimits.js'
import { connectionState } from '../resources/vue/stores/connectionState.js'
const RECONNECT_DELAY_MS = 10000
export function initWebsocket () {
window.addEventListener('EventOutputChunk', onOutputChunk)
@ -16,9 +19,21 @@ async function reconnectWebsocket () {
return
}
connectionState.reconnecting = true
connectionState.connected = false
if (connectionState.disconnectedAt == null) {
connectionState.disconnectedAt = Date.now()
}
connectionState.nextReconnectAt = null
try {
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)
}
} catch (err) {
@ -26,7 +41,13 @@ async function reconnectWebsocket () {
}
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) {

View File

@ -7,6 +7,7 @@
</template>
<template #user-info>
<ConnectionBanner />
<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>
<router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
@ -49,8 +50,6 @@
<span v-if="availableThemes.length > 1">
<a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a>
</span>
<span>{{ t('connected') }}</span>
</p>
<p v-if="showVersionNumber">
<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 Navigation from 'picocrank/vue/components/Navigation.vue';
import Header from 'picocrank/vue/components/Header.vue';
import ConnectionBanner from './components/ConnectionBanner.vue';
import { HugeiconsIcon } from '@hugeicons/vue'
import { Menu01Icon } 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 { useI18n } from 'vue-i18n';
import combinedTranslations from '../../../lang/combined_output.json';
const { t, locale } = useI18n();
const router = useRouter();
@ -116,7 +115,6 @@ const sidebar = ref(null);
const navigation = ref(null);
const username = ref('notset');
const isLoggedIn = ref(false);
const serverConnection = ref(true);
const currentVersion = ref('?');
const pageTitle = ref('OliveTin');
const bannerMessage = ref('');
@ -404,7 +402,6 @@ function handleThemeDialogClick(event) {
window.updateHeaderFromInit = updateHeaderFromInit
onMounted(() => {
serverConnection.value = true;
updateHeaderFromInit()
// 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() {
if (!logEntry.value || !logEntry.value.actionId) {
const bindingId = logEntry.value?.bindingId
if (!logEntry.value || !bindingId) {
console.error('Cannot rerun: no action ID available')
return
}
try {
const startActionArgs = {
"bindingId": logEntry.value.actionId,
"bindingId": bindingId,
"arguments": []
}
@ -281,13 +282,13 @@ async function renderExecutionResult(res) {
}
executionTrackingId.value = res.logEntry.executionTrackingId
canRerun.value = res.logEntry.executionFinished
canRerun.value = res.logEntry.executionFinished && !!res.logEntry.bindingId
canKill.value = res.logEntry.canKill
icon.value = res.logEntry.actionIcon
title.value = res.logEntry.actionTitle
titleTooltip.value = 'Action ID: ' + res.logEntry.actionId + '\nExecution ID: ' + res.logEntry.executionTrackingId
actionId.value = res.logEntry.actionId
titleTooltip.value = 'Action ID: ' + res.logEntry.bindingId + '\nExecution ID: ' + res.logEntry.executionTrackingId
actionId.value = res.logEntry.bindingId
updateDuration(res.logEntry)

View File

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

View File

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

View File

@ -6,6 +6,11 @@ translations:
nav.entities: Entitäten
nav.diagnostics: Diagnostik
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
raise-issue: Ein Problem melden auf GitHub
docs: Dokumentation
@ -50,4 +55,4 @@ translations:
language-dialog.close: Schließen
theme-dialog.title: Design auswählen
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.diagnostics: Diagnostics
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
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.
@ -50,4 +55,4 @@ translations:
language-dialog.close: Close
theme-dialog.title: Select 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.diagnostics: Diagnósticos
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
raise-issue: Reportar un problema en GitHub
docs: Documentación
@ -50,4 +55,4 @@ translations:
language-dialog.close: Cerrar
theme-dialog.title: Seleccionar tema
theme-dialog.default: Tema Predeterminado
theme-dialog.close: Cerrar
theme-dialog.close: Cerrar

View File

@ -7,6 +7,11 @@ translations:
nav.diagnostics: Diagnostica
docs: Documentazione
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
raise-issue: Segnala un problema su GitHub
logs.title: Registri
@ -50,4 +55,4 @@ translations:
language-dialog.close: Chiudi
theme-dialog.title: Seleziona tema
theme-dialog.default: Tema Predefinito
theme-dialog.close: Chiudi
theme-dialog.close: Chiudi

View File

@ -6,6 +6,11 @@ translations:
nav.entities: 实体
nav.diagnostics: 诊断
connected: 已连接
disconnected: 已断开连接
reconnecting: 正在重新连接…
disconnected-banner: "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。"
disconnected-banner-reconnecting: "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…"
disconnected-banner-announcement: 事件 WebSocket 已断开。
login-button: 登录
raise-issue: 在 GitHub 上报告问题
docs: 文档
@ -50,4 +55,4 @@ translations:
diagnostics.copy-to-clipboard: 复制到剪贴板
diagnostics.copied: 已复制!
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 {
ret := []*apiv1.ActionArgumentChoice{}
entList := entities.GetEntityInstances(entityTitle)
for _, ent := range entList {
for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) {
ret = append(ret, &apiv1.ActionArgumentChoice{
Value: tpl.ParseTemplateOfActionBeforeExec(firstChoice.Value, 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)
}
// 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 {
var ids []string
for _, c := range contents {
@ -450,3 +521,54 @@ func bindingIdsFromComponent(c *apiv1.DashboardComponent) []string {
}
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 {
ret := make([]*apiv1.DashboardComponent, 0)
entities := entities.GetEntityInstances(entityTitle)
for _, ent := range entities {
orderedEntities := entities.GetEntityInstancesOrdered(entityTitle)
for _, ent := range orderedEntities {
fs := buildEntityFieldset(tpl, ent, rr)
if len(fs.Contents) > 0 {
@ -30,7 +29,7 @@ func buildEntityFieldset(component *config.DashboardComponent, ent *entities.Ent
Type: "fieldset",
Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(component.Contents, ent, component.Entity, rr)),
CssClass: tpl.ParseTemplateOfActionBeforeExec(component.CssClass, ent),
Action: rr.findAction(component.Title),
Action: rr.findActionForEntity(component.Title, ent),
EntityType: component.Entity,
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 {
clone.Type = "link"
clone.Title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, ent)
// Prefer an entity-specific action when available, but fall back to a
// non-entity-scoped action with the same title. This allows inline actions
// 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 {
action = rr.findAction(subitem.Title)
}
if action == nil {
return nil
}
clone.Type = "link"
clone.Title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, ent)
clone.Action = action
return clone
}

View File

@ -2,6 +2,7 @@ package api
import (
"sort"
"strconv"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
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 {
contents, root := getDashboardComponentContentsWithEntity(dashboard, rr, entity)
return &apiv1.Dashboard{
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
}
fieldset.Contents = append(fieldset.Contents, &apiv1.DashboardComponent{
comp := &apiv1.DashboardComponent{
Type: "link",
Title: action.Title,
Icon: action.Icon,
Action: action,
})
}
if binding.Entity != nil {
comp.EntityKey = binding.Entity.UniqueKey
}
fieldset.Contents = append(fieldset.Contents, comp)
}
if len(fieldset.Contents) > 0 {
fieldset.Contents = sortActions(fieldset.Contents)
fieldset.Contents = sortDashboardComponents(fieldset.Contents)
db.Contents = append(db.Contents, fieldset)
}
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 {
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
}
if components[i].Action.Order == components[j].Action.Order {
return components[i].Action.Title < components[j].Action.Title
} else {
if 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
@ -194,7 +217,58 @@ func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardCompo
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)
rootFieldset := createRootFieldset()
@ -202,7 +276,11 @@ func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponen
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 {
@ -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) {
if subitem.Type != "fieldset" {
rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimpleWithEntity(subitem, rr, entity))
appendComponentIfNotNil(&rootFieldset.Contents, getDashboardComponentOrNil(subitem, rr, entity))
return
}
if subitem.Entity != "" {
*ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...)
} 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 {
var contents []*apiv1.DashboardComponent
if len(subitem.Contents) > 0 {
contents = getDashboardComponentContentsWithEntity(subitem, rr, entity)
contents, _ = getDashboardComponentContentsWithEntity(subitem, rr, entity)
}
action := rr.findActionForEntity(subitem.Title, entity)

View File

@ -281,7 +281,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
config.Prometheus.Enabled = false
config.Prometheus.DefaultGoMetrics = false
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.HeaderXFrameOptions = true
config.Security.XFrameOptions = "DENY"

View File

@ -194,7 +194,7 @@ func (cfg *Config) sanitizeSecurityHeadersCSP() {
if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" {
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() {

View File

@ -1,8 +1,10 @@
package entities
import (
// "github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
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")
*/
}
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 (
"sort"
"strconv"
"strings"
"sync"
)
@ -64,6 +66,49 @@ func GetEntityInstances(entityName string) entityInstancesByKey {
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) {
rwmutex.Lock()

View File

@ -20,6 +20,7 @@ import (
"os"
"os/exec"
"path"
"regexp"
"strings"
"sync"
"time"
@ -30,6 +31,14 @@ const (
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 (
metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{
Name: "olivetin_actions_requested_count",
@ -506,8 +515,7 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
}
_, isDuplicate := e.GetLog(req.TrackingID)
if isDuplicate || req.TrackingID == "" {
if isDuplicate || !isValidTrackingID(req.TrackingID) {
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) {
for _, ent := range entities.GetEntityInstances(entityTitle) {
for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) {
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**.