mirror of
https://github.com/OliveTin/OliveTin
synced 2025-12-10 08:05:34 +00:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2843aa581 | ||
|
|
d1ec688c9a | ||
|
|
c1508a0a65 | ||
|
|
5b57cf2480 | ||
|
|
b97dd23abb | ||
|
|
7c66170ef5 | ||
|
|
167e700e30 | ||
|
|
1f875b05a0 | ||
|
|
00d7285167 | ||
|
|
d32d92baab | ||
|
|
36c786a26d | ||
|
|
afbd1c3abe | ||
|
|
09ecc8d15c | ||
|
|
487bf83f4e | ||
|
|
adae55f24b | ||
|
|
217b17a058 | ||
|
|
67a9d3b1d1 | ||
|
|
de7129e1d7 | ||
|
|
3df22f2ccb | ||
|
|
1140453f30 | ||
|
|
9cf79863b3 | ||
|
|
6a853d9c99 | ||
|
|
83fe489949 | ||
|
|
e7d4747727 | ||
|
|
d4d7bf8135 | ||
|
|
32a062ae93 | ||
|
|
263601170f | ||
|
|
b071b4d036 | ||
|
|
52ff504a9d | ||
|
|
2ed564a403 | ||
|
|
209856eda9 | ||
|
|
3967b91cf0 | ||
|
|
50abb53ace | ||
|
|
110bbd6216 | ||
|
|
1552c104e9 | ||
|
|
4006fd485d | ||
|
|
7dc99b1398 | ||
|
|
581536a60f | ||
|
|
58593c6f04 | ||
|
|
39664a734d | ||
|
|
87f9a0b152 | ||
|
|
d688ab64e1 | ||
|
|
822f3197b6 | ||
|
|
a67b5b4e8f | ||
|
|
a3c5114615 | ||
|
|
28c813762f | ||
|
|
d94f2aca1c | ||
|
|
055472902d | ||
|
|
2b24daa6d0 | ||
|
|
294e33d110 | ||
|
|
d3cd876eec | ||
|
|
52cd5f255a | ||
|
|
2b1f9a9247 | ||
|
|
6782156a58 | ||
|
|
f1250f9caf | ||
|
|
0bf313a3f7 | ||
|
|
092661c7eb | ||
|
|
2a6d9e4f68 | ||
|
|
83f45d71bf | ||
|
|
79a71099f9 | ||
|
|
e6a02ac614 | ||
|
|
e0167c9e42 | ||
|
|
7abffedb14 | ||
|
|
d32db6483e | ||
|
|
44b518a5b2 | ||
|
|
a4e50bfb54 | ||
|
|
a8f5e25454 | ||
|
|
c3d5da1981 | ||
|
|
7a1c4d3efa | ||
|
|
c89979ddb2 | ||
|
|
430aab638b | ||
|
|
961ddac193 | ||
|
|
03ac3b5fa7 | ||
|
|
d21f06e555 | ||
|
|
f25b456c3d | ||
|
|
e1db1e7be5 | ||
|
|
19c3b67cdd | ||
|
|
b9d859ada2 | ||
|
|
61fc771ac3 | ||
|
|
e0fd10a6ec | ||
|
|
2a5732cc27 | ||
|
|
57390be16f | ||
|
|
8a6d61c260 | ||
|
|
f337e05eaf | ||
|
|
6c6d07bf4f | ||
|
|
d54f2307c7 | ||
|
|
49dcc7fb46 | ||
|
|
2ea35697d0 | ||
|
|
a551589840 | ||
|
|
fcd3ccc59a | ||
|
|
dddc0417c2 | ||
|
|
d5eb74e738 | ||
|
|
9fbaa8671f | ||
|
|
a915a654cb | ||
|
|
c86bf629f9 | ||
|
|
c917d1b1e7 | ||
|
|
1cb12b203e | ||
|
|
2a21d74e35 | ||
|
|
8686a5629e | ||
|
|
43cfe41378 | ||
|
|
280234b138 | ||
|
|
02ec8eeb65 | ||
|
|
ef5a67e7b8 | ||
|
|
eb2463aa2d | ||
|
|
a7e7bf869e | ||
|
|
0dd9e9b2b7 | ||
|
|
aa8322c354 | ||
|
|
956e74a6b3 | ||
|
|
c9ff4d1a68 | ||
|
|
88cc1ab080 | ||
|
|
3b8bc49b04 | ||
|
|
31ea8507f5 | ||
|
|
62af851b2c | ||
|
|
2a764acde6 | ||
|
|
02e2ac1676 | ||
|
|
c89579840b | ||
|
|
38d81fafe2 | ||
|
|
8b2b85c3d0 | ||
|
|
76a33e2e54 | ||
|
|
fa94357374 | ||
|
|
439e952a25 | ||
|
|
3dfbbcc770 | ||
|
|
77e8c37599 | ||
|
|
d3aa3b25b0 | ||
|
|
d944b09c51 | ||
|
|
b9851adfde |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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: ''
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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: ''
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/support_request.md
vendored
2
.github/ISSUE_TEMPLATE/support_request.md
vendored
@@ -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: ''
|
||||
|
||||
|
||||
28
.github/workflows/build-and-release.yml
vendored
28
.github/workflows/build-and-release.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -16,3 +16,7 @@ integration-tests/screenshots/
|
||||
webui/
|
||||
server.log
|
||||
OliveTin
|
||||
integration-tests/configs/authRequireGuestsToLogin/sessions.yaml
|
||||
webui
|
||||
webui.dev
|
||||
sessions.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–2 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.
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
11
Makefile
11
Makefile
@@ -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
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
[](https://bestpractices.coreinfrastructure.org/projects/5050)
|
||||
|
||||
[](https://goreportcard.com/report/github.com/OliveTin/OliveTin)
|
||||
[](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
|
||||
|
||||
66
config.yaml
66
config.yaml
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
4395
frontend/package-lock.json
generated
4395
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
389
frontend/resources/vue/views/ActionDetailsView.vue
Normal file
389
frontend/resources/vue/views/ActionDetailsView.vue
Normal 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
178
frontend/resources/vue/views/UserControlPanel.vue
Normal file
178
frontend/resources/vue/views/UserControlPanel.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
@@ -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: "🥱"
|
||||
|
||||
6
integration-tests/configs/include/config.d/00-first.yml
Normal file
6
integration-tests/configs/include/config.d/00-first.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
# This file should be loaded first
|
||||
actions:
|
||||
- title: First Included Action
|
||||
shell: echo "first"
|
||||
icon: ping
|
||||
|
||||
9
integration-tests/configs/include/config.d/01-second.yml
Normal file
9
integration-tests/configs/include/config.d/01-second.yml
Normal 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"
|
||||
|
||||
14
integration-tests/configs/include/config.yaml
Normal file
14
integration-tests/configs/include/config.yaml
Normal 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
|
||||
|
||||
26
integration-tests/configs/localAuth/config.yaml
Normal file
26
integration-tests/configs/localAuth/config.yaml
Normal 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: "🥱"
|
||||
2
integration-tests/proxies/haproxy/Makefile
Normal file
2
integration-tests/proxies/haproxy/Makefile
Normal file
@@ -0,0 +1,2 @@
|
||||
make:
|
||||
haproxy -f ./haproxy.conf
|
||||
@@ -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
|
||||
|
||||
|
||||
39
integration-tests/test/authRequireGuestsToLogin.mjs
Normal file
39
integration-tests/test/authRequireGuestsToLogin.mjs
Normal 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')
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
53
integration-tests/test/include.mjs
Normal file
53
integration-tests/test/include.mjs
Normal 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')
|
||||
})
|
||||
})
|
||||
|
||||
103
integration-tests/test/localAuth.mjs
Normal file
103
integration-tests/test/localAuth.mjs
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
2
lang/Makefile
Normal file
@@ -0,0 +1,2 @@
|
||||
default:
|
||||
go run main.go
|
||||
36
lang/README.md
Normal file
36
lang/README.md
Normal 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
150
lang/combined_output.json
Normal 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
29
lang/de-DE.yaml
Normal 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
29
lang/en.yaml
Normal 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
29
lang/es-ES.yaml
Normal 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
11
lang/go.mod
Normal 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
20
lang/go.sum
Normal 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
29
lang/it-IT.yaml
Normal 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
215
lang/main.go
Normal 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
29
lang/zh-Hans-CN.yaml
Normal 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: 清除搜索筛选器
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
125
service/internal/auth/sessions.go
Normal file
125
service/internal/auth/sessions.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
168
service/internal/config/config_reloader_user_test.go
Normal file
168
service/internal/config/config_reloader_user_test.go
Normal 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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:]...)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user