From 3f46007281769e09704c339231fc260fd4a41e72 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 8 Mar 2026 20:46:52 +0000 Subject: [PATCH 01/11] fix: Relax default CSP to allow iconify to work --- service/internal/config/config.go | 2 +- service/internal/config/sanitize.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 82c466f..742f6bf 100644 --- a/service/internal/config/config.go +++ b/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" diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index ddeac5b..f612edb 100644 --- a/service/internal/config/sanitize.go +++ b/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() { From 0fee24089fba815a219a158d178a7428c53f64e9 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 8 Mar 2026 21:11:27 +0000 Subject: [PATCH 02/11] fix: Restart action button was not working --- frontend/resources/vue/views/ExecutionView.vue | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/resources/vue/views/ExecutionView.vue b/frontend/resources/vue/views/ExecutionView.vue index 169572d..a8de151 100644 --- a/frontend/resources/vue/views/ExecutionView.vue +++ b/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) From 5ff6b5d08083ba8f362964d9a6793a58e5327bce Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 8 Mar 2026 22:16:24 +0000 Subject: [PATCH 03/11] fix: Entity ordering (#886, #762) --- AGENTS.md | 6 +- .../entityFilesWithLongIntsUseStandardForm.js | 10 +-- service/internal/api/apiActions.go | 4 +- service/internal/api/api_test.go | 26 ++++++ service/internal/api/dashboard_entities.go | 7 +- service/internal/api/dashboards.go | 80 +++++++++++++++++-- service/internal/entities/entities_test.go | 45 ++++++++++- service/internal/entities/storage.go | 45 +++++++++++ service/internal/executor/executor_actions.go | 2 +- specs/dashboard-component-ordering.md | 52 ++++++++++++ 10 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 specs/dashboard-component-ordering.md diff --git a/AGENTS.md b/AGENTS.md index db2412f..40dd89a 100644 --- a/AGENTS.md +++ b/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. - - diff --git a/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js b/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js index eb92a07..cc3a320 100644 --- a/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js +++ b/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') diff --git a/service/internal/api/apiActions.go b/service/internal/api/apiActions.go index 4a23125..45fcad1 100644 --- a/service/internal/api/apiActions.go +++ b/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), diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index 6e24d33..7bca057 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -450,3 +450,29 @@ func bindingIdsFromComponent(c *apiv1.DashboardComponent) []string { } return append(ids, bindingIdsInDashboardContents(c.Contents)...) } + +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) + + 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) + + require.Len(t, out, 2) + assert.Equal(t, "Alpha", out[0].Title, "sortables ordered by title") + assert.Equal(t, "Beta", out[1].Title) +} diff --git a/service/internal/api/dashboard_entities.go b/service/internal/api/dashboard_entities.go index 2ddf964..d9a12d6 100644 --- a/service/internal/api/dashboard_entities.go +++ b/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, } diff --git a/service/internal/api/dashboards.go b/service/internal/api/dashboards.go index 575994c..4e98fde 100644 --- a/service/internal/api/dashboards.go +++ b/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" @@ -113,7 +114,7 @@ func buildDashboardFromConfig(dashboard *config.DashboardComponent, rr *Dashboar func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.Dashboard { return &apiv1.Dashboard{ Title: dashboard.Title, - Contents: sortActions(removeNulls(getDashboardComponentContentsWithEntity(dashboard, rr, entity))), + Contents: orderTopLevelDashboardComponents(removeNulls(getDashboardComponentContentsWithEntity(dashboard, rr, entity))), } } @@ -148,33 +149,49 @@ 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 { 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,6 +211,53 @@ func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardCompo return ret } +func isRegularFieldset(component *apiv1.DashboardComponent, index int, totalLen int) bool { + if component == nil || component.Type != "fieldset" || component.EntityType != "" { + return false + } + return index != totalLen-1 +} + +func partitionTopLevelComponents(components []*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, i, len(components)) + 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) []*apiv1.DashboardComponent { + if len(components) == 0 { + return components + } + regular, sortables, isRegular := partitionTopLevelComponents(components) + sortDashboardComponents(sortables) + return mergeOrderedTopLevelComponents(regular, sortables, isRegular) +} + func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) []*apiv1.DashboardComponent { ret := make([]*apiv1.DashboardComponent, 0) rootFieldset := createRootFieldset() diff --git a/service/internal/entities/entities_test.go b/service/internal/entities/entities_test.go index ab135d3..b13fd08 100644 --- a/service/internal/entities/entities_test.go +++ b/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) +} diff --git a/service/internal/entities/storage.go b/service/internal/entities/storage.go index e1fac78..bd61718 100644 --- a/service/internal/entities/storage.go +++ b/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() diff --git a/service/internal/executor/executor_actions.go b/service/internal/executor/executor_actions.go index c55f114..706d16c 100644 --- a/service/internal/executor/executor_actions.go +++ b/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) } } diff --git a/specs/dashboard-component-ordering.md b/specs/dashboard-component-ordering.md new file mode 100644 index 0000000..6723fb5 --- /dev/null +++ b/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**. From 71bb999950ad01014d1482e166c273c83b54b8af Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 8 Mar 2026 22:45:46 +0000 Subject: [PATCH 04/11] security: Actions that people didnt have permission to view were being returned (#921) --- service/internal/api/api_test.go | 67 ++++++++++++++++++++++ service/internal/api/dashboard_entities.go | 8 ++- service/internal/api/dashboards.go | 19 +++++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index 7bca057..57abddd 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -432,6 +432,73 @@ 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) +} + +// 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) +} + func bindingIdsInDashboardContents(contents []*apiv1.DashboardComponent) []string { var ids []string for _, c := range contents { diff --git a/service/internal/api/dashboard_entities.go b/service/internal/api/dashboard_entities.go index d9a12d6..d588a57 100644 --- a/service/internal/api/dashboard_entities.go +++ b/service/internal/api/dashboard_entities.go @@ -82,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 @@ -92,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 } diff --git a/service/internal/api/dashboards.go b/service/internal/api/dashboards.go index 4e98fde..44e2244 100644 --- a/service/internal/api/dashboards.go +++ b/service/internal/api/dashboards.go @@ -277,16 +277,31 @@ 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)) } } From 24e8b48dc9406afec953c8c0c303795dea69c850 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 8 Mar 2026 23:09:23 +0000 Subject: [PATCH 05/11] fix: Websocket reconnection logic (#802, 889, #884) --- frontend/js/websocket.js | 14 +++++++++++++- frontend/resources/vue/App.vue | 17 ++++++++++++++--- .../resources/vue/stores/connectionState.js | 6 ++++++ lang/combined_output.json | 12 +++++++++++- lang/de-DE.yaml | 4 +++- lang/en.yaml | 4 +++- lang/es-ES.yaml | 4 +++- lang/it-IT.yaml | 4 +++- lang/zh-Hans-CN.yaml | 4 +++- 9 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 frontend/resources/vue/stores/connectionState.js diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index 8c0200f..dbabe67 100644 --- a/frontend/js/websocket.js +++ b/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 = 3000 export function initWebsocket () { window.addEventListener('EventOutputChunk', onOutputChunk) @@ -16,9 +19,14 @@ async function reconnectWebsocket () { return } + connectionState.reconnecting = true + connectionState.connected = false + try { window.websocketAvailable = true for await (const e of window.client.eventStream()) { + connectionState.connected = true + connectionState.reconnecting = false handleEvent(e) } } catch (err) { @@ -26,7 +34,11 @@ async function reconnectWebsocket () { } window.websocketAvailable = false - console.log('Reconnecting websocket...') + connectionState.connected = false + console.log('Reconnecting websocket in ' + RECONNECT_DELAY_MS + 'ms...') + setTimeout(() => { + reconnectWebsocket() + }, RECONNECT_DELAY_MS) } function handleEvent (msg) { diff --git a/frontend/resources/vue/App.vue b/frontend/resources/vue/App.vue index 7dfae4a..82830d7 100644 --- a/frontend/resources/vue/App.vue +++ b/frontend/resources/vue/App.vue @@ -50,7 +50,7 @@ {{ currentThemeName }} - {{ t('connected') }} + {{ connectionStatusLabel }}

@@ -107,6 +107,7 @@ import { DashboardSquare01Icon } from '@hugeicons/core-free-icons' import logoUrl from '../../OliveTinLogo.png'; import { useI18n } from 'vue-i18n'; import combinedTranslations from '../../../lang/combined_output.json'; +import { connectionState } from './stores/connectionState.js'; const { t, locale } = useI18n(); @@ -116,7 +117,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(''); @@ -130,6 +130,18 @@ const showVersionNumber = ref(true) const showLoginLink = ref(true) const sectionNavigationStyle = ref('sidebar') +const connectionStatusLabel = computed(() => { + if (connectionState.connected) { + return t('connected') + } + if (connectionState.reconnecting) { + return t('reconnecting') + } + return t('disconnected') +}) + +const connectionStatusTitle = computed(() => connectionStatusLabel.value) + const languageDialog = ref(null) const browserLanguages = ref([]) @@ -404,7 +416,6 @@ function handleThemeDialogClick(event) { window.updateHeaderFromInit = updateHeaderFromInit onMounted(() => { - serverConnection.value = true; updateHeaderFromInit() // Initialize selected language from stored preference diff --git a/frontend/resources/vue/stores/connectionState.js b/frontend/resources/vue/stores/connectionState.js new file mode 100644 index 0000000..c8a4606 --- /dev/null +++ b/frontend/resources/vue/stores/connectionState.js @@ -0,0 +1,6 @@ +import { reactive } from 'vue' + +export const connectionState = reactive({ + connected: false, + reconnecting: false +}) diff --git a/lang/combined_output.json b/lang/combined_output.json index 3fda01a..9e4514b 100644 --- a/lang/combined_output.json +++ b/lang/combined_output.json @@ -20,6 +20,7 @@ "diagnostics.unknown": "Unbekannt", "diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData", "diagnostics.where-to-find-help": "Wo Sie Hilfe finden", + "disconnected": "Getrennt", "docs": "Dokumentation", "language-dialog.browser-languages": "Browser-Sprachen", "language-dialog.close": "Schließen", @@ -47,6 +48,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 +75,7 @@ "diagnostics.unknown": "Unknown", "diagnostics.useragent-data-error": "Error retrieving userAgentData", "diagnostics.where-to-find-help": "Where to find help", + "disconnected": "Disconnected", "docs": "Documentation", "language-dialog.browser-languages": "Browser languages", "language-dialog.close": "Close", @@ -100,6 +103,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 +130,7 @@ "diagnostics.unknown": "Desconocido", "diagnostics.useragent-data-error": "Error al recuperar userAgentData", "diagnostics.where-to-find-help": "Dónde encontrar ayuda", + "disconnected": "Desconectado", "docs": "Documentación", "language-dialog.browser-languages": "Idiomas del navegador", "language-dialog.close": "Cerrar", @@ -153,6 +158,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 +185,7 @@ "diagnostics.unknown": "Sconosciuto", "diagnostics.useragent-data-error": "Errore nel recupero di userAgentData", "diagnostics.where-to-find-help": "Dove trovare aiuto", + "disconnected": "Disconnesso", "docs": "Documentazione", "language-dialog.browser-languages": "Lingue del browser", "language-dialog.close": "Chiudi", @@ -206,6 +213,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 +240,7 @@ "diagnostics.unknown": "未知", "diagnostics.useragent-data-error": "检索 userAgentData 时出错", "diagnostics.where-to-find-help": "在哪里找到帮助", + "disconnected": "已断开连接", "docs": "文档", "language-dialog.browser-languages": "浏览器语言", "language-dialog.close": "关闭", @@ -259,6 +268,7 @@ "nav.entities": "实体", "nav.logs": "日志", "raise-issue": "在 GitHub 上报告问题", + "reconnecting": "正在重新连接…", "return-to-index": "返回首页", "search-filter": "过滤当前页面", "theme-dialog.close": "关闭", @@ -267,4 +277,4 @@ "welcome": "欢迎使用 OliveTin" } } -} \ No newline at end of file +} diff --git a/lang/de-DE.yaml b/lang/de-DE.yaml index 2e5f6c7..e56b45a 100644 --- a/lang/de-DE.yaml +++ b/lang/de-DE.yaml @@ -6,6 +6,8 @@ translations: nav.entities: Entitäten nav.diagnostics: Diagnostik connected: Verbunden + disconnected: Getrennt + reconnecting: Verbinde erneut… login-button: Login raise-issue: Ein Problem melden auf GitHub docs: Dokumentation @@ -50,4 +52,4 @@ translations: language-dialog.close: Schließen theme-dialog.title: Design auswählen theme-dialog.default: Standard-Design - theme-dialog.close: Schließen \ No newline at end of file + theme-dialog.close: Schließen diff --git a/lang/en.yaml b/lang/en.yaml index 8cf5c25..4205514 100644 --- a/lang/en.yaml +++ b/lang/en.yaml @@ -8,6 +8,8 @@ translations: nav.entities: Entities nav.diagnostics: Diagnostics connected: Connected + disconnected: Disconnected + reconnecting: Reconnecting… 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 +52,4 @@ translations: language-dialog.close: Close theme-dialog.title: Select Theme theme-dialog.default: Default Theme - theme-dialog.close: Close \ No newline at end of file + theme-dialog.close: Close diff --git a/lang/es-ES.yaml b/lang/es-ES.yaml index 974eaf6..21a4bff 100644 --- a/lang/es-ES.yaml +++ b/lang/es-ES.yaml @@ -6,6 +6,8 @@ translations: nav.entities: Entidades nav.diagnostics: Diagnósticos connected: Conectado + disconnected: Desconectado + reconnecting: Reconectando… login-button: Iniciar sesión raise-issue: Reportar un problema en GitHub docs: Documentación @@ -50,4 +52,4 @@ translations: language-dialog.close: Cerrar theme-dialog.title: Seleccionar tema theme-dialog.default: Tema Predeterminado - theme-dialog.close: Cerrar \ No newline at end of file + theme-dialog.close: Cerrar diff --git a/lang/it-IT.yaml b/lang/it-IT.yaml index 4bc9c66..b56f498 100644 --- a/lang/it-IT.yaml +++ b/lang/it-IT.yaml @@ -7,6 +7,8 @@ translations: nav.diagnostics: Diagnostica docs: Documentazione connected: Connesso + disconnected: Disconnesso + reconnecting: Riconnessione… login-button: Login raise-issue: Segnala un problema su GitHub logs.title: Registri @@ -50,4 +52,4 @@ translations: language-dialog.close: Chiudi theme-dialog.title: Seleziona tema theme-dialog.default: Tema Predefinito - theme-dialog.close: Chiudi \ No newline at end of file + theme-dialog.close: Chiudi diff --git a/lang/zh-Hans-CN.yaml b/lang/zh-Hans-CN.yaml index ad84897..73f7810 100644 --- a/lang/zh-Hans-CN.yaml +++ b/lang/zh-Hans-CN.yaml @@ -6,6 +6,8 @@ translations: nav.entities: 实体 nav.diagnostics: 诊断 connected: 已连接 + disconnected: 已断开连接 + reconnecting: 正在重新连接… login-button: 登录 raise-issue: 在 GitHub 上报告问题 docs: 文档 @@ -50,4 +52,4 @@ translations: diagnostics.copy-to-clipboard: 复制到剪贴板 diagnostics.copied: 已复制! diagnostics.unknown: 未知 - diagnostics.useragent-data-error: 检索 userAgentData 时出错 \ No newline at end of file + diagnostics.useragent-data-error: 检索 userAgentData 时出错 From 2f77000de44f65690f257e3cf8e2c8462b0e74c7 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 8 Mar 2026 23:29:00 +0000 Subject: [PATCH 06/11] security: GHSA-364q-w7vh-vhpc (HIGH) Unsafe parsing of UniqueTrackingId can be used to write files --- service/internal/executor/executor.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index f6807cf..670aeea 100644 --- a/service/internal/executor/executor.go +++ b/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() } From a5c102dbf16f72b4d97abec9790393862d6a51f2 Mon Sep 17 00:00:00 2001 From: jamesread Date: Mon, 9 Mar 2026 00:27:53 +0000 Subject: [PATCH 07/11] fix: Much more helpful reconnection banner --- frontend/js/websocket.js | 15 ++-- frontend/resources/vue/App.vue | 18 +---- .../vue/components/ConnectionBanner.vue | 68 +++++++++++++++++++ .../resources/vue/stores/connectionState.js | 4 +- lang/combined_output.json | 10 +++ lang/de-DE.yaml | 2 + lang/en.yaml | 2 + lang/es-ES.yaml | 2 + lang/it-IT.yaml | 2 + lang/zh-Hans-CN.yaml | 2 + 10 files changed, 104 insertions(+), 21 deletions(-) create mode 100644 frontend/resources/vue/components/ConnectionBanner.vue diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index dbabe67..d453a70 100644 --- a/frontend/js/websocket.js +++ b/frontend/js/websocket.js @@ -2,7 +2,7 @@ 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 = 3000 +const RECONNECT_DELAY_MS = 10000 export function initWebsocket () { window.addEventListener('EventOutputChunk', onOutputChunk) @@ -21,12 +21,17 @@ async function reconnectWebsocket () { connectionState.reconnecting = true connectionState.connected = false + connectionState.disconnectedAt = Date.now() + connectionState.nextReconnectAt = null try { window.websocketAvailable = true - for await (const e of window.client.eventStream()) { - connectionState.connected = true - connectionState.reconnecting = false + const stream = window.client.eventStream() + connectionState.connected = true + connectionState.reconnecting = false + connectionState.disconnectedAt = null + connectionState.nextReconnectAt = null + for await (const e of stream) { handleEvent(e) } } catch (err) { @@ -35,6 +40,8 @@ async function reconnectWebsocket () { window.websocketAvailable = false 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() diff --git a/frontend/resources/vue/App.vue b/frontend/resources/vue/App.vue index 82830d7..dccdaa0 100644 --- a/frontend/resources/vue/App.vue +++ b/frontend/resources/vue/App.vue @@ -7,6 +7,7 @@