jamesread 3 недель назад
Родитель
Сommit
ec6e796b86

+ 2 - 1
.github/PULL_REQUEST_TEMPLATE.md

@@ -22,6 +22,7 @@ Please put a X in the boxes as evidence of reading through the checklist.
 - [ ] `make -wC service compile` runs without any issues.
 - [ ] `make -wC service codestyle` runs without any issues.
 - [ ] `make -wC service unittests` runs without any issues.
-- [ ] `make -wC webui codestyle` runs without any issues.
+- [ ] `make -wC frontend codestyle` runs without any issues.
+- [ ] `make -w frontend-unittests` runs without any issues.
 - [ ] `make -w it` runs without any issues.
 - [ ] I understand and accept the [AGPL-3.0 license](LICENSE) and [code of conduct](CODE_OF_CONDUCT.md), and my contributions fall under these.

+ 3 - 0
.github/workflows/build-and-release.yml

@@ -104,6 +104,9 @@ jobs:
       - name: unit tests
         run: make -w service-unittests
 
+      - name: frontend unit tests
+        run: make -w frontend-unittests
+
       - name: build service
         run: make -w service
 

+ 3 - 0
.github/workflows/codestyle.yml

@@ -38,3 +38,6 @@ jobs:
 
       - name: frontend
         run: make -wC frontend codestyle
+
+      - name: frontend unit tests
+        run: make -wC frontend unittests

+ 7 - 0
.pre-commit-config.yaml

@@ -47,6 +47,13 @@ repos:
         pass_filenames: false
         files: ^(frontend/|Makefile)
 
+      - id: frontend-unittests
+        name: frontend-unittests
+        entry: make frontend-unittests
+        language: system
+        pass_filenames: false
+        files: ^(frontend/|Makefile)
+
       - id: service-unittests
         name: service-unittests
         entry: make service-unittests

+ 3 - 0
Makefile

@@ -23,6 +23,9 @@ service-codestyle:
 frontend-codestyle:
 	$(MAKE) -wC frontend codestyle
 
+frontend-unittests:
+	$(MAKE) -wC frontend unittests
+
 it:
 	$(MAKE) -wC integration-tests
 

+ 2 - 0
docs/modules/ROOT/pages/args/input.adoc

@@ -32,6 +32,8 @@ actions:
         type: ascii_sentence
 ----
 
+`description` is optional help text under the field. It is rendered as raw HTML; see xref:args/intro.adoc#arg-descriptions[Argument descriptions].
+
 This will give you a normal button, like this;
 
 image::args/input/args1.png[]

+ 18 - 0
docs/modules/ROOT/pages/args/intro.adoc

@@ -10,6 +10,24 @@ Examples of valid argument names are `{{ personName }}`, `{{ customer_number }}`
 * numbers are allowed (argument names can also start with numbers)
 * all other characters are invalid for argument names.
 
