Compare commits

...

126 Commits

Author SHA1 Message Date
James Read
c2843aa581 3k release: terminal sizes, confirmation buttons, translations, entity fieldsets drawn in random order, code quality (#709)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-11-16 00:51:51 +00:00
jamesread
d1ec688c9a chore: cleanup executor.go
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
2025-11-16 00:47:03 +00:00
jamesread
c1508a0a65 fix: Replace custom pagination component with picocrank pagination component
- Replace custom pagination component with picocrank pagination component
- Update package.json and package-lock.json to include picocrank
- Update style.css to include new padding class
- Update ActionDetailsView.vue and LogsListView.vue to use new pagination component
2025-11-16 00:05:50 +00:00
jamesread
5b57cf2480 fix: Logs were being displayed in the wrong order 2025-11-11 23:32:00 +00:00
jamesread
b97dd23abb chore: Better fixups on lang tool and support 2025-11-11 23:24:44 +00:00
jamesread
7c66170ef5 Merge branch 'next' of github.com:OliveTin/OliveTin into next
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
2025-11-11 10:55:54 +00:00
jamesread
167e700e30 chore: cleanup websocket code 2025-11-11 10:55:42 +00:00
James Read
1f875b05a0 Merge branch 'main' into next 2025-11-11 10:55:24 +00:00
jamesread
00d7285167 chore: fix some linting issues 2025-11-11 10:43:47 +00:00
jamesread
d32d92baab Merge branch 'next' of github.com:OliveTin/OliveTin into next 2025-11-11 10:25:00 +00:00
jamesread
36c786a26d chore: Cleanup websocket/marshaller code 2025-11-11 10:20:54 +00:00
jamesread
afbd1c3abe dep updates 2025-11-11 00:30:34 +00:00
James Read
09ecc8d15c feat: Add translations and language support (#708)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
2025-11-11 00:26:03 +00:00
jamesread
487bf83f4e chore: use koanf tag in config reloader test 2025-11-10 23:39:15 +00:00
jamesread
adae55f24b chore: translate logs list view, log statuses, and language dialog 2025-11-10 23:33:55 +00:00
jamesread
217b17a058 chore: (#708) Print key validation errors, translate the language dialog 2025-11-10 23:26:55 +00:00
jamesread
67a9d3b1d1 chore: skip env in config test in 3k 2025-11-10 23:17:53 +00:00
jamesread
de7129e1d7 chore: reduce cyclomatic complexity 2025-11-10 23:15:18 +00:00
jamesread
3df22f2ccb Merge branch 'feat-add-language-support' of github.com:OliveTin/OliveTin into feat-add-language-support 2025-11-10 22:50:58 +00:00
jamesread
1140453f30 fix: broken integration test after fieldsets changes 2025-11-10 22:50:42 +00:00
James Read
9cf79863b3 Merge branch 'next' into feat-add-language-support 2025-11-10 21:53:04 +00:00
jamesread
6a853d9c99 feat: (#708) Added language support 2025-11-10 21:52:36 +00:00
jamesread
83fe489949 fix: (#703) Fixed entity fieldsets being drawn in a random order 2025-11-10 21:08:34 +00:00
jamesread
e7d4747727 chore: fix broken integration test after fieldsets changes 2025-11-10 20:52:49 +00:00
James Read
d4d7bf8135 fix: (#704) Confirmation buttons (#707) 2025-11-10 20:46:48 +00:00
jamesread
32a062ae93 fix: (#707) Confirmation buttons and ASCII input types 2025-11-10 20:12:19 +00:00
jamesread
263601170f fix: (#702) Fixed actions being drawn in a column (an extra fieldset was being added) 2025-11-10 19:46:58 +00:00
jamesread
b071b4d036 feat: Increase terminal size, and in fullscreen mode 2025-11-10 19:46:08 +00:00
jamesread
52ff504a9d fix: (#704) Confirmation buttons 2025-11-10 13:21:54 +00:00
jamesread
2ed564a403 feat: Add translations and language support 2025-11-10 13:20:40 +00:00
James Read
209856eda9 3k release: massively improve config loading, reduce log spam, etc (#700)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-11-07 00:55:08 +00:00
jamesread
3967b91cf0 fix: Exit if no base config file is found
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
2025-11-07 00:51:04 +00:00
James Read
50abb53ace Fix config loading missing values (#699) 2025-11-07 00:40:36 +00:00
James Read
110bbd6216 Merge branch 'next' into fix-config-loading-missing-values 2025-11-07 00:34:11 +00:00
jamesread
1552c104e9 fix: Propperly merge included configs (handling actions) 2025-11-07 00:31:33 +00:00
James Read
4006fd485d fix: Various log noise and spam (#698)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
2025-11-06 23:48:11 +00:00
jamesread
7dc99b1398 fix: Massively improve config loading 2025-11-06 23:45:54 +00:00
jamesread
581536a60f fmt: GetActionLogs 2025-11-06 23:44:56 +00:00
jamesread
58593c6f04 doc: Improve HAProxy example 2025-11-06 23:44:00 +00:00
jamesread
39664a734d fix: Various log noise 2025-11-06 23:42:07 +00:00
James Read
87f9a0b152 fix: #696 - hide login link if login interactively isn't possible (local or oauth2) (#697)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
2025-11-06 15:44:52 +00:00
jamesread
d688ab64e1 fix: #696 - hide login link if login interactively isn't possible (local user or oauth2) 2025-11-06 15:29:39 +00:00
jamesread
822f3197b6 chore: (#675) Remove dead websocket code 2025-11-06 09:52:16 +00:00
James Read
a67b5b4e8f 3k release (#693)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
Buf CI / buf (push) Has been cancelled
2025-11-06 00:44:19 +00:00
James Read
a3c5114615 fix: #685 - show navigation (and picocrank upgrade) (#692)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
2025-11-06 00:42:56 +00:00
jamesread
28c813762f fix: #685 - show navigation (and picocrank upgrade) 2025-11-06 00:38:56 +00:00
James Read
d94f2aca1c Addresses concurrent map read and write (#688)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
2025-11-05 00:46:10 +00:00
jamesread
055472902d fix: #686 - concurrent map read and write - in entity instances 2025-11-04 23:30:14 +00:00
jamesread
2b24daa6d0 fix: Address concurrency issue in entities storage 2025-11-04 23:18:41 +00:00
jamesread
294e33d110 fix: #686 - concurrent map read and write 2025-11-04 23:03:45 +00:00
jamesread
d3cd876eec doc: Added API docs generation tool for 3k
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
Buf CI / buf (push) Has been cancelled
2025-10-31 09:26:40 +00:00
James Read
52cd5f255a doc: Better default config that includes security examples and more doc links (#682) 2025-10-31 09:19:49 +00:00
jamesread
2b1f9a9247 doc: Better layout of default config security section 2025-10-31 09:18:45 +00:00
jamesread
6782156a58 doc: Add date id 2025-10-30 21:33:10 +00:00
jamesread
f1250f9caf doc: Add more security examples to default config 2025-10-30 21:24:13 +00:00
jamesread
0bf313a3f7 doc: Move defaultPolicy to the end in the config 2025-10-30 21:14:49 +00:00
jamesread
092661c7eb doc: Better default config that includes security examples and more doc links 2025-10-30 21:10:58 +00:00
James Read
2a6d9e4f68 3k release (#681)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
Buf CI / buf (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-10-30 16:07:23 +00:00
jamesread
83f45d71bf fix: Several coderabbit suggestions on next branch 2025-10-30 15:51:40 +00:00
jamesread
79a71099f9 fix: fmt api.go 2025-10-30 15:26:29 +00:00
jamesread
e6a02ac614 chore: fix various cyclo checks 2025-10-30 15:25:57 +00:00
James Read
e0167c9e42 fix: Require guests to login (#678) 2025-10-30 13:22:39 +00:00
James Read
7abffedb14 Merge branch 'next' into fix-require-guests-login 2025-10-30 13:15:37 +00:00
James Read
d32db6483e chore: reduce cyclo complexity in service (#680) 2025-10-30 13:05:19 +00:00
jamesread
44b518a5b2 fix: panic when loading sessions.yaml 2025-10-30 13:04:41 +00:00
jamesread
a4e50bfb54 fix: panic when executing action with no arguments 2025-10-30 13:04:12 +00:00
jamesread
a8f5e25454 chore: Conflict in AGENTS.md 2025-10-30 12:57:59 +00:00
jamesread
c3d5da1981 chore: reduce cyclo complexity in service 2025-10-30 12:52:58 +00:00
jamesread
7a1c4d3efa fix: Test authRequireGuestsToLogin redirect
Some checks failed
Buf CI / buf (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
2025-10-30 11:58:51 +00:00
jamesread
c89979ddb2 fix: Wait for login UI to appear in authRequireGuestsToLogin test 2025-10-30 11:05:14 +00:00
James Read
430aab638b Merge branch 'next' into fix-require-guests-login 2025-10-30 09:20:24 +00:00
jamesread
961ddac193 fix: Duplicate ACL rendering bug in UserControlPanel 2025-10-30 09:17:55 +00:00
James Read
03ac3b5fa7 feat: ActionDetailsView component, show timeout and logs for action. … (#676) 2025-10-30 02:15:01 +00:00
jamesread
d21f06e555 fix: Require guests to login 2025-10-30 02:13:40 +00:00
James Read
f25b456c3d Merge branch 'next' into fix-output-streaming 2025-10-30 00:25:49 +00:00
jamesread
e1db1e7be5 fix: Add big error handling for action details view 2025-10-30 00:24:50 +00:00
jamesread
19c3b67cdd fix: fix panic in pagination if we get a bad request 2025-10-30 00:12:06 +00:00
jamesread
b9d859ada2 fix: fix start action button in ActionDetailsView.vue 2025-10-30 00:08:12 +00:00
jamesread
61fc771ac3 fix: Race condition and speedup in accessing streaming clients 2025-10-30 00:03:37 +00:00
jamesread
e0fd10a6ec fix: calculate duration correctly in ExecutionView.vue 2025-10-29 23:35:37 +00:00
James Read
2a5732cc27 feat: enable JSON logging support with OLIVETIN_LOG_FORMAT=json envir… (#677) 2025-10-29 23:03:15 +00:00
jamesread
57390be16f feat: ActionDetailsView component, show timeout and logs for action. Fix output streaming. 2025-10-29 22:45:26 +00:00
jamesread
8a6d61c260 feat: enable JSON logging support with OLIVETIN_LOG_FORMAT=json environment variable 2025-10-29 22:44:49 +00:00
jamesread
f337e05eaf chore: start next cycle 2025-10-29 22:39:54 +00:00
jamesread
6c6d07bf4f fix: #674 Use JSON options for API handler
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-10-27 20:40:15 +00:00
jamesread
d54f2307c7 fix: Use tree for webui nfpm packages
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
2025-10-27 16:27:32 +00:00
jamesread
49dcc7fb46 fix: goreleaser bug for webui 2025-10-27 16:20:48 +00:00
jamesread
2ea35697d0 fix: #672 Empty execution tracking ID in InternalLogEntry 2025-10-27 15:42:13 +00:00
jamesread
a551589840 feat: #428 Initial support for include directive in config files 2025-10-27 15:32:30 +00:00
jamesread
fcd3ccc59a fix: authRequireGuestsToLogin config, and config loading improvements 2025-10-27 14:56:32 +00:00
jamesread
dddc0417c2 fix: #673 Testing fix for broken deb packages 2025-10-27 14:33:40 +00:00
jamesread
d5eb74e738 fix: Include "fix" in the right place in the release notes 2025-10-27 14:22:21 +00:00
jamesread
9fbaa8671f fix: Banner message support 2025-10-27 14:20:26 +00:00
James Read
a915a654cb bugfix: #639 Exec support, disallow URL and similar arguments with (#671)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Codestyle checks / codestyle (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-10-26 19:02:22 +00:00
jamesread
c86bf629f9 bugfix: Added nil checks 2025-10-26 17:32:26 +00:00
jamesread
c917d1b1e7 bugfix: #639 Exec support, disallow URL and similar arguments with 2025-10-26 16:40:44 +00:00
James Read
1cb12b203e Local user login fixes (#669) 2025-10-26 14:39:03 +00:00
James Read
2a21d74e35 Merge branch 'main' into next 2025-10-26 14:22:25 +00:00
jamesread
8686a5629e fix: User Information panel and login/logout flow 2025-10-26 13:42:06 +00:00
jamesread
43cfe41378 fix: Issues with login form and local auth 2025-10-26 13:24:22 +00:00
James Read
280234b138 fix dark mode styles (#668)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-10-26 00:23:09 +00:00
jamesread
02ec8eeb65 fix: Upgraded femtocrank to fix dark mode styles 2025-10-26 01:10:43 +01:00
jamesread
ef5a67e7b8 fix: Upgrade femtocrank for dark styles 2025-10-26 00:47:46 +01:00
James Read
eb2463aa2d 3k release: Connect RPC migration and authentication refactoring (#666)
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-10-24 23:42:39 +00:00
jamesread
a7e7bf869e fix: WIP on login regression 2025-10-25 00:31:02 +01:00
jamesread
0dd9e9b2b7 fix: guard against nil session storage 2025-10-25 00:22:28 +01:00
jamesread
aa8322c354 fix: guard against nil session storage 2025-10-25 00:20:56 +01:00
jamesread
956e74a6b3 fix: Don't log SID (!) SECURITY 2025-10-25 00:20:35 +01:00
jamesread
c9ff4d1a68 fix: broken go.mod 2025-10-25 00:20:15 +01:00
jamesread
88cc1ab080 chore: Makefile whitespace 2025-10-24 23:55:42 +01:00
jamesread
3b8bc49b04 feat: Fixed session management and ripped out the rest of gRPC 2025-10-24 23:48:48 +01:00
jamesread
31ea8507f5 doc: Typo in agents, fix undefined var DATE in pipeline 2025-10-24 22:51:31 +01:00
jamesread
62af851b2c fix: Error getting absolute path for config.yaml 2025-10-24 22:38:26 +01:00
James Read
2a764acde6 chore: Don't run release pipeline on PR branches (#665) 2025-10-24 22:16:45 +01:00
James Read
02e2ac1676 fix: Listen address fields were not being loaded from config.yaml (#664) 2025-10-24 22:16:02 +01:00
jamesread
c89579840b chore: Don't run release pipeline on PR branches 2025-10-24 22:14:00 +01:00
jamesread
38d81fafe2 fix: Listen address fields were not being loaded from config.yaml 2025-10-24 22:06:32 +01:00
James Read
8b2b85c3d0 fix: Argument form start button, and input validation was also broken! (#663) 2025-10-24 21:57:23 +01:00
James Read
76a33e2e54 chore: Remove some old dead code (#662) 2025-10-24 21:57:07 +01:00
jamesread
fa94357374 chore: Stop AI agents adding superflous comments 2025-10-24 21:31:54 +01:00
James Read
439e952a25 fix: sosreport contains pwd and abs paths (#660) 2025-10-24 20:52:01 +01:00
jamesread
3dfbbcc770 doc: Switch to issue types
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
2025-10-24 19:01:04 +01:00
jamesread
77e8c37599 fix: docker latest-3k tag 2025-10-24 18:37:47 +01:00
jamesread
d3aa3b25b0 fix: doc typo
Some checks failed
Build & Release pipeline / build (push) Has been cancelled
DevSkim / DevSkim (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
2025-10-24 01:24:33 +01:00
jamesread
d944b09c51 chore: Move up the saving of integrationtests 2025-10-24 01:18:14 +01:00
jamesread
b9851adfde doc: Add upgrade guide to readme 2025-10-24 01:16:48 +01:00
97 changed files with 7888 additions and 9160 deletions

View File

@@ -2,8 +2,8 @@
name: Bug report
about: Create a report to help us improve
title: ""
type: bug
labels:
- "type: bug"
- "waiting-on-developer"
assignees: ''

View File

@@ -2,8 +2,8 @@
name: Feature request
about: Suggest an idea for this project
title: ''
type: feature
labels:
- "type: feature-request"
- "waiting-on-developer"
assignees: ''

View File

@@ -2,8 +2,8 @@
name: Support request
about: Need some help? Got an error message?
title: ""
type: support
labels:
- "type: support"
- "waiting-on-developer"
assignees: ''

View File

@@ -8,7 +8,8 @@ on:
tags:
- '*'
branches:
- '*'
- main
- next
jobs:
build:
@@ -55,6 +56,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.CONTAINER_TOKEN }}
- name: get date
run: |
echo "DATE=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV"
- name: make webui
run: make -w webui-dist
@@ -67,6 +72,15 @@ jobs:
- name: integration tests
run: cd integration-tests && make -w
- name: Archive integration tests
uses: actions/upload-artifact@v4.3.1
if: always()
with:
name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}"
path: |
integration-tests
!integration-tests/node_modules
- name: Install goreleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -84,21 +98,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
- name: get date
run: |
echo "DATE=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV"
- name: Archive binaries
uses: actions/upload-artifact@v4.3.1
with:
name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
path: dist/OliveTin*.*
- name: Archive integration tests
uses: actions/upload-artifact@v4.3.1
if: always()
with:
name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}"
path: |
integration-tests
!integration-tests/node_modules

4
.gitignore vendored
View File

@@ -16,3 +16,7 @@ integration-tests/screenshots/
webui/
server.log
OliveTin
integration-tests/configs/authRequireGuestsToLogin/sessions.yaml
webui
webui.dev
sessions.yaml

View File

@@ -54,7 +54,7 @@ changelog:
regexp: '^.*?feat.*?(\([[:word:]]+\))??!?:.+$'
order: 1
- title: 'Bug fixes'
regexp: '^.*?bugfix(\([[:word:]]+\))??!?:.+$'
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 2
- title: Others
order: 999
@@ -93,7 +93,7 @@ dockers:
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Tag}}"
extra_files:
- webui
- webui/
- var/entities/
- config.yaml
- var/helper-actions/
@@ -110,7 +110,7 @@ dockers:
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Tag}}"
extra_files:
- webui
- webui/
- var/entities/
- config.yaml
- var/helper-actions/
@@ -126,6 +126,12 @@ docker_manifests:
- docker.io/jamesread/olivetin:{{ .Version }}-amd64
- docker.io/jamesread/olivetin:{{ .Version }}-arm64
- name_template: docker.io/jamesread/olivetin:latest-3k
image_templates:
- docker.io/jamesread/olivetin:{{ .Version }}-amd64
- docker.io/jamesread/olivetin:{{ .Version }}-arm64
- name_template: ghcr.io/olivetin/olivetin:{{ .Version }}
image_templates:
- ghcr.io/olivetin/olivetin:{{ .Version }}-amd64
@@ -136,6 +142,12 @@ docker_manifests:
- ghcr.io/olivetin/olivetin:{{ .Version }}-amd64
- ghcr.io/olivetin/olivetin:{{ .Version }}-arm64
- name_template: ghcr.io/olivetin/olivetin:latest-3k
image_templates:
- ghcr.io/olivetin/olivetin:{{ .Version }}-amd64
- ghcr.io/olivetin/olivetin:{{ .Version }}-arm64
nfpms:
- id: default
maintainer: James Read <contact@jread.com>
@@ -154,8 +166,9 @@ nfpms:
- src: var/systemd/OliveTin.service
dst: /etc/systemd/system/OliveTin.service
- src: webui/*
- src: webui/
dst: /var/www/olivetin/
type: tree
- src: config.yaml
dst: /etc/OliveTin/config.yaml
@@ -184,8 +197,9 @@ nfpms:
- src: var/openrc/OliveTin
dst: /etc/init.d/OliveTin
- src: webui/*
- src: webui/
dst: /var/www/olivetin/
type: tree
- src: config.yaml
dst: /etc/OliveTin/config.yaml

View File

@@ -26,7 +26,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
### Test Notes and Gotchas
- The top-level Makefile does not expose `unittests`; use `cd service && make unittests`.
- Connect RPC API must be mounted correctly; in tests, create the handler via `GetNewHandler(ex)` and serve under `/api/`.
- Frontend “ready” state: the app sets `document.body` attribute `initial-marshal-complete="true"` when loaded. Integration helpers wait for this before selecting elements.
- Frontend “ready” state: the app sets `document.body` attribute `loaded-dashboard="<name>"` when loading a dashboard. Integration helpers that test dashboard functionality wait for this before selecting elements. Certain conditions enforcing login will mean that this attribute is not set until a user is logged in.
- Modern UI uses Vue components:
- Action buttons are rendered as `.action-button button`.
- Logs and Diagnostics are Vue router links available via `/logs` and `/diagnostics`.
@@ -35,6 +35,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
- Footer visibility is controlled by `showFooter` from Init API; tests may assert the footer is absent when config disables it.
### Coding Standards (Go)
- Avoid adding superflous comments that explain what the code is doing. Comments are only to describe business logic decisions.
- Prefer clear, descriptive names; avoid 12 letter identifiers.
- Use early returns and handle edge cases first.
- Do not swallow errors; propagate or log meaningfully.
@@ -56,13 +57,13 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
- Action button behavior: `frontend/resources/vue/ActionButton.vue`
### Contributing Checklist
- Review the contributuing guidelines at `CONTRIBUTING.adoc`.
- 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`.
### 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 `initial-marshal-complete` and use selectors matching the Vue UI.
- Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI.

View File

@@ -45,10 +45,10 @@ cd OliveTin
make githooks
# Step3: compile binary for current dev env (OS, ARCH)
# `make grpc` will also run `make go-tools`, which installs "buf". This binary
# `make proto` will also run `make go-tools`, which installs "buf". This binary
# will be put in your GOPATH/bin/, which should be on your path. buf is used to
# generate the protobuf / grpc stubs.
make grpc
# generate the protobuf / Connect RPC stubs.
make proto
make
./OliveTin
```
@@ -58,7 +58,7 @@ make
The project layout is reasonably straightforward;
* See the `Makefile` for common targets. This project was originally created on top of Fedora, but it should be usable on Debian/your faveourite distro with minor changes (if any).
* The API is defined in protobuf+grpc - you will need to `make grpc`.
* The API is defined in protobuf+Connect RPC - you will need to `make proto`.
* The Go daemon is built from the `cmd` and `internal` directories mostly.
* The webui is just a single page application with a bit of Javascript in the `webui` directory. This can happily be hosted on another webserver.

View File

@@ -17,15 +17,12 @@ it:
go-tools:
$(MAKE) -wC service go-tools
proto: grpc
grpc: go-tools
proto: go-tools
$(MAKE) -wC proto
dist: protoc
dist:
echo "dist noop"
protoc:
protoc --go_out=. --go-grpc_out=. --grpc-gateway_out=. -I .:/usr/include/ OliveTin.proto
podman-image:
buildah bud -t olivetin
@@ -59,4 +56,4 @@ clean:
$(call delete-files,reports)
$(call delete-files,gen)
.PHONY: grpc proto service
.PHONY: proto service

View File

@@ -10,7 +10,8 @@
[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/5050/badge)](https://bestpractices.coreinfrastructure.org/projects/5050)
[![Go Report Card](https://goreportcard.com/badge/github.com/Olivetin/OliveTin)](https://goreportcard.com/report/github.com/OliveTin/OliveTin)
[![Build Snapshot](https://github.com/OliveTin/OliveTin/actions/workflows/build-snapshot.yml/badge.svg)](https://github.com/OliveTin/OliveTin/actions/workflows/build-snapshot.yml)
[OliveTin 2k to 3k upgrade guide](https://docs.olivetin.app/upgrade/2k3k.html)
</div>
<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-laptop_framed.png" />
@@ -45,8 +46,8 @@ All documentation can be found at [docs.olivetin.app](https://docs.olivetin.app)
* **Dark mode** - for those of you that roll that way.
* **Accessible** - passes all the accessibility checks in Firefox, and issues with accessibility are taken seriously.
* **Container** - available for quickly testing and getting it up and running, great for the selfhosted community.
* **Integrate with anything** - OliveTin just runs Linux shell commands, so theoretially you could integrate with a bunch of stuff just by using curl, ping, etc. However, writing your own shell scripts is a great way to extend OliveTin.
* **Lightweight on resources** - uses only a few MB of RAM and barely any CPU. Written in Go, with a web interface written as a modern, responsive, Single Page App that uses the REST/gRPC API.
* **Integrate with anything** - OliveTin just runs Linux shell commands, so theoretically you could integrate with a bunch of stuff just by using curl, ping, etc. However, writing your own shell scripts is a great way to extend OliveTin.
* **Lightweight on resources** - uses only a few MB of RAM and barely any CPU. Written in Go, with a web interface written as a modern, responsive, Single Page App that uses the REST/Connect RPC API.
* **Good amount of unit tests and style checks** - helps potential contributors be consistent, and helps with maintainability.
## Screenshots

View File

@@ -5,21 +5,10 @@
# Listen on all addresses available, port 1337
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
bannerMessage: "This is an early alpha version of OliveTin 3000. Many thanks are broken, many things will change."
bannerCss: "background-color: #b2e4b2; color: black; font-size: small; text-align: center; padding: .6em; border-radius: 0.5em;"
insecureAllowDumpSos: true
insecureAllowDumpVars: true
# Choose from INFO (default), WARN and DEBUG
# Docs: https://docs.olivetin.app/advanced_configuration/logs.html
logLevel: "INFO"
# Checking for updates https://docs.olivetin.app/reference/updateChecks.html
checkForUpdates: false
authLocalUsers:
enabled: true
# Actions are commands that are executed by OliveTin, and normally show up as
# buttons on the WebUI.
#
@@ -55,6 +44,7 @@ actions:
# You can also rate-limit actions too.
- title: date
shell: date
id: date
timeout: 6
icon: clock
popupOnStart: execution-button
@@ -322,3 +312,55 @@ dashboards:
- title: 'Start {{ .CurrentEntity.Names }}'
- title: 'Stop {{ .CurrentEntity.Names }}'
# Security - Authentication
# This setting effectively enables or disables guests.
# If set to "true", then users will have to login to do anything.
authRequireGuestsToLogin: false
# This form of auth is the simplest to setup - just define users and passwords
# in the config. OliveTin also supports header-based auth, OAuth2,
# and JWT authentication which are documented separately.
#
# Docs: https://docs.olivetin.app/security/local.html
#
# How to get a hashed password:
# Docs: https://docs.olivetin.app/security/local.html#_get_a_argon2id_hashed_password
authLocalUsers:
enabled: true
# users:
# - username: alice
# usergroup: admins
# password: "$argon2id$v=19$m=65536,t=4,p=2$puyxA0s555TSFx7hnFLCXA$PyhLGpZtvpMMvc2DgMWkM8OJMKO55euwV5gm//1iwx4"
# Security - Access Control
# Policies affect the whole app (eg: ability to view the log list).
# Docs: https://docs.olivetin.app/security/acl.html
defaultPolicy:
showDiagnostics: true
showLogList: true
# Permissions affect actions (eg: ability to view a specific log).
# Docs: https://docs.olivetin.app/security/acl.html
defaultPermissions:
view: true
exec: true
logs: true
# OliveTin uses access control lists to match up policy and permissions to users.
# Docs: https://docs.olivetin.app/security/acl.html
accessControlLists:
- name: admin_acl
matchUsergroups: ["admins"]
policy:
showDiagnostics: true
permissions:
view: true
exec: true
logs: true
# OliveTin contains many more configuration options not in this default config.
# Check out docs.olivetin.app for a setting if you feel like you're missing something.

View File

@@ -1,273 +0,0 @@
class ArgumentForm extends window.HTMLElement {
getQueryParams () {
return new URLSearchParams(window.location.search.substring(1))
}
setup (json, callback) {
this.setAttribute('class', 'action-arguments')
this.constructTemplate()
this.domTitle.innerText = json.title
this.domIcon.innerHTML = json.icon
this.createDomFormArguments(json.arguments)
this.domBtnStart.onclick = () => {
for (const arg of this.argInputs) {
if (!arg.validity.valid) {
return
}
}
const argvs = this.getArgumentValues()
callback(argvs)
this.remove()
}
this.domBtnCancel.onclick = () => {
this.clearBookmark()
this.remove()
}
}
getArgumentValues () {
const ret = []
for (const arg of this.argInputs) {
if (arg.type === 'checkbox') {
if (arg.checked) {
arg.value = '1'
} else {
arg.value = '0'
}
}
if (arg.name === '') {
continue
}
ret.push({
name: arg.name,
value: arg.value
})
}
return ret
}
constructTemplate () {
const tpl = document.getElementById('tplArgumentForm')
const content = tpl.content.cloneNode(true)
this.appendChild(content)
this.domTitle = this.querySelector('h2')
this.domIcon = this.querySelector('span.icon')
this.domWrapper = this.querySelector('.wrapper')
this.domArgs = this.querySelector('.arguments')
this.domBtnStart = this.querySelector('[name=start]')
this.domBtnCancel = this.querySelector('[name=cancel]')
}
createDomFormArguments (args) {
this.argInputs = []
for (const arg of args) {
this.domArgs.appendChild(this.createDomLabel(arg))
this.domArgs.appendChild(this.createDomSuggestions(arg))
this.domArgs.appendChild(this.createDomInput(arg))
this.domArgs.appendChild(this.createDomDescription(arg))
}
}
createDomLabel (arg) {
const domLbl = document.createElement('label')
const lastChar = arg.title.charAt(arg.title.length - 1)
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
domLbl.innerHTML = arg.title
} else {
domLbl.innerHTML = arg.title + ':'
}
domLbl.setAttribute('for', arg.name)
return domLbl
}
createDomSuggestions (arg) {
if (typeof arg.suggestions !== 'object' || arg.suggestions.length === 0) {
return document.createElement('span')
}
const ret = document.createElement('datalist')
ret.setAttribute('id', arg.name + '-choices')
for (const suggestion of Object.keys(arg.suggestions)) {
const opt = document.createElement('option')
opt.setAttribute('value', suggestion)
if (typeof arg.suggestions[suggestion] !== 'undefined' && arg.suggestions[suggestion].length > 0) {
opt.innerText = arg.suggestions[suggestion]
}
ret.appendChild(opt)
}
return ret
}
createDomInput (arg) {
let domEl = null
if (arg.choices.length > 0 && (arg.type === 'select' || arg.type === '')) {
domEl = document.createElement('select')
// select/choice elements don't get an onchange/validation because theoretically
// the user should only select from a dropdown of valid options. The choices are
// riggeriously checked on StartAction anyway. ValidateArgumentType is only
// meant for showing simple warnings in the UI before running.
for (const choice of arg.choices) {
domEl.appendChild(this.createSelectOption(choice))
}
} else {
switch (arg.type) {
case 'html':
domEl = document.createElement('div')
domEl.innerHTML = arg.defaultValue
return domEl
case 'confirmation':
this.domBtnStart.disabled = true
domEl = document.createElement('input')
domEl.setAttribute('type', 'checkbox')
domEl.onchange = () => {
this.domBtnStart.disabled = false
domEl.disabled = true
}
break
case 'raw_string_multiline':
domEl = document.createElement('textarea')
domEl.setAttribute('rows', '5')
domEl.style.resize = 'vertical'
break
case 'datetime':
domEl = document.createElement('input')
domEl.setAttribute('type', 'datetime-local')
domEl.setAttribute('step', '1')
break
case 'checkbox':
domEl = document.createElement('input')
domEl.setAttribute('type', 'checkbox')
domEl.setAttribute('name', arg.name)
domEl.setAttribute('value', '1')
break
case 'password':
case 'email':
domEl = document.createElement('input')
domEl.setAttribute('type', arg.type)
break
default:
domEl = document.createElement('input')
if (arg.type.startsWith('regex:')) {
domEl.setAttribute('pattern', arg.type.replace('regex:', ''))
}
domEl.onchange = () => {
this.formatValidation(domEl, arg)
}
}
}
domEl.name = arg.name
// Use query parameter value if available
const params = this.getQueryParams()
const paramValue = params.get(arg.name)
if (paramValue !== null) {
domEl.value = paramValue
} else {
domEl.value = arg.defaultValue
}
// update the URL when a parameter is changed
domEl.addEventListener('change', this.updateUrlWithArg)
if (typeof arg.suggestions === 'object' && Object.keys(arg.suggestions).length > 0) {
domEl.setAttribute('list', arg.name + '-choices')
}
this.argInputs.push(domEl)
return domEl
}
async formatValidation (domEl, arg) {
const validateArgumentTypeArgs = {
value: domEl.value,
type: arg.type
}
const validation = await window.validateArgumentType(validateArgumentTypeArgs)
if (validation.valid) {
domEl.setCustomValidity('')
} else {
domEl.setCustomValidity(validation.description)
}
}
updateUrlWithArg (ev) {
if (!ev.target.name) {
return
}
const url = new URL(window.location.href)
if (ev.target.type === 'password') {
return
}
// copy the parameter value
url.searchParams.set(ev.target.name, ev.target.value)
// Update the URL without reloading the page
window.history.replaceState({}, '', url.toString())
}
createDomDescription (arg) {
const domArgumentDescription = document.createElement('span')
domArgumentDescription.classList.add('argument-description')
domArgumentDescription.innerHTML = arg.description
return domArgumentDescription
}
createSelectOption (choice) {
const domEl = document.createElement('option')
domEl.setAttribute('value', choice.value)
domEl.innerText = choice.title
return domEl
}
clearBookmark () {
// remove the action from the URL
window.history.replaceState({
path: window.location.pathname
}, '', window.location.pathname)
}
}
window.customElements.define('argument-form', ArgumentForm)

View File

@@ -1,29 +0,0 @@
export class ExecutionFeedbackButton extends window.HTMLElement {
onExecutionFinished (LogEntry) {
if (LogEntry.timedOut) {
this.renderExecutionResult('action-timeout', 'Timed out')
} else if (LogEntry.blocked) {
this.renderExecutionResult('action-blocked', 'Blocked!')
} else if (LogEntry.exitCode !== 0) {
this.renderExecutionResult('action-nonzero-exit', 'Exit code ' + LogEntry.exitCode)
} else {
this.ellapsed = Math.ceil(new Date(LogEntry.datetimeFinished) - new Date(LogEntry.datetimeStarted)) / 1000
this.renderExecutionResult('action-success', 'Success!')
}
}
renderExecutionResult (resultCssClass, temporaryStatusMessage) {
this.updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
this.onExecStatusChanged()
}
updateDom (resultCssClass, title) {
if (resultCssClass == null) {
this.btn.className = ''
} else {
this.btn.classList.add(resultCssClass)
}
this.domTitle.innerText = title
}
}

View File

@@ -15,7 +15,8 @@ import { Mutex } from './Mutex.js'
* occour in sequential order.
*/
export class OutputTerminal {
constructor () {
constructor (executionTrackingId) {
this.executionTrackingId = executionTrackingId
this.writeMutex = new Mutex()
this.terminal = new Terminal({
convertEol: true

View File

@@ -1,13 +0,0 @@
export function initMarshaller () {
window.addEventListener('EventOutputChunk', onOutputChunk)
}
function onOutputChunk (evt) {
const chunk = evt.payload
if (window.terminal) {
if (chunk.executionTrackingId === window.terminal.executionTrackingId) {
window.terminal.write(chunk.output)
}
}
}

View File

@@ -1,6 +1,8 @@
import { buttonResults } from '../resources/vue/stores/buttonResults.js'
export function checkWebsocketConnection () {
export function initWebsocket () {
window.addEventListener('EventOutputChunk', onOutputChunk)
reconnectWebsocket()
}
@@ -47,3 +49,13 @@ function handleEvent (msg) {
window.showBigError('ws-unhandled-message', 'handling websocket message', 'Unhandled websocket message type from server: ' + typeName, true)
}
}
function onOutputChunk (evt) {
const chunk = evt.payload
if (window.terminal) {
if (chunk.executionTrackingId === window.terminal.executionTrackingId) {
window.terminal.write(chunk.output)
}
}
}

View File

@@ -1,6 +1,7 @@
'use strict'
import 'femtocrank/style.css'
import 'femtocrank/dark.css'
import './style.css'
import 'iconify-icon'
@@ -11,43 +12,91 @@ import { createConnectTransport } from '@connectrpc/connect-web'
import { OliveTinApiService } from './resources/scripts/gen/olivetin/api/v1/olivetin_pb'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import router from './resources/vue/router.js'
import App from './resources/vue/App.vue'
import {
initMarshaller
} from './js/marshaller.js'
import { initWebsocket } from './js/websocket.js'
import combinedTranslations from '../lang/combined_output.json'
import { checkWebsocketConnection } from './js/websocket.js'
function getSelectedLanguage() {
const storedLanguage = localStorage.getItem('olivetin-language');
function initClient () {
if (storedLanguage && storedLanguage !== 'auto') {
return storedLanguage;
}
if (storedLanguage === 'auto') {
localStorage.removeItem('olivetin-language');
}
if (navigator.languages && navigator.languages.length > 0) {
const available = Object.keys(combinedTranslations.messages || {})
for (const candidate of navigator.languages) {
const lowerCandidate = candidate.toLowerCase()
const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
if (exact) {
return exact
}
const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
if (prefix) {
return prefix
}
}
}
return 'en';
}
async function initClient () {
const transport = createConnectTransport({
baseUrl: window.location.protocol + '//' + window.location.host + '/api/'
})
window.client = createClient(OliveTinApiService, transport)
window.initResponse = await window.client.init({})
const i18nSettings = createI18n({
legacy: false,
locale: getSelectedLanguage(),
fallbackLocale: 'en',
messages: combinedTranslations.messages,
postTranslation: (translated) => {
const params = new URLSearchParams(window.location.search)
if (params.has('debug-translations')) {
return '____'
} else {
return translated
}
}
})
return i18nSettings
}
function setupVue () {
function setupVue (i18nSettings) {
const app = createApp(App)
app.use(router)
app.use(i18nSettings)
window.i18n = i18nSettings.global
app.mount('#app')
}
function main () {
initClient()
async function main () {
const i18nSettings = await initClient()
// Expose websocket connection function globally so App.vue can call it after successful init
window.checkWebsocketConnection = checkWebsocketConnection
initWebsocket()
setupVue()
initMarshaller()
// window.addEventListener('EventConfigChanged', fetchGetDashboardComponents)
// window.addEventListener('EventEntityChanged', fetchGetDashboardComponents)
setupVue(i18nSettings)
}
main() // call self
main()

File diff suppressed because it is too large Load Diff

View File

@@ -29,10 +29,12 @@
"@vitejs/plugin-vue": "^6.0.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"iconify-icon": "^3.0.1",
"picocrank": "^1.6.2",
"iconify-icon": "^3.0.2",
"picocrank": "^1.8.7",
"standard": "^17.1.2",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.1.12",
"vite": "^7.2.2",
"vue-i18n": "^11.1.12",
"vue-router": "^4.6.3"
}
}

View File

@@ -48,6 +48,11 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & {
* @generated from field: int32 order = 7;
*/
order: number;
/**
* @generated from field: int32 timeout = 8;
*/
timeout: number;
};
/**
@@ -581,6 +586,63 @@ export declare type GetLogsResponse = Message<"olivetin.api.v1.GetLogsResponse">
*/
export declare const GetLogsResponseSchema: GenMessage<GetLogsResponse>;
/**
* @generated from message olivetin.api.v1.GetActionLogsRequest
*/
export declare type GetActionLogsRequest = Message<"olivetin.api.v1.GetActionLogsRequest"> & {
/**
* @generated from field: string action_id = 1;
*/
actionId: string;
/**
* @generated from field: int64 start_offset = 2;
*/
startOffset: bigint;
};
/**
* Describes the message olivetin.api.v1.GetActionLogsRequest.
* Use `create(GetActionLogsRequestSchema)` to create a new message.
*/
export declare const GetActionLogsRequestSchema: GenMessage<GetActionLogsRequest>;
/**
* @generated from message olivetin.api.v1.GetActionLogsResponse
*/
export declare type GetActionLogsResponse = Message<"olivetin.api.v1.GetActionLogsResponse"> & {
/**
* @generated from field: repeated olivetin.api.v1.LogEntry logs = 1;
*/
logs: LogEntry[];
/**
* @generated from field: int64 count_remaining = 2;
*/
countRemaining: bigint;
/**
* @generated from field: int64 page_size = 3;
*/
pageSize: bigint;
/**
* @generated from field: int64 total_count = 4;
*/
totalCount: bigint;
/**
* @generated from field: int64 start_offset = 5;
*/
startOffset: bigint;
};
/**
* Describes the message olivetin.api.v1.GetActionLogsResponse.
* Use `create(GetActionLogsResponseSchema)` to create a new message.
*/
export declare const GetActionLogsResponseSchema: GenMessage<GetActionLogsResponse>;
/**
* @generated from message olivetin.api.v1.ValidateArgumentTypeRequest
*/
@@ -1316,6 +1378,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
* @generated from field: bool show_log_list = 22;
*/
showLogList: boolean;
/**
* @generated from field: bool login_required = 23;
*/
loginRequired: boolean;
};
/**
@@ -1570,6 +1637,14 @@ export declare const OliveTinApiService: GenService<{
input: typeof GetLogsRequestSchema;
output: typeof GetLogsResponseSchema;
},
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.GetActionLogs
*/
getActionLogs: {
methodKind: "unary";
input: typeof GetActionLogsRequestSchema;
output: typeof GetActionLogsResponseSchema;
},
/**
* @generated from rpc olivetin.api.v1.OliveTinApiService.ValidateArgumentType
*/

File diff suppressed because one or more lines are too long

View File

@@ -293,4 +293,20 @@ watch(
top: 0;
}
@media (prefers-color-scheme: dark) {
.action-button button {
background: #111;
border-color: #000;
box-shadow: 0 0 6px #000;
color: #fff;
}
.action-button button:hover:not(:disabled) {
background: #222;
border-color: #000;
box-shadow: 0 0 6px #444;
color: #fff;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<Header title="OliveTin" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar">
<Header title="OliveTin" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="showNavigation">
<template #toolbar>
<div id="banner" v-if="bannerMessage" :style="bannerCss">
<p>{{ bannerMessage }}</p>
@@ -7,51 +7,44 @@
</template>
<template #user-info>
<div class="flex-row" style="gap: .5em;">
<span id="link-login" v-if="!isLoggedIn"><router-link to="/login">Login</router-link></span>
<div v-else>
<span id="username-text" :title="'Provider: ' + userProvider">{{ username }}</span>
<span id="link-logout" v-if="isLoggedIn"><a href="/api/Logout">Logout</a></span>
</div>
<HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" />
<div class="flex-row user-info" style="gap: .5em;">
<span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">{{ t('login-button') }}</router-link></span>
<router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
<span id="username-text">{{ username }}</span>
</router-link>
<HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" v-if="isLoggedIn" />
</div>
</template>
</Header>
<div id="layout">
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation && !initError" />
<Sidebar ref="sidebar" id = "mainnav" v-if="showNavigation" />
<div id="content" initial-martial-complete="{{ hasLoaded }}">
<main title="Main content">
<section v-if="initError" class="error-container error" style="text-align: center; padding: 2em;">
<h2>Failed to Initialize OliveTin</h2>
<p><strong>Error Message:</strong> {{ initErrorMessage }}</p>
<p>Please check the your browser console first, and then the server logs for more details.</p>
<button @click="retryInit" class="bad">Retry</button>
</section>
<router-view v-else :key="$route.fullPath" />
<router-view :key="$route.fullPath" />
</main>
<footer title="footer" v-if="showFooter && !initError">
<footer title="footer" v-if="showFooter">
<p>
<img title="application icon" src="../../OliveTinLogo.png" alt="OliveTin logo" height="1em"
class="logo" />
OliveTin 3000!
<img title="application icon" :src="logoUrl" alt="OliveTin logo" style="height: 1em;" class="logo" />
OliveTin {{ currentVersion }}
</p>
<p>
<span>
<a href="https://docs.olivetin.app" target="_new">Documentation</a>
<a href="https://docs.olivetin.app" target="_new">{{ t('docs') }}</a>
</span>
<span>
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">Raise an issue on
GitHub</a>
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">{{ t('raise-issue') }}</a>
</span>
<span>{{ currentVersion }}</span>
<span>
<a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
</span>
<span>{{ serverConnection }}</span>
<span>{{ t('connected') }}</span>
</p>
<p>
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
@@ -59,10 +52,30 @@
</footer>
</div>
</div>
<dialog ref="languageDialog" class="language-dialog" @click="handleDialogClick">
<div class="dialog-content" @click.stop>
<h2>{{ t('language-dialog.title') }}</h2>
<select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
<option v-for="(name, code) in availableLanguages" :key="code" :value="code">
{{ code === 'auto' ? name : `${name} (${code})` }}
</option>
</select>
<p class="browser-languages">
{{ t('language-dialog.browser-languages') }}:
<span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
<span v-else>{{ t('language-dialog.not-available') }}</span>
</p>
<div class="dialog-buttons">
<button @click="closeLanguageDialog">{{ t('language-dialog.close') }}</button>
</div>
</div>
</dialog>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import Sidebar from 'picocrank/vue/components/Sidebar.vue';
import Header from 'picocrank/vue/components/Header.vue';
import { HugeiconsIcon } from '@hugeicons/vue'
@@ -70,12 +83,17 @@ import { Menu01Icon } from '@hugeicons/core-free-icons'
import { UserCircle02Icon } from '@hugeicons/core-free-icons'
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();
const sidebar = ref(null);
const username = ref('guest');
const userProvider = ref('system');
const username = ref('notset');
const isLoggedIn = ref(false);
const serverConnection = ref('Connected');
const serverConnection = ref(true);
const currentVersion = ref('?');
const bannerMessage = ref('');
const bannerCss = ref('');
@@ -84,85 +102,247 @@ const showFooter = ref(true)
const showNavigation = ref(true)
const showLogs = ref(true)
const showDiagnostics = ref(true)
const initError = ref(false)
const initErrorMessage = ref('')
const showLoginLink = ref(true)
function toggleSidebar() {
sidebar.value.toggle()
const languageDialog = ref(null)
const browserLanguages = ref([])
const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
const languagePreference = ref(initialLanguagePreference || 'auto')
const selectedLanguage = ref(languagePreference.value)
// Available languages with display names
const availableLanguages = {
'auto': 'Browser Language',
'en': 'English',
'de-DE': 'Deutsch',
'es-ES': 'Español',
'it-IT': 'Italiano',
'zh-Hans-CN': '简体中文'
}
async function requestInit() {
try {
const initResponse = await window.client.init({})
// Computed property to get current language display name
const currentLanguageName = computed(() => {
if (languagePreference.value === 'auto') {
return availableLanguages['auto']
}
window.initResponse = initResponse
window.initError = false
window.initErrorMessage = ''
window.initCompleted = true
return availableLanguages[languagePreference.value] || languagePreference.value
})
username.value = initResponse.authenticatedUser
currentVersion.value = initResponse.currentVersion
bannerMessage.value = initResponse.bannerMessage || '';
bannerCss.value = initResponse.bannerCss || '';
showFooter.value = initResponse.showFooter
showNavigation.value = initResponse.showNavigation
showLogs.value = initResponse.showLogList
showDiagnostics.value = initResponse.showDiagnostics
function normalizeBrowserLanguage() {
const available = Object.keys(combinedTranslations.messages || {})
for (const rootDashboard of initResponse.rootDashboards) {
sidebar.value.addNavigationLink({
id: rootDashboard,
name: rootDashboard,
title: rootDashboard,
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
icon: DashboardSquare01Icon,
})
if (navigator.languages && navigator.languages.length > 0) {
for (const candidate of navigator.languages) {
const lowerCandidate = candidate.toLowerCase()
// Try exact match (case-insensitive)
const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
if (exact) {
return exact
}
// Try prefix match (e.g., "zh-CN" -> "zh-Hans-CN")
const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
if (prefix) {
return prefix
}
}
}
sidebar.value.addSeparator()
sidebar.value.addRouterLink('Entities')
return 'en'
}
if (showLogs.value) {
sidebar.value.addRouterLink('Logs')
}
if (showDiagnostics.value) {
sidebar.value.addRouterLink('Diagnostics')
}
hasLoaded.value = true;
initError.value = false;
// Only start websocket connection after successful init
if (window.checkWebsocketConnection) {
window.checkWebsocketConnection()
}
} catch (error) {
console.error("Error initializing client", error)
initError.value = true
initErrorMessage.value = error.message || 'Failed to connect to OliveTin server'
window.initError = true
window.initErrorMessage = error.message || 'Failed to connect to OliveTin server'
window.initCompleted = false
serverConnection.value = 'Disconnected'
function toggleSidebar() {
if (sidebar.value && showNavigation.value) {
sidebar.value.toggle()
}
}
function retryInit() {
initError.value = false
initErrorMessage.value = ''
window.initError = false
window.initErrorMessage = ''
window.initCompleted = false
requestInit()
function updateHeaderFromInit() {
if (!window.initResponse) {
return
}
username.value = window.initResponse.authenticatedUser
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
currentVersion.value = window.initResponse.currentVersion
bannerMessage.value = window.initResponse.bannerMessage || ''
bannerCss.value = window.initResponse.bannerCss || ''
showFooter.value = window.initResponse.showFooter
showNavigation.value = window.initResponse.showNavigation
showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
renderSidebar()
if (window.initResponse.loginRequired) {
router.push('/login')
return
}
}
function renderSidebar() {
if (!sidebar.value) {
return
}
const rootDashboards = window.initResponse?.rootDashboards || []
if (typeof sidebar.value.clear === 'function') {
sidebar.value.clear()
}
for (const rootDashboard of rootDashboards) {
sidebar.value.addNavigationLink({
id: rootDashboard,
name: rootDashboard,
title: rootDashboard,
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
icon: DashboardSquare01Icon,
})
}
sidebar.value.addSeparator()
sidebar.value.addRouterLink('Entities', t('nav.entities'))
if (showLogs.value) {
sidebar.value.addRouterLink('Logs', t('nav.logs'))
}
if (showDiagnostics.value) {
sidebar.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
}
}
function openLanguageDialog() {
selectedLanguage.value = languagePreference.value
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
} else {
browserLanguages.value = []
}
if (languageDialog.value) {
languageDialog.value.showModal()
}
}
function closeLanguageDialog() {
if (languageDialog.value) {
languageDialog.value.close()
}
}
function changeLanguage() {
if (!window.i18n || !selectedLanguage.value) {
return
}
if (selectedLanguage.value === 'auto') {
localStorage.removeItem('olivetin-language')
languagePreference.value = 'auto'
window.i18n.locale.value = normalizeBrowserLanguage()
} else {
window.i18n.locale.value = selectedLanguage.value
localStorage.setItem('olivetin-language', selectedLanguage.value)
languagePreference.value = selectedLanguage.value
}
// Update sidebar with new translations
if (sidebar.value) {
renderSidebar()
}
closeLanguageDialog()
}
function handleDialogClick(event) {
// Close dialog when clicking on the backdrop
if (event.target === languageDialog.value) {
closeLanguageDialog()
}
}
window.updateHeaderFromInit = updateHeaderFromInit
onMounted(() => {
serverConnection.value = 'Connected';
// Initialize global state
window.initError = false
window.initErrorMessage = ''
window.initCompleted = false
requestInit()
serverConnection.value = true;
updateHeaderFromInit()
// Initialize selected language from stored preference
selectedLanguage.value = languagePreference.value
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
}
})
</script>
<style scoped>
.user-info span {
margin-left: 1em;
}
.user-link {
text-decoration: none;
color: inherit;
}
.user-link:hover {
text-decoration: underline;
}
.language-dialog {
border: 1px solid var(--border-color, #ccc);
border-radius: 0.5rem;
padding: 0;
max-width: 400px;
width: 90%;
}
.language-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
padding: 1.5rem;
}
.dialog-content h2 {
margin-top: 0;
margin-bottom: 1rem;
}
.language-select {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
font-size: 1rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 0.25rem;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialog-buttons button {
padding: 0.5rem 1rem;
cursor: pointer;
}
.browser-languages {
font-size: 0.875rem;
color: var(--fg2, #555);
margin-bottom: 1rem;
}
</style>

View File

@@ -105,7 +105,7 @@ function waitForInitAndLoadDashboard() {
}, 1000)
// Check if init has completed successfully
if (window.initCompleted && window.initResponse) {
if (window.initResponse) {
getDashboard()
} else if (window.initError) {
// Init failed, show error immediately
@@ -118,7 +118,7 @@ function waitForInitAndLoadDashboard() {
} else {
// Init hasn't completed yet, poll for completion
checkInitInterval = setInterval(() => {
if (window.initCompleted && window.initResponse) {
if (window.initResponse) {
clearInterval(checkInitInterval)
checkInitInterval = null
getDashboard()

View File

@@ -14,12 +14,9 @@
</div>
<template v-else-if="component.type == 'fieldset'">
<fieldset>
<legend>{{ component.title }}</legend>
<template v-for="subcomponent in component.contents" :key="subcomponent.title">
<DashboardComponent :component="subcomponent" />
</template>
</fieldset>
<template v-for="subcomponent in component.contents" :key="subcomponent.title">
<DashboardComponent :component="subcomponent" />
</template>
</template>
<div v-else>

View File

@@ -1,284 +0,0 @@
<template>
<div class="pagination">
<div class="pagination-info">
<span class="pagination-text">
Showing {{ startItem + 1 }}-{{ endItem }} of {{ total }} {{ itemTitle }}
</span>
</div>
<div class="pagination-controls">
<button
class="pagination-btn"
:disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
title="Previous page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M15.41 7.41L14 6l-6 6l6 6l1.41-1.41L10.83 12z"/>
</svg>
</button>
<div class="pagination-pages">
<!-- First page -->
<button
v-if="showFirstPage"
class="pagination-btn"
:class="{ active: currentPage === 1 }"
@click="goToPage(1)"
>
1
</button>
<!-- Ellipsis after first page -->
<span v-if="showFirstEllipsis" class="pagination-ellipsis">...</span>
<!-- Page numbers around current page -->
<button
v-for="page in visiblePages"
:key="page"
class="pagination-btn"
:class="{ active: currentPage === page }"
@click="goToPage(page)"
>
{{ page }}
</button>
<!-- Ellipsis before last page -->
<span v-if="showLastEllipsis" class="pagination-ellipsis">...</span>
<!-- Last page -->
<button
v-if="showLastPage"
class="pagination-btn"
:class="{ active: currentPage === totalPages }"
@click="goToPage(totalPages)"
>
{{ totalPages }}
</button>
</div>
<button
class="pagination-btn"
:disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
title="Next page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M8.59 16.59L10 18l6-6l-6-6L8.59 7.41L13.17 12z"/>
</svg>
</button>
</div>
<div class="pagination-size" v-if="canChangePageSize">
<label for="page-size">Items per page:</label>
<select
id="page-size"
v-model="localPageSize"
@change="handlePageSizeChange"
class="page-size-select"
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
pageSize: {
type: Number,
default: 25
},
total: {
type: Number,
required: true
},
currentPage: {
type: Number,
default: 1
},
canChangePageSize: {
type: Boolean,
default: false
},
itemTitle: {
type: String,
default: 'items'
}
})
const emit = defineEmits(['page-change', 'page-size-change'])
const localPageSize = ref(props.pageSize)
const localCurrentPage = ref(props.currentPage)
// Computed properties
const totalPages = computed(() => Math.ceil(props.total / localPageSize.value))
const startItem = computed(() => (localCurrentPage.value - 1) * localPageSize.value)
const endItem = computed(() => Math.min(localCurrentPage.value * localPageSize.value, props.total))
// Pagination logic
const maxVisiblePages = 5
const visiblePages = computed(() => {
const pages = []
const halfVisible = Math.floor(maxVisiblePages / 2)
let start = Math.max(1, localCurrentPage.value - halfVisible)
let end = Math.min(totalPages.value, start + maxVisiblePages - 1)
// Adjust start if we're near the end
if (end - start < maxVisiblePages - 1) {
start = Math.max(1, end - maxVisiblePages + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const showFirstPage = computed(() => visiblePages.value[0] > 1)
const showLastPage = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value)
const showFirstEllipsis = computed(() => visiblePages.value[0] > 2)
const showLastEllipsis = computed(() => visiblePages.value[visiblePages.value.length - 1] < totalPages.value - 1)
// Methods
function goToPage(page) {
if (page >= 1 && page <= totalPages.value && page !== localCurrentPage.value) {
localCurrentPage.value = page
emit('page-change', page)
}
}
function handlePageSizeChange() {
// Reset to first page when changing page size
localCurrentPage.value = 1
emit('page-size-change', localPageSize.value)
emit('page-change', 1)
}
// Watch for prop changes
watch(() => props.currentPage, (newPage) => {
localCurrentPage.value = newPage
})
watch(() => props.pageSize, (newSize) => {
localPageSize.value = newSize
})
</script>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
}
.pagination-info {
flex: 1;
}
.pagination-text {
font-size: 0.875rem;
color: #6c757d;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pagination-pages {
display: flex;
align-items: center;
gap: 0.25rem;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
padding: 0.5rem;
border: 1px solid #dee2e6;
background: #fff;
color: #495057;
text-decoration: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.pagination-btn:hover:not(:disabled) {
background: #e9ecef;
border-color: #adb5bd;
color: #495057;
}
.pagination-btn.active {
background: #c6d0d7;
color: #333;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-ellipsis {
padding: 0.5rem;
color: #6c757d;
font-size: 0.875rem;
}
.pagination-size {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #6c757d;
}
.page-size-select {
padding: 0.25rem 0.5rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #fff;
font-size: 0.875rem;
}
.page-size-select:focus {
outline: none;
border-color: #5681af;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Responsive design */
@media (max-width: 768px) {
.pagination {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.pagination-controls {
justify-content: center;
}
.pagination-size {
justify-content: center;
}
}
</style>

View File

@@ -70,6 +70,19 @@ const routes = [
]
}
},
{
path: '/action/:actionId',
name: 'ActionDetails',
component: () => import('./views/ActionDetailsView.vue'),
props: true,
meta: {
title: 'Action Details',
breadcrumb: [
{ name: "Actions", href: "/" },
{ name: "Action Details" },
]
}
},
{
path: '/diagnostics',
name: 'Diagnostics',
@@ -85,6 +98,12 @@ const routes = [
component: () => import('./views/LoginView.vue'),
meta: { title: 'Login' }
},
{
path: '/user',
name: 'UserInformation',
component: () => import('./views/UserControlPanel.vue'),
meta: { title: 'User Information' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',

View File

@@ -0,0 +1,389 @@
<template>
<Section :title="'Action Details: ' + actionTitle" :padding="false">
<template #toolbar>
<button v-if="action" @click="startAction" title="Start this action" class="button neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 6v12l8-6z" />
</svg>
Start
</button>
</template>
<div class = "flex-row padding" v-if="action">
<div class = "fg1">
<dl>
<dt>Title</dt>
<dd>{{ action.title }}</dd>
<dt>Timeout</dt>
<dd>{{ action.timeout }} seconds</dd>
</dl>
<p v-if="action" class = "fg1">
Execution history for this action. You can filter by execution tracking ID.
</p>
</div>
<div style = "align-self: start; text-align: right;">
<span class="icon" v-html="action.icon"></span>
<div class="filter-container">
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input placeholder="Filter current page" v-model="searchText" />
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</label>
</div>
</div>
</div>
<div v-show="filteredLogs.length > 0">
<table class="logs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Execution ID</th>
<th>Metadata</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td>
<router-link :to="`/logs/${log.executionTrackingId}`">
{{ log.executionTrackingId }}
</router-link>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ log.user }}</span>
</span>
<span v-if="log.tags && log.tags.length > 0" class="tag-list">
<span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<span :class="getStatusClass(log) + ' annotation'">
{{ getStatusText(log) }}
</span>
</td>
</tr>
</tbody>
</table>
<Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" @page-change="handlePageChange" class="padding"
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
</div>
<div v-show="logs.length === 0 && !loading" class="empty-state">
<p>This action has no execution history.</p>
<router-link to="/">Return to index</router-link>
</div>
</Section>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Pagination from 'picocrank/vue/components/Pagination.vue'
import Section from 'picocrank/vue/components/Section.vue'
const route = useRoute()
const router = useRouter()
const logs = ref([])
const action = ref(null)
const actionTitle = ref('Action Details')
const searchText = ref('')
const pageSize = ref(10)
const currentPage = ref(1)
const loading = ref(false)
const totalCount = ref(0)
const filteredLogs = computed(() => {
if (!searchText.value) {
return logs.value
}
const searchLower = searchText.value.toLowerCase()
return logs.value.filter(log =>
log.executionTrackingId.toLowerCase().includes(searchLower) ||
log.actionTitle.toLowerCase().includes(searchLower)
)
})
async function fetchActionLogs() {
loading.value = true
try {
const actionId = route.params.actionId
const startOffset = (currentPage.value - 1) * pageSize.value
const args = {
"actionId": actionId,
"startOffset": BigInt(startOffset),
"pageSize": BigInt(Number(pageSize.value)),
}
const response = await window.client.getActionLogs(args)
logs.value = response.logs
const serverPageSize = Number(response.pageSize)
if (Number.isFinite(serverPageSize) && serverPageSize > 0) {
pageSize.value = serverPageSize
}
totalCount.value = Number(response.totalCount) || 0
} catch (err) {
console.error('Failed to fetch action logs:', err)
window.showBigError('fetch-action-logs', 'getting action logs', err, false)
} finally {
loading.value = false
}
}
async function fetchAction() {
try {
const actionId = route.params.actionId
const args = {
"bindingId": actionId
}
const response = await window.client.getActionBinding(args)
action.value = response.action
actionTitle.value = action.value?.title || 'Unknown Action'
} catch (err) {
console.error('Failed to fetch action:', err)
window.showBigError('fetch-action', 'getting action details', err, false)
}
}
function resetState() {
action.value = null
actionTitle.value = 'Action Details'
logs.value = []
totalCount.value = 0
currentPage.value = 1
searchText.value = ''
loading.value = true
}
function clearSearch() {
searchText.value = ''
}
function formatTimestamp(timestamp) {
if (!timestamp) return 'Unknown'
try {
const date = new Date(timestamp)
return date.toLocaleString()
} catch (err) {
return timestamp
}
}
function getStatusClass(log) {
if (log.timedOut) return 'status-timeout'
if (log.blocked) return 'status-blocked'
if (log.exitCode !== 0) return 'status-error'
return 'status-success'
}
function getStatusText(log) {
if (log.timedOut) return 'Timed out'
if (log.blocked) return 'Blocked'
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
return 'Completed'
}
function handlePageChange(page) {
currentPage.value = page
fetchActionLogs()
}
function handlePageSizeChange(newPageSize) {
pageSize.value = newPageSize
currentPage.value = 1
fetchActionLogs()
}
async function startAction() {
if (!action.value || !action.value.bindingId) {
console.error('Cannot start action: no binding ID')
return
}
try {
const args = {
"bindingId": action.value.bindingId,
"arguments": []
}
const response = await window.client.startAction(args)
router.push(`/logs/${response.executionTrackingId}`)
} catch (err) {
console.error('Failed to start action:', err)
window.showBigError('start-action', 'starting action', err, false)
}
}
onMounted(() => {
fetchAction()
fetchActionLogs()
})
watch(
() => route.params.actionId,
() => {
resetState()
fetchAction()
fetchActionLogs()
},
{ immediate: false }
)
</script>
<style scoped>
.action-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-header h2 {
margin: 0;
}
.icon {
font-size: 1.5rem;
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table th {
background-color: var(--section-background);
padding: 0.5rem;
text-align: left;
font-weight: 600;
}
.logs-table td {
padding: 0.5rem;
border-top: 1px solid var(--border-color);
}
.log-row:hover {
background-color: var(--hover-background);
}
.timestamp {
font-family: monospace;
font-size: 0.9rem;
color: var(--text-secondary);
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.filter-container {
display: flex;
justify-content: flex-end;
padding: 0.5rem 1rem;
}
.input-with-icons {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background: var(--section-background);
width: 100%;
max-width: 300px;
}
.input-with-icons input {
border: none;
outline: none;
background: transparent;
flex: 1;
color: var(--text-primary);
}
.input-with-icons button {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
}
.input-with-icons button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.annotation {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.85rem;
}
.annotation-key {
font-weight: 600;
color: var(--text-secondary);
}
.annotation-val {
color: var(--text-primary);
}
.tag-list {
display: inline-flex;
gap: 0.25rem;
}
.tag {
background-color: var(--accent-color);
color: var(--accent-text);
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.exit-code .status-success {
color: #28a745;
}
.exit-code .status-error {
color: #dc3545;
}
.exit-code .status-timeout {
color: #ffc107;
}
.exit-code .status-blocked {
color: #6c757d;
}
.padding {
padding: 1rem;
}
</style>

View File

@@ -4,33 +4,33 @@
<h2>Start action: {{ title }}</h2>
</div>
<div class="section-content padding">
<form @submit.prevent="handleSubmit">
<form @submit="handleSubmit">
<template v-if="actionArguments.length > 0">
<template v-for="arg in actionArguments" :key="arg.name" class="argument-group">
<label :for="arg.name">
{{ formatLabel(arg.title) }}
</label>
<template v-for="arg in actionArguments" :key="arg.name">
<label :for="arg.name">
{{ formatLabel(arg.title) }}
</label>
<datalist v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0" :id="`${arg.name}-choices`">
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
{{ suggestion }}
</option>
</datalist>
<datalist v-if="arg.suggestions && Object.keys(arg.suggestions).length > 0" :id="`${arg.name}-choices`">
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
{{ suggestion }}
</option>
</datalist>
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
:required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
<option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
{{ choice.title || choice.value }}
</option>
</select>
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
:list="arg.suggestions ? `${arg.name}-choices` : undefined"
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)" :required="arg.required"
@input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
<select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
:required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
<option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
{{ choice.title || choice.value }}
</option>
</select>
<component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
:list="arg.suggestions ? `${arg.name}-choices` : undefined"
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)" :required="arg.required"
@input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
<span class="argument-description" v-html="arg.description"></span>
</template>
@@ -40,7 +40,7 @@
</div>
<div class="buttons">
<button name="start" type="submit" :disabled="!isFormValid || (hasConfirmation && !confirmationChecked)">
<button name="start" type="submit" :disabled="hasConfirmation && !confirmationChecked">
Start
</button>
<button name="cancel" type="button" @click="handleCancel">
@@ -53,11 +53,10 @@
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const emit = defineEmits(['submit', 'cancel', 'close'])
// Reactive data
const dialog = ref(null)
@@ -71,7 +70,6 @@ const formErrors = ref({})
const actionArguments = ref([])
// Computed properties
const isFormValid = computed(() => Object.keys(formErrors.value).length === 0)
const props = defineProps({
bindingId: {
@@ -87,7 +85,6 @@ async function setup() {
})
const action = ret.action
console.log('action', action)
title.value = action.title
icon.value = action.icon
@@ -99,13 +96,30 @@ async function setup() {
// Initialize values from query params or defaults
actionArguments.value.forEach(arg => {
const paramValue = getQueryParamValue(arg.name)
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
if (arg.type === 'confirmation') {
hasConfirmation.value = true
const paramValue = getQueryParamValue(arg.name)
let checkedValue = false
if (paramValue !== null) {
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
} else if (arg.defaultValue !== undefined && arg.defaultValue !== '') {
checkedValue = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
}
argValues.value[arg.name] = checkedValue
confirmationChecked.value = checkedValue
} else {
const paramValue = getQueryParamValue(arg.name)
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
}
})
// Run initial validation on all fields after DOM is updated
await nextTick()
for (const arg of actionArguments.value) {
if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation') {
await validateArgument(arg, argValues.value[arg.name])
}
}
}
function getQueryParamValue(paramName) {
@@ -138,7 +152,11 @@ function getInputType(arg) {
return undefined
}
if (arg.type === 'ascii_identifier') {
if (arg.type === 'confirmation') {
return 'checkbox'
}
if (arg.type === 'ascii_identifier' || arg.type === 'ascii') {
return 'text'
}
@@ -153,8 +171,8 @@ function getPattern(arg) {
}
function getArgumentValue(arg) {
if (arg.type === 'checkbox') {
return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true || argValues.value[arg.name] === 'true'
}
return argValues.value[arg.name] || ''
}
@@ -186,15 +204,31 @@ async function validateArgument(arg, value) {
type: arg.type
}
const validation = await window.validateArgumentType(validateArgumentTypeArgs)
const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
// Get the input element to set custom validity
const inputElement = document.getElementById(arg.name)
if (validation.valid) {
delete formErrors.value[arg.name]
// Clear custom validity message
if (inputElement) {
inputElement.setCustomValidity('')
}
} else {
formErrors.value[arg.name] = validation.description
// Set custom validity message
if (inputElement) {
inputElement.setCustomValidity(validation.description)
}
}
} catch (err) {
console.warn('Validation failed:', err)
// On error, clear any custom validity
const inputElement = document.getElementById(arg.name)
if (inputElement) {
inputElement.setCustomValidity('')
}
}
}
@@ -219,7 +253,7 @@ function getArgumentValues() {
for (const arg of actionArguments.value) {
let value = argValues.value[arg.name] || ''
if (arg.type === 'checkbox') {
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
value = value ? '1' : '0'
}
@@ -232,40 +266,66 @@ function getArgumentValues() {
return ret
}
function handleSubmit() {
// Validate all inputs
let isValid = true
function getUniqueId() {
if (window.isSecureContext) {
return window.crypto.randomUUID()
} else {
return Date.now().toString()
}
}
async function startAction(actionArgs) {
const startActionArgs = {
bindingId: props.bindingId,
arguments: actionArgs,
uniqueTrackingId: getUniqueId()
}
try {
await window.client.startAction(startActionArgs)
console.log('Action started successfully with tracking ID:', startActionArgs.uniqueTrackingId)
} catch (err) {
console.error('Failed to start action:', err)
}
}
async function handleSubmit(event) {
// Set custom validity for required fields
for (const arg of actionArguments.value) {
const value = argValues.value[arg.name]
const inputElement = document.getElementById(arg.name)
if (arg.required && (!value || value === '')) {
formErrors.value[arg.name] = 'This field is required'
isValid = false
// Set custom validity for required field validation
if (inputElement) {
inputElement.setCustomValidity('This field is required')
}
}
}
if (!isValid) {
const form = event.target
if (!form.checkValidity()) {
console.log('argument form has elements that failed validation')
return
}
event.preventDefault()
const argvs = getArgumentValues()
emit('submit', argvs)
close()
console.log('argument form has elements that passed validation')
await startAction(argvs)
router.back()
}
function handleCancel() {
router.back()
clearBookmark()
emit('cancel')
close()
}
function handleClose() {
emit('close')
}
function clearBookmark() {
// Remove the action from the URL
window.history.replaceState({
path: window.location.pathname
}, '', window.location.pathname)
@@ -301,22 +361,6 @@ form {
grid-template-columns: max-content auto auto;
}
.argument-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.argument-group label {
font-weight: 500;
color: #333;
}
.argument-group input:invalid,
.argument-group select:invalid,
.argument-group textarea:invalid {
border-color: #dc3545;
}
.argument-description {
font-size: 0.875rem;

View File

@@ -1,7 +1,13 @@
<template>
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
<Section :title="'Execution Results: ' + title" id = "execution-results-popup">
<template #toolbar>
<button @click="toggleSize" title="Toggle dialog size">
<router-link v-if="actionId" :to="`/action/${actionId}`" title="View all executions for this action" class="button neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm.31-8.86c-1.77-.45-2.34-.94-2.34-1.67 0-.84.79-1.43 2.1-1.43 1.38 0 1.9.66 1.94 1.64h1.71c-.05-1.34-.87-2.57-2.49-2.97V5H10.9v1.69c-1.51.32-2.72 1.3-2.72 2.81 0 1.79 1.49 2.69 3.66 3.21 1.95.46 2.34 1.22 2.34 1.8 0 .53-.39 1.39-2.1 1.39-1.6 0-2.05-.56-2.13-1.45H8.04c.08 1.5 1.18 2.37 2.82 2.69V19h2.34v-1.63c1.65-.35 2.48-1.24 2.48-2.77-.01-1.88-1.51-2.87-3.7-3.23z"/>
</svg>
Action Details
</router-link>
<button @click="toggleSize" title="Toggle dialog size" class = "neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
@@ -22,6 +28,13 @@
<span class="icon" role="img" v-html="icon" style = "align-self: start"></span>
</div>
<div v-if="notFound" class="error-message padded-content">
<h3>Execution Not Found</h3>
<p>{{ errorMessage }}</p>
<p>The execution with ID <code>{{ executionTrackingId }}</code> could not be found.</p>
<router-link to="/logs">View all logs</router-link> or <router-link to="/">return to home</router-link>.
</div>
<div ref="xtermOutput"></div>
<br />
@@ -81,20 +94,35 @@ const duration = ref('')
const logEntry = ref(null)
const canRerun = ref(false)
const canKill = ref(false)
const actionId = ref('')
const notFound = ref(false)
const errorMessage = ref('')
let executionTicker = null
let terminal = null
function initializeTerminal() {
terminal = new OutputTerminal(executionTrackingId.value, this)
terminal = new OutputTerminal(executionTrackingId.value)
terminal.open(xtermOutput.value)
terminal.resize(80, 24)
terminal.resize(80, 40)
window.terminal = terminal
}
function toggleSize() {
terminal.fit()
if (!xtermOutput.value) {
return
}
if (xtermOutput.value.requestFullscreen) {
xtermOutput.value.requestFullscreen()
} else if (xtermOutput.value.webkitRequestFullscreen) {
xtermOutput.value.webkitRequestFullscreen()
} else if (xtermOutput.value.mozRequestFullScreen) {
xtermOutput.value.mozRequestFullScreen()
} else if (xtermOutput.value.msRequestFullscreen) {
xtermOutput.value.msRequestFullscreen()
}
}
async function reset() {
@@ -112,6 +140,8 @@ async function reset() {
canRerun.value = false
canKill.value = false
logEntry.value = null
notFound.value = false
errorMessage.value = ''
if (terminal) {
await terminal.reset()
@@ -139,10 +169,23 @@ function show(actionButton) {
}
async function rerunAction() {
let startActionArgs = {}
const res = await window.client.startAction(startActionArgs)
if (!logEntry.value || !logEntry.value.actionId) {
console.error('Cannot rerun: no action ID available')
return
}
try {
const startActionArgs = {
"bindingId": logEntry.value.actionId,
"arguments": []
}
const res = await window.client.startAction(startActionArgs)
router.push(`/logs/${res.executionTrackingId}`)
} catch (err) {
console.error('Failed to rerun action:', err)
window.showBigError('rerun-action', 'rerunning action', err, false)
}
}
async function killAction() {
@@ -177,6 +220,8 @@ async function fetchExecutionResult(executionTrackingIdParam) {
console.log("fetchExecutionResult", executionTrackingIdParam)
executionTrackingId.value = executionTrackingIdParam
notFound.value = false
errorMessage.value = ''
const executionStatusArgs = {
executionTrackingId: executionTrackingId.value
@@ -187,7 +232,13 @@ async function fetchExecutionResult(executionTrackingIdParam) {
await renderExecutionResult(logEntryResult)
} catch (err) {
renderError(err)
// Check if it's a "not found" error (404 or similar)
if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
notFound.value = true
errorMessage.value = err.message || 'The execution could not be found in the system.'
} else {
renderError(err)
}
throw err
}
}
@@ -204,7 +255,7 @@ function updateDuration(logEntryParam) {
} else {
let delta = ''
try {
delta = (new Date(logEntry.value.datetimeStarted) - new Date(logEntry.value.datetimeStarted)) / 1000
delta = (new Date(logEntry.value.datetimeFinished) - new Date(logEntry.value.datetimeStarted)) / 1000
delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
} catch (e) {
console.warn('Failed to calculate delta', e)
@@ -236,6 +287,7 @@ async function renderExecutionResult(res) {
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
updateDuration(res.logEntry)
@@ -275,6 +327,17 @@ function goBack() {
}
onMounted(() => {
document.addEventListener('fullscreenchange', (e) => {
setTimeout(() => { // Wait for the DOM to settle
if (document.fullscreenElement) {
terminal.fit()
} else {
terminal.resize(80, 40)
terminal.fit()
}
}, 100)
})
initializeTerminal()
fetchExecutionResult(props.executionTrackingId)
@@ -308,3 +371,43 @@ defineExpose({
})
</script>
<style scoped>
.action-history-link {
color: var(--link-color, #007bff);
text-decoration: none;
display: inline-block;
font-size: 0.9rem;
}
.error-message {
background-color: #f8d7da;
border: 1px solid #f5c2c7;
border-radius: 0.25rem;
padding: 1.5rem;
margin: 1rem 0;
}
.error-message h3 {
margin: 0 0 0.5rem 0;
color: #721c24;
}
.error-message p {
margin: 0.5rem 0;
color: #721c24;
}
.error-message code {
background-color: #f8d7da;
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
font-family: monospace;
}
.error-message a {
color: #721c24;
text-decoration: underline;
font-weight: 500;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<Section title="Login to OliveTin" class="small">
<div class="login-form" style="display: grid; grid-template-columns: max-content 1fr; gap: 1em;">
<div class="login-form">
<div v-if="!hasOAuth && !hasLocalLogin" class="login-disabled">
<span>This server is not configured with either OAuth, or local users, so you cannot login.</span>
</div>
@@ -19,15 +19,12 @@
<div v-if="hasLocalLogin" class="login-local">
<h3>Local Login</h3>
<form @submit.prevent="handleLocalLogin" class="local-login-form">
<div v-if="loginError" class="error-message">
<div v-if="loginError" class="bad">
{{ loginError }}
</div>
<label for="username">Username:</label>
<input id="username" v-model="username" type="text" name="username" autocomplete="username" required />
<label for="password">Password:</label>
<input id="password" v-model="password" type="password" name="password" autocomplete="current-password"
<input id="username" v-model="username" type="text" name="username" autocomplete="username" required placeholder="Username" />
<input id="password" v-model="password" type="password" name="password" autocomplete="current-password" placeholder="Password"
required />
<button type="submit" :disabled="loading" class="login-button">
@@ -40,7 +37,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import Section from 'picocrank/vue/components/Section.vue'
@@ -54,19 +51,17 @@ const hasOAuth = ref(false)
const hasLocalLogin = ref(false)
const oauthProviders = ref([])
async function fetchLoginOptions() {
try {
const response = await fetch('webUiSettings.json')
const settings = await response.json()
hasOAuth.value = settings.AuthOAuth2Providers && settings.AuthOAuth2Providers.length > 0
hasLocalLogin.value = settings.AuthLocalLogin
function loadLoginOptions() {
// Use the init response data that was loaded in App.vue
if (window.initResponse) {
hasOAuth.value = window.initResponse.oAuth2Providers && window.initResponse.oAuth2Providers.length > 0
hasLocalLogin.value = window.initResponse.authLocalLogin
if (hasOAuth.value) {
oauthProviders.value = settings.AuthOAuth2Providers
oauthProviders.value = window.initResponse.oAuth2Providers
}
} catch (err) {
console.error('Failed to fetch login options:', err)
} else {
console.warn('Init response not available yet, login options will be empty')
}
}
@@ -75,27 +70,36 @@ async function handleLocalLogin() {
loginError.value = ''
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: username.value,
password: password.value
})
const response = await window.client.localUserLogin({
username: username.value,
password: password.value
})
if (response.ok) {
if (response.success) {
// Re-initialize to get updated user context
try {
const initResponse = await window.client.init({})
window.initResponse = initResponse
window.initError = false
window.initErrorMessage = ''
window.initCompleted = true
// Update the header with new user info
if (window.updateHeaderFromInit) {
window.updateHeaderFromInit()
}
} catch (initErr) {
console.error('Failed to reinitialize after login:', initErr)
}
// Redirect to home page on successful login
router.push('/')
} else {
const error = await response.text()
loginError.value = error || 'Login failed. Please check your credentials.'
loginError.value = 'Login failed. Please check your credentials.'
}
} catch (err) {
console.error('Login error:', err)
loginError.value = 'Network error. Please try again.'
loginError.value = err.message || 'Network error. Please try again.'
} finally {
loading.value = false
}
@@ -107,7 +111,12 @@ function loginWithOAuth(provider) {
}
onMounted(() => {
fetchLoginOptions()
loadLoginOptions()
// Also watch for when init response becomes available
const stopWatcher = watch(() => window.initResponse, () => {
loadLoginOptions()
}, { immediate: true })
})
</script>
@@ -125,7 +134,7 @@ section {
}
form {
grid-template-columns: max-content 1fr;
grid-template-columns: 1fr;
gap: 1em;
}
</style>

View File

@@ -1,13 +1,13 @@
<template>
<Section title="Logs" :padding="false">
<Section :title="t('logs.title')" :padding="false">
<template #toolbar>
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input placeholder="Filter current page" v-model="searchText" />
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
<input :placeholder="t('search-filter')" v-model="searchText" />
<button :title="t('logs.clear-filter')" :disabled="!searchText" @click="clearSearch">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
@@ -16,15 +16,15 @@
</label>
</template>
<p class = "padding">This is a list of logs from actions that have been executed. You can filter the list by action title.</p>
<p class = "padding">{{ t('logs.page-description') }}</p>
<div v-show="filteredLogs.length > 0">
<table class="logs-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Metadata</th>
<th>Status</th>
<th>{{ t('logs.timestamp') }}</th>
<th>{{ t('logs.action') }}</th>
<th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th>
</tr>
</thead>
<tbody>
@@ -59,16 +59,17 @@
</div>
<div v-show="logs.length === 0" class="empty-state">
<p>There are no logs to display.</p>
<router-link to="/">Return to index</router-link>
<p>{{ t('logs.no-logs-to-display') }}</p>
<router-link to="/">{{ t('return-to-index') }}</router-link>
</div>
</Section>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Pagination from '../components/Pagination.vue'
import Pagination from 'picocrank/vue/components/Pagination.vue'
import Section from 'picocrank/vue/components/Section.vue'
import { useI18n } from 'vue-i18n'
const logs = ref([])
const searchText = ref('')
@@ -77,14 +78,24 @@ const currentPage = ref(1)
const loading = ref(false)
const totalCount = ref(0)
const { t } = useI18n()
const filteredLogs = computed(() => {
if (!searchText.value) {
return logs.value
let result = logs.value
if (searchText.value) {
const searchLower = searchText.value.toLowerCase()
result = logs.value.filter(log =>
log.actionTitle.toLowerCase().includes(searchLower)
)
}
const searchLower = searchText.value.toLowerCase()
return logs.value.filter(log =>
log.actionTitle.toLowerCase().includes(searchLower)
)
// Sort by timestamp with most recent first
return [...result].sort((a, b) => {
const dateA = a.datetimeStarted ? new Date(a.datetimeStarted).getTime() : 0
const dateB = b.datetimeStarted ? new Date(b.datetimeStarted).getTime() : 0
return dateB - dateA // Descending order (most recent first)
})
})
async function fetchLogs() {
@@ -131,10 +142,10 @@ function getStatusClass(log) {
}
function getStatusText(log) {
if (log.timedOut) return 'Timed out'
if (log.blocked) return 'Blocked'
if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
return 'Completed'
if (log.timedOut) return t('logs.timed-out')
if (log.blocked) return t('logs.blocked')
if (log.exitCode !== 0) return `${t('logs.exit-code')} ${log.exitCode}`
return t('logs.completed')
}
function handlePageChange(page) {
@@ -161,17 +172,20 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background: var(--section-background);
width: 100%;
max-width: 300px;
}
.input-with-icons input {
border: none;
outline: none;
background: transparent;
flex: 1;
font-size: 1rem;
color: var(--text-primary);
}
.input-with-icons button {
@@ -182,10 +196,6 @@ onMounted(() => {
border-radius: 3px;
}
.input-with-icons button:hover:not(:disabled) {
background: #f5f5f5;
}
.input-with-icons button:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -212,24 +222,25 @@ onMounted(() => {
text-decoration: underline;
}
.status-success {
color: #28a745;
.annotation {
font-weight: 500;
font-size: smaller;
}
.status-success {
color: var(--karma-good-fg);
}
.status-error {
color: #dc3545;
font-weight: 500;
color: var(--karma-bad-fg);
}
.status-timeout {
color: #ffc107;
font-weight: 500;
color: var(--karma-warning-fg);
}
.status-blocked {
color: #6c757d;
font-weight: 500;
color: var(--karma-neutral-fg);
}
.empty-state {

View File

@@ -0,0 +1,178 @@
<template>
<Section title="User Information" class="small">
<div v-if="!isLoggedIn" class="user-not-logged-in">
<p>You are not currently logged in.</p>
<p>To access user settings and logout, please <router-link to="/login">log in</router-link>.</p>
</div>
<div v-else class="user-control-panel">
<dl class="user-info">
<dt>Username</dt>
<dd>{{ username }}</dd>
<dt v-if="userProvider !== 'system'">Provider</dt>
<dd v-if="userProvider !== 'system'">{{ userProvider }}</dd>
<dt v-if="usergroup">Group</dt>
<dd v-if="usergroup">{{ usergroup }}</dd>
<dt v-if="acls && acls.length > 0">Matched ACLs</dt>
<dd v-if="acls && acls.length > 0">
<span class="acl-tag" v-for="(acl, idx) in acls" :key="`acl-${idx}`">{{ acl }}</span>
</dd>
</dl>
<div class="user-actions">
<div class="action-buttons">
<button @click="handleLogout" class="button bad" :disabled="loggingOut">
{{ loggingOut ? 'Logging out...' : 'Logout' }}
</button>
</div>
</div>
</div>
</Section>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import Section from 'picocrank/vue/components/Section.vue'
const router = useRouter()
const isLoggedIn = ref(false)
const username = ref('guest')
const userProvider = ref('system')
const usergroup = ref('')
const loggingOut = ref(false)
const acls = ref([])
function updateUserInfo() {
if (window.initResponse) {
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
username.value = window.initResponse.authenticatedUser
userProvider.value = window.initResponse.authenticatedUserProvider || 'system'
usergroup.value = window.initResponse.effectivePolicy?.usergroup || ''
}
}
async function fetchWhoAmI() {
try {
const res = await window.client.whoAmI({})
acls.value = res.acls || []
// Update usergroup from authoritative WhoAmI response
if (res.usergroup) {
usergroup.value = res.usergroup
}
} catch (e) {
console.warn('Failed to fetch WhoAmI for ACLs', e)
acls.value = []
}
}
async function handleLogout() {
loggingOut.value = true
try {
await window.client.logout({})
// Re-initialize to get updated user context (should be guest)
try {
const initResponse = await window.client.init({})
window.initResponse = initResponse
window.initError = false
window.initErrorMessage = ''
window.initCompleted = true
// Update the header with new user info
if (window.updateHeaderFromInit) {
window.updateHeaderFromInit()
}
} catch (initErr) {
console.error('Failed to reinitialize after logout:', initErr)
}
// Redirect based on init response: if login is required, go to login page
if (window.initResponse && window.initResponse.loginRequired) {
router.push('/login')
} else {
router.push('/')
}
} catch (err) {
console.error('Logout error:', err)
} finally {
loggingOut.value = false
}
}
let watchInterval = null
onMounted(() => {
updateUserInfo()
fetchWhoAmI()
})
onUnmounted(() => {
if (watchInterval) {
clearInterval(watchInterval)
}
})
</script>
<style scoped>
section {
margin: auto;
}
.user-not-logged-in {
padding: 2rem;
text-align: center;
}
.user-not-logged-in p {
margin: 1rem 0;
}
.user-control-panel {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
.action-buttons {
display: flex;
gap: 1rem;
}
.acl-tag {
display: inline-block;
background: var(--section-background);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
padding: 0.1rem 0.4rem;
margin: 0 0.25rem 0.25rem 0;
font-size: 0.85rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 4px;
border: none;
cursor: pointer;
text-align: center;
font-weight: 500;
transition: background-color 0.2s;
}
.button.bad {
background-color: #dc3545;
color: white;
}
.button.bad:hover:not(:disabled) {
background-color: #c82333;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -21,34 +21,10 @@ main {
padding-top: 4em;
}
action-button {
display: flex;
flex-direction: column;
flex-grow: 1;
}
action-button > button {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
font-weight: normal;
font-size: 0.85em;
box-shadow: 0 0 .6em #aaa;
}
action-button > button .icon {
font-size: 3em;
}
dialog {
border-radius: 1em;
}
footer span {
margin-right: 1em;
}
legend {
font-weight: bold;
text-align: center;
@@ -56,11 +32,6 @@ legend {
padding-top: 1.5em;
}
button.neutral {
background-color: transparent;
color: white;
}
section {
padding: 0;
}
@@ -92,14 +63,6 @@ div.buttons button svg {
vertical-align: middle;
}
footer {
font-size: small;
}
th {
background-color: #fff;
}
section.small {
border-radius: .4em;
}

View File

@@ -14,11 +14,6 @@ export default defineConfig({
],
server: {
proxy: {
'/webUiSettings.json': {
target: 'http://localhost:1337',
changeOrigin: true,
secure: false,
},
'/api': {
target: 'http://localhost:1337',
changeOrigin: true,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,700 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.3.0
// - protoc (unknown)
// source: OliveTin.proto
package grpc
import (
context "context"
httpbody "google.golang.org/genproto/googleapis/api/httpbody"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
const (
OliveTinApiService_GetDashboardComponents_FullMethodName = "/OliveTinApiService/GetDashboardComponents"
OliveTinApiService_StartAction_FullMethodName = "/OliveTinApiService/StartAction"
OliveTinApiService_StartActionAndWait_FullMethodName = "/OliveTinApiService/StartActionAndWait"
OliveTinApiService_StartActionByGet_FullMethodName = "/OliveTinApiService/StartActionByGet"
OliveTinApiService_StartActionByGetAndWait_FullMethodName = "/OliveTinApiService/StartActionByGetAndWait"
OliveTinApiService_KillAction_FullMethodName = "/OliveTinApiService/KillAction"
OliveTinApiService_ExecutionStatus_FullMethodName = "/OliveTinApiService/ExecutionStatus"
OliveTinApiService_GetLogs_FullMethodName = "/OliveTinApiService/GetLogs"
OliveTinApiService_ValidateArgumentType_FullMethodName = "/OliveTinApiService/ValidateArgumentType"
OliveTinApiService_WhoAmI_FullMethodName = "/OliveTinApiService/WhoAmI"
OliveTinApiService_SosReport_FullMethodName = "/OliveTinApiService/SosReport"
OliveTinApiService_DumpVars_FullMethodName = "/OliveTinApiService/DumpVars"
OliveTinApiService_DumpPublicIdActionMap_FullMethodName = "/OliveTinApiService/DumpPublicIdActionMap"
OliveTinApiService_GetReadyz_FullMethodName = "/OliveTinApiService/GetReadyz"
OliveTinApiService_LocalUserLogin_FullMethodName = "/OliveTinApiService/LocalUserLogin"
OliveTinApiService_PasswordHash_FullMethodName = "/OliveTinApiService/PasswordHash"
OliveTinApiService_Logout_FullMethodName = "/OliveTinApiService/Logout"
)
// OliveTinApiServiceClient is the client API for OliveTinApiService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type OliveTinApiServiceClient interface {
GetDashboardComponents(ctx context.Context, in *GetDashboardComponentsRequest, opts ...grpc.CallOption) (*GetDashboardComponentsResponse, error)
StartAction(ctx context.Context, in *StartActionRequest, opts ...grpc.CallOption) (*StartActionResponse, error)
StartActionAndWait(ctx context.Context, in *StartActionAndWaitRequest, opts ...grpc.CallOption) (*StartActionAndWaitResponse, error)
StartActionByGet(ctx context.Context, in *StartActionByGetRequest, opts ...grpc.CallOption) (*StartActionByGetResponse, error)
StartActionByGetAndWait(ctx context.Context, in *StartActionByGetAndWaitRequest, opts ...grpc.CallOption) (*StartActionByGetAndWaitResponse, error)
KillAction(ctx context.Context, in *KillActionRequest, opts ...grpc.CallOption) (*KillActionResponse, error)
ExecutionStatus(ctx context.Context, in *ExecutionStatusRequest, opts ...grpc.CallOption) (*ExecutionStatusResponse, error)
GetLogs(ctx context.Context, in *GetLogsRequest, opts ...grpc.CallOption) (*GetLogsResponse, error)
ValidateArgumentType(ctx context.Context, in *ValidateArgumentTypeRequest, opts ...grpc.CallOption) (*ValidateArgumentTypeResponse, error)
WhoAmI(ctx context.Context, in *WhoAmIRequest, opts ...grpc.CallOption) (*WhoAmIResponse, error)
SosReport(ctx context.Context, in *SosReportRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
DumpVars(ctx context.Context, in *DumpVarsRequest, opts ...grpc.CallOption) (*DumpVarsResponse, error)
DumpPublicIdActionMap(ctx context.Context, in *DumpPublicIdActionMapRequest, opts ...grpc.CallOption) (*DumpPublicIdActionMapResponse, error)
GetReadyz(ctx context.Context, in *GetReadyzRequest, opts ...grpc.CallOption) (*GetReadyzResponse, error)
LocalUserLogin(ctx context.Context, in *LocalUserLoginRequest, opts ...grpc.CallOption) (*LocalUserLoginResponse, error)
PasswordHash(ctx context.Context, in *PasswordHashRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
}
type oliveTinApiServiceClient struct {
cc grpc.ClientConnInterface
}
func NewOliveTinApiServiceClient(cc grpc.ClientConnInterface) OliveTinApiServiceClient {
return &oliveTinApiServiceClient{cc}
}
func (c *oliveTinApiServiceClient) GetDashboardComponents(ctx context.Context, in *GetDashboardComponentsRequest, opts ...grpc.CallOption) (*GetDashboardComponentsResponse, error) {
out := new(GetDashboardComponentsResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_GetDashboardComponents_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartAction(ctx context.Context, in *StartActionRequest, opts ...grpc.CallOption) (*StartActionResponse, error) {
out := new(StartActionResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartAction_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartActionAndWait(ctx context.Context, in *StartActionAndWaitRequest, opts ...grpc.CallOption) (*StartActionAndWaitResponse, error) {
out := new(StartActionAndWaitResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartActionAndWait_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartActionByGet(ctx context.Context, in *StartActionByGetRequest, opts ...grpc.CallOption) (*StartActionByGetResponse, error) {
out := new(StartActionByGetResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartActionByGet_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) StartActionByGetAndWait(ctx context.Context, in *StartActionByGetAndWaitRequest, opts ...grpc.CallOption) (*StartActionByGetAndWaitResponse, error) {
out := new(StartActionByGetAndWaitResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_StartActionByGetAndWait_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) KillAction(ctx context.Context, in *KillActionRequest, opts ...grpc.CallOption) (*KillActionResponse, error) {
out := new(KillActionResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_KillAction_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) ExecutionStatus(ctx context.Context, in *ExecutionStatusRequest, opts ...grpc.CallOption) (*ExecutionStatusResponse, error) {
out := new(ExecutionStatusResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_ExecutionStatus_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) GetLogs(ctx context.Context, in *GetLogsRequest, opts ...grpc.CallOption) (*GetLogsResponse, error) {
out := new(GetLogsResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_GetLogs_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, in *ValidateArgumentTypeRequest, opts ...grpc.CallOption) (*ValidateArgumentTypeResponse, error) {
out := new(ValidateArgumentTypeResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_ValidateArgumentType_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) WhoAmI(ctx context.Context, in *WhoAmIRequest, opts ...grpc.CallOption) (*WhoAmIResponse, error) {
out := new(WhoAmIResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_WhoAmI_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) SosReport(ctx context.Context, in *SosReportRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, OliveTinApiService_SosReport_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) DumpVars(ctx context.Context, in *DumpVarsRequest, opts ...grpc.CallOption) (*DumpVarsResponse, error) {
out := new(DumpVarsResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_DumpVars_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) DumpPublicIdActionMap(ctx context.Context, in *DumpPublicIdActionMapRequest, opts ...grpc.CallOption) (*DumpPublicIdActionMapResponse, error) {
out := new(DumpPublicIdActionMapResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_DumpPublicIdActionMap_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) GetReadyz(ctx context.Context, in *GetReadyzRequest, opts ...grpc.CallOption) (*GetReadyzResponse, error) {
out := new(GetReadyzResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_GetReadyz_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) LocalUserLogin(ctx context.Context, in *LocalUserLoginRequest, opts ...grpc.CallOption) (*LocalUserLoginResponse, error) {
out := new(LocalUserLoginResponse)
err := c.cc.Invoke(ctx, OliveTinApiService_LocalUserLogin_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) PasswordHash(ctx context.Context, in *PasswordHashRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, OliveTinApiService_PasswordHash_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *oliveTinApiServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, OliveTinApiService_Logout_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// OliveTinApiServiceServer is the server API for OliveTinApiService service.
// All implementations should embed UnimplementedOliveTinApiServiceServer
// for forward compatibility
type OliveTinApiServiceServer interface {
GetDashboardComponents(context.Context, *GetDashboardComponentsRequest) (*GetDashboardComponentsResponse, error)
StartAction(context.Context, *StartActionRequest) (*StartActionResponse, error)
StartActionAndWait(context.Context, *StartActionAndWaitRequest) (*StartActionAndWaitResponse, error)
StartActionByGet(context.Context, *StartActionByGetRequest) (*StartActionByGetResponse, error)
StartActionByGetAndWait(context.Context, *StartActionByGetAndWaitRequest) (*StartActionByGetAndWaitResponse, error)
KillAction(context.Context, *KillActionRequest) (*KillActionResponse, error)
ExecutionStatus(context.Context, *ExecutionStatusRequest) (*ExecutionStatusResponse, error)
GetLogs(context.Context, *GetLogsRequest) (*GetLogsResponse, error)
ValidateArgumentType(context.Context, *ValidateArgumentTypeRequest) (*ValidateArgumentTypeResponse, error)
WhoAmI(context.Context, *WhoAmIRequest) (*WhoAmIResponse, error)
SosReport(context.Context, *SosReportRequest) (*httpbody.HttpBody, error)
DumpVars(context.Context, *DumpVarsRequest) (*DumpVarsResponse, error)
DumpPublicIdActionMap(context.Context, *DumpPublicIdActionMapRequest) (*DumpPublicIdActionMapResponse, error)
GetReadyz(context.Context, *GetReadyzRequest) (*GetReadyzResponse, error)
LocalUserLogin(context.Context, *LocalUserLoginRequest) (*LocalUserLoginResponse, error)
PasswordHash(context.Context, *PasswordHashRequest) (*httpbody.HttpBody, error)
Logout(context.Context, *LogoutRequest) (*httpbody.HttpBody, error)
}
// UnimplementedOliveTinApiServiceServer should be embedded to have forward compatible implementations.
type UnimplementedOliveTinApiServiceServer struct {
}
func (UnimplementedOliveTinApiServiceServer) GetDashboardComponents(context.Context, *GetDashboardComponentsRequest) (*GetDashboardComponentsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDashboardComponents not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartAction(context.Context, *StartActionRequest) (*StartActionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartAction not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartActionAndWait(context.Context, *StartActionAndWaitRequest) (*StartActionAndWaitResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartActionAndWait not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartActionByGet(context.Context, *StartActionByGetRequest) (*StartActionByGetResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartActionByGet not implemented")
}
func (UnimplementedOliveTinApiServiceServer) StartActionByGetAndWait(context.Context, *StartActionByGetAndWaitRequest) (*StartActionByGetAndWaitResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartActionByGetAndWait not implemented")
}
func (UnimplementedOliveTinApiServiceServer) KillAction(context.Context, *KillActionRequest) (*KillActionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method KillAction not implemented")
}
func (UnimplementedOliveTinApiServiceServer) ExecutionStatus(context.Context, *ExecutionStatusRequest) (*ExecutionStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ExecutionStatus not implemented")
}
func (UnimplementedOliveTinApiServiceServer) GetLogs(context.Context, *GetLogsRequest) (*GetLogsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetLogs not implemented")
}
func (UnimplementedOliveTinApiServiceServer) ValidateArgumentType(context.Context, *ValidateArgumentTypeRequest) (*ValidateArgumentTypeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidateArgumentType not implemented")
}
func (UnimplementedOliveTinApiServiceServer) WhoAmI(context.Context, *WhoAmIRequest) (*WhoAmIResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method WhoAmI not implemented")
}
func (UnimplementedOliveTinApiServiceServer) SosReport(context.Context, *SosReportRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method SosReport not implemented")
}
func (UnimplementedOliveTinApiServiceServer) DumpVars(context.Context, *DumpVarsRequest) (*DumpVarsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DumpVars not implemented")
}
func (UnimplementedOliveTinApiServiceServer) DumpPublicIdActionMap(context.Context, *DumpPublicIdActionMapRequest) (*DumpPublicIdActionMapResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DumpPublicIdActionMap not implemented")
}
func (UnimplementedOliveTinApiServiceServer) GetReadyz(context.Context, *GetReadyzRequest) (*GetReadyzResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetReadyz not implemented")
}
func (UnimplementedOliveTinApiServiceServer) LocalUserLogin(context.Context, *LocalUserLoginRequest) (*LocalUserLoginResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LocalUserLogin not implemented")
}
func (UnimplementedOliveTinApiServiceServer) PasswordHash(context.Context, *PasswordHashRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method PasswordHash not implemented")
}
func (UnimplementedOliveTinApiServiceServer) Logout(context.Context, *LogoutRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
}
// UnsafeOliveTinApiServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to OliveTinApiServiceServer will
// result in compilation errors.
type UnsafeOliveTinApiServiceServer interface {
mustEmbedUnimplementedOliveTinApiServiceServer()
}
func RegisterOliveTinApiServiceServer(s grpc.ServiceRegistrar, srv OliveTinApiServiceServer) {
s.RegisterService(&OliveTinApiService_ServiceDesc, srv)
}
func _OliveTinApiService_GetDashboardComponents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDashboardComponentsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).GetDashboardComponents(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_GetDashboardComponents_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).GetDashboardComponents(ctx, req.(*GetDashboardComponentsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartAction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartAction(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartAction_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartAction(ctx, req.(*StartActionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartActionAndWait_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionAndWaitRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartActionAndWait(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartActionAndWait_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartActionAndWait(ctx, req.(*StartActionAndWaitRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartActionByGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionByGetRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartActionByGet(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartActionByGet_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartActionByGet(ctx, req.(*StartActionByGetRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_StartActionByGetAndWait_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartActionByGetAndWaitRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).StartActionByGetAndWait(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_StartActionByGetAndWait_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).StartActionByGetAndWait(ctx, req.(*StartActionByGetAndWaitRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_KillAction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(KillActionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).KillAction(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_KillAction_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).KillAction(ctx, req.(*KillActionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_ExecutionStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExecutionStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).ExecutionStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_ExecutionStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).ExecutionStatus(ctx, req.(*ExecutionStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_GetLogs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLogsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).GetLogs(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_GetLogs_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).GetLogs(ctx, req.(*GetLogsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_ValidateArgumentType_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ValidateArgumentTypeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).ValidateArgumentType(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_ValidateArgumentType_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).ValidateArgumentType(ctx, req.(*ValidateArgumentTypeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_WhoAmI_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(WhoAmIRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).WhoAmI(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_WhoAmI_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).WhoAmI(ctx, req.(*WhoAmIRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_SosReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SosReportRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).SosReport(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_SosReport_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).SosReport(ctx, req.(*SosReportRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_DumpVars_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DumpVarsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).DumpVars(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_DumpVars_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).DumpVars(ctx, req.(*DumpVarsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_DumpPublicIdActionMap_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DumpPublicIdActionMapRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).DumpPublicIdActionMap(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_DumpPublicIdActionMap_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).DumpPublicIdActionMap(ctx, req.(*DumpPublicIdActionMapRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_GetReadyz_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetReadyzRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).GetReadyz(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_GetReadyz_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).GetReadyz(ctx, req.(*GetReadyzRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_LocalUserLogin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LocalUserLoginRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).LocalUserLogin(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_LocalUserLogin_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).LocalUserLogin(ctx, req.(*LocalUserLoginRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_PasswordHash_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PasswordHashRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).PasswordHash(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_PasswordHash_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).PasswordHash(ctx, req.(*PasswordHashRequest))
}
return interceptor(ctx, in, info, handler)
}
func _OliveTinApiService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(OliveTinApiServiceServer).Logout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: OliveTinApiService_Logout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(OliveTinApiServiceServer).Logout(ctx, req.(*LogoutRequest))
}
return interceptor(ctx, in, info, handler)
}
// OliveTinApiService_ServiceDesc is the grpc.ServiceDesc for OliveTinApiService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var OliveTinApiService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "OliveTinApiService",
HandlerType: (*OliveTinApiServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetDashboardComponents",
Handler: _OliveTinApiService_GetDashboardComponents_Handler,
},
{
MethodName: "StartAction",
Handler: _OliveTinApiService_StartAction_Handler,
},
{
MethodName: "StartActionAndWait",
Handler: _OliveTinApiService_StartActionAndWait_Handler,
},
{
MethodName: "StartActionByGet",
Handler: _OliveTinApiService_StartActionByGet_Handler,
},
{
MethodName: "StartActionByGetAndWait",
Handler: _OliveTinApiService_StartActionByGetAndWait_Handler,
},
{
MethodName: "KillAction",
Handler: _OliveTinApiService_KillAction_Handler,
},
{
MethodName: "ExecutionStatus",
Handler: _OliveTinApiService_ExecutionStatus_Handler,
},
{
MethodName: "GetLogs",
Handler: _OliveTinApiService_GetLogs_Handler,
},
{
MethodName: "ValidateArgumentType",
Handler: _OliveTinApiService_ValidateArgumentType_Handler,
},
{
MethodName: "WhoAmI",
Handler: _OliveTinApiService_WhoAmI_Handler,
},
{
MethodName: "SosReport",
Handler: _OliveTinApiService_SosReport_Handler,
},
{
MethodName: "DumpVars",
Handler: _OliveTinApiService_DumpVars_Handler,
},
{
MethodName: "DumpPublicIdActionMap",
Handler: _OliveTinApiService_DumpPublicIdActionMap_Handler,
},
{
MethodName: "GetReadyz",
Handler: _OliveTinApiService_GetReadyz_Handler,
},
{
MethodName: "LocalUserLogin",
Handler: _OliveTinApiService_LocalUserLogin_Handler,
},
{
MethodName: "PasswordHash",
Handler: _OliveTinApiService_PasswordHash_Handler,
},
{
MethodName: "Logout",
Handler: _OliveTinApiService_Logout_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "OliveTin.proto",
}

View File

@@ -0,0 +1,53 @@
#
# Integration Test Config: Require Guests to Login
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
# Require guests to login
authRequireGuestsToLogin: true
# Enable local user authentication
authLocalUsers:
enabled: true
users:
- username: "alice"
usergroup: "admins"
password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
- username: "bob"
usergroup: "users"
password: "$argon2id$v=19$m=65536,t=4,p=6$ORxyZZGW6E3FWZnbQmHJ9Q$BzIOWeXry/BZ6+JV1T4UASBnebVLB9QJ4f5TmUPXsg4" # notsecret: password
accessControlLists:
- name: "admin"
matchUsergroups: ["admins"]
addToEveryAction: true
permissions:
view: true
exec: true
logs: true
kill: true
- name: "users"
matchUsergroups: ["users"]
addToEveryAction: true
permissions:
view: true
exec: false
logs: false
kill: false
# Simple actions for testing
actions:
- title: Ping Google.com
shell: ping google.com -c 1
icon: ping
- title: sleep 2 seconds
shell: sleep 2
icon: "&#x1F971"

View File

@@ -0,0 +1,6 @@
# This file should be loaded first
actions:
- title: First Included Action
shell: echo "first"
icon: ping

View File

@@ -0,0 +1,9 @@
# This file should be loaded second
actions:
- title: Second Included Action
shell: echo "second"
icon: ping
# Override base setting
logLevel: "INFO"

View File

@@ -0,0 +1,14 @@
#
# Integration Test Config: Include Directive
#
logLevel: "DEBUG"
checkForUpdates: false
include: config.d
actions:
- title: Base Action
shell: echo "base"
icon: ping

View File

@@ -0,0 +1,26 @@
#
# Integration Test Config: Local User Authentication
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
# Enable local user authentication
authLocalUsers:
enabled: true
users:
- username: "testuser"
usergroup: "admin"
password: "testpass123"
# Simple actions for testing
actions:
- title: Ping Google.com
shell: ping google.com -c 1
icon: ping
- title: sleep 2 seconds
shell: sleep 2
icon: "&#x1F971"

View File

@@ -0,0 +1,2 @@
make:
haproxy -f ./haproxy.conf

View File

@@ -1,15 +1,32 @@
global
stats socket /var/run/haproxy/admin.sock mode 660 level admin
log stdout local0
frontend cleartext_frontend
bind 0.0.0.0:80
bind 0.0.0.0:8080
timeout client 10s
stats enable
stats uri /stats
stats refresh 30s
log global
option httplog
option dontlognull
log-format "[%t] %ci:%cp -> BACKEND:%b/%s | %HM %HU | Status:%ST | Bytes:%B"
mode http
use_backend be_olivetin_webs if { hdr(Host) -i olivetin.example.com && path_beg /websocket }
use_backend be_olivetin_http if { hdr(Host) -i olivetin.example.com }
use_backend be_olivetin if { hdr(Host) -i -m beg olivetin.example.com }
backend be_olivetin_http
server olivetinServer 127.0.0.1:1337 check
backend be_olivetin_webs
backend be_olivetin
mode http
timeout connect 10s
timeout server 10s
timeout tunnel 1h
option http-server-close
server olivetinServer 127.0.0.1:1337
http-request set-header X-Forwarded-User "Alice"
http-request set-header X-Forwarded-Group "Group1,Group2"
server olivetinServer 127.0.0.1:1337 check

View File

@@ -0,0 +1,39 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, until } from 'selenium-webdriver'
import {
getRootAndWait,
takeScreenshotOnFailure,
} from '../lib/elements.js'
describe('config: authRequireGuestsToLogin', function () {
this.timeout(30000)
before(async function () {
await runner.start('authRequireGuestsToLogin')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver);
});
it('Guest is redirected to login', async function () {
// Don't use getRootAndWait here because we want to test the redirect, and getRootAndWait waits for the dashboard to load
await webdriver.get(runner.baseUrl())
await webdriver.wait(until.urlContains('/login'), 10000)
// Verify login UI elements are present
const loginElements = await webdriver.findElements(By.css('form.local-login-form, .login-oauth2, .login-disabled'))
expect(loginElements.length).to.be.greaterThan(0)
console.log('✓ Login page loaded correctly')
})
})

View File

@@ -43,7 +43,7 @@ describe('config: dashboards with basic fieldsets', function () {
// Check that we have the expected number of fieldsets
const allFieldsets = await webdriver.findElements(By.css('fieldset'))
expect(allFieldsets).to.have.length(5, 'Expected 5 fieldsets total')
expect(allFieldsets).to.have.length(3, 'Expected 3 fieldsets total')
// Check that we have fieldsets with the expected titles
const fieldsetTitles = []

View File

@@ -0,0 +1,53 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, until } from 'selenium-webdriver'
import {
getRootAndWait,
getActionButtons,
takeScreenshotOnFailure,
} from '../lib/elements.js'
describe('config: include', function () {
this.timeout(30000)
before(async function () {
await runner.start('include')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver);
});
it('Should load actions from base config and included files', async function () {
await getRootAndWait()
// Wait for the page to be ready
await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000)
const buttons = await getActionButtons()
// We should have:
// 1. Base Action from config.yaml
// 2. First Included Action from 00-first.yml
// 3. Second Included Action from 01-second.yml
expect(buttons.length).to.be.at.least(3, 'Should have at least 3 actions from base + includes')
// Verify all actions are present
const buttonTexts = await Promise.all(buttons.map(btn => btn.getText()))
console.log('Found actions:', buttonTexts)
// Text includes newline, so check with includes
const allText = buttonTexts.join(' ')
expect(allText).to.include('Base Action')
expect(allText).to.include('First Included Action')
expect(allText).to.include('Second Included Action')
console.log('✓ Include directive loaded actions from all files')
})
})

View File

@@ -0,0 +1,103 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, until, Condition } from 'selenium-webdriver'
import {
getRootAndWait,
takeScreenshotOnFailure,
} from '../lib/elements.js'
describe('config: localAuth', function () {
this.timeout(30000) // Increase timeout to 30 seconds
before(async function () {
await runner.start('localAuth')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver);
});
it('Server starts successfully with local auth enabled', async function () {
await webdriver.get(runner.baseUrl())
// Wait for the page to load
await webdriver.wait(until.titleContains('OliveTin'), 10000)
// Check that the page loaded
const title = await webdriver.getTitle()
expect(title).to.contain('OliveTin')
console.log('Server started successfully with local auth enabled')
})
it('Login page is accessible and shows login form', async function () {
// Navigate to login page
await webdriver.get(runner.baseUrl() + '/login')
// Wait for the page to load
await webdriver.wait(until.titleContains('OliveTin'), 10000)
// Wait longer for Vue to render
await new Promise(resolve => setTimeout(resolve, 5000))
// Check if any login-related elements are present
const bodyText = await webdriver.findElement(By.tagName('body')).getText()
console.log('Login page content:', bodyText.substring(0, 300))
// For now, just verify we can navigate to the login page
// The page content rendering is a separate frontend issue
console.log('Login page navigation successful')
})
it('Can perform local login with correct credentials', async function () {
await webdriver.get(runner.baseUrl() + '/login')
// Wait for the page to load
await webdriver.wait(until.titleContains('OliveTin'), 10000)
await new Promise(resolve => setTimeout(resolve, 2000))
// Try to find and fill login form
const usernameFields = await webdriver.findElements(By.css('input[name="username"], input[type="text"]'))
const passwordFields = await webdriver.findElements(By.css('input[name="password"], input[type="password"]'))
const loginButtons = await webdriver.findElements(By.css('button, input[type="submit"]'))
if (usernameFields.length > 0 && passwordFields.length > 0 && loginButtons.length > 0) {
console.log('Login form found, attempting login')
// Fill in credentials
await usernameFields[0].clear()
await usernameFields[0].sendKeys('testuser')
await passwordFields[0].clear()
await passwordFields[0].sendKeys('testpass123')
// Submit form
await loginButtons[0].click()
// Wait for potential redirect
await new Promise(resolve => setTimeout(resolve, 3000))
const currentUrl = await webdriver.getCurrentUrl()
console.log('URL after login attempt:', currentUrl)
// Check if we're still on login page (failed) or redirected (success)
if (currentUrl.includes('/login')) {
console.log('Login failed - still on login page')
// Check for error messages
const errorElements = await webdriver.findElements(By.css('.error-message, .error'))
if (errorElements.length > 0) {
const errorText = await errorElements[0].getText()
console.log('Error message:', errorText)
}
} else {
console.log('Login successful - redirected away from login page')
}
} else {
console.log('Login form not found - skipping login test')
}
})
})

View File

@@ -52,15 +52,8 @@ describe('config: multipleDropdowns', function () {
return url.includes('/actionBinding/') && url.includes('/argumentForm')
}), 8000)
// Wait for form elements to be rendered
await webdriver.wait(new Condition('wait for form elements', async () => {
const selects = await webdriver.findElements(By.tagName('select'))
return selects.length >= 2
}), 5000)
// Find the select elements after the wait condition
const selects = await webdriver.findElements(By.tagName('select'))
const selects = await webdriver.findElements(By.css('main select'))
expect(selects).to.have.length(2)
expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2)
expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3)

2
lang/Makefile Normal file
View File

@@ -0,0 +1,2 @@
default:
go run main.go

36
lang/README.md Normal file
View File

@@ -0,0 +1,36 @@
Hey, thanks for reading this quick introduction to translations. The project founder
only speaks two languages; English, and Bad English (!), so the initial translations
have all been AI-generated. It is assumed that "something is better than nothing".
It would be most welcome to have human contributors who are native speakers improve these
translations, or add new ones.
## How to contribute
If a translation file does not exist for your locale, you can create one by copying
en.yaml and changing the locale code in the filename. You can view the language that
your browser reports by opening the "Select Language" dialog from the footer.
## File format
Internally, OliveTin uses the vue-i18n library for translations. This does support
language pluralization and other advanced features. For docs, check the following;
[Vue i18n Pluralization Guide](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html)
The translation files are in YAML format. Each file contains key-value pairs.
OliveTin developers then "process" these files into JSON format used for the app.
If you are able, it would be appreciated if you run `make` in the language directory
to process your language file before submitting a PR. This will ensure that the JSON
file is up-to-date. If you don't understand how to do this, don't worry; just submit
the YAML file and the developers will take care of it.
## Contributing improvements
Please check out the file `CONTRIBUTING.md` for instructions on how to submit a pull
request with your improvements.
As always, if you need any help, please feel free to raise an issue on GitHub or
jump into the Discord server for OliveTin.

150
lang/combined_output.json Normal file
View File

@@ -0,0 +1,150 @@
{
"_comment": "This file is generated. Please re-generate this file using 'make' when you update a translation.",
"messages": {
"de-DE": {
"connected": "Verbunden",
"docs": "Dokumentation",
"language-dialog.browser-languages": "Browser-Sprachen",
"language-dialog.close": "Schließen",
"language-dialog.not-available": "Nicht verfügbar",
"language-dialog.title": "Sprache auswählen",
"login-button": "Login",
"logs.action": "Aktion",
"logs.blocked": "Blockiert",
"logs.clear-filter": "Suchfilter löschen",
"logs.completed": "Abgeschlossen",
"logs.exit-code": "Ausführungscode",
"logs.metadata": "Metadaten",
"logs.no-logs-to-display": "Es gibt keine Protokolle zu anzeigen.",
"logs.page-description": "Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.",
"logs.status": "Status",
"logs.timed-out": "Zeitüberschreitung",
"logs.timestamp": "Zeitstempel",
"logs.title": "Protokolle",
"nav.actions": "Aktionen",
"nav.diagnostics": "Diagnostik",
"nav.entities": "Entitäten",
"nav.logs": "Protokolle",
"raise-issue": "Ein Problem melden auf GitHub",
"return-to-index": "Zurück zur Startseite",
"search-filter": "Filter aktuelle Seite",
"welcome": "Willkommen bei OliveTin"
},
"en": {
"connected": "Connected",
"docs": "Documentation",
"language-dialog.browser-languages": "Browser languages",
"language-dialog.close": "Close",
"language-dialog.not-available": "Not available",
"language-dialog.title": "Select Language",
"login-button": "Login",
"logs.action": "Action",
"logs.blocked": "Blocked",
"logs.clear-filter": "Clear search filter",
"logs.completed": "Completed",
"logs.exit-code": "Exit code",
"logs.metadata": "Metadata",
"logs.no-logs-to-display": "There are no logs to display.",
"logs.page-description": "This is a list of logs from actions that have been executed. You can filter the list by action title.",
"logs.status": "Status",
"logs.timed-out": "Timed out",
"logs.timestamp": "Timestamp",
"logs.title": "Logs",
"nav.actions": "Actions",
"nav.diagnostics": "Diagnostics",
"nav.entities": "Entities",
"nav.logs": "Logs",
"raise-issue": "Raise an issue on GitHub",
"return-to-index": "Return to index",
"search-filter": "Filter current page",
"welcome": "Welcome to OliveTin"
},
"es-ES": {
"connected": "Conectado",
"docs": "Documentación",
"language-dialog.browser-languages": "Idiomas del navegador",
"language-dialog.close": "Cerrar",
"language-dialog.not-available": "No disponible",
"language-dialog.title": "Seleccionar idioma",
"login-button": "Iniciar sesión",
"logs.action": "Acción",
"logs.blocked": "Bloqueado",
"logs.clear-filter": "Limpiar filtro de búsqueda",
"logs.completed": "Completado",
"logs.exit-code": "Código de salida",
"logs.metadata": "Metadatos",
"logs.no-logs-to-display": "No hay registros para mostrar.",
"logs.page-description": "Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.",
"logs.status": "Estado",
"logs.timed-out": "Tiempo agotado",
"logs.timestamp": "Marca de tiempo",
"logs.title": "Registros",
"nav.actions": "Acciones",
"nav.diagnostics": "Diagnósticos",
"nav.entities": "Entidades",
"nav.logs": "Registros",
"raise-issue": "Reportar un problema en GitHub",
"return-to-index": "Volver a la página principal",
"search-filter": "Filtrar página actual",
"welcome": "Bienvenido a OliveTin"
},
"it-IT": {
"connected": "Connesso",
"docs": "Documentazione",
"language-dialog.browser-languages": "Lingue del browser",
"language-dialog.close": "Chiudi",
"language-dialog.not-available": "Non disponibile",
"language-dialog.title": "Seleziona lingua",
"login-button": "Login",
"logs.action": "Azione",
"logs.blocked": "Bloccato",
"logs.clear-filter": "Cancella filtro di ricerca",
"logs.completed": "Completato",
"logs.exit-code": "Codice di uscita",
"logs.metadata": "Metadati",
"logs.no-logs-to-display": "Non ci sono registri da mostrare.",
"logs.page-description": "Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.",
"logs.status": "Stato",
"logs.timed-out": "Tempo scaduto",
"logs.timestamp": "Date e ora",
"logs.title": "Registri",
"nav.actions": "Azioni",
"nav.diagnostics": "Diagnostica",
"nav.entities": "Entità",
"nav.logs": "Registri",
"raise-issue": "Segnala un problema su GitHub",
"return-to-index": "Torna alla pagina principale",
"search-filter": "Filtra la pagina corrente",
"welcome": "Benvenuto in OliveTin"
},
"zh-Hans-CN": {
"connected": "已连接",
"docs": "文档",
"language-dialog.browser-languages": "浏览器语言",
"language-dialog.close": "关闭",
"language-dialog.not-available": "不可用",
"language-dialog.title": "选择语言",
"login-button": "登录",
"logs.action": "动作",
"logs.blocked": "阻塞",
"logs.clear-filter": "清除搜索筛选器",
"logs.completed": "完成",
"logs.exit-code": "退出代码",
"logs.metadata": "元数据",
"logs.no-logs-to-display": "没有日志可显示。",
"logs.page-description": "这是一个动作执行日志列表。您可以按动作标题过滤列表。",
"logs.status": "状态",
"logs.timed-out": "超时",
"logs.timestamp": "时间戳",
"logs.title": "日志",
"nav.actions": "动作",
"nav.diagnostics": "诊断",
"nav.entities": "实体",
"nav.logs": "日志",
"raise-issue": "在 GitHub 上报告问题",
"return-to-index": "返回首页",
"search-filter": "过滤当前页面",
"welcome": "欢迎使用 OliveTin"
}
}
}

29
lang/de-DE.yaml Normal file
View File

@@ -0,0 +1,29 @@
schemaVersion: 1
translations:
welcome: Willkommen bei OliveTin
nav.actions: Aktionen
nav.logs: Protokolle
nav.entities: Entitäten
nav.diagnostics: Diagnostik
connected: Verbunden
login-button: Login
raise-issue: Ein Problem melden auf GitHub
docs: Dokumentation
logs.title: Protokolle
logs.page-description: Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.
logs.timestamp: Zeitstempel
logs.action: Aktion
logs.metadata: Metadaten
logs.status: Status
logs.no-logs-to-display: Es gibt keine Protokolle zu anzeigen.
logs.timed-out: Zeitüberschreitung
logs.blocked: Blockiert
logs.exit-code: Ausführungscode
logs.completed: Abgeschlossen
logs.clear-filter: Suchfilter löschen
return-to-index: Zurück zur Startseite
search-filter: Filter aktuelle Seite
language-dialog.title: Sprache auswählen
language-dialog.browser-languages: Browser-Sprachen
language-dialog.not-available: Nicht verfügbar
language-dialog.close: Schließen

29
lang/en.yaml Normal file
View File

@@ -0,0 +1,29 @@
schemaVersion: 1
translations:
welcome: Welcome to OliveTin
docs: Documentation
raise-issue: Raise an issue on GitHub
nav.actions: Actions
nav.logs: Logs
nav.entities: Entities
nav.diagnostics: Diagnostics
connected: Connected
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.
logs.timestamp: Timestamp
logs.action: Action
logs.metadata: Metadata
logs.status: Status
logs.no-logs-to-display: There are no logs to display.
logs.timed-out: Timed out
logs.blocked: Blocked
logs.exit-code: Exit code
logs.completed: Completed
logs.clear-filter: Clear search filter
return-to-index: Return to index
search-filter: Filter current page
language-dialog.title: Select Language
language-dialog.browser-languages: Browser languages
language-dialog.not-available: Not available
language-dialog.close: Close

29
lang/es-ES.yaml Normal file
View File

@@ -0,0 +1,29 @@
schemaVersion: 1
translations:
welcome: Bienvenido a OliveTin
nav.actions: Acciones
nav.logs: Registros
nav.entities: Entidades
nav.diagnostics: Diagnósticos
connected: Conectado
login-button: Iniciar sesión
raise-issue: Reportar un problema en GitHub
docs: Documentación
logs.title: Registros
logs.page-description: Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.
logs.timestamp: Marca de tiempo
logs.action: Acción
logs.metadata: Metadatos
logs.status: Estado
logs.no-logs-to-display: No hay registros para mostrar.
logs.timed-out: Tiempo agotado
logs.blocked: Bloqueado
logs.exit-code: Código de salida
logs.completed: Completado
logs.clear-filter: Limpiar filtro de búsqueda
return-to-index: Volver a la página principal
search-filter: Filtrar página actual
language-dialog.title: Seleccionar idioma
language-dialog.browser-languages: Idiomas del navegador
language-dialog.not-available: No disponible
language-dialog.close: Cerrar

11
lang/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module github.com/OliveTin/OliveTin/langtool
go 1.24.0
require (
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c
github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect

20
lang/go.sum Normal file
View File

@@ -0,0 +1,20 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM=
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

29
lang/it-IT.yaml Normal file
View File

@@ -0,0 +1,29 @@
schemaVersion: 1
translations:
welcome: Benvenuto in OliveTin
nav.actions: Azioni
nav.logs: Registri
nav.entities: Entità
nav.diagnostics: Diagnostica
docs: Documentazione
connected: Connesso
login-button: Login
raise-issue: Segnala un problema su GitHub
logs.title: Registri
logs.page-description: Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.
logs.timestamp: Date e ora
logs.action: Azione
logs.metadata: Metadati
logs.status: Stato
logs.no-logs-to-display: Non ci sono registri da mostrare.
logs.timed-out: Tempo scaduto
logs.blocked: Bloccato
logs.exit-code: Codice di uscita
logs.completed: Completato
logs.clear-filter: Cancella filtro di ricerca
return-to-index: Torna alla pagina principale
search-filter: Filtra la pagina corrente
language-dialog.title: Seleziona lingua
language-dialog.browser-languages: Lingue del browser
language-dialog.not-available: Non disponibile
language-dialog.close: Chiudi

215
lang/main.go Normal file
View File

@@ -0,0 +1,215 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
"github.com/jamesread/golure/pkg/dirs"
log "github.com/sirupsen/logrus"
)
type LanguageFilev1 struct {
SchemaVersion int `json:"schemaVersion"`
Translations map[string]string `json:"translations"`
}
type CombinedTranslationsOutput struct {
Comment string `json:"_comment"`
Messages map[string]map[string]string `json:"messages"`
}
func main() {
combinedContent := getCombinedLanguageContent()
sortedContent := sortTranslations(combinedContent)
jsonData, err := json.MarshalIndent(sortedContent, "", " ")
if err != nil {
log.Fatalf("Error marshalling combined language content: %v", err)
}
err = os.WriteFile("combined_output.json", jsonData, 0644)
if err != nil {
log.Fatalf("Error saving combined language content to file: %v", err)
}
log.Infof("Combined language content saved to combined_output.json")
}
// sortTranslations creates a new structure with sorted keys for deterministic output.
func sortTranslations(input *CombinedTranslationsOutput) *CombinedTranslationsOutput {
sorted := &CombinedTranslationsOutput{
Comment: input.Comment,
Messages: make(map[string]map[string]string),
}
// Sort language names
langNames := make([]string, 0, len(input.Messages))
for langName := range input.Messages {
langNames = append(langNames, langName)
}
sort.Strings(langNames)
// For each language, sort the translation keys
for _, langName := range langNames {
translations := input.Messages[langName]
sortedTranslations := make(map[string]string)
keys := make([]string, 0, len(translations))
for key := range translations {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
sortedTranslations[key] = translations[key]
}
sorted.Messages[langName] = sortedTranslations
}
return sorted
}
func getLanguageDir() string {
dirsToSearch := []string{
"../lang",
"../../../../lang/", // Relative to this file, for unit tests
"/app/lang/",
}
dir, _ := dirs.GetFirstExistingDirectory("lang", dirsToSearch)
return dir
}
func getCombinedLanguageContent() *CombinedTranslationsOutput {
output := &CombinedTranslationsOutput{
Comment: "This file is generated. Please re-generate this file using 'make' when you update a translation.",
Messages: make(map[string]map[string]string),
}
languageDir := getLanguageDir()
files, err := os.ReadDir(languageDir)
if err != nil {
log.Errorf("Error reading language directory %s: %v", languageDir, err)
return output
}
for _, file := range filterLanguageFiles(files) {
languageName := strings.Replace(file.Name(), ".yaml", "", 1)
fullPath := filepath.Join(languageDir, file.Name())
log.Infof("Loading language file: %s", fullPath)
content, err := os.ReadFile(fullPath)
if err != nil {
log.Errorf("Error reading language file %s: %v", fullPath, err)
continue
}
var yamlData LanguageFilev1
err = yaml.Unmarshal(content, &yamlData)
if err != nil {
log.Errorf("Error reading language file %s: %v", fullPath, err)
continue
}
output.Messages[languageName] = yamlData.Translations
}
validateTranslations(output)
return output
}
// getReferenceKeys returns the keys from the "en" translation as the reference set.
func getReferenceKeys(messages map[string]map[string]string) map[string]bool {
enTranslations, exists := messages["en"]
if !exists {
return nil
}
referenceKeys := make(map[string]bool, len(enTranslations))
for key := range enTranslations {
referenceKeys[key] = true
}
return referenceKeys
}
// findMissingKeys returns the keys that are in referenceKeys but not in translations.
func findMissingKeys(referenceKeys map[string]bool, translations map[string]string) []string {
missing := make([]string, 0)
for key := range referenceKeys {
if _, exists := translations[key]; !exists {
missing = append(missing, key)
}
}
return missing
}
// findExtraKeys returns the keys that are in translations but not in referenceKeys.
func findExtraKeys(referenceKeys map[string]bool, translations map[string]string) []string {
extra := make([]string, 0)
for key := range translations {
if !referenceKeys[key] {
extra = append(extra, key)
}
}
return extra
}
// validateTranslations checks all translations against the "en" reference and prints warnings for missing and extra keys.
func validateTranslations(output *CombinedTranslationsOutput) {
referenceKeys := getReferenceKeys(output.Messages)
if referenceKeys == nil {
log.Warnf("No 'en' translation found, skipping validation")
return
}
for langName, translations := range output.Messages {
if langName == "en" {
continue
}
missing := findMissingKeys(referenceKeys, translations)
if len(missing) > 0 {
log.Warnf("Translation '%s' is missing %d key(s): %v", langName, len(missing), missing)
}
extra := findExtraKeys(referenceKeys, translations)
if len(extra) > 0 {
log.Warnf("Translation '%s' has %d extra key(s) not in 'en': %v", langName, len(extra), extra)
}
}
}
func filterLanguageFiles(files []os.DirEntry) []os.DirEntry {
ret := make([]os.DirEntry, 0)
for _, file := range files {
if file.IsDir() {
continue
}
if !strings.HasSuffix(file.Name(), ".yaml") {
continue
}
ret = append(ret, file)
}
return ret
}

29
lang/zh-Hans-CN.yaml Normal file
View File

@@ -0,0 +1,29 @@
schemaVersion: 1
translations:
welcome: 欢迎使用 OliveTin
nav.actions: 动作
nav.logs: 日志
nav.entities: 实体
nav.diagnostics: 诊断
connected: 已连接
login-button: 登录
raise-issue: 在 GitHub 上报告问题
docs: 文档
logs.title: 日志
logs.page-description: 这是一个动作执行日志列表。您可以按动作标题过滤列表。
logs.timestamp: 时间戳
logs.action: 动作
logs.metadata: 元数据
logs.status: 状态
logs.no-logs-to-display: 没有日志可显示。
return-to-index: 返回首页
search-filter: 过滤当前页面
language-dialog.title: 选择语言
language-dialog.browser-languages: 浏览器语言
language-dialog.not-available: 不可用
language-dialog.close: 关闭
logs.timed-out: 超时
logs.blocked: 阻塞
logs.exit-code: 退出代码
logs.completed: 完成
logs.clear-filter: 清除搜索筛选器

View File

@@ -11,8 +11,7 @@ plugins:
- remote: buf.build/bufbuild/es
out: ../frontend/resources/scripts/gen/
# - name: swagger
# out: reports/swagger
# - local: ["go", "run", "github.com/sudorandom/protoc-gen-connect-openapi@latest"]
# out: gen
# - local: protoc-gen-openapiv2
# out: reports/openapiv2

View File

@@ -12,6 +12,7 @@ message Action {
repeated ActionArgument arguments = 5;
string popup_on_start = 6;
int32 order = 7;
int32 timeout = 8;
}
message ActionArgument {
@@ -141,6 +142,19 @@ message GetLogsResponse {
int64 start_offset = 5;
}
message GetActionLogsRequest {
string action_id = 1;
int64 start_offset = 2;
}
message GetActionLogsResponse {
repeated LogEntry logs = 1;
int64 count_remaining = 2;
int64 page_size = 3;
int64 total_count = 4;
int64 start_offset = 5;
}
message ValidateArgumentTypeRequest {
string value = 1;
string type = 2;
@@ -305,6 +319,7 @@ message InitResponse {
string banner_css = 20;
bool show_diagnostics = 21;
bool show_log_list = 22;
bool login_required = 23;
}
message AdditionalLink {
@@ -366,6 +381,8 @@ service OliveTinApiService {
rpc ExecutionStatus(ExecutionStatusRequest) returns (ExecutionStatusResponse) {}
rpc GetLogs(GetLogsRequest) returns (GetLogsResponse) {}
rpc GetActionLogs(GetActionLogsRequest) returns (GetActionLogsResponse) {}
rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {}

View File

@@ -50,7 +50,4 @@ go-tools-all:
go install "github.com/bufbuild/buf/cmd/buf"
go install "github.com/fzipp/gocyclo/cmd/gocyclo"
go install "github.com/go-critic/go-critic/cmd/gocritic"
go install "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
go install "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
go install "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
go install "google.golang.org/protobuf/cmd/protoc-gen-go"

View File

@@ -60,6 +60,9 @@ const (
// OliveTinApiServiceGetLogsProcedure is the fully-qualified name of the OliveTinApiService's
// GetLogs RPC.
OliveTinApiServiceGetLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetLogs"
// OliveTinApiServiceGetActionLogsProcedure is the fully-qualified name of the OliveTinApiService's
// GetActionLogs RPC.
OliveTinApiServiceGetActionLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetActionLogs"
// OliveTinApiServiceValidateArgumentTypeProcedure is the fully-qualified name of the
// OliveTinApiService's ValidateArgumentType RPC.
OliveTinApiServiceValidateArgumentTypeProcedure = "/olivetin.api.v1.OliveTinApiService/ValidateArgumentType"
@@ -117,6 +120,7 @@ type OliveTinApiServiceClient interface {
KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error)
GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error)
GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error)
ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error)
SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error)
@@ -199,6 +203,12 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
connect.WithClientOptions(opts...),
),
getActionLogs: connect.NewClient[v1.GetActionLogsRequest, v1.GetActionLogsResponse](
httpClient,
baseURL+OliveTinApiServiceGetActionLogsProcedure,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")),
connect.WithClientOptions(opts...),
),
validateArgumentType: connect.NewClient[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse](
httpClient,
baseURL+OliveTinApiServiceValidateArgumentTypeProcedure,
@@ -303,6 +313,7 @@ type oliveTinApiServiceClient struct {
killAction *connect.Client[v1.KillActionRequest, v1.KillActionResponse]
executionStatus *connect.Client[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse]
getLogs *connect.Client[v1.GetLogsRequest, v1.GetLogsResponse]
getActionLogs *connect.Client[v1.GetActionLogsRequest, v1.GetActionLogsResponse]
validateArgumentType *connect.Client[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse]
whoAmI *connect.Client[v1.WhoAmIRequest, v1.WhoAmIResponse]
sosReport *connect.Client[v1.SosReportRequest, v1.SosReportResponse]
@@ -365,6 +376,11 @@ func (c *oliveTinApiServiceClient) GetLogs(ctx context.Context, req *connect.Req
return c.getLogs.CallUnary(ctx, req)
}
// GetActionLogs calls olivetin.api.v1.OliveTinApiService.GetActionLogs.
func (c *oliveTinApiServiceClient) GetActionLogs(ctx context.Context, req *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) {
return c.getActionLogs.CallUnary(ctx, req)
}
// ValidateArgumentType calls olivetin.api.v1.OliveTinApiService.ValidateArgumentType.
func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, req *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) {
return c.validateArgumentType.CallUnary(ctx, req)
@@ -451,6 +467,7 @@ type OliveTinApiServiceHandler interface {
KillAction(context.Context, *connect.Request[v1.KillActionRequest]) (*connect.Response[v1.KillActionResponse], error)
ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error)
GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error)
GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error)
ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error)
WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error)
SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error)
@@ -529,6 +546,12 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetLogs")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceGetActionLogsHandler := connect.NewUnaryHandler(
OliveTinApiServiceGetActionLogsProcedure,
svc.GetActionLogs,
connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")),
connect.WithHandlerOptions(opts...),
)
oliveTinApiServiceValidateArgumentTypeHandler := connect.NewUnaryHandler(
OliveTinApiServiceValidateArgumentTypeProcedure,
svc.ValidateArgumentType,
@@ -639,6 +662,8 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect
oliveTinApiServiceExecutionStatusHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetLogsProcedure:
oliveTinApiServiceGetLogsHandler.ServeHTTP(w, r)
case OliveTinApiServiceGetActionLogsProcedure:
oliveTinApiServiceGetActionLogsHandler.ServeHTTP(w, r)
case OliveTinApiServiceValidateArgumentTypeProcedure:
oliveTinApiServiceValidateArgumentTypeHandler.ServeHTTP(w, r)
case OliveTinApiServiceWhoAmIProcedure:
@@ -714,6 +739,10 @@ func (UnimplementedOliveTinApiServiceHandler) GetLogs(context.Context, *connect.
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetLogs is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetActionLogs is not implemented"))
}
func (UnimplementedOliveTinApiServiceHandler) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.ValidateArgumentType is not implemented"))
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
module github.com/OliveTin/OliveTin
go 1.24
go 1.24.0
toolchain go1.24.4
toolchain go1.24.9
exclude google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884
require (
connectrpc.com/connect v1.18.1
@@ -16,11 +18,11 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1
github.com/jamesread/golure v0.0.0-20250619190948-fa38cbd93cc4
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/providers/rawbytes v1.0.0
github.com/knadh/koanf/v2 v2.3.0
github.com/prometheus/client_golang v1.22.0
github.com/robfig/cron/v3 v3.0.1
@@ -28,10 +30,8 @@ require (
github.com/stretchr/testify v1.10.0
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.33.0
google.golang.org/grpc v1.73.0
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1
google.golang.org/protobuf v1.36.6
golang.org/x/sys v0.35.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
@@ -89,17 +89,16 @@ require (
github.com/google/cel-go v0.25.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-containerregistry v0.20.6 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jdx/go-netrc v1.0.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/providers/rawbytes v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -128,6 +127,7 @@ require (
github.com/stoewer/go-strcase v1.3.1 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
go.akshayshah.org/connectproto v0.6.0 // indirect
go.lsp.dev/jsonrpc2 v0.10.0 // indirect
go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect
go.lsp.dev/protocol v0.12.0 // indirect
@@ -140,17 +140,18 @@ require (
go.uber.org/mock v0.5.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/crypto v0.39.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect
google.golang.org/grpc v1.75.1 // indirect
pluginrpc.com/pluginrpc v0.5.0 // indirect
)

View File

@@ -128,8 +128,6 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=
github.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -142,8 +140,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jamesread/golure v0.0.0-20250619190948-fa38cbd93cc4 h1:MIZEqAaeMP1/saH0w6I5mzGKSv2lw8fAO7Hm2FgJb9k=
@@ -184,8 +182,6 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -266,6 +262,8 @@ github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVO
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.akshayshah.org/connectproto v0.6.0 h1:tqmysQF2AfvUeYS03mRAAZTFpiQeXqhGIDnH1GO2D2U=
go.akshayshah.org/connectproto v0.6.0/go.mod h1:uA9TR/6MhBlLn0fh8VXRyL26EKTJlimWao4jbz7JHbA=
go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI=
go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac=
go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE=
@@ -302,15 +300,15 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
@@ -321,8 +319,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -331,8 +329,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -340,8 +338,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -356,23 +354,23 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -381,22 +379,20 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -2,13 +2,15 @@ package acl
import (
"context"
"net/http"
"strings"
"connectrpc.com/connect"
"github.com/OliveTin/OliveTin/internal/auth"
config "github.com/OliveTin/OliveTin/internal/config"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"google.golang.org/grpc/metadata"
)
type PermissionBits int
@@ -55,6 +57,9 @@ func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string {
} else {
ret = strings.Fields(u.UsergroupLine)
}
log.Debugf("parseUsergroupLine: %v, %v, sep:%v", u.UsergroupLine, ret, sep)
return ret
}
@@ -184,43 +189,83 @@ func IsAllowedKill(cfg *config.Config, user *AuthenticatedUser, action *config.A
return aclCheck(Kill, cfg.DefaultPermissions.Kill, cfg, "isAllowedKill", user, action)
}
func getMetadataKeyOrEmpty(md metadata.MD, key string) string {
mdValues := md.Get(key)
if len(mdValues) > 0 {
return mdValues[0]
func getHeaderKeyOrEmpty(headers http.Header, key string) string {
values := headers.Values(key)
if len(values) > 0 {
return values[0]
}
return ""
}
// UserFromContext tries to find a user from a grpc context
func UserFromContext(ctx context.Context, cfg *config.Config) *AuthenticatedUser {
var ret *AuthenticatedUser
md, ok := metadata.FromIncomingContext(ctx)
if ok {
ret = &AuthenticatedUser{}
ret.Username = getMetadataKeyOrEmpty(md, "username")
ret.UsergroupLine = getMetadataKeyOrEmpty(md, "usergroup")
ret.Provider = getMetadataKeyOrEmpty(md, "provider")
buildUserAcls(cfg, ret)
// UserFromContext tries to find a user from a Connect RPC context
func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *config.Config) *AuthenticatedUser {
user := userFromHeaders(req, cfg)
if user.Username == "" {
user = userFromLocalSession(req, cfg, user)
}
if user.Username == "" {
user = *UserGuest(cfg)
} else {
buildUserAcls(cfg, &user)
}
if !ok || ret.Username == "" {
ret = UserGuest(cfg)
path := ""
if req != nil {
path = req.Spec().Procedure
}
log.WithFields(log.Fields{
"username": ret.Username,
"usergroupLine": ret.UsergroupLine,
"provider": ret.Provider,
"acls": ret.Acls,
}).Debugf("UserFromContext")
"username": user.Username,
"usergroupLine": user.UsergroupLine,
"provider": user.Provider,
"acls": user.Acls,
"path": path,
}).Debugf("Authenticated API request")
return &user
}
return ret
//gocyclo:ignore
func userFromHeaders[T any](req *connect.Request[T], cfg *config.Config) AuthenticatedUser {
var u AuthenticatedUser
if req == nil {
return u
}
if cfg.AuthHttpHeaderUsername != "" {
u.Username = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUsername)
}
if cfg.AuthHttpHeaderUserGroup != "" {
u.UsergroupLine = getHeaderKeyOrEmpty(req.Header(), cfg.AuthHttpHeaderUserGroup)
}
if prov := getHeaderKeyOrEmpty(req.Header(), "provider"); prov != "" {
u.Provider = prov
}
return u
}
//gocyclo:ignore
func userFromLocalSession[T any](req *connect.Request[T], cfg *config.Config, u AuthenticatedUser) AuthenticatedUser {
if req == nil || u.Username != "" {
return u
}
dummy := &http.Request{Header: req.Header()}
c, err := dummy.Cookie("olivetin-sid-local")
if err != nil || c == nil || c.Value == "" {
return u
}
sess := auth.GetUserSession("local", c.Value)
if sess == nil {
log.WithFields(log.Fields{"sid": c.Value, "provider": "local"}).Warn("UserFromContext: stale local session")
return u
}
if cfgUser := cfg.FindUserByUsername(sess.Username); cfgUser != nil {
u.Username = cfgUser.Username
u.UsergroupLine = cfgUser.Usergroup
u.Provider = "local"
u.SID = c.Value
return u
}
log.WithFields(log.Fields{"username": sess.Username}).Warn("UserFromContext: local session user not in config")
return u
}
func UserGuest(cfg *config.Config) *AuthenticatedUser {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"connectrpc.com/connect"
"google.golang.org/protobuf/encoding/protojson"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
@@ -13,22 +14,41 @@ import (
"fmt"
"net/http"
"sync"
acl "github.com/OliveTin/OliveTin/internal/acl"
auth "github.com/OliveTin/OliveTin/internal/auth"
config "github.com/OliveTin/OliveTin/internal/config"
entities "github.com/OliveTin/OliveTin/internal/entities"
executor "github.com/OliveTin/OliveTin/internal/executor"
installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
connectproto "go.akshayshah.org/connectproto"
)
type oliveTinAPI struct {
executor *executor.Executor
cfg *config.Config
connectedClients []*connectedClients
// streamingClients is a set of currently connected clients.
// The empty struct value models set semantics (keys only) and keeps add/remove O(1).
// We use a map for efficient membership and deletion; ordering is not required.
streamingClients map[*streamingClient]struct{}
streamingClientsMutex sync.RWMutex
}
type connectedClients struct {
// This is used to avoid race conditions when iterating over the connectedClients map.
// and holds the lock for as minimal time as possible to avoid blocking the API for too long.
func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
api.streamingClientsMutex.RLock()
defer api.streamingClientsMutex.RUnlock()
clients := make([]*streamingClient, 0, len(api.streamingClients))
for client := range api.streamingClients {
clients = append(clients, client)
}
return clients
}
type streamingClient struct {
channel chan *apiv1.EventStreamResponse
AuthenticatedUser *acl.AuthenticatedUser
}
@@ -57,7 +77,7 @@ func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.K
return connect.NewResponse(ret), nil
}
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
api.killActionByTrackingId(user, action, execReqLogEntry, ret)
@@ -94,7 +114,7 @@ func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
}
authenticatedUser := acl.UserFromContext(ctx, api.cfg)
authenticatedUser := acl.UserFromContext(ctx, req, api.cfg)
execReq := executor.ExecutionRequest{
Binding: pair,
@@ -128,10 +148,41 @@ func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1
}
func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
// Check if local user authentication is enabled
if !api.cfg.AuthLocalUsers.Enabled {
return connect.NewResponse(&apiv1.LocalUserLoginResponse{
Success: false,
}), nil
}
match := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
response := connect.NewResponse(&apiv1.LocalUserLoginResponse{
Success: match,
})
if match {
// grpc.SendHeader(ctx, metadata.Pairs("set-username", req.Username))
// Set authentication cookie for successful login
user := api.cfg.FindUserByUsername(req.Msg.Username)
if user != nil {
sid := uuid.NewString()
// Register the session in the session storage
auth.RegisterUserSession(api.cfg, "local", sid, user.Username)
log.WithFields(log.Fields{
"username": user.Username,
}).Info("LocalUserLogin: Session created and registered")
// Set the authentication cookie in the response headers
cookie := &http.Cookie{
Name: "olivetin-sid-local",
Value: sid,
MaxAge: 31556952, // 1 year
HttpOnly: true,
Path: "/",
}
response.Header().Set("Set-Cookie", cookie.String())
}
log.WithFields(log.Fields{
"username": req.Msg.Username,
@@ -142,9 +193,7 @@ func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[api
}).Warn("LocalUserLogin: User login failed.")
}
return connect.NewResponse(&apiv1.LocalUserLoginResponse{
Success: match,
}), nil
return response, nil
}
func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
@@ -154,7 +203,7 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
args[arg.Name] = arg.Value
}
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
execReq := executor.ExecutionRequest{
Binding: api.executor.FindBindingByID(req.Msg.ActionId),
@@ -185,7 +234,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
Binding: api.executor.FindBindingByID(req.Msg.ActionId),
TrackingID: uuid.NewString(),
Arguments: args,
AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
AuthenticatedUser: acl.UserFromContext(ctx, req, api.cfg),
Cfg: api.cfg,
}
@@ -199,7 +248,7 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a
func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
args := make(map[string]string)
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
execReq := executor.ExecutionRequest{
Binding: api.executor.FindBindingByID(req.Msg.ActionId),
@@ -277,7 +326,11 @@ func getMostRecentExecutionStatusById(api *oliveTinAPI, actionId string) *execut
func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
res := &apiv1.ExecutionStatusResponse{}
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
}
var ile *executor.InternalLogEntry
@@ -298,28 +351,52 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
}
func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
// user := acl.UserFromContext(ctx, cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
// grpc.SendHeader(ctx, metadata.Pairs("logout-provider", user.Provider))
// grpc.SendHeader(ctx, metadata.Pairs("logout-sid", user.SID))
log.WithFields(log.Fields{
"username": user.Username,
"provider": user.Provider,
}).Info("Logout: User logged out")
return nil, nil
response := connect.NewResponse(&apiv1.LogoutResponse{})
// Clear the authentication cookie by setting it to expire
cookie := &http.Cookie{
Name: "olivetin-sid-local",
Value: "",
MaxAge: -1, // This tells the browser to delete the cookie
HttpOnly: true,
Path: "/",
}
response.Header().Set("Set-Cookie", cookie.String())
return response, nil
}
func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
}
binding := api.executor.FindBindingByID(req.Msg.BindingId)
if binding == nil {
return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
}
return connect.NewResponse(&apiv1.GetActionBindingResponse{
Action: buildAction(binding, &DashboardRenderRequest{
cfg: api.cfg,
AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
AuthenticatedUser: user,
ex: api.executor,
}),
}), nil
}
func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
@@ -369,30 +446,135 @@ func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest,
}
func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
ret := &apiv1.GetLogsResponse{}
logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
for _, logEntry := range logEntries {
action := logEntry.Binding.Action
if action == nil || acl.IsAllowedLogs(api.cfg, user, action) {
pbLogEntry := api.internalLogEntryToPb(logEntry, user)
ret.Logs = append(ret.Logs, pbLogEntry)
}
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
}
ret.CountRemaining = pagingResult.CountRemaining
ret.PageSize = pagingResult.PageSize
ret.TotalCount = pagingResult.TotalCount
ret.StartOffset = pagingResult.StartOffset
ret := &apiv1.GetLogsResponse{}
logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
for _, le := range logEntries {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
}
ret.CountRemaining = paging.CountRemaining
ret.PageSize = paging.PageSize
ret.TotalCount = paging.TotalCount
ret.StartOffset = paging.StartOffset
return connect.NewResponse(ret), nil
}
// isValidLogEntry checks if a log entry has all required fields populated.
func isValidLogEntry(e *executor.InternalLogEntry) bool {
return e != nil && e.Binding != nil && e.Binding.Action != nil
}
// isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *acl.AuthenticatedUser) bool {
return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
}
// buildEmptyPageResponse creates a response for an empty page.
func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
return &apiv1.GetActionLogsResponse{
CountRemaining: 0,
PageSize: page.size,
TotalCount: page.total,
StartOffset: page.start,
}
}
// calculateReversedIndices computes the reversed indices for newest-first pagination.
func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
startIdx := page.total - page.end
endIdx := page.total - page.start
if startIdx < 0 {
startIdx = 0
}
if endIdx > int64(filteredLen) {
endIdx = int64(filteredLen)
}
return startIdx, endIdx
}
// buildActionLogsResponse builds the response with paginated log entries.
func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *acl.AuthenticatedUser) *apiv1.GetActionLogsResponse {
startIdx, endIdx := calculateReversedIndices(page, len(filtered))
ret := &apiv1.GetActionLogsResponse{}
for _, le := range filtered[startIdx:endIdx] {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
}
ret.CountRemaining = page.start
ret.PageSize = page.size
ret.TotalCount = page.total
ret.StartOffset = page.start
return ret
}
func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
}
filtered := api.filterLogsByACL(api.executor.GetLogsByActionId(req.Msg.ActionId), user)
page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
if page.empty {
return connect.NewResponse(buildEmptyPageResponse(page)), nil
}
return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
}
func (api *oliveTinAPI) pbLogsFiltered(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*apiv1.LogEntry {
out := make([]*apiv1.LogEntry, 0, len(entries))
for _, e := range entries {
if !isValidLogEntry(e) {
continue
}
if api.isLogEntryAllowed(e, user) {
out = append(out, api.internalLogEntryToPb(e, user))
}
}
return out
}
func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *acl.AuthenticatedUser) []*executor.InternalLogEntry {
filtered := make([]*executor.InternalLogEntry, 0, len(entries))
for _, e := range entries {
if !isValidLogEntry(e) {
continue
}
if api.isLogEntryAllowed(e, user) {
filtered = append(filtered, e)
}
}
return filtered
}
type pageInfo struct {
total int64
size int64
start int64
end int64
empty bool
}
func paginate(total int64, size int64, start int64) pageInfo {
if start < 0 {
start = 0
}
if start >= total {
return pageInfo{total: total, size: size, start: start, end: start, empty: true}
}
end := start + size
if end > total {
end = total
}
return pageInfo{total: total, size: size, start: start, end: end, empty: false}
}
/*
This function is ONLY a helper for the UI - the arguments are validated properly
on the StartAction -> Executor chain. This is here basically to provide helpful
@@ -413,7 +595,11 @@ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Reque
}
func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
}
res := &apiv1.WhoAmIResponse{
AuthenticatedUser: user.Username,
@@ -495,20 +681,33 @@ func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.Ge
func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
log.Debugf("EventStream: %v", req.Msg)
client := &connectedClients{
channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
AuthenticatedUser: acl.UserFromContext(ctx, api.cfg),
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return err
}
log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username)
client := &streamingClient{
channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
AuthenticatedUser: user,
}
api.connectedClients = append(api.connectedClients, client)
log.WithFields(log.Fields{
"authenticatedUser": user.Username,
}).Debugf("EventStream: client connected")
api.streamingClientsMutex.Lock()
api.streamingClients[client] = struct{}{}
api.streamingClientsMutex.Unlock()
// loop over client channel and send events to connectedClient
for msg := range client.channel {
log.Debugf("Sending event to client: %v", msg)
if err := srv.Send(msg); err != nil {
log.Errorf("Error sending event to client: %v", err)
// Remove disconnected client from the list
api.removeClient(client)
break
}
}
@@ -517,8 +716,17 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
return nil
}
func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
api.streamingClientsMutex.Lock()
delete(api.streamingClients, clientToRemove)
api.streamingClientsMutex.Unlock()
close(clientToRemove.channel)
}
func (api *oliveTinAPI) OnActionMapRebuilt() {
for _, client := range api.connectedClients {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ConfigChanged{
@@ -526,13 +734,20 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
log.Warnf("EventStream: client channel is full, removing client")
toRemove = append(toRemove, client)
}
}
for _, client := range toRemove {
api.removeClient(client)
}
}
func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
for _, client := range api.connectedClients {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionStarted{
@@ -542,13 +757,20 @@ func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
log.Warnf("EventStream: client channel is full, removing client")
toRemove = append(toRemove, client)
}
}
for _, client := range toRemove {
api.removeClient(client)
}
}
func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
for _, client := range api.connectedClients {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_ExecutionFinished{
@@ -558,9 +780,14 @@ func (api *oliveTinAPI) OnExecutionFinished(ex *executor.InternalLogEntry) {
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
log.Warnf("EventStream: client channel is full, removing client")
toRemove = append(toRemove, client)
}
}
for _, client := range toRemove {
api.removeClient(client)
}
}
func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
@@ -573,7 +800,9 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
}
func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
user := acl.UserFromContext(ctx, api.cfg)
user := acl.UserFromContext(ctx, req, api.cfg)
loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
res := &apiv1.InitResponse{
ShowFooter: api.cfg.ShowFooter,
@@ -598,6 +827,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
BannerCss: api.cfg.BannerCSS,
ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
ShowLogList: user.EffectivePolicy.ShowLogList,
LoginRequired: loginRequired,
}
return connect.NewResponse(res), nil
@@ -616,7 +846,7 @@ func (api *oliveTinAPI) buildRootDashboards(user *acl.AuthenticatedUser, dashboa
func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
defaultDashboard := buildDefaultDashboard(rr)
if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
log.Infof("defaultDashboard: %+v", defaultDashboard.Contents)
log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
*rootDashboards = append(*rootDashboards, "Actions")
}
}
@@ -660,7 +890,9 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin
}
func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
for _, client := range api.connectedClients {
toRemove := []*streamingClient{}
for _, client := range api.copyOfStreamingClients() {
select {
case client.channel <- &apiv1.EventStreamResponse{
Event: &apiv1.EventStreamResponse_OutputChunk{
@@ -671,12 +903,23 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
},
}:
default:
log.Warnf("EventStream: client channel is full, dropping message")
log.Warnf("EventStream: client channel is full, removing client")
toRemove = append(toRemove, client)
}
}
for _, client := range toRemove {
api.removeClient(client)
}
}
func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
}
res := &apiv1.GetEntitiesResponse{
EntityDefinitions: make([]*apiv1.EntityDefinition, 0),
}
@@ -724,6 +967,12 @@ func findEntityInComponents(entityTitle string, parentTitle string, components [
}
func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
user := acl.UserFromContext(ctx, req, api.cfg)
if err := api.checkDashboardAccess(user); err != nil {
return nil, err
}
res := &apiv1.Entity{}
instances := entities.GetEntityInstances(req.Msg.Type)
@@ -778,6 +1027,7 @@ func newServer(ex *executor.Executor) *oliveTinAPI {
server := oliveTinAPI{}
server.cfg = ex.Cfg
server.executor = ex
server.streamingClients = make(map[*streamingClient]struct{})
ex.AddListener(&server)
return &server
@@ -786,5 +1036,14 @@ func newServer(ex *executor.Executor) *oliveTinAPI {
func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
server := newServer(ex)
return apiv1connect.NewOliveTinApiServiceHandler(server)
jsonOpt := connectproto.WithJSON(
protojson.MarshalOptions{
EmitUnpopulated: true, // https://github.com/OliveTin/OliveTin/issues/674
},
protojson.UnmarshalOptions{
DiscardUnknown: true,
},
)
return apiv1connect.NewOliveTinApiServiceHandler(server, jsonOpt)
}

View File

@@ -46,6 +46,7 @@ func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderReque
CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action),
PopupOnStart: action.PopupOnStart,
Order: int32(actionBinding.ConfigOrder),
Timeout: int32(action.Timeout),
}
for _, cfgArg := range action.Arguments {

View File

@@ -90,12 +90,8 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard {
func sortActions(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent {
sort.Slice(components, func(i, j int) bool {
if components[i].Action == nil {
return false
}
if components[j].Action == nil {
return true
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 {

View File

@@ -0,0 +1,125 @@
package auth
import (
"os"
"sync"
"time"
"github.com/OliveTin/OliveTin/internal/config"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// Session management for user authentication
type UserSession struct {
Username string
Expiry int64
}
type SessionProvider struct {
Sessions map[string]*UserSession
}
type SessionStorage struct {
Providers map[string]*SessionProvider
}
var (
sessionStorage *SessionStorage
sessionStorageMutex sync.RWMutex
)
func init() {
sessionStorage = &SessionStorage{
Providers: make(map[string]*SessionProvider),
}
}
// RegisterUserSession registers a user session
func RegisterUserSession(cfg *config.Config, provider string, sid string, username string) {
sessionStorageMutex.Lock()
defer sessionStorageMutex.Unlock()
if sessionStorage.Providers[provider] == nil {
sessionStorage.Providers[provider] = &SessionProvider{
Sessions: make(map[string]*UserSession),
}
}
if sessionStorage.Providers == nil {
sessionStorage.Providers = make(map[string]*SessionProvider)
}
sessionStorage.Providers[provider].Sessions[sid] = &UserSession{
Username: username,
Expiry: time.Now().Unix() + 31556952, // 1 year
}
saveUserSessions(cfg)
}
// GetUserSession retrieves a user session
func GetUserSession(provider string, sid string) *UserSession {
sessionStorageMutex.Lock()
defer sessionStorageMutex.Unlock()
if sessionStorage.Providers[provider] == nil {
return nil
}
session := sessionStorage.Providers[provider].Sessions[sid]
if session == nil {
return nil
}
if session.Expiry < time.Now().Unix() {
delete(sessionStorage.Providers[provider].Sessions, sid)
return nil
}
return session
}
// LoadUserSessions loads sessions from disk
func LoadUserSessions(cfg *config.Config) {
sessionStorageMutex.Lock()
defer sessionStorageMutex.Unlock()
data, err := os.ReadFile(cfg.GetDir() + "/sessions.yaml")
if err != nil {
logrus.WithError(err).Warn("Failed to read sessions.yaml file")
ensureEmptySessionStorage()
return
}
if err := yaml.Unmarshal(data, &sessionStorage); err != nil {
logrus.WithError(err).Error("Failed to unmarshal sessions.yaml")
ensureEmptySessionStorage()
return
}
ensureEmptySessionStorage()
}
func ensureEmptySessionStorage() {
if sessionStorage == nil {
sessionStorage = &SessionStorage{Providers: make(map[string]*SessionProvider)}
}
if sessionStorage.Providers == nil {
sessionStorage.Providers = make(map[string]*SessionProvider)
}
}
func saveUserSessions(cfg *config.Config) {
out, err := yaml.Marshal(sessionStorage)
if err != nil {
logrus.WithError(err).Error("Failed to marshal session storage")
return
}
err = os.WriteFile(cfg.GetDir()+"/sessions.yaml", out, 0600)
if err != nil {
logrus.WithError(err).Error("Failed to write sessions.yaml file")
return
}
}

View File

@@ -7,211 +7,212 @@ import (
// Action represents the core functionality of OliveTin - commands that show up
// as buttons in the UI.
type Action struct {
ID string
Title string
Icon string
Shell string
ShellAfterCompleted string
Timeout int
Acls []string
Entity string
Hidden bool
ExecOnStartup bool
ExecOnCron []string
ExecOnFileCreatedInDir []string
ExecOnFileChangedInDir []string
ExecOnCalendarFile string
Triggers []string
MaxConcurrent int
MaxRate []RateSpec
Arguments []ActionArgument
PopupOnStart string
SaveLogs SaveLogsConfig
ID string `koanf:"id"`
Title string `koanf:"title"`
Icon string `koanf:"icon"`
Shell string `koanf:"shell"`
Exec []string `koanf:"exec"`
ShellAfterCompleted string `koanf:"shellAfterCompleted"`
Timeout int `koanf:"timeout"`
Acls []string `koanf:"acls"`
Entity string `koanf:"entity"`
Hidden bool `koanf:"hidden"`
ExecOnStartup bool `koanf:"execOnStartup"`
ExecOnCron []string `koanf:"execOnCron"`
ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
Triggers []string `koanf:"triggers"`
MaxConcurrent int `koanf:"maxConcurrent"`
MaxRate []RateSpec `koanf:"maxRate"`
Arguments []ActionArgument `koanf:"arguments"`
PopupOnStart string `koanf:"popupOnStart"`
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
}
// ActionArgument objects appear on Actions.
type ActionArgument struct {
Name string
Title string
Description string
Type string
Default string
Choices []ActionArgumentChoice
Entity string
RejectNull bool
Suggestions map[string]string
Name string `koanf:"name"`
Title string `koanf:"title"`
Description string `koanf:"description"`
Type string `koanf:"type"`
Default string `koanf:"default"`
Choices []ActionArgumentChoice `koanf:"choices"`
Entity string `koanf:"entity"`
RejectNull bool `koanf:"rejectNull"`
Suggestions map[string]string `koanf:"suggestions"`
}
// ActionArgumentChoice represents a predefined choice for an argument.
type ActionArgumentChoice struct {
Value string
Title string
Value string `koanf:"value"`
Title string `koanf:"title"`
}
// RateSpec allows you to set a max frequency for an action.
type RateSpec struct {
Limit int
Duration string
Limit int `koanf:"limit"`
Duration string `koanf:"duration"`
}
// Entity represents a "thing" that can have multiple actions associated with it.
// for example, a media player with a start and stop action.
type EntityFile struct {
File string
Name string
Icon string
File string `koanf:"file"`
Name string `koanf:"name"`
Icon string `koanf:"icon"`
}
// PermissionsList defines what users can do with an action.
type PermissionsList struct {
View bool
Exec bool
Logs bool
Kill bool
View bool `koanf:"view"`
Exec bool `koanf:"exec"`
Logs bool `koanf:"logs"`
Kill bool `koanf:"kill"`
}
// AccessControlList defines what permissions apply to a user or user group.
type AccessControlList struct {
Name string
AddToEveryAction bool
MatchUsergroups []string
MatchUsernames []string
Permissions PermissionsList
Policy ConfigurationPolicy
Name string `koanf:"name"`
AddToEveryAction bool `koanf:"addToEveryAction"`
MatchUsergroups []string `koanf:"matchUsergroups"`
MatchUsernames []string `koanf:"matchUsernames"`
Permissions PermissionsList `koanf:"permissions"`
Policy ConfigurationPolicy `koanf:"policy"`
}
// ConfigurationPolicy defines global settings which are overridden with an ACL.
type ConfigurationPolicy struct {
ShowDiagnostics bool
ShowLogList bool
ShowDiagnostics bool `koanf:"showDiagnostics"`
ShowLogList bool `koanf:"showLogList"`
}
type PrometheusConfig struct {
Enabled bool
DefaultGoMetrics bool
Enabled bool `koanf:"enabled"`
DefaultGoMetrics bool `koanf:"defaultGoMetrics"`
}
// Config is the global config used through the whole app.
type Config struct {
UseSingleHTTPFrontend bool
ThemeName string
ThemeCacheDisabled bool
ListenAddressSingleHTTPFrontend string
ListenAddressWebUI string
ListenAddressRestActions string
ListenAddressGrpcActions string
ListenAddressPrometheus string
ExternalRestAddress string
LogLevel string
LogDebugOptions LogDebugOptions
LogHistoryPageSize int64
Actions []*Action `mapstructure:"actions"`
Entities []*EntityFile `mapstructure:"entities"`
Dashboards []*DashboardComponent `mapstructure:"dashboards"`
CheckForUpdates bool
PageTitle string
ShowFooter bool
ShowNavigation bool
ShowNewVersions bool
EnableCustomJs bool
AuthJwtCookieName string
AuthJwtHeader string
AuthJwtAud string
AuthJwtDomain string
AuthJwtCertsURL string
AuthJwtHmacSecret string // mutually exclusive with pub key config fields
AuthJwtClaimUsername string
AuthJwtClaimUserGroup string
AuthJwtPubKeyPath string // will read pub key from file on disk
AuthHttpHeaderUsername string
AuthHttpHeaderUserGroup string
AuthHttpHeaderUserGroupSep string
AuthLocalUsers AuthLocalUsersConfig
AuthLoginUrl string
AuthRequireGuestsToLogin bool
AuthOAuth2RedirectURL string
AuthOAuth2Providers map[string]*OAuth2Provider
DefaultPermissions PermissionsList
DefaultPolicy ConfigurationPolicy
AccessControlLists []*AccessControlList
WebUIDir string
CronSupportForSeconds bool
SectionNavigationStyle string
DefaultPopupOnStart string
InsecureAllowDumpOAuth2UserData bool
InsecureAllowDumpVars bool
InsecureAllowDumpSos bool
InsecureAllowDumpActionMap bool
InsecureAllowDumpJwtClaims bool
Prometheus PrometheusConfig
SaveLogs SaveLogsConfig
DefaultIconForActions string
DefaultIconForDirectories string
DefaultIconForBack string
AdditionalNavigationLinks []*NavigationLink
ServiceHostMode string
StyleMods []string
BannerMessage string
BannerCSS string
UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"`
ThemeName string `koanf:"themeName"`
ThemeCacheDisabled bool `koanf:"themeCacheDisabled"`
ListenAddressSingleHTTPFrontend string `koanf:"listenAddressSingleHTTPFrontend"`
ListenAddressWebUI string `koanf:"listenAddressWebUI"`
ListenAddressRestActions string `koanf:"listenAddressRestActions"`
ListenAddressPrometheus string `koanf:"listenAddressPrometheus"`
ExternalRestAddress string `koanf:"externalRestAddress"`
LogLevel string `koanf:"logLevel"`
LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"`
LogHistoryPageSize int64 `koanf:"logHistoryPageSize"`
Actions []*Action `koanf:"actions"`
Entities []*EntityFile `koanf:"entities"`
Dashboards []*DashboardComponent `koanf:"dashboards"`
CheckForUpdates bool `koanf:"checkForUpdates"`
PageTitle string `koanf:"pageTitle"`
ShowFooter bool `koanf:"showFooter"`
ShowNavigation bool `koanf:"showNavigation"`
ShowNewVersions bool `koanf:"showNewVersions"`
EnableCustomJs bool `koanf:"enableCustomJs"`
AuthJwtCookieName string `koanf:"authJwtCookieName"`
AuthJwtHeader string `koanf:"authJwtHeader"`
AuthJwtAud string `koanf:"authJwtAud"`
AuthJwtDomain string `koanf:"authJwtDomain"`
AuthJwtCertsURL string `koanf:"authJwtCertsUrl"`
AuthJwtHmacSecret string `koanf:"authJwtHmacSecret"` // mutually exclusive with pub key config fields
AuthJwtClaimUsername string `koanf:"authJwtClaimUsername"`
AuthJwtClaimUserGroup string `koanf:"authJwtClaimUserGroup"`
AuthJwtPubKeyPath string `koanf:"authJwtPubKeyPath"` // will read pub key from file on disk
AuthHttpHeaderUsername string `koanf:"authHttpHeaderUsername"`
AuthHttpHeaderUserGroup string `koanf:"authHttpHeaderUserGroup"`
AuthHttpHeaderUserGroupSep string `koanf:"authHttpHeaderUserGroupSep"`
AuthLocalUsers AuthLocalUsersConfig `koanf:"authLocalUsers"`
AuthLoginUrl string `koanf:"authLoginUrl"`
AuthRequireGuestsToLogin bool `koanf:"authRequireGuestsToLogin"`
AuthOAuth2RedirectURL string `koanf:"authOAuth2RedirectUrl"`
AuthOAuth2Providers map[string]*OAuth2Provider `koanf:"authOAuth2Providers"`
DefaultPermissions PermissionsList `koanf:"defaultPermissions"`
DefaultPolicy ConfigurationPolicy `koanf:"defaultPolicy"`
AccessControlLists []*AccessControlList `koanf:"accessControlLists"`
WebUIDir string `koanf:"webUIDir"`
CronSupportForSeconds bool `koanf:"cronSupportForSeconds"`
SectionNavigationStyle string `koanf:"sectionNavigationStyle"`
DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
InsecureAllowDumpSos bool `koanf:"insecureAllowDumpSos"`
InsecureAllowDumpActionMap bool `koanf:"insecureAllowDumpActionMap"`
InsecureAllowDumpJwtClaims bool `koanf:"insecureAllowDumpJwtClaims"`
Prometheus PrometheusConfig `koanf:"prometheus"`
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
DefaultIconForActions string `koanf:"defaultIconForActions"`
DefaultIconForDirectories string `koanf:"defaultIconForDirectories"`
DefaultIconForBack string `koanf:"defaultIconForBack"`
AdditionalNavigationLinks []*NavigationLink `koanf:"additionalNavigationLinks"`
ServiceHostMode string `koanf:"serviceHostMode"`
StyleMods []string `koanf:"styleMods"`
BannerMessage string `koanf:"bannerMessage"`
BannerCSS string `koanf:"bannerCss"`
Include string `koanf:"include"`
sourceFiles []string
}
type AuthLocalUsersConfig struct {
Enabled bool
Users []*LocalUser
Enabled bool `koanf:"enabled"`
Users []*LocalUser `koanf:"users"`
}
type LocalUser struct {
Username string
Usergroup string
Password string
Username string `koanf:"username"`
Usergroup string `koanf:"usergroup"`
Password string `koanf:"password"`
}
type OAuth2Provider struct {
Name string
Title string
ClientID string
ClientSecret string
Icon string
Scopes []string
AuthUrl string
TokenUrl string
WhoamiUrl string
UsernameField string
UserGroupField string
InsecureSkipVerify bool
CallbackTimeout int
CertBundlePath string
Name string `koanf:"name"`
Title string `koanf:"title"`
ClientID string `koanf:"clientId"`
ClientSecret string `koanf:"clientSecret"`
Icon string `koanf:"icon"`
Scopes []string `koanf:"scopes"`
AuthUrl string `koanf:"authUrl"`
TokenUrl string `koanf:"tokenUrl"`
WhoamiUrl string `koanf:"whoamiUrl"`
UsernameField string `koanf:"usernameField"`
UserGroupField string `koanf:"userGroupField"`
InsecureSkipVerify bool `koanf:"insecureSkipVerify"`
CallbackTimeout int `koanf:"callbackTimeout"`
CertBundlePath string `koanf:"certBundlePath"`
}
type NavigationLink struct {
Title string
Url string
Target string
Title string `koanf:"title"`
Url string `koanf:"url"`
Target string `koanf:"target"`
}
type SaveLogsConfig struct {
ResultsDirectory string
OutputDirectory string
ResultsDirectory string `koanf:"resultsDirectory"`
OutputDirectory string `koanf:"outputDirectory"`
}
type LogDebugOptions struct {
SingleFrontendRequests bool
SingleFrontendRequestHeaders bool
AclCheckStarted bool
AclMatched bool
AclNotMatched bool
AclNoneMatched bool
SingleFrontendRequests bool `koanf:"singleFrontendRequests"`
SingleFrontendRequestHeaders bool `koanf:"singleFrontendRequestHeaders"`
AclCheckStarted bool `koanf:"aclCheckStarted"`
AclMatched bool `koanf:"aclMatched"`
AclNotMatched bool `koanf:"aclNotMatched"`
AclNoneMatched bool `koanf:"aclNoneMatched"`
}
type DashboardComponent struct {
Title string
Type string
Entity string
Icon string
CssClass string
Contents []*DashboardComponent
Title string `koanf:"title"`
Type string `koanf:"type"`
Entity string `koanf:"entity"`
Icon string `koanf:"icon"`
CssClass string `koanf:"cssClass"`
Contents []*DashboardComponent `koanf:"contents"`
}
func DefaultConfig() *Config {
@@ -256,7 +257,6 @@ func DefaultConfigWithBasePort(basePort int) *Config {
config.ListenAddressSingleHTTPFrontend = fmt.Sprintf("0.0.0.0:%d", basePort)
config.ListenAddressRestActions = fmt.Sprintf("localhost:%d", basePort+1)
config.ListenAddressGrpcActions = fmt.Sprintf("localhost:%d", basePort+2)
config.ListenAddressWebUI = fmt.Sprintf("localhost:%d", basePort+3)
config.ListenAddressPrometheus = fmt.Sprintf("localhost:%d", basePort+4)

View File

@@ -1,8 +1,9 @@
package config
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFindAction(t *testing.T) {
@@ -51,3 +52,40 @@ func TestSetDir(t *testing.T) {
assert.Equal(t, "test", c.GetDir(), "SetDir")
}
func TestFindUserByUsername(t *testing.T) {
c := DefaultConfig()
// Test with empty users list
assert.Nil(t, c.FindUserByUsername("nonexistent"), "Find user in empty list should return nil")
// Add test users
user1 := &LocalUser{
Username: "admin",
Usergroup: "admin",
Password: "adminpass",
}
user2 := &LocalUser{
Username: "guest",
Usergroup: "guest",
Password: "guestpass",
}
c.AuthLocalUsers.Users = append(c.AuthLocalUsers.Users, user1, user2)
// Test finding existing users
foundUser := c.FindUserByUsername("admin")
assert.NotNil(t, foundUser, "Find existing user 'admin'")
assert.Equal(t, "admin", foundUser.Username, "Found user should have correct username")
assert.Equal(t, "admin", foundUser.Usergroup, "Found user should have correct usergroup")
assert.Equal(t, "adminpass", foundUser.Password, "Found user should have correct password")
foundUser = c.FindUserByUsername("guest")
assert.NotNil(t, foundUser, "Find existing user 'guest'")
assert.Equal(t, "guest", foundUser.Username, "Found user should have correct username")
assert.Equal(t, "guest", foundUser.Usergroup, "Found user should have correct usergroup")
// Test finding non-existent user
assert.Nil(t, c.FindUserByUsername("nonexistent"), "Find non-existent user should return nil")
assert.Nil(t, c.FindUserByUsername(""), "Find empty username should return nil")
}

View File

@@ -5,7 +5,11 @@ import (
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -31,85 +35,32 @@ func AddListener(l func()) {
}
func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
log.Infof("Appending cfg source: %s", configPath)
log.WithFields(log.Fields{
"configPath": configPath,
}).Info("Appending cfg source")
// Try default unmarshaling first
err := k.Unmarshal(".", cfg)
if err != nil {
log.Errorf("Error unmarshalling config: %v", err)
loadIncludedConfigsFromDir(k, configPath)
if !unmarshalRoot(k, cfg) {
return
}
// If actions are not loaded by default unmarshaling, try manual unmarshaling
// This is a workaround for a koanf issue where []*Action fields are not unmarshaled correctly
if len(cfg.Actions) == 0 && k.Exists("actions") {
var actions []*Action
err := k.Unmarshal("actions", &actions)
if err != nil {
log.Errorf("Error manually unmarshaling actions: %v", err)
} else {
cfg.Actions = actions
}
}
afterLoadFinalize(cfg, configPath)
}
// If dashboards are not loaded by default unmarshaling, try manual unmarshaling
// This is a workaround for a koanf issue where []*DashboardComponent fields are not unmarshaled correctly
if len(cfg.Dashboards) == 0 && k.Exists("dashboards") {
var dashboards []*DashboardComponent
err := k.Unmarshal("dashboards", &dashboards)
if err != nil {
log.Errorf("Error manually unmarshaling dashboards: %v", err)
} else {
cfg.Dashboards = dashboards
}
}
func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf",
})
// If entities are not loaded by default unmarshaling, try manual unmarshaling
// This is a workaround for a koanf issue where []*EntityFile fields are not unmarshaled correctly
if len(cfg.Entities) == 0 && k.Exists("entities") {
var entities []*EntityFile
err := k.Unmarshal("entities", &entities)
if err != nil {
log.Errorf("Error manually unmarshaling entities: %v", err)
} else {
cfg.Entities = entities
}
}
// Manual field assignment for other config fields that might not be unmarshaled correctly
if k.Exists("showFooter") {
cfg.ShowFooter = k.Bool("showFooter")
}
if k.Exists("showNavigation") {
cfg.ShowNavigation = k.Bool("showNavigation")
}
if k.Exists("checkForUpdates") {
cfg.CheckForUpdates = k.Bool("checkForUpdates")
}
if k.Exists("pageTitle") {
cfg.PageTitle = k.String("pageTitle")
}
// Handle defaultPolicy nested struct
if k.Exists("defaultPolicy") {
if k.Exists("defaultPolicy.showDiagnostics") {
cfg.DefaultPolicy.ShowDiagnostics = k.Bool("defaultPolicy.showDiagnostics")
}
if k.Exists("defaultPolicy.showLogList") {
cfg.DefaultPolicy.ShowLogList = k.Bool("defaultPolicy.showLogList")
}
}
// Handle prometheus nested struct
if k.Exists("prometheus") {
if k.Exists("prometheus.enabled") {
cfg.Prometheus.Enabled = k.Bool("prometheus.enabled")
}
if k.Exists("prometheus.defaultGoMetrics") {
cfg.Prometheus.DefaultGoMetrics = k.Bool("prometheus.defaultGoMetrics")
}
if err != nil {
log.Errorf("Error unmarshalling config: %v", err)
return false
}
return true
}
func afterLoadFinalize(cfg *Config, configPath string) {
metricConfigReloadedCount.Inc()
metricConfigActionCount.Set(float64(len(cfg.Actions)))
@@ -121,6 +72,133 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
}
}
// buildIncludePath constructs the full path to the include directory.
func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string {
relativeIncludePath := k.String("include")
return filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
}
// loadAndMergeYamlFiles loads and merges all YAML files from the include directory.
func loadAndMergeYamlFiles(k *koanf.Koanf, includePath string, yamlFiles []string) {
sort.Strings(yamlFiles)
for _, filename := range yamlFiles {
loadAndMergeIncludedFile(k, includePath, filename)
}
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
}
// loadIncludedConfigsFromDir loads configuration files from an include directory and merges them
func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
relativeIncludePath := k.String("include")
if relativeIncludePath == "" {
return
}
includePath := buildIncludePath(k, baseConfigPath)
log.WithFields(log.Fields{
"includePath": includePath,
}).Infof("Loading included configs from dir")
yamlFiles, ok := listYamlFiles(includePath)
if !ok || len(yamlFiles) == 0 {
return
}
loadAndMergeYamlFiles(k, includePath, yamlFiles)
}
// validateIncludeDirectory checks if the given path exists and is a directory.
func validateIncludeDirectory(includePath string) bool {
dirInfo, err := os.Stat(includePath)
if err != nil {
log.Warnf("Include directory not found: %s", includePath)
return false
}
if !dirInfo.IsDir() {
log.Warnf("Include path is not a directory: %s", includePath)
return false
}
return true
}
// isYamlFile checks if a filename has a YAML extension.
func isYamlFile(name string) bool {
return strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml")
}
// filterYamlFilesFromEntries extracts YAML file names from directory entries.
func filterYamlFilesFromEntries(entries []os.DirEntry) []string {
var yamlFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
if isYamlFile(entry.Name()) {
yamlFiles = append(yamlFiles, entry.Name())
}
}
return yamlFiles
}
func listYamlFiles(includePath string) ([]string, bool) {
if !validateIncludeDirectory(includePath) {
return nil, false
}
entries, err := os.ReadDir(includePath)
if err != nil {
log.Errorf("Error reading include directory: %v", err)
return nil, false
}
yamlFiles := filterYamlFilesFromEntries(entries)
if len(yamlFiles) == 0 {
log.Infof("No YAML files found in include directory: %s", includePath)
}
return yamlFiles, true
}
func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
filePath := filepath.Join(includePath, filename)
if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFunc)); err != nil {
log.Errorf("Error loading included config file %s: %v", filePath, err)
return
}
log.WithFields(log.Fields{
"filePath": filePath,
}).Info("Successfully loaded included config file")
}
// mergeActionsWhenBothExist merges actions when both src and dest have actions.
func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}) {
srcSlice, ok1 := srcActions.([]interface{})
destSlice, ok2 := destActions.([]interface{})
if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...)
} else {
dest["actions"] = srcActions
}
}
// mergeActionsFromSource merges actions from source into destination.
func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}) {
if destActions, ok := dest["actions"]; ok {
mergeActionsWhenBothExist(srcActions, destActions, dest)
} else {
dest["actions"] = srcActions
}
}
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
if srcActions, ok := src["actions"]; ok {
mergeActionsFromSource(srcActions, dest)
}
return nil
}
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {

View File

@@ -90,55 +90,49 @@ var envConfigTests = []struct {
}
func TestEnvInConfig(t *testing.T) {
t.Skip("Skipping test in 3k")
for _, tt := range envConfigTests {
cfg := DefaultConfig()
if tt.input != "" {
os.Setenv("INPUT", tt.input)
}
// Process the YAML content to replace environment variables
processedYaml := envRegex.ReplaceAllStringFunc(tt.yaml, func(match string) string {
submatches := envRegex.FindStringSubmatch(match)
key := submatches[1]
val, _ := os.LookupEnv(key)
return val
})
k := koanf.New(".")
err := k.Load(rawbytes.Provider([]byte(processedYaml)), yaml.Parser())
setIfNotEmpty("INPUT", tt.input)
processed := processYamlWithEnv(tt.yaml)
k, err := loadKoanf(processed)
if err != nil {
t.Errorf("Error loading YAML: %v", err)
continue
}
// Try default unmarshaling
err = k.Unmarshal(".", cfg)
if err != nil {
if err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf",
}); err != nil {
t.Errorf("Error unmarshalling config: %v", err)
continue
}
// Manual field assignment for testing (since default unmarshaling has issues with field mapping)
if k.Exists("PageTitle") {
cfg.PageTitle = k.String("PageTitle")
}
if k.Exists("CheckForUpdates") {
cfg.CheckForUpdates = k.Bool("CheckForUpdates")
}
if k.Exists("LogHistoryPageSize") {
cfg.LogHistoryPageSize = k.Int64("LogHistoryPageSize")
}
if k.Exists("actions") {
var actions []*Action
if err := k.Unmarshal("actions", &actions); err == nil {
cfg.Actions = actions
}
}
field := tt.selector(cfg)
assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input)
os.Unsetenv("INPUT")
}
}
func setIfNotEmpty(key, val string) {
if val != "" {
os.Setenv(key, val)
}
}
func processYamlWithEnv(content string) string {
return envRegex.ReplaceAllStringFunc(content, func(match string) string {
submatches := envRegex.FindStringSubmatch(match)
key := submatches[1]
val, _ := os.LookupEnv(key)
return val
})
}
func loadKoanf(processed string) (*koanf.Koanf, error) {
k := koanf.New(".")
if err := k.Load(rawbytes.Provider([]byte(processed)), yaml.Parser()); err != nil {
return nil, err
}
return k, nil
}

View File

@@ -0,0 +1,168 @@
package config
import (
"os"
"testing"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"github.com/stretchr/testify/assert"
)
func TestUserLoadingFromConfig(t *testing.T) {
// Create a temporary test config file
testConfig := `
authLocalUsers:
enabled: true
users:
- username: testuser1
usergroup: admin
password: password1
- username: testuser2
usergroup: guest
password: password2
actions:
- title: Test Action
shell: echo "test"
`
// Create temporary file
tmpFile, err := os.CreateTemp("", "test_config_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
// Write test config to file
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
// Load config using koanf
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
assert.NoError(t, err, "Should load config file")
// Create config struct and load it
cfg := &Config{}
AppendSource(cfg, k, tmpFile.Name())
// Test that authLocalUsers was loaded correctly
assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
assert.Equal(t, 2, len(cfg.AuthLocalUsers.Users), "Should load 2 users")
// Test individual users
user1 := cfg.FindUserByUsername("testuser1")
assert.NotNil(t, user1, "Should find testuser1")
assert.Equal(t, "testuser1", user1.Username, "User1 should have correct username")
assert.Equal(t, "admin", user1.Usergroup, "User1 should have correct usergroup")
assert.Equal(t, "password1", user1.Password, "User1 should have correct password")
user2 := cfg.FindUserByUsername("testuser2")
assert.NotNil(t, user2, "Should find testuser2")
assert.Equal(t, "testuser2", user2.Username, "User2 should have correct username")
assert.Equal(t, "guest", user2.Usergroup, "User2 should have correct usergroup")
assert.Equal(t, "password2", user2.Password, "User2 should have correct password")
// Test non-existent user
assert.Nil(t, cfg.FindUserByUsername("nonexistent"), "Should return nil for non-existent user")
}
func TestUserLoadingWithEmptyUsers(t *testing.T) {
// Test config with enabled but no users
testConfig := `
authLocalUsers:
enabled: true
users: []
actions:
- title: Test Action
shell: echo "test"
`
tmpFile, err := os.CreateTemp("", "test_config_empty_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
assert.NoError(t, err, "Should load config file")
cfg := &Config{}
AppendSource(cfg, k, tmpFile.Name())
assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
assert.Equal(t, 0, len(cfg.AuthLocalUsers.Users), "Should have 0 users")
assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
}
func TestUserLoadingWithDisabledAuth(t *testing.T) {
// Test config with disabled auth
testConfig := `
authLocalUsers:
enabled: false
users:
- username: testuser
usergroup: admin
password: password
actions:
- title: Test Action
shell: echo "test"
`
tmpFile, err := os.CreateTemp("", "test_config_disabled_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
assert.NoError(t, err, "Should load config file")
cfg := &Config{}
AppendSource(cfg, k, tmpFile.Name())
assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled")
assert.Equal(t, 1, len(cfg.AuthLocalUsers.Users), "Should still load users even when disabled")
// User should still be findable even when auth is disabled
user := cfg.FindUserByUsername("testuser")
assert.NotNil(t, user, "Should find user even when auth is disabled")
}
func TestUserLoadingWithoutAuthSection(t *testing.T) {
// Test config without authLocalUsers section
testConfig := `
actions:
- title: Test Action
shell: echo "test"
`
tmpFile, err := os.CreateTemp("", "test_config_no_auth_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
assert.NoError(t, err, "Should load config file")
cfg := &Config{}
AppendSource(cfg, k, tmpFile.Name())
// Should have default values
assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled by default")
assert.Equal(t, 0, len(cfg.AuthLocalUsers.Users), "Should have 0 users by default")
assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
}

View File

@@ -30,36 +30,42 @@ func AddListener(l func()) {
}
func SetupEntityFileWatchers(cfg *config.Config) {
configDir := cfg.GetDir()
baseDir := resolveEntitiesBaseDir(cfg.GetDir())
for i := range cfg.Entities { // #337 - iterate by key, not by value
ef := cfg.Entities[i]
watchAndLoadEntity(baseDir, ef)
}
}
// Only use var directory if not in integration test mode
absConfigDir, _ := filepath.Abs(configDir)
if !strings.Contains(absConfigDir, "integration-tests") {
configDirVar := filepath.Join(configDir, "var") // for development purposes
//gocyclo:ignore
func resolveEntitiesBaseDir(configDir string) string {
absConfigDir, err := filepath.Abs(configDir)
if _, err := os.Stat(configDirVar); err == nil {
configDir = configDirVar
}
if err != nil {
log.Errorf("Error getting absolute path for %s: %v", configDir, err)
return configDir
}
for entityIndex := range cfg.Entities { // #337 - iterate by key, not by value
ef := cfg.Entities[entityIndex]
p := ef.File
if !filepath.IsAbs(p) {
p = filepath.Join(configDir, p)
log.WithFields(log.Fields{
"entityFile": p,
}).Debugf("Adding config dir to entity file path")
}
go filehelper.WatchFileWrite(p, func(filename string) {
loadEntityFile(p, ef.Name)
})
loadEntityFile(p, ef.Name)
if strings.Contains(absConfigDir, "integration-tests") {
return configDir
}
devVar := filepath.Join(configDir, "var")
if _, err := os.Stat(devVar); err == nil {
return devVar
}
return absConfigDir
}
func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
p := ef.File
if !filepath.IsAbs(p) {
p = filepath.Join(baseDir, p)
log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
}
go filehelper.WatchFileWrite(p, func(filename string) { loadEntityFile(p, ef.Name) })
loadEntityFile(p, ef.Name)
}
func loadEntityFile(filename string, entityname string) {

View File

@@ -60,15 +60,38 @@ func GetAll() *variableBase {
}
func GetEntities() entitiesByClass {
return contents.Entities
rwmutex.RLock()
copiedEntities := make(entitiesByClass, len(contents.Entities))
for entityName, entityInstances := range contents.Entities {
copiedInstances := make(entityInstancesByKey, len(entityInstances))
for key, entity := range entityInstances {
copiedInstances[key] = entity
}
copiedEntities[entityName] = copiedInstances
}
rwmutex.RUnlock()
return copiedEntities
}
func GetEntityInstances(entityName string) entityInstancesByKey {
rwmutex.RLock()
defer rwmutex.RUnlock()
if entities, ok := contents.Entities[entityName]; ok {
return entities
copiedInstances := make(entityInstancesByKey, len(entities))
for key, entity := range entities {
copiedInstances[key] = entity
}
return copiedInstances
}
return nil
return make(entityInstancesByKey, 0)
}
func AddEntity(entityName string, entityKey string, data any) {

View File

@@ -91,7 +91,7 @@ func ParseTemplateWithArgs(source string, ent *Entity, args map[string]string) s
}
templateVariables := &variableBase{
OliveTin: contents.OliveTin,
OliveTin: GetAll().OliveTin,
Arguments: args,
CurrentEntity: entdata,
}
@@ -126,5 +126,8 @@ func ParseTemplateBoolWith(source string, ent *Entity) bool {
}
func ClearEntities(entityType string) {
rwmutex.Lock()
defer rwmutex.Unlock()
delete(contents.Entities, entityType)
}

View File

@@ -42,6 +42,57 @@ func parseCommandForReplacements(shellCommand string, values map[string]string,
return shellCommand, nil
}
// parseExecArray parses all exec arguments in the action.
func parseExecArray(action *config.Action, values map[string]string, entity *entities.Entity) ([]string, error) {
parsed := make([]string, len(action.Exec))
for i, a := range action.Exec {
out, err := parseSingleExec(a, values, entity)
if err != nil {
return nil, err
}
parsed[i] = out
}
return parsed, nil
}
func parseActionExec(values map[string]string, action *config.Action, entity *entities.Entity) ([]string, error) {
if action == nil {
return nil, fmt.Errorf("action is nil")
}
if err := validateArguments(values, action); err != nil {
return nil, err
}
parsed, err := parseExecArray(action, values, entity)
if err != nil {
return nil, err
}
logParsedExec(action, parsed, values)
return parsed, nil
}
func parseSingleExec(a string, values map[string]string, entity *entities.Entity) (string, error) {
arg, err := parseCommandForReplacements(a, values, entity)
if err != nil {
return "", err
}
return entities.ParseTemplateWithArgs(arg, entity, values), nil
}
func validateArguments(values map[string]string, action *config.Action) error {
for _, arg := range action.Arguments {
if err := typecheckActionArgument(&arg, values[arg.Name], action); err != nil {
return err
}
log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned")
}
return nil
}
func logParsedExec(action *config.Action, parsed []string, values map[string]string) {
redacted := redactExecArgs(parsed, action.Arguments, values)
log.WithFields(log.Fields{"actionTitle": action.Title, "cmd": redacted}).Infof("Action parse args - After (Exec)")
}
func parseActionArguments(values map[string]string, action *config.Action, entity *entities.Entity) (string, error) {
log.WithFields(log.Fields{
"actionTitle": action.Title,
@@ -103,6 +154,15 @@ func redactShellCommand(shellCommand string, arguments []config.ActionArgument,
return shellCommand
}
//gocyclo:ignore
func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argumentValues map[string]string) []string {
redacted := make([]string, len(execArgs))
for i, arg := range execArgs {
redacted[i] = redactShellCommand(arg, arguments, argumentValues)
}
return redacted
}
func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error {
if arg.Type == "confirmation" {
return nil
@@ -243,6 +303,19 @@ func typeSafetyCheckUrl(value string) error {
return err
}
func checkShellArgumentSafety(action *config.Action) error {
if action.Shell == "" {
return nil
}
unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}}
for _, arg := range action.Arguments {
if _, bad := unsafe[arg.Type]; bad {
return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type)
}
}
return nil
}
func mangleInvalidArgumentValues(req *ExecutionRequest) {
for _, arg := range req.Binding.Action.Arguments {
if arg.Type == "datetime" {

View File

@@ -92,6 +92,110 @@ func TestArgumentNotProvided(t *testing.T) {
assert.Equal(t, err.Error(), "required arg not provided: personName")
}
func TestExecArrayParsing(t *testing.T) {
a1 := config.Action{
Title: "List files",
Exec: []string{"ls", "-alh"},
Arguments: []config.ActionArgument{},
}
values := map[string]string{}
out, err := parseActionExec(values, &a1, nil)
assert.Nil(t, err)
assert.Equal(t, []string{"ls", "-alh"}, out)
}
func TestExecArrayWithTemplateReplacement(t *testing.T) {
a1 := config.Action{
Title: "List specific path",
Exec: []string{"ls", "-alh", "{{path}}"},
Arguments: []config.ActionArgument{
{
Name: "path",
Type: "ascii_identifier",
},
},
}
values := map[string]string{
"path": "tmp",
}
out, err := parseActionExec(values, &a1, nil)
assert.Nil(t, err)
assert.Equal(t, []string{"ls", "-alh", "tmp"}, out)
}
func TestCheckShellArgumentSafetyWithURL(t *testing.T) {
a1 := config.Action{
Title: "Download file",
Shell: "curl {{url}}",
Arguments: []config.ActionArgument{
{
Name: "url",
Type: "url",
},
},
}
err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'url' cannot be used with Shell execution")
assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html")
}
func TestCheckShellArgumentSafetyWithEmail(t *testing.T) {
a1 := config.Action{
Title: "Send email",
Shell: "sendmail {{email}}",
Arguments: []config.ActionArgument{
{
Name: "email",
Type: "email",
},
},
}
err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'email' cannot be used with Shell execution")
}
func TestCheckShellArgumentSafetyWithExec(t *testing.T) {
a1 := config.Action{
Title: "Download file",
Exec: []string{"curl", "{{url}}"},
Arguments: []config.ActionArgument{
{
Name: "url",
Type: "url",
},
},
}
err := checkShellArgumentSafety(&a1)
assert.Nil(t, err)
}
func TestCheckShellArgumentSafetyWithSafeTypes(t *testing.T) {
a1 := config.Action{
Title: "List files",
Shell: "ls {{path}}",
Arguments: []config.ActionArgument{
{
Name: "path",
Type: "ascii_identifier",
},
},
}
err := checkShellArgumentSafety(&a1)
assert.Nil(t, err)
}
func TestTypeSafetyCheckUrl(t *testing.T) {
assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments")

View File

@@ -15,6 +15,7 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path"
"strings"
"sync"
@@ -73,6 +74,8 @@ type ExecutionRequest struct {
logEntry *InternalLogEntry
finalParsedCommand string
execArgs []string
useDirectExec bool
executor *Executor
}
@@ -221,6 +224,67 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int
return trackingIds, pagingResult
}
// isValidLogEntryForACL checks if a log entry has all required fields for ACL checking.
func isValidLogEntryForACL(entry *InternalLogEntry) bool {
return entry != nil && entry.Binding != nil && entry.Binding.Action != nil
}
// isLogEntryAllowedByACL checks if a log entry is allowed to be viewed by the user.
func isLogEntryAllowedByACL(cfg *config.Config, user *acl.AuthenticatedUser, entry *InternalLogEntry) bool {
return acl.IsAllowedLogs(cfg, user, entry.Binding.Action)
}
func (e *Executor) filterLogsByACL(cfg *config.Config, user *acl.AuthenticatedUser) []*InternalLogEntry {
e.logmutex.RLock()
defer e.logmutex.RUnlock()
filtered := make([]*InternalLogEntry, 0, len(e.logsTrackingIdsByDate))
for _, trackingId := range e.logsTrackingIdsByDate {
entry := e.logs[trackingId]
if !isValidLogEntryForACL(entry) {
continue
}
if isLogEntryAllowedByACL(cfg, user, entry) {
filtered = append(filtered, entry)
}
}
return filtered
}
// paginateFilteredLogs applies pagination to a filtered list of logs and returns
// the paginated results along with pagination metadata.
func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
total := int64(len(filtered))
paging := &PagingResult{PageSize: pageCount, TotalCount: total, StartOffset: startOffset}
if total == 0 {
paging.CountRemaining = 0
return []*InternalLogEntry{}, paging
}
startIndex := getPagingStartIndex(startOffset, total)
pageCount = min(total, pageCount)
endIndex := max(0, (startIndex-pageCount)+1)
out := make([]*InternalLogEntry, 0, pageCount)
for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ {
out = append(out, filtered[i])
}
paging.CountRemaining = endIndex
return out, paging
}
// GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and
// paginated correctly based on the filtered set.
func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *acl.AuthenticatedUser, startOffset int64, pageCount int64) ([]*InternalLogEntry, *PagingResult) {
filtered := e.filterLogsByACL(cfg, user)
return paginateFilteredLogs(filtered, startOffset, pageCount)
}
func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) {
e.logmutex.RLock()
@@ -283,6 +347,9 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
req.TrackingID = uuid.NewString()
}
// Update the log entry with the final tracking ID
req.logEntry.ExecutionTrackingID = req.TrackingID
log.Tracef("executor.ExecRequest(): %v", req)
e.SetLog(req.TrackingID, req.logEntry)
@@ -421,30 +488,75 @@ func stepACLCheck(req *ExecutionRequest) bool {
}
func stepParseArgs(req *ExecutionRequest) bool {
var err error
ensureArgumentMap(req)
injectSystemArgs(req)
if req.Arguments == nil {
req.Arguments = make(map[string]string)
if !hasBindingAndAction(req) {
return fail(req, fmt.Errorf("cannot parse arguments: Binding or Action is nil"))
}
req.Arguments["ot_executionTrackingId"] = req.TrackingID
req.Arguments["ot_username"] = req.AuthenticatedUser.Username
mangleInvalidArgumentValues(req)
req.finalParsedCommand, err = parseActionArguments(req.Arguments, req.Binding.Action, req.Binding.Entity)
if hasExec(req) {
return handleExecBranch(req)
} else {
return handleShellBranch(req)
}
}
func handleExecBranch(req *ExecutionRequest) bool {
args, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
if err != nil {
req.logEntry.Output = err.Error()
log.Warn(err.Error())
return false
return fail(req, err)
}
req.useDirectExec = true
req.execArgs = args
return true
}
func handleShellBranch(req *ExecutionRequest) bool {
if err := checkShellArgumentSafety(req.Binding.Action); err != nil {
return fail(req, err)
}
cmd, err := parseActionArguments(req.Arguments, req.Binding.Action, req.Binding.Entity)
if err != nil {
return fail(req, err)
}
req.useDirectExec = false
req.finalParsedCommand = cmd
return true
}
func ensureArgumentMap(req *ExecutionRequest) {
if req.Arguments == nil {
req.Arguments = make(map[string]string)
}
}
func injectSystemArgs(req *ExecutionRequest) {
req.Arguments["ot_executionTrackingId"] = req.TrackingID
req.Arguments["ot_username"] = req.AuthenticatedUser.Username
}
func hasBindingAndAction(req *ExecutionRequest) bool {
return !(req.Binding == nil || req.Binding.Action == nil)
}
func hasExec(req *ExecutionRequest) bool {
return len(req.Binding.Action.Exec) > 0
}
func fail(req *ExecutionRequest, err error) bool {
req.logEntry.Output = err.Error()
log.Warn(err.Error())
return false
}
func stepRequestAction(req *ExecutionRequest) bool {
metricActionsRequested.Inc()
@@ -455,6 +567,7 @@ func stepRequestAction(req *ExecutionRequest) bool {
return false
}
req.logEntry.Binding = req.Binding
req.logEntry.ActionConfigTitle = req.Binding.Action.Title
req.logEntry.ActionTitle = entities.ParseTemplateWith(req.Binding.Action.Title, req.Binding.Entity)
req.logEntry.ActionIcon = req.Binding.Action.Icon
@@ -558,22 +671,17 @@ func buildEnv(args map[string]string) []string {
func stepExec(req *ExecutionRequest) bool {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second)
defer cancel()
streamer := &OutputStreamer{Req: req}
cmd := wrapCommandInShell(ctx, req.finalParsedCommand)
cmd.Stdout = streamer
cmd.Stderr = streamer
cmd.Env = buildEnv(req.Arguments)
req.logEntry.ExecutionStarted = true
cmd := buildCommand(ctx, req)
if cmd == nil {
req.logEntry.Output = "Cannot execute: no command arguments provided"
log.Warn("Cannot execute: no command arguments provided")
return false
}
prepareCommand(cmd, streamer, req)
runerr := cmd.Start()
req.logEntry.Process = cmd.Process
waiterr := cmd.Wait()
req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
req.logEntry.Output = streamer.String()
@@ -603,6 +711,20 @@ func stepExec(req *ExecutionRequest) bool {
return true
}
func buildCommand(ctx context.Context, req *ExecutionRequest) *exec.Cmd {
if req.useDirectExec {
return wrapCommandDirect(ctx, req.execArgs)
}
return wrapCommandInShell(ctx, req.finalParsedCommand)
}
func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionRequest) {
cmd.Stdout = streamer
cmd.Stderr = streamer
cmd.Env = buildEnv(req.Arguments)
req.logEntry.ExecutionStarted = true
}
func stepExecAfter(req *ExecutionRequest) bool {
if req.Binding.Action.ShellAfterCompleted == "" {
return true

View File

@@ -53,7 +53,9 @@ func (e *Executor) RebuildActionMap() {
findDashboardActionTitles(req)
log.Infof("dashboardActionTitles: %v", req.DashboardActionTitles)
log.WithFields(log.Fields{
"titles": req.DashboardActionTitles,
}).Trace("dashboardActionTitles")
for configOrder, action := range e.Cfg.Actions {
if action.Entity != "" {

View File

@@ -21,5 +21,17 @@ func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cm
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd
}
func wrapCommandDirect(ctx context.Context, execArgs []string) *exec.Cmd {
if len(execArgs) == 0 {
return nil
}
cmd := exec.CommandContext(ctx, execArgs[0], execArgs[1:]...)
// This is to ensure that the process group is killed when the parent process is killed.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd
}

View File

@@ -22,3 +22,11 @@ func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cm
return exec.CommandContext(ctx, "cmd", "/u", "/C", finalParsedCommand)
}
}
func wrapCommandDirect(ctx context.Context, execArgs []string) *exec.Cmd {
if len(execArgs) == 0 {
return nil
}
return exec.CommandContext(ctx, execArgs[0], execArgs[1:]...)
}

View File

@@ -1,149 +0,0 @@
package httpservers
import (
"context"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/reflect/protoreflect"
"net/http"
"strings"
// apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
)
func parseHttpHeaderForAuth(cfg *config.Config, req *http.Request) (string, string) {
username, ok := req.Header[cfg.AuthHttpHeaderUsername]
if !ok {
log.Warnf("Config has AuthHttpHeaderUsername set to %v, but it was not found", cfg.AuthHttpHeaderUsername)
return "", ""
}
if cfg.AuthHttpHeaderUserGroup != "" {
usergroup, ok := req.Header[cfg.AuthHttpHeaderUserGroup]
if ok {
log.Debugf("HTTP Header Auth found a username and usergroup")
return username[0], usergroup[0]
} else {
log.Warnf("Config has AuthHttpHeaderUserGroup set to %v, but it was not found", cfg.AuthHttpHeaderUserGroup)
}
}
log.Debugf("HTTP Header Auth found a username, but usergroup is not being used")
return username[0], ""
}
//gocyclo:ignore
func parseRequestMetadata(cfg *config.Config, ctx context.Context, req *http.Request) metadata.MD {
username := ""
usergroup := ""
provider := "unknown"
sid := ""
if cfg.AuthJwtHeader != "" {
username, usergroup = parseJwtHeader(cfg, req)
provider = "jwt-header"
}
if cfg.AuthJwtCookieName != "" {
username, usergroup = parseJwtCookie(cfg, req)
provider = "jwt-cookie"
}
if cfg.AuthHttpHeaderUsername != "" && username == "" {
username, usergroup = parseHttpHeaderForAuth(cfg, req)
provider = "http-header"
}
// if len(cfg.AuthOAuth2Providers) > 0 && username == "" {
// username, usergroup, sid = parseOAuth2Cookie(req)
// provider = "oauth2"
// }
if cfg.AuthLocalUsers.Enabled && username == "" {
username, usergroup, sid = parseLocalUserCookie(cfg, req)
provider = "local"
}
md := metadata.New(map[string]string{
"username": username,
"usergroup": usergroup,
"provider": provider,
"sid": sid,
})
log.Tracef("api request metadata: %+v", md)
return md
}
func parseJwtHeader(cfg *config.Config, req *http.Request) (string, string) {
// JWTs in the Authorization header are usually prefixed with "Bearer " which is not part of the JWT token.
return parseJwt(cfg, strings.TrimPrefix(req.Header.Get(cfg.AuthJwtHeader), "Bearer "))
}
func (h *OAuth2Handler) forwardResponseHandler(cfg *config.Config, ctx context.Context, w http.ResponseWriter, msg protoreflect.ProtoMessage) error {
md, ok := runtime.ServerMetadataFromContext(ctx)
if !ok {
log.Warn("Could not get ServerMetadata from context")
return nil
}
forwardResponseHandlerLoginLocalUser(cfg, md.HeaderMD, w)
h.forwardResponseHandlerLogout(cfg, md.HeaderMD, w)
return nil
}
func (h *OAuth2Handler) forwardResponseHandlerLogout(cfg *config.Config, md metadata.MD, w http.ResponseWriter) {
if getMetadataKeyOrEmpty(md, "logout-provider") != "" {
sid := getMetadataKeyOrEmpty(md, "logout-sid")
delete(h.registeredStates, sid)
http.SetCookie(
w,
&http.Cookie{
Name: "olivetin-sid-oauth",
MaxAge: 31556952, // 1 year
Value: "",
HttpOnly: true,
Path: "/",
},
)
deleteLocalUserSession(cfg, "local", sid)
http.SetCookie(
w,
&http.Cookie{
Name: "olivetin-sid-local",
MaxAge: 31556952, // 1 year
Value: "",
HttpOnly: true,
Path: "/",
},
)
w.Header().Set("Content-Type", "text/html")
// We cannot send a HTTP redirect here, because we don't have access to req.
w.Write([]byte("<script>window.location.href = '/';</script>"))
}
}
func getMetadataKeyOrEmpty(md metadata.MD, key string) string {
mdValues := md.Get(key)
if len(mdValues) > 0 {
return mdValues[0]
}
return ""
}

View File

@@ -1,177 +0,0 @@
package httpservers
import (
"github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/filehelper"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
"os"
"path/filepath"
"sync"
"time"
)
var sessionStorageMutex sync.Mutex
type UserSession struct {
Username string
Expiry int64
}
type SessionProvider struct {
Sessions map[string]*UserSession
}
type SessionStorage struct {
Providers map[string]*SessionProvider
}
var (
sessionStorage *SessionStorage
)
func registerSessionProviders() {
sessionStorage = &SessionStorage{
Providers: make(map[string]*SessionProvider),
}
registerSessionProvider("local")
registerSessionProvider("oauth2")
}
func registerSessionProvider(provider string) {
sessionStorage.Providers[provider] = &SessionProvider{
Sessions: make(map[string]*UserSession),
}
}
func deleteLocalUserSession(cfg *config.Config, provider string, sid string) {
sessionStorageMutex.Lock()
deleteLocalUserSessionBatch(provider, sid)
sessionStorageMutex.Unlock()
saveUserSessions(cfg)
}
func deleteLocalUserSessionBatch(provider string, sid string) {
log.WithFields(log.Fields{
"sid": sid,
"provider": provider,
}).Debug("Deleting user session")
if _, ok := sessionStorage.Providers[provider]; !ok {
return
}
delete(sessionStorage.Providers[provider].Sessions, sid)
}
func registerUserSession(cfg *config.Config, provider string, sid string, username string) {
sessionStorageMutex.Lock()
sessionStorage.Providers[provider].Sessions[sid] = &UserSession{
Username: username,
Expiry: time.Now().Unix() + 31556952, // 1 year
}
sessionStorageMutex.Unlock()
saveUserSessions(cfg)
}
func saveUserSessions(cfg *config.Config) {
sessionStorageMutex.Lock()
defer sessionStorageMutex.Unlock()
filename := filepath.Join(cfg.GetDir(), "sessions.db.yaml")
out, err := yaml.Marshal(sessionStorage)
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Errorf("Failed to marshal session data to %v", filename)
return
}
filehelper.WriteFile(filename, out)
}
func loadUserSessions(cfg *config.Config) {
registerSessionProviders()
filename := filepath.Join(cfg.GetDir(), "sessions.db.yaml")
if _, err := os.Stat(filename); os.IsNotExist(err) {
return
}
data, err := os.ReadFile(filename)
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Errorf("Failed to read %v", filename)
return
}
err = yaml.Unmarshal(data, &sessionStorage)
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Error("Failed to unmarshal sessions.local.db")
return
}
deleteExpiredSessions(cfg)
}
func deleteExpiredSessions(cfg *config.Config) {
sessionStorageMutex.Lock()
for provider, sessions := range sessionStorage.Providers {
for sid, session := range sessions.Sessions {
if session.Expiry < time.Now().Unix() {
deleteLocalUserSessionBatch(provider, sid)
}
}
}
sessionStorageMutex.Unlock()
saveUserSessions(cfg)
}
func getUserFromSession(cfg *config.Config, providerName string, sid string) *config.LocalUser {
provider, ok := sessionStorage.Providers[providerName]
if !ok {
log.WithFields(log.Fields{
"provider": providerName,
}).Warnf("Provider not found")
return nil
}
session, ok := provider.Sessions[sid]
if !ok {
log.WithFields(log.Fields{
"sid": sid,
"provider": providerName,
}).Warnf("Stale session")
return nil
}
user := cfg.FindUserByUsername(session.Username)
if user == nil {
log.WithFields(log.Fields{
"sid": sid,
"provider": providerName,
}).Warnf("User not found")
return nil
}
return user
}

View File

@@ -1,11 +1,11 @@
package httpservers
import (
"github.com/OliveTin/OliveTin/internal/config"
"google.golang.org/grpc/metadata"
"net/http"
"github.com/google/uuid"
"github.com/OliveTin/OliveTin/internal/auth"
"github.com/OliveTin/OliveTin/internal/config"
log "github.com/sirupsen/logrus"
)
func parseLocalUserCookie(cfg *config.Config, req *http.Request) (string, string, string) {
@@ -17,39 +17,18 @@ func parseLocalUserCookie(cfg *config.Config, req *http.Request) (string, string
cookieValue := cookie.Value
user := getUserFromSession(cfg, "local", cookieValue)
session := auth.GetUserSession("local", cookieValue)
if session == nil {
return "", "", ""
}
user := cfg.FindUserByUsername(session.Username)
if user == nil {
log.WithFields(log.Fields{
"username": session.Username,
}).Warnf("User not found in config")
return "", "", ""
}
return user.Username, user.Usergroup, cookie.Value
}
func forwardResponseHandlerLoginLocalUser(cfg *config.Config, md metadata.MD, w http.ResponseWriter) error {
setUsername := getMetadataKeyOrEmpty(md, "set-username")
if setUsername != "" {
user := cfg.FindUserByUsername(setUsername)
if user == nil {
return nil
}
sid := uuid.NewString()
registerUserSession(cfg, "local", sid, user.Username)
http.SetCookie(
w,
&http.Cookie{
Name: "olivetin-sid-local",
Value: sid,
MaxAge: 31556952, // 1 year
HttpOnly: true,
Path: "/",
},
)
}
return nil
}

View File

@@ -18,7 +18,6 @@ import (
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
)
func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
@@ -54,13 +53,11 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
r.URL.Path = apiPath + fn
log.Debugf("SingleFrontend HTTP API Req URL after rewrite: %v", r.URL.Path)
log.WithFields(log.Fields{
"path": r.URL.Path,
}).Tracef("SingleFrontend HTTP API Req URL after rewrite")
// Process HTTP headers for authentication and add to context
ctx := r.Context()
md := parseRequestMetadata(cfg, ctx, r)
ctx = metadata.NewIncomingContext(ctx, md)
r = r.WithContext(ctx)
logDebugRequest(cfg, "api", r)
apiHandler.ServeHTTP(w, r)
}))

View File

@@ -45,7 +45,7 @@ func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path.Join(s.webuiDir, "index.html"))
} else {
log.Infof("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
log.Tracef("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
http.ServeFile(w, r, path.Join(s.webuiDir, r.URL.Path))
// http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
}

View File

@@ -19,7 +19,6 @@ type sosReportConfig struct {
ListenAddressSingleHTTPFrontend string
ListenAddressWebUI string
ListenAddressRestActions string
ListenAddressGrpcActions string
Timezone string
TimeNow string
ConfigDirectory string
@@ -34,7 +33,6 @@ func configToSosreport(cfg *config.Config) *sosReportConfig {
ListenAddressSingleHTTPFrontend: cfg.ListenAddressSingleHTTPFrontend,
ListenAddressWebUI: cfg.ListenAddressWebUI,
ListenAddressRestActions: cfg.ListenAddressRestActions,
ListenAddressGrpcActions: cfg.ListenAddressGrpcActions,
Timezone: time.Now().Location().String(),
TimeNow: time.Now().String(),
ConfigDirectory: cfg.GetDir(),

View File

@@ -1,185 +0,0 @@
package websocket
import (
"net/http"
"sync"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
"github.com/OliveTin/OliveTin/internal/executor"
ws "github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
)
var upgrader = ws.Upgrader{
CheckOrigin: checkOriginPermissive,
}
var (
sendmutex = sync.Mutex{}
)
type WebsocketClient struct {
conn *ws.Conn
}
var clients []*WebsocketClient
var marshalOptions = protojson.MarshalOptions{
UseProtoNames: false, // eg: canExec for js instead of can_exec from protobuf
EmitUnpopulated: true,
}
var ExecutionListener WebsocketExecutionListener
type WebsocketExecutionListener struct{}
func (WebsocketExecutionListener) OnExecutionStarted(ile *executor.InternalLogEntry) {
broadcast(&apiv1.EventExecutionStarted{
LogEntry: internalLogEntryToPb(ile),
})
}
func OnEntityChanged() {
broadcast(&apiv1.EventEntityChanged{})
}
func (WebsocketExecutionListener) OnActionMapRebuilt() {
broadcast(&apiv1.EventConfigChanged{})
}
/*
The default checkOrigin function checks that the origin (browser) matches the
request origin. However in OliveTin we expect many users to deliberately proxy
the connection with reverse proxies.
So, we just permit any origin. After some searching I'm not sure if this exposes
OliveTin to security issues, but it seems probably not. It would be possible to
create a config option like PermitWebsocketConnectionsFrom or something, but
I'd prefer if OliveTin works as much as possible "out of the box".
If this does expose OliveTin to security issues, it will be changed in the
future obviously.
*/
func checkOriginPermissive(r *http.Request) bool {
return true
}
func (WebsocketExecutionListener) OnOutputChunk(chunk []byte, executionTrackingId string) {
log.Tracef("outputchunk: %s", string(chunk))
oc := &apiv1.EventOutputChunk{
Output: string(chunk),
ExecutionTrackingId: executionTrackingId,
}
broadcast(oc)
}
func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) {
evt := &apiv1.EventExecutionFinished{
LogEntry: internalLogEntryToPb(logEntry),
}
log.Infof("WS Execution finished: %v", evt.LogEntry)
broadcast(evt)
}
func broadcast(pbmsg protoreflect.ProtoMessage) {
payload, err := marshalOptions.Marshal(pbmsg)
if err != nil {
log.Errorf("websocket marshal error: %v", err)
return
}
messageType := pbmsg.ProtoReflect().Descriptor().FullName()
// <EVIL>
// So, the websocket wants to encode messages using the same protomarshaller
// as the REST API - this gives consistency instead of using encoding/json
// and allows us to set specific marshalOptions.
//
// However, the protomarshaller will marshal the type, but the JavaScript at
// the other end has no idea what type this object is - as we're just sending
// it as JSON over the websocket.
//
// Therefore, we wrap the nicely marsheled bytes in a hacky JSON string
// literal and encode that string just with a byte array cast.
hackyMessageEnvelope := "{\"type\": \"" + messageType + "\", \"payload\": "
hackyMessage := []byte{}
hackyMessage = append(hackyMessage, []byte(hackyMessageEnvelope)...)
hackyMessage = append(hackyMessage, payload...)
hackyMessage = append(hackyMessage, []byte("}")...)
// </EVIL>
sendmutex.Lock()
for _, client := range clients {
err := client.conn.WriteMessage(ws.TextMessage, hackyMessage)
if err != nil {
log.Warnf("websocket send error: %v", err)
}
}
sendmutex.Unlock()
}
func (c *WebsocketClient) messageLoop() {
for {
mt, message, err := c.conn.ReadMessage()
if err != nil {
log.Debugf("err: %v", err)
break
}
log.Tracef("websocket recv: %s %d", message, mt)
}
}
func HandleWebsocket(w http.ResponseWriter, r *http.Request) bool {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Warnf("Websocket issue: %v", err)
return false
}
// defer c.Close()
wsclient := &WebsocketClient{
conn: c,
}
sendmutex.Lock()
clients = append(clients, wsclient)
sendmutex.Unlock()
go wsclient.messageLoop()
return true
}
func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *apiv1.LogEntry {
return &apiv1.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
Output: logEntry.Output,
TimedOut: logEntry.TimedOut,
Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags,
ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
User: logEntry.Username,
}
}

View File

@@ -7,6 +7,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/OliveTin/OliveTin/internal/auth"
"github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/executor"
"github.com/OliveTin/OliveTin/internal/httpservers"
@@ -17,7 +18,6 @@ import (
"github.com/OliveTin/OliveTin/internal/onstartup"
"github.com/OliveTin/OliveTin/internal/servicehost"
updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
"github.com/OliveTin/OliveTin/internal/websocket"
"os"
"strconv"
@@ -50,10 +50,16 @@ func init() {
}
func initLog() {
log.SetFormatter(&log.TextFormatter{
ForceQuote: true,
DisableTimestamp: true,
})
logFormat := os.Getenv("OLIVETIN_LOG_FORMAT")
if logFormat == "json" {
log.SetFormatter(&log.JSONFormatter{})
} else {
log.SetFormatter(&log.TextFormatter{
ForceQuote: true,
DisableTimestamp: true,
})
}
// Use debug this early on to catch details about startup errors. The
// default config will raise the log level later, if not set.
@@ -109,6 +115,19 @@ func getBasePort() int {
return basePort
}
func getConfigPath(directory string) string {
joinedPath := filepath.Join(directory, "config.yaml")
configPath, err := filepath.Abs(joinedPath)
if err != nil {
log.WithError(err).Warnf("Error getting absolute path for %s", joinedPath)
return joinedPath
}
return configPath
}
func initConfig(configDir string) {
k := koanf.New(".")
k.Load(env.Provider(".", ".", nil), nil)
@@ -127,22 +146,33 @@ func initConfig(configDir string) {
)
}
var firstConfigPath string
var baseConfigPath string
for _, directory := range directories {
configPath := filepath.Join(directory, "config.yaml")
log.Debugf("Checking config path: %s", configPath)
configPath := getConfigPath(directory)
found := true
if _, err := os.Stat(configPath); err != nil {
log.Debugf("Config file not found at %s: %v", configPath, err)
found = false
}
log.WithFields(log.Fields{
"configPath": configPath,
"found": found,
}).Debug("Checking base config path")
if !found {
continue
}
if firstConfigPath == "" {
firstConfigPath = configPath
if baseConfigPath == "" {
baseConfigPath = configPath
}
log.Infof("Loading config from %s", configPath)
log.WithFields(log.Fields{
"configPath": configPath,
}).Info("Loading config from path")
f := file.Provider(configPath)
if err := k.Load(f, yaml.Parser()); err != nil {
@@ -156,16 +186,18 @@ func initConfig(configDir string) {
k.Load(f, yaml.Parser())
config.AppendSource(cfg, k, configPath)
})
break
}
cfg = config.DefaultConfigWithBasePort(getBasePort())
if firstConfigPath != "" {
config.AppendSource(cfg, k, firstConfigPath)
} else {
config.AppendSource(cfg, k, "base")
if baseConfigPath == "" {
log.Fatalf("No base config file found")
os.Exit(1)
}
config.AppendSource(cfg, k, baseConfigPath)
}
func initInstallationInfo() {
@@ -204,7 +236,6 @@ func main() {
executor := executor.DefaultExecutor(cfg)
executor.RebuildActionMap()
executor.AddListener(websocket.ExecutionListener)
config.AddListener(executor.RebuildActionMap)
go onstartup.Execute(cfg, executor)
@@ -212,11 +243,13 @@ func main() {
go onfileindir.WatchFilesInDirectory(cfg, executor)
go oncalendarfile.Schedule(cfg, executor)
entities.AddListener(websocket.OnEntityChanged)
entities.AddListener(executor.RebuildActionMap)
go entities.SetupEntityFileWatchers(cfg)
go updatecheck.StartUpdateChecker(cfg)
// Load persistent sessions from disk
auth.LoadUserSessions(cfg)
httpservers.StartServers(cfg, executor)
}

View File

@@ -7,6 +7,5 @@ import (
_ "github.com/bufbuild/buf/cmd/buf"
_ "github.com/fzipp/gocyclo/cmd/gocyclo"
_ "github.com/go-critic/go-critic/cmd/gocritic"
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)

View File

@@ -8,7 +8,4 @@ WORKDIR /workspace
RUN go install -v "github.com/bufbuild/buf/cmd/buf"
RUN go install -v "github.com/fzipp/gocyclo/cmd/gocyclo"
RUN go install -v "github.com/go-critic/go-critic/cmd/gocritic"
RUN go install -v "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
RUN go install -v "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
RUN go install -v "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
RUN go install -v "google.golang.org/protobuf/cmd/protoc-gen-go"