James Read 3 miesięcy temu
rodzic
commit
b03af0e2ec

+ 3 - 3
AGENTS.md

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

+ 23 - 2
frontend/js/websocket.js

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

+ 2 - 5
frontend/resources/vue/App.vue

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

+ 84 - 0
frontend/resources/vue/components/ConnectionBanner.vue

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

+ 8 - 0
frontend/resources/vue/stores/connectionState.js

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

+ 6 - 5
frontend/resources/vue/views/ExecutionView.vue

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

+ 5 - 5
integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js

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

+ 26 - 1
lang/combined_output.json

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

+ 6 - 1
lang/de-DE.yaml

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

+ 6 - 1
lang/en.yaml

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

+ 6 - 1
lang/es-ES.yaml

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

+ 6 - 1
lang/it-IT.yaml

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

+ 6 - 1
lang/zh-Hans-CN.yaml

@@ -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 时出错

+ 1 - 3
service/internal/api/apiActions.go

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

+ 122 - 0
service/internal/api/api_test.go

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

+ 8 - 7
service/internal/api/dashboard_entities.go

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

+ 106 - 20
service/internal/api/dashboards.go

@@ -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))
-	}
-}
-
-func appendRootFieldsetIfNeeded(ret []*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent) []*apiv1.DashboardComponent {
-	if len(rootFieldset.Contents) > 0 {
-		ret = append(ret, rootFieldset)
+		appendComponentIfNotNil(ret, getDashboardComponentOrNil(subitem, rr, entity))
 	}
-	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)

+ 1 - 1
service/internal/config/config.go

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

+ 1 - 1
service/internal/config/sanitize.go

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

+ 44 - 1
service/internal/entities/entities_test.go

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

+ 45 - 0
service/internal/entities/storage.go

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

+ 10 - 2
service/internal/executor/executor.go

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

+ 1 - 1
service/internal/executor/executor_actions.go

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

+ 52 - 0
specs/dashboard-component-ordering.md

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