+[#arg-descriptions]
+== Argument descriptions
+
+Each argument can include a `description:` shown under the field on the argument form. OliveTin renders that value as **raw HTML**, so you can use markup such as links, line breaks, or emphasis:
+
+[source,yaml]
+----
+arguments:
+  - name: host
+    title: Hostname
+    type: ascii_identifier
+    description: |
+      Enter a host OliveTin can reach.
+      See the <a href="https://example.com/docs" target="_blank" rel="noopener noreferrer">network guide</a>.
+----
+
+Treat `description` as trusted markup that you control (the same trust model as xref:dashboards/4-displays.adoc[dashboard displays]). Do not put untrusted or entity-derived strings into it without careful escaping.
+
 == What's Next?
 
 Now that you understand how arguments work, explore the different argument types and features:

+ 4 - 1
frontend/Makefile

@@ -7,6 +7,9 @@ codestyle:
 	npx eslint --fix main.js js/* resources/vue
 	npx stylelint style.css
 
+unittests: deps
+	npm test
+
 clean:
 	$(call delete-files,dist)
 
@@ -18,4 +21,4 @@ build:
 
 dist: deps clean build
 
-.PHONY: codestyle
+.PHONY: codestyle unittests

+ 25 - 25
frontend/package-lock.json

@@ -23,7 +23,7 @@
 				"unplugin-vue-components": "^32.1.0",
 				"vite": "^8.1.5",
 				"vue": "^3.5.40",
-				"vue-i18n": "^11.4.7",
+				"vue-i18n": "^11.4.8",
 				"vue-router": "^5.2.0"
 			},
 			"devDependencies": {
@@ -998,14 +998,14 @@
 			"license": "MIT"
 		},
 		"node_modules/@intlify/core-base": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
-			"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
+			"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/devtools-types": "11.4.7",
-				"@intlify/message-compiler": "11.4.7",
-				"@intlify/shared": "11.4.7"
+				"@intlify/devtools-types": "11.4.8",
+				"@intlify/message-compiler": "11.4.8",
+				"@intlify/shared": "11.4.8"
 			},
 			"engines": {
 				"node": ">= 22"
@@ -1015,13 +1015,13 @@
 			}
 		},
 		"node_modules/@intlify/devtools-types": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
-			"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
+			"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.4.7",
-				"@intlify/shared": "11.4.7"
+				"@intlify/core-base": "11.4.8",
+				"@intlify/shared": "11.4.8"
 			},
 			"engines": {
 				"node": ">= 22"
@@ -1031,12 +1031,12 @@
 			}
 		},
 		"node_modules/@intlify/message-compiler": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
-			"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
+			"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/shared": "11.4.7",
+				"@intlify/shared": "11.4.8",
 				"source-map-js": "^1.0.2"
 			},
 			"engines": {
@@ -1047,9 +1047,9 @@
 			}
 		},
 		"node_modules/@intlify/shared": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
-			"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
+			"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
 			"license": "MIT",
 			"engines": {
 				"node": ">= 22"
@@ -7118,14 +7118,14 @@
 			}
 		},
 		"node_modules/vue-i18n": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
-			"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
+			"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.4.7",
-				"@intlify/devtools-types": "11.4.7",
-				"@intlify/shared": "11.4.7",
+				"@intlify/core-base": "11.4.8",
+				"@intlify/devtools-types": "11.4.8",
+				"@intlify/shared": "11.4.8",
 				"@vue/devtools-api": "^6.5.0"
 			},
 			"engines": {

+ 1 - 1
frontend/package.json

@@ -37,7 +37,7 @@
 		"unplugin-vue-components": "^32.1.0",
 		"vite": "^8.1.5",
 		"vue": "^3.5.40",
-		"vue-i18n": "^11.4.7",
+		"vue-i18n": "^11.4.8",
 		"vue-router": "^5.2.0"
 	},
 	"engines": {

+ 2 - 0
frontend/resources/vue/components/ActionIconGlyph.vue

@@ -7,10 +7,12 @@
       height="1em"
       class="action-icon-glyph-svg"
     />
+    <!-- eslint-disable vue/no-v-html -- intentional: action icons may use HTML / custom-webui <img> -->
     <span
       v-else-if="decodedTextGlyphIsHtml"
       v-html="decodedTextGlyph"
     />
+    <!-- eslint-enable vue/no-v-html -->
     <span
       v-else
       v-text="decodedTextGlyph"

+ 2 - 0
frontend/resources/vue/components/DashboardComponentDirectory.vue

@@ -4,10 +4,12 @@
     :class="component.cssClass"
     @click="navigateToDirectory"
   >
+    <!-- eslint-disable vue/no-v-html -- intentional: directory icons from config (HTML entities / markup) -->
     <span
       class="icon"
       v-html="unicodeIcon"
     />
+    <!-- eslint-enable vue/no-v-html -->
     <span class="title">{{ component.title }}</span>
   </button>
 </template>

+ 2 - 0
frontend/resources/vue/components/DashboardComponentDisplay.vue

@@ -3,7 +3,9 @@
     class="display"
     :class="component.cssClass"
   >
+    <!-- eslint-disable vue/no-v-html -- intentional: type display titles are trusted config HTML (hackable dashboards) -->
     <div v-html="component.title" />
+    <!-- eslint-enable vue/no-v-html -->
   </div>
 </template>
 

+ 2 - 0
frontend/resources/vue/views/ArgumentForm.vue

@@ -89,10 +89,12 @@
               @change="handleChange(arg, $event)"
             />
 
+            <!-- eslint-disable vue/no-v-html -- intentional: argument description is documented as raw HTML -->
             <span
               class="argument-description"
               v-html="arg.description"
             />
+            <!-- eslint-enable vue/no-v-html -->
           </template>
         </template>
 

+ 2 - 2
frontend/resources/vue/views/ExecutionView.vue

@@ -58,7 +58,7 @@
     >
       <dl class="fg1">
         <dt>Duration</dt>
-        <dd><span v-html="duration" /></dd>
+        <dd>{{ duration }}</dd>
 
         <dt>Status</dt>
         <dd class="execution-dialog-status">
@@ -373,7 +373,7 @@ function updateDuration (logEntryParam) {
     } catch (e) {
 	  console.warn('Failed to calculate delta', e)
     }
-    duration.value = logEntry.value.datetimeStarted + ' &rarr; ' + logEntry.value.datetimeFinished
+    duration.value = logEntry.value.datetimeStarted + '  ' + logEntry.value.datetimeFinished
     if (delta !== '') {
 	  duration.value += ' (' + delta + ')'
     }

+ 50 - 0
specs/config-issues.md

@@ -0,0 +1,50 @@
+# Configuration issues
+
+This spec describes how OliveTin collects configuration warnings and errors and surfaces them in the web UI.
+
+## Purpose
+
+Operators should see configuration problems in Diagnostics instead of only in server logs. When any issues exist, the Diagnostics navigation link shows a count badge.
+
+## When issues are rebuilt
+
+The issue list is cleared and rebuilt when the action map is rebuilt. That happens after configuration load or reload, and after entity data changes. Some load-time findings that cannot be re-derived after decode (for example unset environment variables already expanded away) are kept across rebuilds until the next configuration load begins.
+
+## What is collected
+
+Issues include:
+
+- Unknown or unenforced action group references
+- Checklist arguments with missing or invalid choice templates
+- Arguments whose type was left unset (defaulted to ascii)
+- Unset environment variables referenced from configuration
+- Missing or invalid include directories
+- Argument default or choice templates that fail to parse
+- Literal argument defaults that fail type validation (templated defaults are not type-checked as raw text)
+- Entity files that cannot be read or parsed, or are empty
+- Entity-bound actions with no entity instances (after OliveTin has attempted to load that entity type, so startup does not report a false positive before entity files are read)
+- Invalid cron schedules
+- Entity-bound actions that also use scheduled cron execution (cron runs without an entity binding and will not execute)
+- Filesystem watch paths that cannot be created (missing directories for file-in-dir triggers, calendar files, or entity files). Runtime watcher setup failures for action triggers include the related action so view permissions still apply; entity-file watchers without an action remain visible to anyone who may view Diagnostics.
+
+Each issue has a severity of warning or error, a stable code, a human-readable message, and optional context such as action title, argument name, configuration source file, or detail value.
+
+When the issue list is rebuilt, OliveTin logs only newly appeared issues so startup does not repeat the same warning for every action-map rebuild.
+
+When configuration is loaded from a base file and an include directory, OliveTin records which file defined each action and entity declaration. That path is shown as the configuration source file when available. Some issues (for example unset environment variables) may not have a specific file. Entity data file problems also show the entity data path in the detail column.
+
+## Diagnostics page
+
+Users who are allowed to view Diagnostics see a Configuration issues section listing the current issues in a table. When there are none, the section states that no configuration issues were detected.
+
+Action-scoped issues are only included when the user is allowed to view that action. Issues that are not tied to an action (for example unset environment variables or missing include directories) remain visible to anyone who may view Diagnostics.
+
+Users who are not allowed to view Diagnostics cannot retrieve the issue list.
+
+## Navigation count
+
+When Diagnostics is visible and at least one configuration issue exists that the user is allowed to see, the Diagnostics navigation link shows a count badge with the number of those issues. The badge clears when the visible issue count becomes zero after a configuration or entity refresh.
+
+## Init count
+
+The Init response includes the configuration issue count for users who may view Diagnostics, using the same per-user filtering as the Diagnostics list. For other users the count is zero.

+ 35 - 0
specs/dashboard-nav-categories.md

@@ -0,0 +1,35 @@
+# Spec: Dashboard navigation categories
+
+This spec describes how root dashboards can be grouped into categories in the sidebar navigation.
+
+---
+
+## 1. Configuration
+
+Root dashboard entries in the configuration may include an optional category label.
+
+- The category applies only to root dashboards (top-level items in the dashboards list). Nested dashboard contents ignore category.
+- If category is omitted or empty, the dashboard is uncategorized.
+- Dashboards that share the same category label are grouped together under that label in the sidebar.
+
+## 2. Visibility
+
+Only dashboards the current user is allowed to view appear in navigation.
+
+- Access-denied dashboards are omitted from the list and do not create empty category sections.
+- If every dashboard in a category is hidden, that category does not appear.
+
+## 3. Sidebar ordering
+
+When building the sidebar:
+
+1. Uncategorized dashboards appear first, as a flat list above any category sections, in configuration order among visible uncategorized dashboards.
+2. Category sections follow, in the order each category first appears among visible categorized dashboards.
+3. Within a category, dashboards keep the order they appear in the configuration among visible dashboards in that category.
+4. After all dashboard links, a **System** category lists Entities, Logs, and Diagnostics (each only when the user is allowed to see that item). If none of those links are visible, the System category is omitted.
+
+The default Actions dashboard, when present, is uncategorized unless configuration gives it a category (it is not a configured root entry, so it stays uncategorized).
+
+## 4. Navigation style
+
+Category sections apply when section navigation uses the sidebar. Top-bar navigation does not show category section headers; dashboard links still appear in the same relative order without collapsible category groups.