major update to sub-users creation
This commit is contained in:
739
FAQ.md
739
FAQ.md
@@ -1,386 +1,350 @@
|
||||
# FAQ
|
||||
|
||||
To see list of all the available commands run
|
||||
# How to find the API key?
|
||||
|
||||
```shell
|
||||
$ docker exec -ti watchstate console list
|
||||
There are two ways to locate the API key:
|
||||
|
||||
## Via `.env` file
|
||||
|
||||
The API key is stored in the following file `/config/config/.env`, Open the file and look for the line starting with:
|
||||
|
||||
```
|
||||
WS_API_KEY=random_string
|
||||
```
|
||||
|
||||
This command will list all available commands, and each command has help document attached to it, simply run
|
||||
The value after the equals sign is your API key.
|
||||
|
||||
```shell
|
||||
$ docker exec -ti watchstate console help [COMMAND_NAME]
|
||||
## Via command
|
||||
|
||||
You can also retrieve the API key by running the following command on the docker host machine:
|
||||
|
||||
```bash
|
||||
docker exec watchstate console system:apikey
|
||||
```
|
||||
|
||||
It will show you the relevant information regarding the command and some frequently asked question about that command.
|
||||
This command will show the following lines:
|
||||
|
||||
```
|
||||
Current API key:
|
||||
random_string
|
||||
```
|
||||
|
||||
The `random_string` is your API key.
|
||||
|
||||
----
|
||||
|
||||
# What Is the API key used for?
|
||||
|
||||
The API key is used to authenticate requests to the system and prevent unauthorized access.
|
||||
|
||||
It is required for all API endpoints, **except** the following:
|
||||
|
||||
```
|
||||
/v1/api/[user@backend_name]/webhook
|
||||
```
|
||||
|
||||
This webhook endpoint is open by default, unless you have enabled the `WS_SECURE_API_ENDPOINTS` environment variable.
|
||||
If enabled, the API key will also be required for webhook access.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **The help document attached to each command is more up to date and precise. So, please read it.**
|
||||
> The WebUI operates in standalone mode and is decoupled from the backend, so it requires the API key to fetch and
|
||||
> display data.
|
||||
|
||||
----
|
||||
|
||||
# How to turn on scheduled tasks for import/export?
|
||||
# How to enable scheduled/automatic tasks?
|
||||
|
||||
Scheduled tasks are configured via specific environment variables refers
|
||||
to [environment variables](#environment-variables) section,
|
||||
To turn on automatic import or export tasks:
|
||||
|
||||
## Via WebUI
|
||||
1. Go to the `Tasks` page in the WebUI.
|
||||
2. Enable the tasks you want to schedule for automatic execution.
|
||||
|
||||
Simply go to `Tasks` page and enable the tasks you want to run.
|
||||
By default:
|
||||
|
||||
## Via CLI
|
||||
- **Import** task runs every **1 hour**
|
||||
- **Export** task runs every **1 hour and 30 minutes**
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console system:env -k WS_CRON_IMPORT -e true
|
||||
$ docker exec -ti watchstate console system:env -k WS_CRON_EXPORT -e true
|
||||
```
|
||||
If you want to customize the schedule, you can do so by adding environment variables with valid cron expressions:
|
||||
|
||||
By default, `import` is scheduled to run every `1hour` while `export` is scheduled to run every `1hour 30minutes`, you
|
||||
can alter the time when the tasks are run via adding the following variables with valid cron expression. good source to
|
||||
check your cron expression is [crontab.guru](https://crontab.guru/).
|
||||
- **WS_CRON_IMPORT_AT**
|
||||
- **WS_CRON_EXPORT_AT**
|
||||
|
||||
While we think they are reasonable defaults, you can change them by setting the following environment variables:
|
||||
|
||||
## Via WebUI
|
||||
|
||||
Go to the `env` page, click on `+` button, then select the key in this case `WS_CRON_IMPORT_AT`, `WS_CRON_EXPORT_AT`
|
||||
and set the value to a valid cron expression. Then click save to apply the new timer. This will be change later to be
|
||||
included with
|
||||
the tasks page.
|
||||
|
||||
## Via CLI
|
||||
|
||||
Execute the following commands:
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console system:env -k WS_CRON_IMPORT_AT -e '"*/1 * * * *"'
|
||||
$ docker exec -ti watchstate console system:env -k WS_CRON_EXPORT_AT -e '"30 */1 * * *"'
|
||||
```
|
||||
|
||||
For Values with space, they must be enclosed in double quotes. to see the status of your scheduled tasks,
|
||||
|
||||
## Via WebUI
|
||||
|
||||
Go to the `Tasks` page, you will see the status of each task.
|
||||
|
||||
## Via CLI
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console system:tasks
|
||||
```
|
||||
You can set these variables from the <code>Env</code> page.
|
||||
|
||||
> [!NOTE]
|
||||
> All scheduled tasks are configured via the same variables style, refer
|
||||
> to [Tool specific environment variables](#tool-specific-environment-variables) for more information.
|
||||
> A great tool to validate your cron expression is [crontab.guru](https://crontab.guru/)
|
||||
|
||||
----
|
||||
After making changes, visit the `Tasks` page again to see the updated schedule next to `Next Run`.
|
||||
|
||||
# Container is crashing on startup?
|
||||
---
|
||||
|
||||
This is likely due to misconfigured `user:` in `compose.yaml`, the container is rootless as such it will crash if
|
||||
the tool unable to access the data path. to check permissions simply do the following
|
||||
# Container is crashing on start-up?
|
||||
|
||||
This usually happens due to a misconfigured `user:` in your `compose.yaml` file or an incorrect `--user` argument. The
|
||||
container runs in **rootless mode**, so it will crash if it doesn’t have permission to access the data directory.
|
||||
|
||||
## Check permissions
|
||||
|
||||
Run the following command to inspect ownership of your data directory:
|
||||
|
||||
```bash
|
||||
$ stat data/config/ | grep 'Uid:'
|
||||
```
|
||||
|
||||
It should show something like
|
||||
The path `data/config/` refers to where you have mounted your data directory. Adjust it if necessary.
|
||||
|
||||
You should see output like:
|
||||
|
||||
```
|
||||
Access: (0755/drwxr-xr-x) Uid: ( 1000/ user) Gid: ( 1000/ user)
|
||||
```
|
||||
|
||||
Use the ids as parameters for `user:` in this case it should be `user:"1000:1000"`.
|
||||
## Fixing the issue
|
||||
|
||||
----
|
||||
Use the UID and GID from the output as parameters:
|
||||
|
||||
# How to find the apikey?
|
||||
### compose.yaml
|
||||
|
||||
You can find the apikey inside the following file `/config/config/.env`. The apikey is stored inside this
|
||||
variable `WS_API_KEY=`.
|
||||
Or you can run the following command to get it directly:
|
||||
```yaml
|
||||
user: "1000:1000"
|
||||
```
|
||||
|
||||
### docker run
|
||||
|
||||
```bash
|
||||
$ docker exec -ti console system:apikey
|
||||
docker run ... --user 1000:1000
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
# What the API key used for?
|
||||
|
||||
The API key is used to authenticate the requests to the tool, it's used to prevent unauthorized access. The API key is
|
||||
required for all endpoints except the `/v1/api/[backend_name]/webhook` endpoint which is open by default unless you have
|
||||
enabled `WS_SECURE_API_ENDPOINTS` environment variable. which then you also need to use the apikey for it webhook
|
||||
endpoint.
|
||||
|
||||
The new `WebUI` will also require the API key to access data as it's decoupled from the backend and run in standalone
|
||||
mode.
|
||||
Make sure the container has the correct permissions to access all necessary data paths to prevent crashes.
|
||||
|
||||
----
|
||||
|
||||
# MAPPER: Watch state conflict detected in [BACKEND_NAME]...?
|
||||
# MAPPER: Conflict Detected in `'user@backend_name`...?
|
||||
|
||||
This warning occurs when the database has the movie/episode marked as played but a backend reporting the
|
||||
item as unplayed and there is no metadata that indicates that the movie was previously imported from the backend.
|
||||
So, Preserving your current watch state takes priority, and thus we mark the item as tainted and re-process it.
|
||||
To Fix this conflict you should re-export your database state back to the problematic backend using the following
|
||||
command:
|
||||
This warning appears when there is a mismatch between the local database and a backend regarding the watch state of a
|
||||
movie or episode. Specifically, it means:
|
||||
|
||||
- The item is marked as **played** in the local database.
|
||||
- The backend is reporting it as **unplayed**.
|
||||
- There is no metadata indicating that the item was previously imported from that backend to make it possible to mark it
|
||||
as unplayed.
|
||||
|
||||
In this case, the system prioritizes preserving your local play state. The item is marked as **tainted** and will be
|
||||
re-processed accordingly.
|
||||
|
||||
## How to resolve the conflict?
|
||||
|
||||
To resolve this conflict and sync the backend with your local state:
|
||||
|
||||
* Go to the `WebUI > Backends`.
|
||||
* Under the relevant backend, find the **Frequently used commands** list.
|
||||
* Select **3. Force export local play state to this backend.**
|
||||
|
||||
This operation will overwrite the backend's watch state with your current local state to bring them back in sync.
|
||||
|
||||
----
|
||||
|
||||
# How to Use Jellyfin or Emby OAuth Tokens
|
||||
|
||||
Due to limitations on the Jellyfin/Emby side, our implementation requires you to provide your credentials in the
|
||||
`username:password` format. This is necessary because their API does not allow us to determine the currently
|
||||
authenticated user directly.
|
||||
|
||||
When prompted for the API key, simply enter your credentials like this:
|
||||
|
||||
```
|
||||
username:password
|
||||
```
|
||||
|
||||
WatchState will then generate an OAuth token for you and automatically replace the username and password with the token.
|
||||
This is a one-time process, and you won’t need to repeat it.
|
||||
|
||||
Your `username` and `password` are **not** stored.
|
||||
|
||||
----
|
||||
|
||||
# My New Backend Is Overriding My Old Backend’s State / My Watch State Is Incorrect
|
||||
|
||||
This issue typically occurs when a newly added backend reports **newer timestamps** than an existing backend.
|
||||
By default, the system prioritizes data with the latest timestamps, which usually ensures the most up-to-date watch
|
||||
state is preserved.
|
||||
|
||||
However, if the new backend's state is incorrect, it may unintentionally override your accurate local watch history.
|
||||
|
||||
## How to Fix the the play state
|
||||
|
||||
To synchronize both backends correctly:
|
||||
|
||||
* **Add the backend** that contains the correct watch state first.
|
||||
* Enable **Full Import** for that backend.
|
||||
* Go to `Tasks` page, and run the **Import** task via `Run via console` button.
|
||||
* Once the import is complete, **add the second backend** (the one with incorrect or outdated play state).
|
||||
* Under the newly added backend, locate the **Frequently used commands** section.
|
||||
* Select **3. Force export local play state to this backend.**
|
||||
|
||||
This will push your local watch state to the backend and ensure both are in sync.
|
||||
|
||||
----
|
||||
|
||||
# My New Backend Watch State Is Not Being Updated?
|
||||
|
||||
This issue is most likely caused by a **date mismatch**. When exporting watch state, the system compares the date of the
|
||||
item on the backend with the date stored in the local database. If the backend's date is equal to or newer than the
|
||||
local one, the export may be skipped.
|
||||
|
||||
To confirm if this is the issue, follow these steps:
|
||||
|
||||
1. Go to the `WebUI > Backends`.
|
||||
2. Under the relevant backend, locate the **Frequently Used Commands** section.
|
||||
3. Select **7. Run export and save debug log.**
|
||||
|
||||
This will generate a log file at `/config/user@backend_name.export.txt` If the file is too large to view in a regular
|
||||
text editor, you can read it using:
|
||||
|
||||
```bash
|
||||
$ docker exec -ti console state:export -fi -s [BACKEND_NAME]
|
||||
docker exec -ti watchstate bash -c "cat /config/user@backend_name.export.txt | more"
|
||||
```
|
||||
|
||||
----
|
||||
|
||||
# How to use Jellyfin, Emby oauth tokens?
|
||||
|
||||
Due to limitation on jellyfin/emby side, the way we implemented support for oauth token require that you provide the
|
||||
username and password in `username:password` format, This is due to the API not providing a way for us to inquiry about
|
||||
the current user.
|
||||
|
||||
Simply, when asked for API Key, provide the username and password in the following format `username:password`.
|
||||
`WatchState` will then generate the token for you. and replace the username and password with the generated token. This
|
||||
is a one time process, and you should not need to do it again. Your `username` and `password` will not be stored.
|
||||
|
||||
----
|
||||
|
||||
# My new backend overriding my old backend state / My watch state is not correct?
|
||||
|
||||
This likely due to the new backend reporting newer date than your old backend. as such the typical setup is to
|
||||
prioritize items with newer date compared to old ones. This is what you want to happen normally. However, if the new
|
||||
media backend state is not correct this might override your current watch state. The solution to get both in sync, and
|
||||
to do so follow these steps:
|
||||
|
||||
Add your backend that has correct watch state and enable full import. Second, add your new backend as metadata source.
|
||||
|
||||
In `CLI` context Answer `N` to the question `Enable importing of metadata and play state from this backend?`. Make sure
|
||||
to select yes
|
||||
for export to get the option to select the backend as metadata source.
|
||||
|
||||
In `WebUI` if you disable import, you will get an extra option that is normally hidden to select the backend as metadata
|
||||
source.
|
||||
|
||||
After that, do single backend export by using the following command:
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console state:export -vvif -s new_backend_name
|
||||
```
|
||||
|
||||
Running this command will force full export your current database state to the selected backend. Once that done you can
|
||||
turn on import from the new backend.
|
||||
|
||||
In `CLI` context you can enable import by running the following command:
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console config:manage -s backend_name
|
||||
```
|
||||
|
||||
In `WebUI` you can enable import by going to the `backends` page and click on import for the new backend.
|
||||
|
||||
----
|
||||
|
||||
# My new backend watch state not being updated?
|
||||
|
||||
The likely cause of this problem is date related problem, as we check the date on backend object and compare that to the
|
||||
date in local database, to make sure this is the error you are facing please do the following.
|
||||
Look for lines like the following:
|
||||
|
||||
```
|
||||
$ docker exec -ti watchstate console state:export -s new_backend_name --debug --logfile /config/export.txt
|
||||
[YYYY-MM-DDTHH:MM:SS-ZZ] DEBUG: Ignoring 'user@backend_name' 'Title - (Year or episode)'. reason. { ..., (comparison: [ ... ]) }
|
||||
```
|
||||
|
||||
After running the command, open the log file and look for episode and movie that has the problem and read the text next
|
||||
to it. The error usually looks like:
|
||||
If you see messages such as `Backend date is equal or newer than database date.` that confirms the issue.
|
||||
|
||||
```
|
||||
[YYYY-MM-DDTHH:MM:SS-ZZ] DEBUG: Ignoring [backend_name] [Title - (Year or episode)]. reason. { ..., (comparison: [ ... ]) }
|
||||
```
|
||||
## How to Fix It
|
||||
|
||||
In this case the error text should be `Backend date is equal or newer than database date.`
|
||||
To override the date check and force an update do the following:
|
||||
|
||||
To bypass the date check you need to force ignore date comparison by using the `[-i, --ignore-date]` flag, so to get
|
||||
your new backend in sync with your old database, do the following:
|
||||
* Go to the `WebUI > Backends`.
|
||||
* Under the relevant backend, find the **Frequently used commands** list.
|
||||
* Select **3. Force export local play state to this backend.**
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console state:export -vvif -s new_backend_name
|
||||
```
|
||||
|
||||
This command will ignore your `lastSync` date and will also ignore `object date comparison` and thus will mirror your
|
||||
database state back to the selected backend.
|
||||
This will sync your local database state to the backend, ignoring date comparisons.
|
||||
|
||||
----
|
||||
|
||||
# Is there support for Multi-user setup?
|
||||
|
||||
We are on early stage of supporting multi-user setups, initially few operations are supported. To get started, first you
|
||||
need to create your own main user backends using admin token for Plex and api key for Jellyfin/Emby.
|
||||
There is **basic** support for multi-user setups, but it's not fully developed yet. The tool is primarily designed for
|
||||
single-user use, and multi-user functionality is built on top of that. Because of this, you might encounter some issues
|
||||
when using it with multiple users.
|
||||
|
||||
Once your own main user is added, make sure to turn on the `import` and `export` for all backends, as the sub users are
|
||||
initial configuration is based on your own main user configuration. Once your own user is working, turn on the `import`
|
||||
and `export` tasks in the Tasks page.
|
||||
## Getting started with a multi-user setup
|
||||
|
||||
Now, to create the sub users configurations, you need to run `backend:create` command, which can be done via
|
||||
`WebUI > Backends > Purple button (users) icon` or via CLI by running the following command:
|
||||
1. **Add your backends** as you normally would. Make sure to include the backends for your main user.
|
||||
2. For the `Plex` backend, you must use an **Admin-level `X-Plex-Token`**. Without it, we won’t be able to retrieve the
|
||||
list of users.
|
||||
You can check your token by going to `Tools > Plex Token`.
|
||||
- If you see a success message, you’re good to go.
|
||||
- If you see an error message, it likely means your token has limited permissions and can’t be used to access the
|
||||
user list.
|
||||
3. For `Jellyfin` and `Emby` backends, use an **API key**, which can be generated from your server settings:
|
||||
- Go to `Dashboard > Advanced > API Keys` and create a new key.
|
||||
4. After setting up your backends and verifying they work, go to `Tools > Sub Users`.
|
||||
The system will attempt to automatically group users based on their names. However, because naming can vary between
|
||||
setups, not all users may be matched correctly. You can manually organize the groups by dragging and dropping
|
||||
users.
|
||||
5. Once you're satisfied with the setup, click the `Create Sub-users` button to generate the configuration.
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console backend:create -v
|
||||
```
|
||||
> [!NOTE]
|
||||
> The sub-user configurations are based on your current main user settings. If you change the main configuration (e.g.,
|
||||
> backend URL), you must either:
|
||||
> * Manually update the sub-user backends, or
|
||||
> * Click `Update Sub-users`, which will try to update them automatically. This action can also **create new sub-users**
|
||||
if they don’t already exist—so use it carefully.
|
||||
|
||||
Once the sub users configuration is created, You can start using the multi-user functionality.
|
||||
|
||||
If your users usernames are different between the backends, you can use the `mapper.yaml` file to map the users between
|
||||
the backends. For more information about the `mapper.yaml` file, please refer to
|
||||
the [mapper.yaml](#whats-the-schema-for-the-mapperyaml-file) section.
|
||||
|
||||
# Whats the schema for the `mapper.yaml` file?
|
||||
|
||||
The schema is simple, it's a list of users in the following format:
|
||||
|
||||
```yaml
|
||||
version: "1.5"
|
||||
map:
|
||||
# 1st user...
|
||||
- my_plex_server: # your main user backend name
|
||||
name: "mike_jones"
|
||||
options: { } # optional key.
|
||||
my_jellyfin_server: # your main user backend name
|
||||
name: "jones_mike"
|
||||
my_emby_server: # your main user backend name
|
||||
name: "mikeJones"
|
||||
replace_with: "mike_jones" # optional action, to replace the username with the new one.
|
||||
# 2nd user...
|
||||
- my_emby_server: # your main user backend name
|
||||
name: "jiji_jones"
|
||||
options: { } # optional key.
|
||||
my_plex_server: # your main user backend name
|
||||
name: "jones_jiji"
|
||||
my_jellyfin_server: # your main user backend name
|
||||
name: "jiji.jones"
|
||||
replace_with: "jiji_jones" # optional action, to replace the username with the new one.
|
||||
#.... more users
|
||||
```
|
||||
|
||||
If you create a map for a user, it SHOULD include all the backends you want to sync the user data with. while the
|
||||
matcher might automatically detect the other backends even if not included in the map, it best to manually set them in
|
||||
group to prevent any issues that might arise. Each list item is a user, and each user has a list of backends. Each
|
||||
backend.
|
||||
|
||||
> [!NOTE]
|
||||
> the backend names `my_plex_server`, `my_jellyfin_server`, `my_emby_server` are the names you have chosen for
|
||||
> your backends.
|
||||
>
|
||||
> The `name` field must match the name after normalization, so if you have a backend with the name `Mike Jones` as
|
||||
> username, the `name:` in the `mapper.yaml` file should be `mike_jones` as the `backend:create` command will normalize
|
||||
> the name before passing it to the mapper which the mapper will convert it to `mike_jones`.
|
||||
Once your sub-user setup is ready, you can start using the multi-user features.
|
||||
|
||||
## Important
|
||||
|
||||
We enforce strict naming convention for backend names and usernames, So they must follow the following format
|
||||
`^[a-z_0-9]+$` which means, lowercase letters, numbers and `_` are allowed. No spaces, uppercase letters or special
|
||||
characters or name entirely made of digits are allowed. If the username is not complying with the naming convention, we
|
||||
forcibly normalize it to make it comply with the naming convention.
|
||||
We enforce a strict naming convention for both backend names and usernames:
|
||||
|
||||
If you want another name, you can use `replace_with` key to replace the username with the new one. However, the name
|
||||
also must comply with the naming convention.
|
||||
**Format:** `^[a-z_0-9]+$`
|
||||
|
||||
This yaml file helps map your users usernames in the different backends, so the tool can sync the correct user data. If
|
||||
you added or updated mapping, you should delete `users` directory and generate new data. by running the `backend:create`
|
||||
command as described in the previous section.
|
||||
Which means
|
||||
|
||||
You can run the `backend:create` command with `-v --dry-run` to see what it will do and if you need to create a mapping
|
||||
file or not.
|
||||
* Allowed: lowercase letters, numbers, and underscores (`_`)
|
||||
* Not allowed: spaces, uppercase letters, or special characters
|
||||
|
||||
If any username doesn’t follow this convention, we’ll **automatically normalize** it, if the name is made entirely of
|
||||
digits, we’ll automatically prefix it with `user_`.
|
||||
|
||||
----
|
||||
|
||||
# How do i migrate invited friends i.e. (external user) data from from plex to emby/jellyfin?
|
||||
# Does WatchState requires Webhooks to work?
|
||||
|
||||
As this tool is designed to work with single user, You have to treat each invited friend as a separate user. what is
|
||||
needed, you need to contact that friend of yours and ask him/her to give you a copy of
|
||||
the [X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/),
|
||||
then create new container and add the backend with the token you got from your friend.
|
||||
|
||||
After that, add your other backends like emby/jellyfin using your regular API key. jellyfin/emby differentiate between
|
||||
users by using the userId which you should select at the start of the add process.
|
||||
|
||||
After that. run the `state:import -f -s [plex_server_name]` command to import the user watch state. After that, you can
|
||||
run the `state:export -fi -s [emby/jellyfin_server_name]` to export the watch state to the new backend.
|
||||
|
||||
You have to repeat these steps for each user you want to migrate their data off the plex server.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> YOU MUST always start with fresh data for **EACH USER**, otherwise unexpected things might happen.
|
||||
> Make sure to delete compose.yaml `./data` directory. to start fresh.
|
||||
|
||||
----
|
||||
|
||||
# Does this tool require webhooks to work?
|
||||
|
||||
No, You can use the `task scheduler` or on `on demand sync` if you want.
|
||||
No, webhooks are **not required** for the tool to function. You can use the built-in **scheduled tasks** or manually run
|
||||
**import/export operations** on demand through the WebUI or console.
|
||||
|
||||
---
|
||||
|
||||
# I get tired of writing the whole command everytime is there an easy way run the commands?
|
||||
# I'm Using Media Backends Hosted Behind HTTPS and See Errors Related to HTTP/2
|
||||
|
||||
Good News, There is a way to make your life easier, We recently added a `WebUI` which should cover most of the use
|
||||
cases.
|
||||
However, if you still want to use the `CLI` You can create a shell script to omit
|
||||
the `docker exec -ti watchstate console`
|
||||
In some cases, issues may arise due to HTTP/2 compatibility problems with our internal http client. Before submitting a
|
||||
bug report, please try the following workaround:
|
||||
|
||||
```bash
|
||||
$ echo 'docker exec -ti watchstate console "$@"' > ws
|
||||
$ chmod +x ws
|
||||
```
|
||||
* Go to the `WebUI > Backends`.
|
||||
* Find the backend where the issue occurs and click the **Edit** button.
|
||||
* Expand the **Additional options...** section.
|
||||
* Under **Add new option**, select `client.http_version` from the dropdown list.
|
||||
* Click the green **+** add button.
|
||||
* Once the option appears, set its value to `1.0`.
|
||||
* Click **Save Settings**.
|
||||
|
||||
after that you can do `./ws command` for example, `./ws db:list`
|
||||
|
||||
---
|
||||
|
||||
# I am using media backends hosted behind HTTPS, and see errors related to HTTP/2?
|
||||
|
||||
Sometimes there are problems related to HTTP/2, so before reporting bug please try running the following command:
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console config:edit --key options.client.http_version --set 1.0 -s backend_name
|
||||
```
|
||||
|
||||
This will force set the internal http client to use `http/v1` if it does not fix your problem, please open bug report
|
||||
about it.
|
||||
This setting forces the internal HTTP client to use **HTTP/1.1** instead of HTTP/2. If the issue persists after making
|
||||
this change, please open a bug report so it can be investigated further.
|
||||
|
||||
---
|
||||
|
||||
# Sync operations are failing due to request timeout?
|
||||
|
||||
If you want to increase the timeout for specific backend you can run the following command:
|
||||
If you're encountering request timeouts during sync operations, you can increase the timeout for a specific backend by
|
||||
following these steps:
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console config:edit --key options.client.timeout --set 600 -s backend_name
|
||||
```
|
||||
* Go to the `WebUI > Backends`.
|
||||
* Find the backend where the issue occurs and click the **Edit** button.
|
||||
* Expand the **Additional options...** section.
|
||||
* Under **Add new option**, select `client.timeout` from the dropdown list.
|
||||
* Click the green **+** add button.
|
||||
* Once the option appears, set its value to `600`.
|
||||
* Click **Save Settings**.
|
||||
|
||||
where `600` is the number of seconds before the timeout handler will kill the request.
|
||||
The value `600` represents the number of seconds the system will wait before terminating the request due to a timeout.
|
||||
|
||||
---
|
||||
|
||||
# How to fix corrupt SQLite database?
|
||||
# How to fix a corrupt sqlite database
|
||||
|
||||
Sometimes your SQLite database will be corrupted, and you will get an error similar to this
|
||||
`General error: 11 database disk image is malformed`. To fix this error simply execute the following commands:
|
||||
If your SQLite database becomes corrupted, you may see an error like:
|
||||
|
||||
```
|
||||
General error: 11 database disk image is malformed
|
||||
```
|
||||
|
||||
To repair the database, follow these steps:
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate bash
|
||||
$ sqlite3 /config/db/watchstate_v01.db '.dump' | sqlite3 /config/db/watchstate_v01-repaired.db
|
||||
```
|
||||
|
||||
After executing the previous command you should run `integrity check`, by running the following command:
|
||||
Once the dump and rebuild are complete, perform an integrity check:
|
||||
|
||||
```bash
|
||||
$ sqlite3 /config/db/watchstate_v01-repaired.db 'PRAGMA integrity_check'
|
||||
```
|
||||
|
||||
it should simply say `ok`. then you should run the following command to replace the corrupted database.
|
||||
If the output is simply `ok`, the repaired database is valid. You can then replace the corrupted database with the
|
||||
repaired one:
|
||||
|
||||
```bash
|
||||
$ mv /config/db/watchstate_v01-repaired.db /config/db/watchstate_v01.db
|
||||
```
|
||||
|
||||
Your system should now use the repaired database without errors.
|
||||
|
||||
---
|
||||
|
||||
# Which external db ids `GUIDS` supported for Plex Media Server?
|
||||
@@ -426,22 +390,11 @@ The recommended approach is for keys that starts with `WS_` use the `WebUI > Env
|
||||
For other keys that aren't directly related to the tool, you **MUST** load them via container environment or
|
||||
the `compose.yaml` file.
|
||||
|
||||
to see list of loaded environment variables
|
||||
|
||||
## Via WebUI
|
||||
|
||||
Go to `Env` page, you will see all the environment variables loaded.
|
||||
|
||||
## Via CLI
|
||||
|
||||
```shell
|
||||
$ docker exec -ti watchstate console system:env
|
||||
```
|
||||
to see list of loaded environment variables, click on `Env` page in the WebUI.
|
||||
|
||||
## Tool specific environment variables.
|
||||
|
||||
These environment variables relates to the tool itself, You should manage them via `WebUI > Env` page or `system:env`
|
||||
command via CLI.
|
||||
These environment variables relates to the tool itself, You should manage them via `WebUI > Env` page
|
||||
|
||||
| Key | Type | Description | Default |
|
||||
|-------------------------|---------|-------------------------------------------------------------------------|--------------------------|
|
||||
@@ -462,26 +415,13 @@ command via CLI.
|
||||
| WS_SECURE_API_ENDPOINTS | bool | Disregard the open route policy and require API key for all endpoints. | `false` |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> for environment variables that has `{TASK}` tag, you **MUST** replace it with one
|
||||
> of `IMPORT`, `EXPORT`, `BACKUP`, `PRUNE`, `INDEXES`. To see tasks active settings run
|
||||
> for environment variables that has `{TASK}` tag, you **MUST** replace it with one of `IMPORT`, `EXPORT`, `BACKUP`,
|
||||
`PRUNE`, `INDEXES`.
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console system:tasks
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To see all supported tool specific environment variables
|
||||
|
||||
### Via WebUI
|
||||
## Add tool specific environment variables
|
||||
|
||||
Go to the `Env` page, click `+` button, you will get list of all supported keys with description.
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
$ docker exec -ti watchstate console system:env --list
|
||||
```
|
||||
|
||||
## Container specific environment variables.
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -503,27 +443,50 @@ $ docker exec -ti watchstate console system:env --list
|
||||
|
||||
---
|
||||
|
||||
# How to add webhooks?
|
||||
# How to Add Webhooks
|
||||
|
||||
The Webhook URL is backend specific, the request path is `/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`,
|
||||
Where `[USER]` is the username for sub user or `main` for main user and `[BACKEND_NAME]` is the name of the backend you
|
||||
want to add webhook for. Typically, the full URL
|
||||
is `http://localhost:8080/v1/api/backend/[USER]@[BACKEND_NAME]/webhook`. Or simply go to the `WebUI > Backends` and
|
||||
click on `Copy Webhook URL`.
|
||||
Webhook URLs are **backend-specific** and follow this structure:
|
||||
|
||||
> [!NOTE]
|
||||
> You will keep seeing the `webhook.token` key, it's being kept for backward compatibility, and will be removed in the
|
||||
> future. It has no effect except as pointer to the new method.
|
||||
```
|
||||
/v1/api/backend/[USER]@[BACKEND_NAME]/webhook
|
||||
```
|
||||
|
||||
- `[USER]` should be the username of the sub-user, or `main` for the main user.
|
||||
- `[BACKEND_NAME]` is the name of the backend you want to configure the webhook for.
|
||||
|
||||
A typical full URL might look like:
|
||||
|
||||
```
|
||||
http://localhost:8080/v1/api/backend/main@plex_foo/webhook
|
||||
```
|
||||
|
||||
To get the correct URL easily:
|
||||
|
||||
* Go to `WebUI > Backends`.
|
||||
* Click on **Copy Webhook URL** next to the relevant backend.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> As support more sub users expands, it's important to turn on `Webhook match user` for all backends to prevent
|
||||
> sub users from changing the main user watch state. in case of plex backend.
|
||||
> If you have enabled `WS_SECURE_API_ENDPOINTS`, you have to add `?apikey=yourapikey` to the end of the the webhook URL.
|
||||
|
||||
> [!NOTE]
|
||||
> You may see a `webhook.token` key in older configurations. This is retained only for backward compatibility and has no
|
||||
> effect. It will be removed in future versions.
|
||||
>
|
||||
> If you're using Plex and have sub-users, make sure to enable **Webhook Match User** to prevent sub-user activity from
|
||||
> affecting the main user's watch state.
|
||||
|
||||
-----
|
||||
|
||||
## Emby (you need `Emby Premiere` to use webhooks).
|
||||
|
||||
Go to your Manage Emby Server > Server > Webhooks > (Click Add Webhook)
|
||||
Go to your Manage Emby Server:
|
||||
|
||||
* Old Emby versions: Server > Webhooks > (Click Add Webhook) `Old version`
|
||||
* New Emby versions: username Preferences > Notifications > + Add Notification > Webhooks
|
||||
|
||||
### Name (Emby v4.9+):
|
||||
|
||||
`user@backend` or whatever you want, i simply prefer the name to reflect which user it belongs to.
|
||||
|
||||
### Webhook/Notifications URL:
|
||||
|
||||
@@ -569,7 +532,7 @@ go back again to dashboard > plugins > webhook. Add `Add Generic Destination`,
|
||||
|
||||
### Webhook Name:
|
||||
|
||||
`Watchstate-Webhook`
|
||||
`user@backend` or whatever you want, i simply prefer the name to reflect which user it belongs to.
|
||||
|
||||
#### Webhook Url:
|
||||
|
||||
@@ -613,9 +576,6 @@ Go to your Plex Web UI > Settings > Your Account > Webhooks > (Click ADD WEBHOOK
|
||||
* Replace `[BACKEND_NAME]` with the name you have chosen for your backend.
|
||||
* Replace `[USER]` with the `main` for main user or the sub user username.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you have enabled `WS_SECURE_API_ENDPOINTS`, you have to add `?apikey=yourapikey` to the end of the URL.
|
||||
|
||||
Click `Save Changes`
|
||||
|
||||
> [!NOTE]
|
||||
@@ -763,60 +723,67 @@ Click `Save`
|
||||
|
||||
---
|
||||
|
||||
# What are the webhook limitations?
|
||||
# Webhook Limitations by Media Backend
|
||||
|
||||
Those are some webhook limitations we discovered for the following media backends.
|
||||
Below are known limitations and issues when using webhooks with different media backends:
|
||||
|
||||
## Plex
|
||||
|
||||
* Plex does not send webhooks events for "marked as played/unplayed" for all item types.
|
||||
* Sometimes does not send events if you add more than one item at time.
|
||||
* When you mark items as unwatched, Plex reset the date on the object.
|
||||
- Webhooks are **not sent** for "marked as played/unplayed" actions on all item types.
|
||||
- Webhook events may be **skipped** if multiple items are added at once.
|
||||
- When items are marked as **unwatched**, Plex resets the date on the media object.
|
||||
|
||||
## Plex Via Tautulli
|
||||
## Plex via Tautulli
|
||||
|
||||
* Tautulli does not send user id with itemAdd `created` event. as such if you enabled `Match webhook user`, new items
|
||||
will not be added and fail with `Request user id '' does not match configured value`.
|
||||
* Marked as unplayed will most likely not work with Tautulli webhook events as it's missing critical data we need to
|
||||
determine if the item is marked as unplayed.
|
||||
- Tautulli does **not send user IDs** with `itemAdd` (`created`) events. If `Match webhook user` is enabled, the request
|
||||
will fail with: `Request user id '' does not match configured value`.
|
||||
- **Marking items as unplayed** is not reliable, as Tautulli's webhook payload lacks critical data required to detect
|
||||
this change.
|
||||
|
||||
## Emby
|
||||
|
||||
* Emby does not send webhooks events for newly added items.
|
||||
~~[See feature request](https://emby.media/community/index.php?/topic/97889-new-content-notification-webhook/)~~
|
||||
implemented in `4.7.9` ~~still does not work as expected no metadata being sent when the item notification goes out.
|
||||
* Emby webhook test event does not contain data~~. It seems to have been fixed in `4.9.0.37+` To test if your setup
|
||||
works, play something or do mark an item as played or unplayed you should see changes reflected in
|
||||
`docker exec -ti watchstate console db:list`.
|
||||
- Emby does **not send webhook events** for newly added items.
|
||||
~~[See feature request](https://emby.media/community/index.php?/topic/97889-new-content-notification-webhook/)~~ This
|
||||
was implemented in version `4.7.9`, but still does **not include metadata**, making it ineffective.
|
||||
- The Emby **webhook test event** previously contained no data. This appears to be **fixed in `4.9.0.37+`**.
|
||||
- To verify if your Emby webhook setup is working, try playing or marking an item as played/unplayed, go to the history
|
||||
page after a min or two and check if the changes are reflected in the database.
|
||||
|
||||
## Jellyfin
|
||||
|
||||
* If you don't select a user id, the plugin will send `itemAdd` event without user data, and will fail the check if you
|
||||
happen to enable `webhook.match.user` for jellyfin.
|
||||
* Sometimes jellyfin will fire webhook `itemAdd` event without the item being matched.
|
||||
* Even if you select user id, sometimes `itemAdd` event will fire without user data.
|
||||
* Items might be marked as unplayed if Libraries > Display - `Date added behavior for new content:` is set
|
||||
to `Use date scanned into library`. This happens if the media file has been replaced.
|
||||
- If no user ID is selected in the plugin, the `itemAdd` event will be sent **without user data**, which will cause a
|
||||
failure if `webhook.match.user` is enabled.
|
||||
- Occasionally, Jellyfin will fire `itemAdd` events that **without it being matched**.
|
||||
- Even if a user ID is selected, Jellyfin may **still send events without user data**.
|
||||
- Items may be marked as **unplayed** if the following setting is enabled **Libraries > Display > Date added behavior
|
||||
for new content: `Use date scanned into library`** This often happens when media files are replaced or updated.
|
||||
|
||||
---
|
||||
|
||||
# Sometimes newly added episodes or movies don't make it to webhook endpoint?
|
||||
# Sometimes newly added episodes or movies don't reach the webhook endpoint?
|
||||
|
||||
As stated in webhook limitation section sometimes media backends don't make it easy to receive those events, as such, to
|
||||
complement webhooks, you should enable import/export tasks by settings their respective environment variables in
|
||||
your `compose.yaml` file. For more information run help on `system:env` command as well as `system:tasks`
|
||||
command.
|
||||
As noted in the webhook limitations section, some media backends do not reliably send webhook events for newly added
|
||||
content. To address this, you should enable **import/export tasks** to complement webhook functionality.
|
||||
|
||||
Simply, visit the `Tasks` page and enable the `import` and `export` task.
|
||||
|
||||
---
|
||||
|
||||
# How to disable the included HTTP server and use external server?
|
||||
|
||||
Set this environment variable in your `compose.yaml` file `DISABLE_HTTP` with value of `1`. your external
|
||||
server need to send correct fastcgi environment variables. Example caddy file:
|
||||
To disable the built-in HTTP server, set the following environment variable and restart the container:
|
||||
|
||||
```
|
||||
DISABLE_HTTP=1
|
||||
```
|
||||
|
||||
Your external web server must forward requests using the correct **FastCGI** environment variables.
|
||||
|
||||
## Example: Caddy Configuration
|
||||
|
||||
```caddyfile
|
||||
https://watchstate.example.org {
|
||||
# Change "172.23.1.2" to your watchstate container ip e.g. "172.23.20.20"
|
||||
# Replace "172.23.1.2" with the actual IP address of your WatchState container
|
||||
reverse_proxy 172.23.1.2:9000 {
|
||||
transport fastcgi {
|
||||
root /opt/app/public
|
||||
@@ -829,47 +796,73 @@ https://watchstate.example.org {
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you change the FastCGI Process Manager port using the `FPM_PORT` environment variable, make sure to update the port
|
||||
> in the reverse proxy configuration as well.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you change the FastCGI Process Manager TCP port via FPM_PORT environment variable, you should change the port in
|
||||
> the caddy file as well.
|
||||
> This configuration mode is **not officially supported** by WatchState. If issues arise, please verify your web server
|
||||
> setup. Support does not cover external web server configurations.
|
||||
|
||||
---
|
||||
|
||||
# WS_API_AUTO
|
||||
|
||||
The purpose of this environment variable is to automate the configuration process. It's mainly used for people who use
|
||||
many browsers to access the `WebUI` and want to automate the configuration process. as it's requires the API settings to
|
||||
be configured before it can be used. This environment variable can be enabled by setting `WS_API_AUTO=true` in
|
||||
`${WS_DATA_PATH}/config/.env`.
|
||||
The `WS_API_AUTO` environment variable is designed to **automate the initial configuration process**, particularly
|
||||
useful for users who access the WebUI from multiple browsers or devices. Since the WebUI requires API settings to be
|
||||
configured before use, enabling this variable allows the system to auto-configure itself.
|
||||
|
||||
## Why you should use it?
|
||||
To enable it, write `WS_API_AUTO=true` to `/config/.env` file, note the file may not exist, and you may need to create
|
||||
it.
|
||||
|
||||
You normally should not use it, as it's a **GREAT SECURITY RISK**. However, if you are using the tool in a secure
|
||||
environment and not worried about exposing your API key, you can use it to automate the configuration process.
|
||||
## Why You Might Use It
|
||||
|
||||
## Why you should not use it?
|
||||
You may consider using this if:
|
||||
|
||||
Because, by exposing your API key, you are also exposing every data you have in the tool. This is a **GREAT SECURITY
|
||||
RISK**, any person or bot that are able to access the `WebUI` will also be able to visit `/v1/api/system/auto` and get
|
||||
your API key. And with this key they can do anything they want with your data. including viewing your media servers API
|
||||
keys. So, please while we have this option available, we strongly recommend not to use it if `WatchState` is exposed to
|
||||
the internet.
|
||||
- You're operating in a **secure, local environment**.
|
||||
- You want to **automate setup** across multiple devices or browsers without repeatedly entering API details.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This environment variable is **GREAT SECURITY RISK**, and we strongly recommend not to use it if `WatchState` is
|
||||
> exposed to the internet. I cannot stress this enough, please do not use it unless you are in a secure environment.
|
||||
## Why You Should **NOT** Use It (Recommended)
|
||||
|
||||
Enabling this poses a **serious security risk**:
|
||||
|
||||
- It **exposes your API key** publicly through the endpoint `/v1/api/system/auto`.
|
||||
- Anyone (or any bot) that can access the WebUI can retrieve your API key and gain **access** to any and all data that
|
||||
is exposed by the API including your media servers API keys.
|
||||
|
||||
**If WatchState is exposed to the internet, do not enable this setting.**
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `WS_API_AUTO` variable is a **major security risk**. It should only be used in isolated or trusted environments.
|
||||
> We strongly recommend keeping this option disabled.
|
||||
|
||||
---
|
||||
|
||||
# How to disable the included cache server and use external cache server?
|
||||
# How to disable the included cache server and use an external cache server?
|
||||
|
||||
Set this environment variable in your `compose.yaml` file `DISABLE_CACHE` with value of `1`. to use external redis
|
||||
server you need to alter the value of `WS_CACHE_URL` environment variable. the format for this variable is
|
||||
`redis://host:port?password=auth&db=db_num`, for example to use redis from another container you could use something
|
||||
like `redis://172.23.1.10:6379?password=my_secert_password&db=8`. We only support `redis` and API compatible
|
||||
alternative.
|
||||
To disable the built-in cache server and connect to an external Redis instance, follow these steps:
|
||||
|
||||
Once that done, restart the container.
|
||||
In your `compose.yaml` file, set the following environment variable `DISABLE_CACHE=1`.
|
||||
|
||||
Configure the external Redis connection by setting the `WS_CACHE_URL` environment variable.
|
||||
|
||||
The format is:
|
||||
|
||||
```
|
||||
redis://host:port?password=your_password&db=db_number
|
||||
```
|
||||
|
||||
For example, to connect to a Redis server running in another container:
|
||||
|
||||
```
|
||||
redis://172.23.1.10:6379?password=my_secret_password&db=8
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
* Only **Redis** and **API-compatible alternatives** are supported.
|
||||
|
||||
After updating the environment variables, **restart the container** to apply the changes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)"
|
||||
v-if="hasAPISettings">
|
||||
<span class="icon"><i class="fas fa-key"/></span>
|
||||
@@ -64,20 +65,8 @@
|
||||
<span class="icon"><i class="fas fa-users"/></span>
|
||||
<span>Sub Users</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item has-dropdown">
|
||||
<a class="navbar-link" @click="(e) => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-ellipsis-vertical"/></span>
|
||||
<span>More</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
|
||||
<span class="icon"><i class="fas fa-terminal"/></span>
|
||||
<span>Console</span>
|
||||
</NuxtLink>
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)"
|
||||
v-if="hasAPISettings">
|
||||
@@ -96,33 +85,19 @@
|
||||
<span class="icon"><i class="fas fa-file"/></span>
|
||||
<span>Files Integrity</span>
|
||||
</NuxtLink>
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/events" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-calendar-alt"/></span>
|
||||
<span>Events</span>
|
||||
</NuxtLink>
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/ignore" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-ban"/></span>
|
||||
<span>Ignore List</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/report" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-flag"/></span>
|
||||
<span>Basic Report</span>
|
||||
</NuxtLink>
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/suppression" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-bug-slash"/></span>
|
||||
<span>Log Suppression</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="navbar-item" to="/custom" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-map"/></span>
|
||||
<span>Custom GUIDs</span>
|
||||
</NuxtLink>
|
||||
|
||||
<hr class="navbar-divider">
|
||||
|
||||
@@ -137,6 +112,42 @@
|
||||
<span class="icon"><i class="fas fa-redo"/></span>
|
||||
<span>System reset</span>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item has-dropdown">
|
||||
<a class="navbar-link" @click="(e) => openMenu(e)">
|
||||
<span class="icon"><i class="fas fa-ellipsis-vertical"/></span>
|
||||
<span>More</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
|
||||
<span class="icon"><i class="fas fa-terminal"/></span>
|
||||
<span>Console</span>
|
||||
</NuxtLink>
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/events" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-calendar-alt"/></span>
|
||||
<span>Events</span>
|
||||
</NuxtLink>
|
||||
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/report" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-flag"/></span>
|
||||
<span>Basic Report</span>
|
||||
</NuxtLink>
|
||||
|
||||
<hr class="navbar-divider">
|
||||
|
||||
<NuxtLink class="navbar-item" to="/custom" @click.native="(e) => changeRoute(e)">
|
||||
<span class="icon"><i class="fas fa-map"/></span>
|
||||
<span>Custom GUIDs</span>
|
||||
</NuxtLink>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,28 +3,21 @@
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon"><i class="fas fa-server"></i></span>
|
||||
<span class="icon"><i class="fas fa-server"/></span>
|
||||
Backends
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control" v-if="backends && backends.length>0">
|
||||
<button class="button is-purple" v-tooltip.bottom="'Create sub users backends.'"
|
||||
@click="navigateTo(makeConsoleCommand('backend:create -B -v', true))"
|
||||
:disabled="'main' !== api_user">
|
||||
<span class="icon"><i class="fas fa-users"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-primary" v-tooltip.bottom="'Add New Backend'"
|
||||
@click="toggleForm = !toggleForm" :disabled="isLoading">
|
||||
<span class="icon"><i class="fas fa-add"></i></span>
|
||||
<span class="icon"><i class="fas fa-add"/></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="loadContent" :disabled="isLoading"
|
||||
:class="{'is-loading':isLoading}">
|
||||
<span class="icon"><i class="fas fa-sync"></i></span>
|
||||
<span class="icon"><i class="fas fa-sync"/></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -48,18 +41,23 @@
|
||||
No backends found. Please add new backends to start using the tool. You can add new backend by
|
||||
<NuxtLink @click="toggleForm=true" v-text="'clicking here'"/>
|
||||
or by clicking the <span class="icon is-clickable" @click="toggleForm=true"><i
|
||||
class="fas fa-add"></i></span>
|
||||
class="fas fa-add"/></span>
|
||||
button above.
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="content">
|
||||
<h1 class="title is-4">Tools</h1>
|
||||
<h1 class="title is-4">
|
||||
<span class="icon"><i class="fas fa-tools"/></span> Tools
|
||||
</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<NuxtLink :to="`/tools/plex_token`" v-text="'Validate plex token'"/>
|
||||
</li>
|
||||
<li v-if="backends && backends.length>0">
|
||||
<NuxtLink :to="`/tools/sub_users`" v-text="'Create sub-users'"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,12 +75,12 @@
|
||||
<div class="control">
|
||||
<NuxtLink :to="`/backend/${backend.name}/edit?redirect=/backends`"
|
||||
v-tooltip="'Edit backend settings'">
|
||||
<span class="icon has-text-warning"><i class="fas fa-cog"></i></span>
|
||||
<span class="icon has-text-warning"><i class="fas fa-cog"/></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="control">
|
||||
<NuxtLink :to="`/backend/${backend.name}/delete?redirect=/backends`" v-tooltip="'Delete backend'">
|
||||
<span class="icon has-text-danger"><i class="fas fa-trash"></i></span>
|
||||
<span class="icon has-text-danger"><i class="fas fa-trash"/></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +145,7 @@
|
||||
<div class="card-footer-item">
|
||||
<NuxtLink :to="api_url + backend.urls.webhook" class="is-info is-light"
|
||||
@click.prevent="copyUrl(backend)">
|
||||
<span class="icon"><i class="fas fa-copy"></i></span>
|
||||
<span class="icon"><i class="fas fa-copy"/></span>
|
||||
<span class="is-hidden-mobile">Copy Webhook URL</span>
|
||||
<span class="is-hidden-tablet">Webhook</span>
|
||||
</NuxtLink>
|
||||
@@ -166,7 +164,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="icon is-left">
|
||||
<i class="fas fa-terminal"></i>
|
||||
<i class="fas fa-terminal"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,10 +182,6 @@
|
||||
<li>
|
||||
<strong>Export</strong> means pushing data from the local database to the backends.
|
||||
</li>
|
||||
<li v-if="backends && backends.length>0">
|
||||
To create sub users backends, click on the <span class="icon has-text-purple"><i class="fas fa-users"/></span>
|
||||
button.
|
||||
</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
@@ -241,6 +235,16 @@ const usefulCommands = {
|
||||
title: "Import this backend metadata.",
|
||||
command: "state:import -v --metadata-only -u {user} -s {name}",
|
||||
},
|
||||
import_debug: {
|
||||
id: 6,
|
||||
title: "Run import and save debug log.",
|
||||
command: "state:import -v --debug -u {user} -s {name} --logfile '/config/{user}@{name}.import.txt'",
|
||||
},
|
||||
export_debug: {
|
||||
id: 7,
|
||||
title: "Run export and save debug log.",
|
||||
command: "state:export -v --debug -u {user} -s {name} --logfile '/config/{user}@{name}.export.txt'",
|
||||
},
|
||||
}
|
||||
|
||||
const forwardCommand = async backend => {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<input type="text" class="input is-fullwidth" v-model="command"
|
||||
:placeholder="`system:view ${allEnabled ? 'or $ ls' : ''}`"
|
||||
list="recent_commands"
|
||||
autocomplete="off" ref="command_input" @keydown.enter="RunCommand" :disabled="isLoading">
|
||||
autocomplete="off" ref="commandInput" @keydown.enter="RunCommand" :disabled="isLoading">
|
||||
<span class="icon is-left"><i class="fas fa-terminal" :class="{'fa-spin':isLoading}"></i></span>
|
||||
</p>
|
||||
<p class="control" v-if="!isLoading">
|
||||
@@ -53,49 +53,50 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help" v-if="hasPrefix">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-danger"><i class="fas fa-exclamation-triangle"></i></span>
|
||||
<span>Remove the <code>console</code> or <code>docker exec -ti watchstate console</code> from the
|
||||
input. You should use the command directly, For example i.e <code>db:list --output
|
||||
yaml</code></span>
|
||||
</span>
|
||||
</p>
|
||||
<p class="help" v-if="hasPlaceholder">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-warning"><i class="fas fa-exclamation-circle"></i></span>
|
||||
<span>The command contains <code>[...]</code> which are considered a placeholder, So, please replace
|
||||
<code>[...]</code> with the intended value if applicable.</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="hasPrefix || hasPlaceholder">
|
||||
|
||||
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
|
||||
icon="fas fa-exclamation-triangle" v-if="hasPrefix">
|
||||
<p>Use the command directly, For example i.e. <code>db:list -o yaml</code></p>
|
||||
</Message>
|
||||
|
||||
<Message message_class="has-background-warning-90 has-text-dark" title="Warning"
|
||||
icon="fas fa-exclamation-triangle" v-if="hasPlaceholder">
|
||||
<span class="icon has-text-warning"><i class="fas fa-exclamation-circle"></i></span>
|
||||
<span>The command contains <code>[...]</code> which are considered a placeholder, So, please replace
|
||||
<code>[...]</code> with the intended value if applicable.</span>
|
||||
</Message>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-info-90 has-text-dark" :toggle="show_page_tips"
|
||||
@toggle="show_page_tips = !show_page_tips" :use-toggle="true" title="Tips" icon="fas fa-info-circle">
|
||||
<ul>
|
||||
<li>
|
||||
You don't need to write <code>console</code> or <code>docker exec -ti watchstate console</code> Using
|
||||
this interface. Use the command followed by the options directly. For example, <code>db:list --output
|
||||
yaml</code>.
|
||||
<li>You don’t need to type <code>console</code> or run <code>docker exec -ti watchstate console</code> when
|
||||
using this interface. Just enter the command and options directly. For example: <code>db:list --output
|
||||
yaml</code>.
|
||||
</li>
|
||||
<li>
|
||||
Clicking close connection does not stop the command. It only stops the output from being displayed. The
|
||||
command will continue to run until it finishes.
|
||||
<li>Clicking <strong>Close Connection</strong> only stops the output from being shown—it does <em>not</em>
|
||||
stop the command itself. The command will continue running until it finishes.
|
||||
</li>
|
||||
<li>
|
||||
The majority of the commands will not show any output unless error has occurred or important information
|
||||
needs to be communicated. Use the <code>-v[v[v]]</code> option to increase verbosity. <code>-v</code>
|
||||
should be enough for most people, If you are debugging, then use <code>-vv --context</code>.
|
||||
<li>Most commands won’t display anything unless there’s an error or important message. Use <code>-v</code>
|
||||
to see more details. If you’re debugging, try <code>-vv --context</code> for even more information.
|
||||
</li>
|
||||
<li>
|
||||
There is an environment variable <code>WS_CONSOLE_ENABLE_ALL</code> that can be set to <code>true</code>
|
||||
to enable all commands to be run from the console. This is disabled by default.
|
||||
<li>There’s an environment variable <code>WS_CONSOLE_ENABLE_ALL</code> that you can set to <code>true</code>
|
||||
to allow all commands to run from the console. It’s turned off by default.
|
||||
</li>
|
||||
<li>To clear the recent command suggestions, use the <code>clear_ac</code> command.</li>
|
||||
<li>
|
||||
The number inside the parentheses is the exit code of the last command. If it’s <code>0</code>, the
|
||||
command ran successfully. Any other value usually means something went wrong.
|
||||
</li>
|
||||
<li>To clear the recent commands auto-suggestions, you can use the <code>clear_ac</code> command.</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
@@ -139,14 +140,15 @@ const response = ref([])
|
||||
const command = ref(fromCommand)
|
||||
const isLoading = ref(false)
|
||||
const outputConsole = ref()
|
||||
const command_input = ref()
|
||||
const commandInput = ref()
|
||||
const executedCommands = useStorage('executedCommands', [])
|
||||
const exitCode = ref(0)
|
||||
|
||||
const bg_enable = useStorage('bg_enable', true)
|
||||
const bg_opacity = useStorage('bg_opacity', 0.95)
|
||||
|
||||
const hasPrefix = computed(() => command.value.startsWith('console') || command.value.startsWith('docker'))
|
||||
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*\]/))
|
||||
const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*]/))
|
||||
const show_page_tips = useStorage('show_page_tips', true)
|
||||
const allEnabled = ref(false)
|
||||
|
||||
@@ -155,12 +157,13 @@ const RunCommand = async () => {
|
||||
const api_url = useStorage('api_url', '')
|
||||
const api_token = useStorage('api_token', '')
|
||||
|
||||
/** @type {string} */
|
||||
let userCommand = command.value
|
||||
|
||||
// -- check if the user command starts with console or docker exec -ti watchstate
|
||||
if (userCommand.startsWith('console') || userCommand.startsWith('docker')) {
|
||||
notification('error', 'Warning', 'Please remove the [console] or [docker exec -ti watchstate console] from the command.')
|
||||
return
|
||||
notification('info', 'Warning', 'Removing leading prefix command from the input.', 2000)
|
||||
userCommand = userCommand.replace(/^(console|docker exec -ti watchstate)/i, '')
|
||||
}
|
||||
|
||||
// use regex to check if command contains [...]
|
||||
@@ -189,7 +192,7 @@ const RunCommand = async () => {
|
||||
if (userCommand.startsWith('$')) {
|
||||
if (!allEnabled.value) {
|
||||
notification('error', 'Error', 'The option to execute all commands is disabled.')
|
||||
command_input.value.focus()
|
||||
commandInput.value.focus()
|
||||
return
|
||||
}
|
||||
userCommand = userCommand.slice(1)
|
||||
@@ -223,11 +226,12 @@ const RunCommand = async () => {
|
||||
sse = new EventSource(`${api_url.value}${api_path.value}/system/command/${token}?apikey=${api_token.value}`)
|
||||
|
||||
if ('' !== command.value) {
|
||||
terminal.value.writeln(`~ ${userCommand}`)
|
||||
terminal.value.writeln(`(${exitCode.value}) ~ ${userCommand}`)
|
||||
}
|
||||
|
||||
sse.addEventListener('data', async e => terminal.value.write(JSON.parse(e.data).data))
|
||||
sse.addEventListener('close', async () => finished())
|
||||
sse.addEventListener('exit_code', async e => exitCode.value = e.data)
|
||||
sse.onclose = async () => finished()
|
||||
sse.onerror = async () => finished()
|
||||
}
|
||||
@@ -257,10 +261,12 @@ const finished = async () => {
|
||||
executedCommands.value.shift()
|
||||
}
|
||||
|
||||
terminal.value.writeln(`\n(${exitCode.value}) ~ `)
|
||||
|
||||
command.value = ''
|
||||
await nextTick()
|
||||
|
||||
command_input.value.focus()
|
||||
commandInput.value.focus()
|
||||
}
|
||||
|
||||
const recentCommands = computed(() => executedCommands.value.reverse().slice(-10))
|
||||
@@ -276,7 +282,7 @@ const clearOutput = async () => {
|
||||
if (terminal.value) {
|
||||
terminal.value ? terminal.value.clear() : ''
|
||||
}
|
||||
command_input.value.focus()
|
||||
commandInput.value.focus()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -296,7 +302,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
window.addEventListener("resize", reSizeTerminal);
|
||||
command_input.value.focus()
|
||||
commandInput.value.focus()
|
||||
|
||||
if (!terminal.value) {
|
||||
terminalFit.value = new FitAddon()
|
||||
@@ -304,7 +310,6 @@ onMounted(async () => {
|
||||
fontSize: 16,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'none',
|
||||
cols: 108,
|
||||
rows: 10,
|
||||
disableStdin: true,
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="columns is-multiline">
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span id="env_page_title" class="title is-4">
|
||||
<span class="icon"><i class="fas fa-users"/></span>
|
||||
Create Sub-users
|
||||
Sub Users
|
||||
</span>
|
||||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button class="button is-purple" v-tooltip.bottom="'Export Association.'" @click="exportMapping">
|
||||
<button class="button is-purple" v-tooltip.bottom="'Export to mapper.yaml file.'" @click="generateFile">
|
||||
<span class="icon"><i class="fas fa-file-export"/></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-primary" v-tooltip.bottom="'Create new user assoication.'" @click="addNewUser">
|
||||
<span class="icon"><i class="fas fa-plus"></i></span>
|
||||
<button class="button is-primary" v-tooltip.bottom="'Create new user association.'" @click="addNewUser">
|
||||
<span class="icon"><i class="fas fa-plus"/></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-info" @click="loadContent" :disabled="isLoading"
|
||||
:class="{'is-loading':isLoading}">
|
||||
:class="{ 'is-loading': isLoading }">
|
||||
<span class="icon"><i class="fas fa-sync"/></span>
|
||||
</button>
|
||||
</p>
|
||||
@@ -28,29 +28,46 @@
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">
|
||||
Drag & Drop the relevant users accounts to form association.
|
||||
Drag & Drop the relevant users accounts to form association. Read the information section.
|
||||
<template v-if="expires">The cached users list will expire {{ moment(expires).fromNow() }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<h2 class="title is-4">Matched users</h2>
|
||||
<div class="column is-12" v-if="isLoading">
|
||||
<Message v-if="isLoading" message_class="is-background-info-90 has-text-dark" icon="fas fa-spinner fa-spin"
|
||||
title="Loading" message="Loading data. Please wait..."/>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-for="(group, index) in matchedUsers" :key="index">
|
||||
<div class="card">
|
||||
<header class="card-header is-block">
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input is-fullwidth" v-model="group.user" required>
|
||||
<span class="icon is-left"><i class="fas fa-user"/></span>
|
||||
</div>
|
||||
<div class="column is-12" v-if="matched?.length < 1 && !isLoading">
|
||||
<Message message_class="has-background-danger-90 has-text-dark" icon="fas fa-exclamation-triangle"
|
||||
title="No matched users.">
|
||||
<p>
|
||||
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
|
||||
<span>Click on the add button to user group</span>
|
||||
</p>
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile" v-for="(group, index) in matched" :key="index">
|
||||
<div class="card" :class="{ 'is-success': group.matched.length >= 2, 'is-warning': group.matched.length <= 1 }">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title is-centered is-text-overflow">{{ group.user }}</p>
|
||||
<span class="card-header-icon">
|
||||
<span class="icon" @click="deleteGroup(index)"><i class="fas fa-trash-can"/></span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<draggable v-model="group.matched" :group="{ name: 'shared', pull: true, put: true }" animation="150"
|
||||
item-key="id">
|
||||
:move="checkBackend" item-key="id">
|
||||
<template #item="{ element }">
|
||||
<div class="draggable-item">
|
||||
<span>{{ element.backend }}@{{ element.username }}</span>
|
||||
<span>
|
||||
{{ element.backend }}@{{ element.username }}
|
||||
<span v-if="!isSameName(element.real_name, element.username)">
|
||||
( <u>{{ element.real_name }}</u> )
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
@@ -58,26 +75,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<h2 class="title is-4">Users with no association.</h2>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="card">
|
||||
<div class="column is-12" v-if="!isLoading">
|
||||
<div class="card is-danger">
|
||||
<header class="card-header is-block">
|
||||
<p class="card-header-title is-text-overflow">Users with no association.</p>
|
||||
<p class="card-header-title is-centered is-text-overflow has-text-danger">
|
||||
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
|
||||
Unmatched Users
|
||||
</p>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<draggable v-model="unmatched" :group="{ name: 'shared', pull: true, put: true }" animation="150"
|
||||
item-key="id">
|
||||
:move="checkBackend" item-key="id">
|
||||
<template #item="{ element }">
|
||||
<div class="draggable-item">
|
||||
<span>{{ element.backend }}@{{ element.username }}</span>
|
||||
<span>
|
||||
{{ element.backend }}@{{ element.username }}
|
||||
<span v-if="!isSameName(element.real_name, element.username)">
|
||||
( <u>{{ element.real_name }}</u> )
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<div v-if="unmatched?.length <1">
|
||||
<div v-if="unmatched?.length < 1">
|
||||
<Message message_class="has-background-success-90 has-text-dark" icon="fas fa-check-circle">
|
||||
<p>
|
||||
<span class="icon"><i class="fas fa-check"/></span>
|
||||
@@ -87,106 +108,348 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!isLoading">
|
||||
<div class="box">
|
||||
<h1 class="title is-4">
|
||||
Action form
|
||||
</h1>
|
||||
|
||||
<div class="field" v-if="hasUsers">
|
||||
<div class="control">
|
||||
<input id="recreate" type="checkbox" class="switch is-success" v-model="recreate">
|
||||
<label for="recreate" class="has-text-danger">
|
||||
Delete current local sub-users data, and re-create them.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input id="backup" type="checkbox" class="switch is-success" v-model="backup">
|
||||
<label for="backup">Create initial backup for each sub-user remote backend data.</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input id="no_save" type="checkbox" class="switch is-danger" v-model="noSave">
|
||||
<label for="no_save">Do not save mapper.</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input id="verbose" type="checkbox" class="switch is-info" v-model="verbose">
|
||||
<label for="verbose">Show more indepth logs.</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input id="dry_run" type="checkbox" class="switch is-info" v-model="dryRun">
|
||||
<label for="dry_run">Dry-run do not make changes.</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-fullwidth is-grouped">
|
||||
|
||||
<div class="control is-expanded">
|
||||
<button class="button is-fullwidth is-warning" @click="saveMap">
|
||||
<span class="icon"><i class="fas fa-save"/></span>
|
||||
<span>Save mapping</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control is-expanded">
|
||||
<button class="button is-fullwidth" @click="createUsers"
|
||||
:class="{'is-primary': !dryRun && !recreate, 'is-info':dryRun, 'is-danger': !dryRun && recreate}">
|
||||
<span class="icon"><i class="fas fa-users"/></span>
|
||||
<span v-if="!dryRun">
|
||||
<span v-if="recreate || !hasUsers">
|
||||
{{ recreate ? 'Re-create' : 'Create' }} sub-users
|
||||
</span>
|
||||
<span v-if="!recreate && hasUsers">Update sub-users</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
Test create sub-users
|
||||
<span v-if="hasUsers">(Safe operation)</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<Message message_class="has-background-info-90 has-text-dark" title="Information" icon="fas fa-info-circle">
|
||||
<ul>
|
||||
<li>This page lets you guide the system in matching sub-users across different backends.</li>
|
||||
<li>
|
||||
When you click <code>Create sub-users</code>, your mapping will be uploaded—unless you’ve selected <code>Do
|
||||
not save mapper</code>. Based on your choice, the system will either delete and recreate the local
|
||||
sub-users, or try to update the existing ones.
|
||||
</li>
|
||||
<li class="has-text-danger is-bold">
|
||||
Warning: If you choose not to delete the existing local sub-users and the matching changes for any reason,
|
||||
you may end up with duplicate users. We strongly recommend deleting the current local sub-users.
|
||||
</li>
|
||||
<li>
|
||||
Clicking <code>Save mapping</code> will only save your current mapping to the system. It will
|
||||
<strong>not</strong> create any sub-users.
|
||||
</li>
|
||||
<li>
|
||||
Clicking the <i class="fas fa-file-export"></i> icon will download the current mapping as a YAML file. You
|
||||
can review and manually upload it to the system later if needed.
|
||||
</li>
|
||||
<li>
|
||||
Users in the <b>Not matched</b> group aren’t currently linked to any others and likely won’t be matched
|
||||
automatically.
|
||||
</li>
|
||||
<li>
|
||||
Each user group must have at least two users to be considered a valid group.
|
||||
</li>
|
||||
<li>
|
||||
You can drag and drop users from the <b>Not matched</b> group into any other group to manually associate
|
||||
them.
|
||||
</li>
|
||||
<li>
|
||||
A user group can only include <b>one</b> user from <b>each</b> backend. If you try to add a second user
|
||||
from the same backend, an error will be shown.
|
||||
</li>
|
||||
<li>
|
||||
The display name format is: <code>backend_name@normalized_name (real_username)</code>. The <code>(real_username)</code>
|
||||
part only appears if it’s different from the <code>normalized_name</code>.
|
||||
</li>
|
||||
<li>
|
||||
There is a 5-minute cache when retrieving users from the API, so the data you see might be slightly out of
|
||||
date. This is to prevent overwhelming external APIs with requests and to have better response times.
|
||||
</li>
|
||||
</ul>
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import moment from 'moment'
|
||||
import {makeConsoleCommand, parse_api_response} from "~/utils/index.js";
|
||||
import {notification} from '~/utils/index'
|
||||
|
||||
const data = {
|
||||
"matched": [
|
||||
{
|
||||
"user": "user1",
|
||||
"matched": [
|
||||
{
|
||||
"id": "u1a", "backend": "backend_name1", "username": "user_u1a"
|
||||
},
|
||||
{
|
||||
"id": "u1b", "backend": "backend_name2", "username": "user_u1b"
|
||||
},
|
||||
{
|
||||
"id": "u1c", "backend": "backend_name3", "username": "user_u1c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "user2",
|
||||
"matched": [
|
||||
{
|
||||
"id": "u2a", "backend": "backend_name1", "username": "user_u2a"
|
||||
},
|
||||
{
|
||||
"id": "u2b", "backend": "backend_name2", "username": "user_u2b"
|
||||
},
|
||||
{
|
||||
"id": "u2c", "backend": "backend_name3", "username": "user_u2c"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "user3",
|
||||
"matched": [
|
||||
{
|
||||
"id": "u3a", "backend": "backend_name1", "username": "user_u3a"
|
||||
},
|
||||
{
|
||||
"id": "u3b", "backend": "backend_name2", "username": "user_u3b"
|
||||
},
|
||||
{
|
||||
"id": "u3c", "backend": "backend_name3", "username": "user_u3c"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"unmatched": [
|
||||
{
|
||||
"id": "u4a", "backend": "backend_name1", "username": "user_u4a"
|
||||
},
|
||||
{
|
||||
"id": "u4b", "backend": "backend_name2", "username": "user_u4b"
|
||||
},
|
||||
{
|
||||
"id": "u4c", "backend": "backend_name3", "username": "user_u4c"
|
||||
}
|
||||
]
|
||||
const matched = ref([])
|
||||
const unmatched = ref([])
|
||||
const isLoading = ref(false)
|
||||
const toastIsVisible = ref(false)
|
||||
const recreate = ref(false)
|
||||
const backup = ref(false)
|
||||
const noSave = ref(false)
|
||||
const dryRun = ref(false)
|
||||
const hasUsers = ref(false)
|
||||
const verbose = ref(false)
|
||||
const expires = ref()
|
||||
|
||||
const addNewUser = () => {
|
||||
const newUserName = `User group #${matched.value.length + 1}`
|
||||
matched.value.push({user: newUserName, matched: []})
|
||||
}
|
||||
|
||||
const matchedUsers = ref(data.matched)
|
||||
const unmatched = ref(data.unmatched)
|
||||
const loadContent = async () => {
|
||||
if (matched.value.length > 0) {
|
||||
if (!confirm('Reloading will remove all modifications. Are you sure?')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Function to add a new matched group with a default name
|
||||
const addNewUser = () => {
|
||||
const newUserName = 'user ' + (matchedUsers.value.length + 1)
|
||||
matchedUsers.value.push({
|
||||
user: newUserName,
|
||||
matched: []
|
||||
matched.value = []
|
||||
unmatched.value = []
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await request('/backends/mapper')
|
||||
const json = await response.json()
|
||||
|
||||
if (useRoute().name !== 'tools-sub_users') {
|
||||
return
|
||||
}
|
||||
|
||||
matched.value = json.matched
|
||||
unmatched.value = json.unmatched
|
||||
recreate.value = json.has_users
|
||||
backup.value = !json.has_users
|
||||
hasUsers.value = json.has_users
|
||||
expires.value = json?.expires
|
||||
} catch (e) {
|
||||
notification('error', 'Error', e.message)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateFile = async () => {
|
||||
const filename = 'mapper.yaml'
|
||||
const data = formatData()
|
||||
|
||||
if (!data.map.length) {
|
||||
notification('error', 'Error', 'No data to export.')
|
||||
return
|
||||
}
|
||||
|
||||
const response = request(`/system/yaml/${filename}`, {
|
||||
method: 'POST',
|
||||
headers: {'Accept': 'text/yaml'},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
if ('showSaveFilePicker' in window) {
|
||||
response.then(async res => {
|
||||
return res.body.pipeTo(await (await showSaveFilePicker({
|
||||
suggestedName: `${filename}`
|
||||
})).createWritable())
|
||||
})
|
||||
}
|
||||
|
||||
response.then(res => res.blob()).then(blob => {
|
||||
const fileURL = URL.createObjectURL(blob)
|
||||
const fileLink = document.createElement('a')
|
||||
fileLink.href = fileURL
|
||||
fileLink.download = `${filename}`
|
||||
fileLink.click()
|
||||
})
|
||||
}
|
||||
|
||||
const checkBackend = e => {
|
||||
if (e.draggedContext.list === e.relatedContext.list) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isMatchedContainer = matched.value.some(
|
||||
group => group.matched === e.relatedContext.list
|
||||
);
|
||||
|
||||
if (false === isMatchedContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const draggedUser = e.draggedContext.element;
|
||||
const alreadyExists = e.relatedContext.list.some(item => item.backend === draggedUser.backend)
|
||||
|
||||
if (true === alreadyExists) {
|
||||
if (!toastIsVisible.value) {
|
||||
toastIsVisible.value = true;
|
||||
nextTick(() => {
|
||||
notification('error', 'error', `A user from '${draggedUser.backend}' backend, already mapped in this group.`, 3001, {
|
||||
onClose: () => toastIsVisible.value = false,
|
||||
})
|
||||
})
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const deleteGroup = i => {
|
||||
const group = matched.value[i]
|
||||
if (group && group.matched && group.matched.length) {
|
||||
if (false === confirm(`Delete user group #${i + 1}?, Users will be moved to unmatched`)) {
|
||||
return
|
||||
}
|
||||
|
||||
unmatched.value.push(...group.matched)
|
||||
}
|
||||
|
||||
nextTick(() => matched.value.splice(i, 1))
|
||||
}
|
||||
|
||||
const saveMap = async (no_toast = false) => {
|
||||
const data = formatData()
|
||||
|
||||
if (!data.map.length) {
|
||||
if (!no_toast) {
|
||||
notification('error', 'Error', 'No mapping data to save.')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const req = await request('/backends/mapper', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
const response = await parse_api_response(req)
|
||||
if (req.status >= 200 && req.status < 300 && !no_toast) {
|
||||
notification('success', 'Success', response.info.message)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!no_toast) {
|
||||
notification('error', 'Error', `${req.status}: ${response.error.message}`)
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (e) {
|
||||
notification('error', 'Error', `Error: ${e.message}`)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const formatData = () => {
|
||||
const data = {version: "1.5", map: []}
|
||||
|
||||
matched.value.forEach((group, i) => {
|
||||
const users = {}
|
||||
|
||||
group.matched.forEach(user => users[user.backend] = {name: user.username})
|
||||
|
||||
if (Object.keys(users).length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
data.map.push(users)
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
const createUsers = async () => {
|
||||
if (!noSave.value) {
|
||||
const state = await saveMap()
|
||||
if (state === false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const command = ['backend:create']
|
||||
|
||||
command.push(verbose.value ? '-vvv' : '-vv')
|
||||
|
||||
command.push(recreate.value ? '--re-create' : '--run --update')
|
||||
|
||||
if (backup.value) {
|
||||
command.push('--generate-backup')
|
||||
}
|
||||
|
||||
if (dryRun.value) {
|
||||
command.push('--dry-run')
|
||||
}
|
||||
|
||||
await navigateTo(makeConsoleCommand(command.join(' '), true))
|
||||
}
|
||||
|
||||
const isSameName = (name1, name2) => {
|
||||
return name1.toLowerCase() === name2.toLowerCase()
|
||||
}
|
||||
|
||||
onMounted(async () => await loadContent())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Make containers flex so items wrap side by side */
|
||||
.users-list,
|
||||
.unmatched-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px dashed #ccc;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
/* Let draggable items size to content */
|
||||
.draggable-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -161,14 +161,19 @@ const ucFirst = (str) => {
|
||||
* @param {string} title The title of the notification.
|
||||
* @param {string} text The text of the notification.
|
||||
* @param {number} duration The duration of the notification.
|
||||
* @param {object} opts Additional options for the notification.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const notification = (type, title, text, duration = 3000) => {
|
||||
const notification = (type, title, text, duration = 3000, opts = {}) => {
|
||||
let method = '', options = {
|
||||
timeout: duration,
|
||||
}
|
||||
|
||||
if (opts) {
|
||||
options = {...options, ...opts}
|
||||
}
|
||||
|
||||
switch (type.toLowerCase()) {
|
||||
case 'info':
|
||||
default:
|
||||
|
||||
154
src/API/Backends/Mapper.php
Normal file
154
src/API/Backends/Mapper.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\Backends;
|
||||
|
||||
use App\Commands\Backend\CreateUsersCommand;
|
||||
use App\Libs\Attributes\Route\Get;
|
||||
use App\Libs\Attributes\Route\Put;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\ConfigFile;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use DateInterval;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Psr\SimpleCache\CacheInterface as iCache;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
|
||||
final class Mapper
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
private const string CACHE_KEY = 'all-backends-users';
|
||||
|
||||
public function __construct(private readonly iLogger $logger, private readonly iCache $cache)
|
||||
{
|
||||
}
|
||||
|
||||
#[Get(Index::URL . '/mapper[/]', name: 'backends.mappers.list')]
|
||||
public function list(iRequest $request): iResponse
|
||||
{
|
||||
$ignore = (bool)ag($request->getQueryParams(), 'force', false);
|
||||
$cached = new DateInterval('PT5M');
|
||||
|
||||
$mapping = CreateUsersCommand::loadMappings();
|
||||
|
||||
$data = cacheableItem(key: self::CACHE_KEY, function: function () use ($mapping, $cached): array {
|
||||
$backends = CreateUsersCommand::loadBackends();
|
||||
if (count($backends) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$backendsUser = CreateUsersCommand::get_backends_users($backends, $mapping);
|
||||
|
||||
if (count($backendsUser) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$obj = CreateUsersCommand::generate_users_list($backendsUser, $mapping);
|
||||
$matched = ag($obj, 'matched', []);
|
||||
$unmatched = ag($obj, 'unmatched', []);
|
||||
|
||||
if (count($matched) < 1 && count($unmatched) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'matched' => [],
|
||||
'unmatched' => [],
|
||||
];
|
||||
|
||||
foreach ($unmatched as $user) {
|
||||
$data['unmatched'][] = [
|
||||
'username' => ag($user, 'name', null),
|
||||
'backend' => ag($user, 'backend', null),
|
||||
'real_name' => ag($user, 'real_name', null),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($matched as $i => $user) {
|
||||
$perUser = [
|
||||
'user' => "User group #" . ($i + 1),
|
||||
'matched' => [],
|
||||
];
|
||||
|
||||
foreach (ag($user, 'backends', []) as $backend => $backendData) {
|
||||
$perUser['matched'][] = [
|
||||
'id' => ag($backendData, 'id', null),
|
||||
'username' => ag($backendData, 'name', null),
|
||||
'backend' => $backend,
|
||||
'real_name' => ag($backendData, 'real_name', null),
|
||||
];
|
||||
}
|
||||
|
||||
$data['matched'][] = $perUser;
|
||||
}
|
||||
|
||||
$data['expires'] = (string)makeDate()->add($cached);
|
||||
|
||||
return $data;
|
||||
}, ttl: $cached, ignoreCache: $ignore, opts: [iCache::class => $this->cache]);
|
||||
|
||||
$response = [
|
||||
'has_users' => CreateUsersCommand::hasUsers(),
|
||||
'has_mapper' => count($mapping) > 0,
|
||||
...$data,
|
||||
];
|
||||
|
||||
return api_response(Status::OK, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the mapper file.
|
||||
*
|
||||
* @param iRequest $request The request object.
|
||||
*
|
||||
* @throws InvalidArgumentException May be thrown by the cache service.
|
||||
*/
|
||||
#[Put(Index::URL . '/mapper[/]', name: 'backends.mappers.create')]
|
||||
public function update(iRequest $request): iResponse
|
||||
{
|
||||
$body = DataUtil::fromRequest($request);
|
||||
|
||||
$data = [
|
||||
'version' => (string)$body->get('version', '1.5'),
|
||||
'map' => $body->get('map', []),
|
||||
];
|
||||
|
||||
if (!is_array($data['map'])) {
|
||||
return api_error('Invalid map data.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (count($data['map']) < 1) {
|
||||
return api_error('Empty map data.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (true === version_compare($data['version'], '1.5', '<')) {
|
||||
return api_error('Invalid version. must be 1.5 or greater.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$mapFile = Config::get('mapper_file');
|
||||
$exists = file_exists($mapFile);
|
||||
if (true === $exists) {
|
||||
unlink($mapFile);
|
||||
}
|
||||
|
||||
$c = ConfigFile::open($mapFile, 'yaml', true, true, true)
|
||||
->set('version', $data['version'])
|
||||
->set('map', $data['map'])
|
||||
->persist();
|
||||
|
||||
if ($this->cache->has(self::CACHE_KEY)) {
|
||||
$this->cache->delete(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
return api_message(r('Mapper file successfully {state}.', [
|
||||
'state' => $exists ? 'updated' : 'created',
|
||||
]), $exists ? Status::OK : Status::CREATED, body: $c->getAll());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,22 +12,16 @@ use App\Libs\Options;
|
||||
use App\Libs\Traits\APITraits;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Throwable;
|
||||
|
||||
final class Users
|
||||
{
|
||||
use APITraits;
|
||||
|
||||
public function __construct(private readonly LoggerInterface $logger) {}
|
||||
|
||||
#[Route(['GET', 'POST'], Index::URL . '/users/{type}[/]', name: 'backends.get.users')]
|
||||
public function __invoke(iRequest $request, array $args = []): iResponse
|
||||
#[Route(['GET', 'POST'], Index::URL . '/users/{type}[/]', name: 'backends.get.backend.users')]
|
||||
public function __invoke(iRequest $request, string $type, iLogger $logger): iResponse
|
||||
{
|
||||
if (null === ($type = ag($args, 'type'))) {
|
||||
return api_error('Invalid value for type path parameter.', Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$params = DataUtil::fromRequest($request, true);
|
||||
|
||||
try {
|
||||
@@ -51,7 +45,7 @@ final class Users
|
||||
$users[] = $user;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error($e->getMessage(), ['trace' => $e->getTrace()]);
|
||||
$logger->error($e->getMessage(), $e->getTrace());
|
||||
return api_error($e->getMessage(), Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
|
||||
44
src/API/System/ToYaml.php
Normal file
44
src/API/System/ToYaml.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\API\System;
|
||||
|
||||
use App\Libs\Attributes\Route\Post;
|
||||
use App\Libs\Config;
|
||||
use App\Libs\DataUtil;
|
||||
use App\Libs\Enums\Http\Status;
|
||||
use App\Libs\Stream;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Symfony\Component\Yaml\Exception\DumpException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
final readonly class ToYaml
|
||||
{
|
||||
public const string URL = '%{api.prefix}/system/yaml';
|
||||
|
||||
#[Post(self::URL . '[/[{filename}[/]]]', name: 'system.to_yaml')]
|
||||
public function __invoke(iRequest $request, string|null $filename = null): iResponse
|
||||
{
|
||||
$params = DataUtil::fromArray($request->getQueryParams());
|
||||
try {
|
||||
$stream = Stream::create(Yaml::dump(
|
||||
input: $request->getParsedBody(),
|
||||
inline: (int)$params->get('inline', 4),
|
||||
indent: (int)$params->get('indent', 2),
|
||||
flags: Yaml::DUMP_OBJECT | Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
|
||||
));
|
||||
} catch (DumpException $e) {
|
||||
return api_error(r("Failed to convert to yaml. '{error}'.", ['error' => $e->getMessage()]), Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
return api_response(Status::OK, body:$stream, headers: [
|
||||
'Content-Type' => 'text/yaml',
|
||||
'Content-Disposition' => r('{mode}; filename="{filename}"', [
|
||||
'mode' => $filename ? 'attachment' : 'inline',
|
||||
'filename' => $filename ?? 'to_yaml.yaml',
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,11 @@ class CreateUsersCommand extends Command
|
||||
{
|
||||
public const string ROUTE = 'backend:create';
|
||||
|
||||
public function __construct(private iLogger $logger)
|
||||
private static iLogger|null $logger = null;
|
||||
|
||||
public function __construct(iLogger $logger)
|
||||
{
|
||||
self::$logger = $logger;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -141,11 +144,11 @@ class CreateUsersCommand extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->notice("SYSTEM: Deleting users directory '{path}' contents.", [
|
||||
self::$logger?->notice("SYSTEM: Deleting users directory '{path}' contents.", [
|
||||
'path' => $path
|
||||
]);
|
||||
|
||||
deletePath(path: $path, logger: $this->logger, dryRun: $dryRun);
|
||||
deletePath(path: $path, logger: self::$logger, dryRun: $dryRun);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,18 +156,20 @@ class CreateUsersCommand extends Command
|
||||
*
|
||||
* @return array The list of backends.
|
||||
*/
|
||||
private function loadBackends(): array
|
||||
public static function loadBackends(): array
|
||||
{
|
||||
$backends = [];
|
||||
$supported = Config::get('supported', []);
|
||||
$configFile = ConfigFile::open(Config::get('backends_file'), 'yaml');
|
||||
$configFile->setLogger($this->logger);
|
||||
if (null !== self::$logger) {
|
||||
$configFile->setLogger(self::$logger);
|
||||
}
|
||||
|
||||
foreach ($configFile->getAll() as $backendName => $backend) {
|
||||
$type = strtolower(ag($backend, 'type', 'unknown'));
|
||||
|
||||
if (!isset($supported[$type])) {
|
||||
$this->logger->error("SYSTEM: Ignoring '{backend}'. Unexpected backend type '{type}'.", [
|
||||
self::$logger?->error("SYSTEM: Ignoring '{backend}'. Unexpected backend type '{type}'.", [
|
||||
'type' => $type,
|
||||
'backend' => $backendName,
|
||||
'types' => implode(', ', array_keys($supported)),
|
||||
@@ -173,7 +178,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
if (null === ($url = ag($backend, 'url')) || false === isValidURL($url)) {
|
||||
$this->logger->error("SYSTEM: Ignoring '{backend}'. Invalid url '{url}'.", [
|
||||
self::$logger?->error("SYSTEM: Ignoring '{backend}'. Invalid url '{url}'.", [
|
||||
'url' => $url ?? 'None',
|
||||
'backend' => $backendName,
|
||||
]);
|
||||
@@ -181,7 +186,13 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
$backend['name'] = $backendName;
|
||||
$backend['class'] = $this->getBackend($backendName, $backend)->setLogger($this->logger);
|
||||
$backend['class'] = makeBackend($backend, $backendName);
|
||||
|
||||
|
||||
if (null !== self::$logger) {
|
||||
$backend['class']->setLogger(self::$logger);
|
||||
}
|
||||
|
||||
$backends[$backendName] = $backend;
|
||||
}
|
||||
|
||||
@@ -193,7 +204,7 @@ class CreateUsersCommand extends Command
|
||||
*
|
||||
* @return array The list of user mappings. or empty array.
|
||||
*/
|
||||
private function loadMappings(): array
|
||||
public static function loadMappings(): array
|
||||
{
|
||||
$mapFile = Config::get('mapper_file');
|
||||
|
||||
@@ -210,14 +221,14 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
if (false === $map->has('version')) {
|
||||
$this->logger->warning("SYSTEM: Starting with mapper.yaml v1.5, the version key is required.");
|
||||
self::$logger?->warning("SYSTEM: Starting with mapper.yaml v1.5, the version key is required.");
|
||||
}
|
||||
|
||||
if (false === $map->has('map')) {
|
||||
$this->logger->warning("SYSTEM: Please upgrade your mapper.yaml file to v1.5 format spec.");
|
||||
self::$logger?->warning("SYSTEM: Please upgrade your mapper.yaml file to v1.5 format spec.");
|
||||
}
|
||||
|
||||
$this->logger->info("SYSTEM: Mapper file found, using it to map users.", [
|
||||
self::$logger?->info("SYSTEM: Mapper file found, using it to map users.", [
|
||||
'map' => arrayToString($mapping)
|
||||
]);
|
||||
|
||||
@@ -229,10 +240,11 @@ class CreateUsersCommand extends Command
|
||||
*
|
||||
* @param array $backends The list of backends.
|
||||
* @param array $map The user mappings.
|
||||
* @param bool $noMapActions If true, do not run map actions.
|
||||
*
|
||||
* @return array The list of backends users.
|
||||
*/
|
||||
private function get_backends_users(array $backends, array &$map): array
|
||||
public static function get_backends_users(array $backends, array &$map, bool $noMapActions = false): array
|
||||
{
|
||||
$users = [];
|
||||
|
||||
@@ -241,7 +253,7 @@ class CreateUsersCommand extends Command
|
||||
$client = ag($backend, 'class');
|
||||
assert($backend instanceof iClient);
|
||||
|
||||
$this->logger->info("SYSTEM: Getting users from '{backend}'.", [
|
||||
self::$logger?->info("SYSTEM: Getting users from '{backend}'.", [
|
||||
'backend' => $client->getContext()->backendName
|
||||
]);
|
||||
|
||||
@@ -252,19 +264,22 @@ class CreateUsersCommand extends Command
|
||||
|
||||
$backedName = ag($backend, 'name');
|
||||
|
||||
$user['real_name'] = ag($user, 'name');
|
||||
|
||||
// -- this was source of lots of bugs and confusion for users,
|
||||
// -- we decided to normalize the user-names early in the process.
|
||||
$user['name'] = normalizeName((string)$user['name'], $this->logger, [
|
||||
$user['name'] = normalizeName((string)ag($user, 'name'), self::$logger, [
|
||||
'log_message' => "Normalized '{backend}: {name}' to '{backend}: {new_name}'",
|
||||
'context' => [ 'backend' => $backedName ],
|
||||
'context' => ['backend' => $backedName],
|
||||
]);
|
||||
|
||||
// -- run map actions.
|
||||
$this->map_actions($backedName, $user, $map);
|
||||
if (false === $noMapActions) {
|
||||
self::map_actions($backedName, $user, $map);
|
||||
}
|
||||
|
||||
// -- If normalization fails, ignore the user.
|
||||
if (false === isValidName($user['name'])) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
message: "SYSTEM: Invalid user name '{backend}: {name}'. User names must be in [a-z_0-9] format. Skipping user.",
|
||||
context: ['name' => $user['name'], 'backend' => $backedName]
|
||||
);
|
||||
@@ -280,10 +295,10 @@ class CreateUsersCommand extends Command
|
||||
$info['backendName'] = normalizeName(r("{backend}_{user}", [
|
||||
'backend' => $backedName,
|
||||
'user' => $user['name']
|
||||
]),$this->logger);
|
||||
]), self::$logger);
|
||||
|
||||
if (false === isValidName($info['backendName'])) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
message: "SYSTEM: Invalid backend name '{name}'. Backend name must be in [a-z_0-9] format. skipping the associated users.",
|
||||
context: ['name' => $info['backendName']]
|
||||
);
|
||||
@@ -316,7 +331,7 @@ class CreateUsersCommand extends Command
|
||||
$users[] = $user;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
"Exception '{error.kind}' was thrown unhandled during '{client}: {user}@{backend}' get users list. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
[
|
||||
'client' => $client->getContext()->clientName,
|
||||
@@ -350,7 +365,7 @@ class CreateUsersCommand extends Command
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function create_user(iInput $input, array $users): void
|
||||
public static function create_user(iInput $input, array $users): void
|
||||
{
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$updateUsers = (bool)$input->getOption('update');
|
||||
@@ -360,10 +375,10 @@ class CreateUsersCommand extends Command
|
||||
|
||||
foreach ($users as $user) {
|
||||
// -- User subdirectory name.
|
||||
$userName = normalizeName(ag($user, 'name', 'unknown'), $this->logger);
|
||||
$userName = normalizeName(ag($user, 'name', 'unknown'), self::$logger);
|
||||
|
||||
if (false === isValidName($userName)) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
message: "SYSTEM: Invalid username '{user}'. User names must be in [a-z_0-9] format. skipping user.",
|
||||
context: ['user' => $userName]
|
||||
);
|
||||
@@ -372,7 +387,7 @@ class CreateUsersCommand extends Command
|
||||
|
||||
$subUserPath = r(fixPath(Config::get('path') . '/users/{user}'), ['user' => $userName]);
|
||||
|
||||
$this->logger->info(
|
||||
self::$logger?->info(
|
||||
false === is_dir(
|
||||
$subUserPath
|
||||
) ? "SYSTEM: Creating '{user}' directory '{path}'." : "SYSTEM: '{user}' directory '{path}' already exists.",
|
||||
@@ -383,7 +398,7 @@ class CreateUsersCommand extends Command
|
||||
);
|
||||
|
||||
if (false === $dryRun && false === is_dir($subUserPath) && false === mkdir($subUserPath, 0755, true)) {
|
||||
$this->logger->error("SYSTEM: Failed to create '{user}' directory '{path}'.", [
|
||||
self::$logger?->error("SYSTEM: Failed to create '{user}' directory '{path}'.", [
|
||||
'user' => $userName,
|
||||
'path' => $subUserPath
|
||||
]);
|
||||
@@ -391,7 +406,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
$config_file = "{$subUserPath}/servers.yaml";
|
||||
$this->logger->notice(
|
||||
self::$logger?->notice(
|
||||
file_exists(
|
||||
$config_file
|
||||
) ? "SYSTEM: '{user}' configuration file '{file}' already exists." : "SYSTEM: Creating '{user}' configuration file '{file}'.",
|
||||
@@ -409,13 +424,15 @@ class CreateUsersCommand extends Command
|
||||
autoBackup: !$dryRun
|
||||
);
|
||||
|
||||
$perUser->setLogger($this->logger);
|
||||
if (null !== self::$logger) {
|
||||
$perUser->setLogger(self::$logger);
|
||||
}
|
||||
|
||||
foreach (ag($user, 'backends', []) as $backend) {
|
||||
$name = ag($backend, 'client_data.backendName');
|
||||
|
||||
if (false === isValidName($name)) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
message: "SYSTEM: Invalid backend name '{name}'. Backend name must be in [a-z_0-9] format. skipping backend.",
|
||||
context: ['name' => $name]
|
||||
);
|
||||
@@ -466,7 +483,7 @@ class CreateUsersCommand extends Command
|
||||
$update['options.' . Options::ADMIN_TOKEN] = $adminToken;
|
||||
}
|
||||
|
||||
$this->logger->info("SYSTEM: Updating user configuration for '{user}@{name}' backend.", [
|
||||
self::$logger?->info("SYSTEM: Updating user configuration for '{user}@{name}' backend.", [
|
||||
'name' => $name,
|
||||
'user' => $userName,
|
||||
]);
|
||||
@@ -493,7 +510,7 @@ class CreateUsersCommand extends Command
|
||||
opts: $requestOpts,
|
||||
);
|
||||
if (false === $token) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
message: "Failed to generate access token for '{user}@{backend}' backend.",
|
||||
context: ['user' => $userName, 'backend' => $name]
|
||||
);
|
||||
@@ -503,7 +520,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
message: "Failed to generate access token for '{user}@{name}' backend. {error} at '{file}:{line}'.",
|
||||
context: [
|
||||
'name' => $name,
|
||||
@@ -527,7 +544,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
$dbFile = $subUserPath . "/user.db";
|
||||
$this->logger->notice(
|
||||
self::$logger?->notice(
|
||||
file_exists(
|
||||
$dbFile
|
||||
) ? "SYSTEM: '{user}' database file '{db}' already exists." : "SYSTEM: Creating '{user}' database file '{db}'.",
|
||||
@@ -546,7 +563,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
if (true === $generateBackups && false === $isReCreate) {
|
||||
$this->logger->notice("SYSTEM: Queuing event to backup '{user}' remote watch state.", [
|
||||
self::$logger?->notice("SYSTEM: Queuing event to backup '{user}' remote watch state.", [
|
||||
'user' => $userName
|
||||
]);
|
||||
|
||||
@@ -571,13 +588,10 @@ class CreateUsersCommand extends Command
|
||||
protected function runCommand(iInput $input, iOutput $output): int
|
||||
{
|
||||
if (true === ($dryRun = $input->getOption('dry-run'))) {
|
||||
$this->logger->notice('SYSTEM: Running in dry-run mode. No changes will be made.');
|
||||
self::$logger?->notice('SYSTEM: Running in dry-run mode. No changes will be made.');
|
||||
}
|
||||
|
||||
$usersPath = Config::get('path') . '/users';
|
||||
$hasConfig = is_dir($usersPath) && count(glob($usersPath . '/*/*.yaml')) > 0;
|
||||
|
||||
if ($hasConfig && (false === $input->getOption('run') && false === $input->getOption('re-create'))) {
|
||||
if (self::hasUsers() && (false === $input->getOption('run') && false === $input->getOption('re-create'))) {
|
||||
$output->writeln(
|
||||
<<<Text
|
||||
<error>ERROR:</error> Users configuration already exists.
|
||||
@@ -602,41 +616,42 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
if (true === $input->getOption('re-create')) {
|
||||
$this->purgeUsersConfig($usersPath, $dryRun);
|
||||
self::purgeUsersConfig(Config::get('path') . '/users', $dryRun);
|
||||
}
|
||||
|
||||
$backends = $this->loadBackends();
|
||||
$backends = self::loadBackends();
|
||||
|
||||
if (empty($backends)) {
|
||||
$this->logger->error('SYSTEM: No valid backends were found.');
|
||||
self::$logger?->error('SYSTEM: No valid backends were found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$mapping = $this->loadMappings();
|
||||
$mapping = self::loadMappings();
|
||||
|
||||
$this->logger->notice("SYSTEM: Getting users list from '{backends}'.", [
|
||||
self::$logger?->notice("SYSTEM: Getting users list from '{backends}'.", [
|
||||
'backends' => join(', ', array_keys($backends))
|
||||
]);
|
||||
|
||||
$backendsUser = $this->get_backends_users($backends, $mapping);
|
||||
$backendsUser = self::get_backends_users($backends, $mapping);
|
||||
|
||||
if (count($backendsUser) < 1) {
|
||||
$this->logger->error('SYSTEM: No Backend users were found.');
|
||||
self::$logger?->error('SYSTEM: No Backend users were found.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$users = $this->generate_users_list($backendsUser, $mapping);
|
||||
$obj = self::generate_users_list($backendsUser, $mapping);
|
||||
$users = ag($obj, 'matched', []);
|
||||
|
||||
if (count($users) < 1) {
|
||||
$this->logger->warning("We weren't able to match any users across backends.");
|
||||
self::$logger?->warning("We weren't able to match any users across backends.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->logger->notice("SYSTEM: User matching results {results}.", [
|
||||
'results' => arrayToString($this->usersList($users)),
|
||||
self::$logger?->notice("SYSTEM: Matched '{results}'.", [
|
||||
'results' => arrayToString(self::usersList($users)),
|
||||
]);
|
||||
|
||||
$this->create_user(input: $input, users: $users);
|
||||
self::create_user(input: $input, users: $users);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
@@ -647,9 +662,10 @@ class CreateUsersCommand extends Command
|
||||
* @param array $users The list of users from all backends.
|
||||
* @param array{string: array{string: string, options: array}} $map The map of users to match.
|
||||
*
|
||||
* @return array{matched: array{name: string, backends: array<string, array<string, mixed>>,unmatched: array<array-key,string>} The list of matched users and unmatched.
|
||||
* @return array{name: string, backends: array<string, array<string, mixed>>}[] The list of matched users.
|
||||
*/
|
||||
private function generate_users_list(array $users, array $map = []): array
|
||||
public static function generate_users_list(array $users, array $map = []): array
|
||||
{
|
||||
$allBackends = [];
|
||||
foreach ($users as $u) {
|
||||
@@ -665,7 +681,7 @@ class CreateUsersCommand extends Command
|
||||
$backend = $user['backend'];
|
||||
$nameLower = strtolower($user['name']);
|
||||
if (ag($user, 'id') === ag($user, 'client_data.options.' . Options::ALT_ID)) {
|
||||
$this->logger->debug('Skipping main user "{name}".', ['name' => $user['name']]);
|
||||
self::$logger?->debug('Skipping main user "{name}".', ['name' => $user['name']]);
|
||||
continue;
|
||||
}
|
||||
if (!isset($usersBy[$backend])) {
|
||||
@@ -675,6 +691,7 @@ class CreateUsersCommand extends Command
|
||||
$usersList[$backend][] = $nameLower;
|
||||
}
|
||||
|
||||
$unmatched = [];
|
||||
$results = [];
|
||||
|
||||
// Track used combos: array of [backend, nameLower].
|
||||
@@ -776,7 +793,7 @@ class CreateUsersCommand extends Command
|
||||
$matchedMapEntry = null;
|
||||
foreach ($map as $mapRow) {
|
||||
if (ag($mapRow, "{$backend}.name") === $nameLower) {
|
||||
$this->logger->notice("Mapper: Found map entry for '{backend}: {user}'", [
|
||||
self::$logger?->notice("Mapper: Found map entry for '{backend}: {user}'", [
|
||||
'backend' => $backend,
|
||||
'user' => $nameLower,
|
||||
'map' => $mapRow,
|
||||
@@ -844,7 +861,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
$this->logger->error("No partial fallback match via map for '{backend}: {user}'", [
|
||||
self::$logger?->error("No partial fallback match via map for '{backend}: {user}'", [
|
||||
'backend' => $userObj['backend'],
|
||||
'user' => $userObj['name'],
|
||||
]);
|
||||
@@ -878,19 +895,29 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
// If neither map nor direct matched for ≥2
|
||||
$this->logger->error("No other users were found that match '{backend}: {user}'.", [
|
||||
self::$logger?->error("No other users were found that match '{backend}: {user}{real_name}'.", [
|
||||
'backend' => $userObj['backend'],
|
||||
'user' => $userObj['name'],
|
||||
'real_name' => $userObj['real_name'] !== $userObj['name'] ? r(' ({rl})', [
|
||||
'rl' => $userObj['real_name']
|
||||
]) : '',
|
||||
'map' => arrayToString($map),
|
||||
'list' => arrayToString($usersList),
|
||||
]);
|
||||
|
||||
$unmatched[] = [
|
||||
'id' => $userObj['id'],
|
||||
'name' => $userObj['name'],
|
||||
'backend' => $backend,
|
||||
'real_name' => $userObj['real_name'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
return ['matched' => $results, 'unmatched' => $unmatched];
|
||||
}
|
||||
|
||||
private function usersList(array $list): array
|
||||
public static function usersList(array $list): array
|
||||
{
|
||||
$chunks = [];
|
||||
|
||||
@@ -931,10 +958,12 @@ class CreateUsersCommand extends Command
|
||||
* options: { }
|
||||
* ```
|
||||
*/
|
||||
private function map_actions(string $backend, array &$user, array &$mapping): void
|
||||
public static function map_actions(string $backend, array &$user, array &$mapping): void
|
||||
{
|
||||
static $reported = [];
|
||||
|
||||
if (null === ($username = ag($user, 'name'))) {
|
||||
$this->logger->error("MAPPER: No username was given from one user of '{backend}' backend.", [
|
||||
self::$logger?->error("MAPPER: No username was given from one user of '{backend}' backend.", [
|
||||
'backend' => $backend
|
||||
]);
|
||||
return;
|
||||
@@ -942,9 +971,12 @@ class CreateUsersCommand extends Command
|
||||
|
||||
$hasMapping = array_filter($mapping, fn($map) => array_key_exists($backend, $map));
|
||||
if (count($hasMapping) < 1) {
|
||||
$this->logger->info("MAPPER: No mapping exists for '{backend}' backend.", [
|
||||
'backend' => $backend
|
||||
]);
|
||||
if (!isset($reported[$backend])) {
|
||||
$reported[$backend] = true;
|
||||
self::$logger?->info("MAPPER: No mapping with '{backend}' as backend exists.", [
|
||||
'backend' => $backend
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -966,7 +998,7 @@ class CreateUsersCommand extends Command
|
||||
}
|
||||
|
||||
if (false === $found) {
|
||||
$this->logger->debug("MAPPER: No map exists for '{backend}: {username}'.", [
|
||||
self::$logger?->debug("MAPPER: No map exists for '{backend}: {username}'.", [
|
||||
'backend' => $backend,
|
||||
'username' => $username
|
||||
]);
|
||||
@@ -975,7 +1007,7 @@ class CreateUsersCommand extends Command
|
||||
|
||||
if (null !== ($newUsername = ag($user_map, 'replace_with'))) {
|
||||
if (false === is_string($newUsername) || false === isValidName($newUsername)) {
|
||||
$this->logger->error(
|
||||
self::$logger?->error(
|
||||
message: "MAPPER: Failed to replace '{backend}: {username}' with '{backend}: {new_username}' name must be in [a-z_0-9] format.",
|
||||
context: [
|
||||
'backend' => $backend,
|
||||
@@ -986,7 +1018,7 @@ class CreateUsersCommand extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->notice(
|
||||
self::$logger?->notice(
|
||||
message: "MAPPER: Renaming '{backend}: {username}' to '{backend}: {new_username}'.",
|
||||
context: [
|
||||
'backend' => $backend,
|
||||
@@ -999,4 +1031,10 @@ class CreateUsersCommand extends Command
|
||||
$user_map['name'] = $newUsername;
|
||||
}
|
||||
}
|
||||
|
||||
public static function hasUsers(): bool
|
||||
{
|
||||
$usersPath = Config::get('path') . '/users';
|
||||
return is_dir($usersPath) && count(glob($usersPath . '/*/*.yaml')) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user