Archived
feat: target settings to selected machine
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Make Settings edit machine-scoped PI WEB config on the selected machine for session daemon tools, plugin enablement, external path access, and upload defaults while keeping gateway/browser-only settings local, and show those forms as unavailable when a remote machine does not advertise support.
|
||||
@@ -97,9 +97,9 @@ Read more: [Remote-first development](https://pi-web.dev/remote-first)
|
||||
|
||||
## Machines and fleets
|
||||
|
||||
PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, activity, and Pi package management from trusted remote machines.
|
||||
PI WEB can register other PI WEB runtimes as remote machines. One browser-facing PI WEB instance can proxy projects, files, git state, sessions, terminals, activity, Pi package management, and selected-machine settings from trusted remote machines.
|
||||
|
||||
When a remote machine is selected, **Settings → Pi packages** installs, removes, and updates packages on that selected machine. Other Settings sections such as PI WEB plugin enablement and gateway configuration stay gateway-local unless they say otherwise.
|
||||
When a remote machine is selected, Settings tabs label their target. Pi packages, PI WEB plugin enablement, session daemon toggles, external file access, and upload defaults target the selected machine. Gateway/server settings such as host, port, allowed hosts, registered machines/tokens, and keyboard shortcuts stay local to the gateway/browser.
|
||||
|
||||
Read more: [Fleet and machines guide](https://pi-web.dev/machines)
|
||||
|
||||
@@ -107,7 +107,7 @@ Read more: [Fleet and machines guide](https://pi-web.dev/machines)
|
||||
|
||||
PI WEB supports trusted browser-side PI WEB plugins that can add actions, workspace panels, and workspace metadata.
|
||||
|
||||
Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine and labels where installs, updates, or removals will run. Use **Settings → PI WEB plugins** only to enable or disable discovered browser plugins for the gateway you opened.
|
||||
Pi packages are managed separately through Pi's package manager or **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine and labels where installs, updates, or removals will run. Use **Settings → PI WEB plugins** to enable or disable discovered browser plugins on the selected machine.
|
||||
|
||||
After installing, updating, or removing a Pi package, type `/reload` in each idle PI WEB session on that machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB plugins.
|
||||
|
||||
@@ -128,7 +128,7 @@ Project-local PI WEB config lives at:
|
||||
<project>/.pi-web/config.json
|
||||
```
|
||||
|
||||
Common configuration includes host/port, path access, uploads, PI WEB plugin enablement, shortcuts, and session daemon options.
|
||||
Common configuration includes host/port, path access, uploads, PI WEB plugin enablement, shortcuts, and session daemon options. In Settings, machine-affecting config targets the selected machine; gateway host/port/allowed-hosts, remote machine registration, tokens, and keyboard shortcuts stay local.
|
||||
|
||||
Read more: [Configuration reference](https://pi-web.dev/config)
|
||||
|
||||
|
||||
+36
-13
@@ -110,15 +110,20 @@
|
||||
<li><strong>Project config:</strong> <code><project>/.pi-web/config.json</code> for commit-able project settings.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's
|
||||
config by opening that machine directly or changing files on that machine.
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected
|
||||
machine for config that affects work running there: session daemon tools, PI WEB plugin enablement,
|
||||
external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway:
|
||||
keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers
|
||||
that do not advertise selected-machine settings support report those settings as unavailable instead of
|
||||
silently falling back to the gateway.
|
||||
</p>
|
||||
<p>
|
||||
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the
|
||||
target machine and are managed by Pi (<code>pi install</code>, <code>pi remove</code>, <code>pi update</code>) or
|
||||
<strong>Settings → Pi packages</strong>. In a federated setup, <strong>Settings → Pi packages</strong>
|
||||
targets the currently selected machine; the PI WEB <code>plugins</code> config key only enables or
|
||||
disables discovered PI WEB browser plugins for the gateway you opened.
|
||||
targets the currently selected machine. The PI WEB <code>plugins</code> config key only enables or
|
||||
disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not
|
||||
install, remove, or update Pi packages.
|
||||
</p>
|
||||
<p>
|
||||
If you installed services with a custom config path, rerun
|
||||
@@ -144,9 +149,9 @@
|
||||
and <code>PI_WEB_SUBSESSIONS</code>.
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>host</code> / <code>port</code>: restart the web/API service or process.</li>
|
||||
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon.</li>
|
||||
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon.</li>
|
||||
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
|
||||
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
|
||||
<li><code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
|
||||
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
|
||||
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
|
||||
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
|
||||
@@ -160,8 +165,9 @@
|
||||
<p>
|
||||
<code>pi-web install</code> creates the initial file. You can also save PI WEB config settings from
|
||||
<strong>Settings → General</strong>, <strong>Settings → PI WEB plugins</strong>,
|
||||
<strong>Settings → Keyboard</strong>, and <strong>Settings → Session daemon</strong>. Pi package operations
|
||||
live separately under <strong>Settings → Pi packages</strong>.
|
||||
<strong>Settings → Keyboard</strong>, and <strong>Settings → Session daemon</strong>. Machine-affecting
|
||||
Settings fields target the selected machine; gateway host/port/allowed-hosts and keyboard shortcuts stay
|
||||
local. Pi package operations live separately under <strong>Settings → Pi packages</strong>.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -235,7 +241,11 @@
|
||||
<p>
|
||||
Use this table as the quick reference for where a setting can live, which environment variable overrides
|
||||
it, and whether project-local config overrides or merges with global config. Rows with JSON key
|
||||
<code>—</code> are runtime-only environment variables, not config-file keys.
|
||||
<code>—</code> are runtime-only environment variables, not config-file keys. <code>Global</code> means
|
||||
machine-global. In Settings, selected-machine-safe global keys (<code>pathAccess</code>, <code>uploads</code>,
|
||||
<code>maxUploadBytes</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
|
||||
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
|
||||
registry/tokens stay local.
|
||||
</p>
|
||||
<div class="table-scroll" role="region" aria-label="PI WEB configuration matrix" tabindex="0">
|
||||
<table class="config-matrix">
|
||||
@@ -297,7 +307,7 @@
|
||||
<td><code>PI_WEB_MAX_UPLOAD_BYTES</code></td>
|
||||
<td>Global</td>
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart web/API and session daemon</td>
|
||||
<td>Restart web/API and session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Agent can spawn sessions</td>
|
||||
@@ -305,7 +315,7 @@
|
||||
<td><code>PI_WEB_SPAWN_SESSIONS</code></td>
|
||||
<td>Global/session daemon</td>
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart session daemon</td>
|
||||
<td>Restart session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tracked subsessions (beta)</td>
|
||||
@@ -313,7 +323,7 @@
|
||||
<td><code>PI_WEB_SUBSESSIONS</code></td>
|
||||
<td>Global/session daemon</td>
|
||||
<td>Not supported locally; also requires <code>spawnSessions</code></td>
|
||||
<td>Restart session daemon</td>
|
||||
<td>Restart session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PI WEB plugin enablement/settings</td>
|
||||
@@ -451,6 +461,10 @@
|
||||
<code>realpath</code>, requires roots to be existing directories, and rejects symlink escapes outside the
|
||||
allowed roots.
|
||||
</p>
|
||||
<p>
|
||||
In <strong>Settings → General</strong>, external filesystem roots are saved on the selected machine.
|
||||
Gateway host, port, and allowed-hosts fields stay on the gateway config.
|
||||
</p>
|
||||
<div class="callout warning">
|
||||
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API
|
||||
file exposure outside a workspace. Add only roots you trust PI WEB to list and read through the browser UI.
|
||||
@@ -475,6 +489,11 @@
|
||||
enabled by default, and overwrite is disabled by default. Browser-owned XHR progress is shown per
|
||||
batch/file, and conflicts or errors stay visible in the upload progress UI.
|
||||
</p>
|
||||
<p>
|
||||
For machine federation, Settings saves the global upload default on the selected machine. Current remote
|
||||
PI WEB servers also return workspace-effective upload defaults on workspace responses; older remote
|
||||
servers may omit them and the browser falls back to the global/default upload folder.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="session-tools">
|
||||
@@ -497,6 +516,10 @@
|
||||
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop
|
||||
working, and inspect their transcripts. Restart the session daemon after changing this setting.
|
||||
</p>
|
||||
<p>
|
||||
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
|
||||
session daemon on that machine after changing them.
|
||||
</p>
|
||||
<p>Environment override: <code>PI_WEB_SUBSESSIONS=0|1|true|false</code>.</p>
|
||||
</section>
|
||||
|
||||
|
||||
+17
-13
@@ -11,9 +11,9 @@ PI WEB uses two config files:
|
||||
- **Global PI WEB config:** `$PI_WEB_CONFIG`, or `$XDG_CONFIG_HOME/pi-web/config.json`, or `~/.config/pi-web/config.json`.
|
||||
- **Project-local PI WEB config:** `<project>/.pi-web/config.json` for commit-able project settings.
|
||||
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, edit a remote machine's config by opening that machine directly or changing files on that machine.
|
||||
Each PI WEB machine has its own config. When using Fleet/machine federation, Settings uses the selected machine for config that affects work running there: session daemon tools, PI WEB plugin enablement, external path access, and upload defaults. Gateway/browser-only settings stay local to the gateway: keyboard shortcuts, remote machine registry/tokens, and gateway host/port/allowed-hosts. Remote servers that do not advertise selected-machine settings support report those settings as unavailable instead of silently falling back to the gateway.
|
||||
|
||||
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine; the PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins for the gateway you opened.
|
||||
Pi package settings are separate from PI WEB config. They live in Pi's package-manager settings on the target machine and are managed by Pi (`pi install`, `pi remove`, `pi update`) or **Settings → Pi packages**. In a federated setup, **Settings → Pi packages** targets the currently selected machine. The PI WEB `plugins` config key only enables or disables discovered PI WEB browser plugins on the machine whose config you are editing; it does not install, remove, or update Pi packages.
|
||||
|
||||
If you installed services with a custom config path, rerun `pi-web install --config /path/to/config.json` after changing that path or after upgrading from a version that only applied the custom path to the web service. This regenerates service files so the web/API and session daemon use the same `PI_WEB_CONFIG`.
|
||||
|
||||
@@ -31,9 +31,9 @@ Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALL
|
||||
|
||||
Process restarts depend on the key:
|
||||
|
||||
- `host` / `port`: restart the web/API service or process.
|
||||
- `maxUploadBytes`: restart both the web/API process and the session daemon.
|
||||
- `spawnSessions` / `subsessions`: restart the session daemon.
|
||||
- `host` / `port`: restart the gateway web/API service or process.
|
||||
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
|
||||
- `spawnSessions` / `subsessions`: restart the session daemon on that machine.
|
||||
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
|
||||
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
|
||||
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
|
||||
@@ -91,7 +91,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui
|
||||
|
||||
## Configuration matrix
|
||||
|
||||
Rows with JSON key `—` are runtime-only environment variables, not config-file keys.
|
||||
Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
|
||||
|
||||
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -101,9 +101,9 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
|
||||
| Dev-server allowed hosts | `allowedHosts` | `PI_WEB_ALLOWED_HOSTS` | Global | Not supported locally | Restart dev web/UI |
|
||||
| External filesystem roots | `pathAccess.allowedPaths` | — | Global + project | **Merges**: global roots first, then project roots; duplicates removed | Next file request; refresh existing views if needed |
|
||||
| Manual file upload default folder | `uploads.defaultFolder` | — | Global + project | **Overrides**: project value wins for workspaces in that project; otherwise global/default applies | New Upload dialogs and direct drag/drop batches after config/workspace refresh |
|
||||
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon |
|
||||
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon |
|
||||
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon |
|
||||
| Upload/body limit | `maxUploadBytes` | `PI_WEB_MAX_UPLOAD_BYTES` | Global | Not supported locally | Restart web/API and session daemon on that machine |
|
||||
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
|
||||
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
|
||||
| Plugin enablement/settings | `plugins.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
|
||||
| Keyboard shortcuts | `shortcuts.<actionId>` | — | Global | Not supported locally | Applies after settings save/config refresh |
|
||||
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
|
||||
@@ -136,6 +136,8 @@ Accepted root forms:
|
||||
|
||||
When an absolute request is served, PI WEB expands `~`, canonicalizes the configured roots with `realpath`, requires roots to be existing directories, and rejects symlink escapes outside the allowed roots.
|
||||
|
||||
In **Settings → General**, external filesystem roots are saved on the selected machine. Gateway host, port, and allowed-hosts fields stay on the gateway config.
|
||||
|
||||
This is not a sandbox for the underlying Pi Coding Agent or your OS user. It only controls PI WEB UI/API file exposure outside a workspace.
|
||||
|
||||
### Manual upload defaults
|
||||
@@ -159,9 +161,9 @@ The value must be a non-empty workspace-relative folder. PI WEB normalizes repea
|
||||
|
||||
Manual uploads use the workspace file-write path: paths stay workspace-relative, parent folder creation is enabled by default, and overwrite is disabled by default. Direct drag/drop always keeps `overwrite` off; the review dialog lets you explicitly enable overwrite when needed. Browser-owned XHR progress is shown per batch/file, conflicts and errors stay visible in the upload progress UI, and the final file-write response is the source of truth.
|
||||
|
||||
For machine federation, current remote PI WEB servers return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder.
|
||||
For machine federation, Settings saves the global upload default on the selected machine. Current remote PI WEB servers also return `workspace.effectiveConfig.uploads.defaultFolder` on the existing workspace-list response. Older remote servers can omit that optional field without breaking clients; the Files panel falls back to the global/default upload folder.
|
||||
|
||||
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES`.
|
||||
The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX_UPLOAD_BYTES` on the machine serving the upload.
|
||||
|
||||
### Session daemon tools
|
||||
|
||||
@@ -171,11 +173,13 @@ The per-request size limit is still controlled by `maxUploadBytes` / `PI_WEB_MAX
|
||||
|
||||
Tracked subsessions let an agent delegate work to child sessions, get notified when children stop working, and inspect their transcripts.
|
||||
|
||||
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
|
||||
|
||||
### Plugin config
|
||||
|
||||
The `plugins` key is only for PI WEB browser plugin enablement/settings on the PI WEB gateway you opened. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. When a remote machine is selected, **Settings → Pi packages** operates on that selected machine rather than the gateway.
|
||||
The `plugins` key is only for PI WEB browser plugin enablement/settings on the machine whose config you are editing. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. In a federated setup, **Settings → PI WEB plugins** and **Settings → Pi packages** both target the currently selected machine, and each panel labels where changes will be saved or run.
|
||||
|
||||
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from `/pi-web-plugins/manifest.json` before the browser imports it.
|
||||
Plugins are enabled by default. Set `plugins.<id>.enabled` to `false` to remove a plugin from that machine's `/pi-web-plugins/manifest.json` before the browser imports it. Settings lists discovered plugins from the selected machine, including disabled entries exposed by that machine.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+17
-12
@@ -153,12 +153,14 @@
|
||||
</p>
|
||||
<p>
|
||||
Use <strong>Settings → PI WEB plugins</strong> to enable or disable discovered PI WEB browser plugins
|
||||
before the browser imports them. This plugin enablement surface is for the PI WEB gateway you opened; to
|
||||
change remote plugin enablement, open that remote machine directly or edit its PI WEB config on that
|
||||
machine. After installing, removing, or updating a Pi package, type <code>/reload</code> in each idle
|
||||
PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt
|
||||
templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately
|
||||
for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.
|
||||
before the browser imports them. In a federated setup, this plugin enablement surface targets the
|
||||
currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB
|
||||
server does not advertise selected-machine settings support, PI WEB reports the plugin settings as
|
||||
unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or
|
||||
updating a Pi package, type <code>/reload</code> in each idle PI WEB session on the target machine to
|
||||
refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system
|
||||
prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed
|
||||
PI WEB browser plugins. A routine session daemon restart is not required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -325,15 +327,17 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
<h2>Manage PI WEB plugins</h2>
|
||||
<p>
|
||||
Open <strong>Settings → PI WEB plugins</strong> to review discovered bundled, local, dev, and Pi package
|
||||
plugins for the PI WEB gateway you opened. PI WEB can disable any discovered plugin before the browser
|
||||
imports it. Core app contributions such as the command palette, base workspace tools, and themes are not
|
||||
managed through this plugin list.
|
||||
plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin
|
||||
list; when a remote machine is selected, the list comes from that remote PI WEB server and includes
|
||||
disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before
|
||||
the browser imports it. Core app contributions such as the command palette, base workspace tools, and
|
||||
themes are not managed through this plugin list.
|
||||
</p>
|
||||
<p>
|
||||
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may
|
||||
provide plugins or other Pi resources, use <strong>Settings → Pi packages</strong>. In a federated setup,
|
||||
the Pi packages panel targets the selected machine; the PI WEB plugins panel still controls gateway plugin
|
||||
enablement.
|
||||
both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement
|
||||
still writes the PI WEB <code>plugins</code> config key rather than changing Pi package-manager settings.
|
||||
</p>
|
||||
<div class="code-card">
|
||||
<div class="copy-row">
|
||||
@@ -379,7 +383,8 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
||||
</ul>
|
||||
<p>
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable
|
||||
one, open that machine directly or update its config file.
|
||||
one, select that machine and use <strong>Settings → PI WEB plugins</strong> when the remote server exposes
|
||||
selected-machine settings, or open that machine directly/update its config file.
|
||||
</p>
|
||||
<p>
|
||||
Plugin package metadata can set <code>machineSpecific: true</code>. Use it for plugins like Updates whose
|
||||
|
||||
+4
-4
@@ -23,7 +23,7 @@ Use **Settings → Pi packages** to view configured Pi packages or install/remov
|
||||
|
||||
When machine federation is enabled, **Settings → Pi packages** targets the currently selected machine. The panel labels whether changes will run on the local/gateway machine or on a selected remote PI WEB machine. If an older or unavailable remote PI WEB server does not expose package-management routes, PI WEB reports the package management operation as unsupported or unavailable instead of silently falling back to the gateway.
|
||||
|
||||
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. This plugin enablement surface is for the PI WEB gateway you opened; to change remote plugin enablement, open that remote machine directly or edit its PI WEB config on that machine. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.
|
||||
Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB browser plugins before the browser imports them. In a federated setup, this plugin enablement surface targets the currently selected machine and labels where changes are saved. If an older or unavailable remote PI WEB server does not advertise selected-machine settings support, PI WEB reports the plugin settings as unsupported or unavailable instead of silently falling back to the gateway. After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh Pi runtime resources such as extensions, skills, prompt templates, themes, and context/system prompt files as supported by Pi. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A routine session daemon restart is not required.
|
||||
|
||||
## Trust model
|
||||
|
||||
@@ -152,7 +152,7 @@ When [machine federation](https://pi-web.dev/machines) is enabled, PI WEB also l
|
||||
- remote theme contributions are ignored for now because themes are app-wide;
|
||||
- mixed PI WEB versions across federated machines are best-effort and not guaranteed compatible.
|
||||
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, open that machine directly or update its config file.
|
||||
Remote plugin enablement is controlled by the remote machine's PI WEB plugin config. To edit or disable a remote machine plugin, select that machine and use **Settings → PI WEB plugins** when the remote server exposes selected-machine settings, or open that machine directly/update its config file.
|
||||
|
||||
Plugin package metadata may set `machineSpecific: true` when the plugin's meaning is tied to the selected PI WEB machine:
|
||||
|
||||
@@ -169,9 +169,9 @@ If a remote plugin constructs absolute asset URLs, it should use the `pluginId`
|
||||
|
||||
## Manage PI WEB plugins
|
||||
|
||||
Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the PI WEB gateway you opened. PI WEB can disable any discovered gateway plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
|
||||
Open **Settings → PI WEB plugins** to review discovered bundled, local, dev, and Pi package plugins for the selected PI WEB machine. When the local machine is selected, this is the gateway plugin list; when a remote machine is selected, the list comes from that remote PI WEB server and includes disabled discovered plugins it exposes. PI WEB can disable any discovered selected-machine plugin before the browser imports it. Core app contributions such as the built-in command palette, base workspace tools, and themes are not managed through this plugin list.
|
||||
|
||||
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, the Pi packages panel targets the selected machine; the PI WEB plugins panel still controls gateway plugin enablement.
|
||||
This surface is only for PI WEB plugin enablement. To install, remove, or update Pi packages that may provide plugins or other Pi resources, use **Settings → Pi packages**. In a federated setup, both the Pi packages panel and the PI WEB plugins panel target the selected machine; plugin enablement still writes the PI WEB `plugins` config key rather than changing Pi package-manager settings.
|
||||
|
||||
Plugin preferences are stored under the top-level `plugins` config key in the PI WEB config file:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { filesApi, machinesApi, piPackagesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import type { PiWebConfigValues, TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
|
||||
import { configApi, filesApi, machinesApi, piPackagesApi, piWebApi, pluginsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
|
||||
const workspace: Workspace = {
|
||||
id: "w/1",
|
||||
@@ -60,6 +60,48 @@ describe("machine-scoped runtime API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings config and plugin APIs", () => {
|
||||
it("preserves gateway config and plugin routes by default", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(piWebConfigResponse({ host: "127.0.0.1" })),
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
|
||||
jsonResponse(piWebPluginsResponse()),
|
||||
]);
|
||||
|
||||
await expect(configApi.config()).resolves.toMatchObject({ config: { host: "127.0.0.1" } });
|
||||
await expect(configApi.saveConfig({ spawnSessions: true })).resolves.toMatchObject({ config: { spawnSessions: true } });
|
||||
await expect(pluginsApi.plugins()).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/config",
|
||||
"/api/config",
|
||||
"/api/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
});
|
||||
|
||||
it("uses machine-scoped config and plugin routes when a machine id is provided", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: false })),
|
||||
jsonResponse(piWebConfigResponse({ spawnSessions: true })),
|
||||
jsonResponse(piWebPluginsResponse()),
|
||||
]);
|
||||
|
||||
await expect(configApi.config("remote a")).resolves.toMatchObject({ config: { spawnSessions: false } });
|
||||
await expect(configApi.saveConfig({ spawnSessions: true }, "remote a")).resolves.toMatchObject({ config: { spawnSessions: true } });
|
||||
await expect(pluginsApi.plugins("remote a")).resolves.toEqual(piWebPluginsResponse());
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
"/api/machines/remote%20a/config",
|
||||
"/api/machines/remote%20a/config",
|
||||
"/api/machines/remote%20a/plugins",
|
||||
]);
|
||||
expect(fetchCall(fetchMock, 1)[1]?.method).toBe("PUT");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ config: { spawnSessions: true } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi package API", () => {
|
||||
it("preserves the legacy local Pi package-management routes by default", async () => {
|
||||
const packages = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/home/test/.pi/packages/tools" }];
|
||||
@@ -358,6 +400,20 @@ function requestBody(init: RequestInit | undefined): string {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues) {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function piWebPluginsResponse() {
|
||||
return { plugins: [{ id: "info", module: "/pi-web-plugins/info/plugin.js", source: "test", scope: "local", machineSpecific: false, enabled: true }] };
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
@@ -112,13 +112,21 @@ export const machinesApi = {
|
||||
runtime: (machineId: string) => request(`/api/machines/${encodeURIComponent(machineId)}/runtime`, parseMachineRuntime),
|
||||
};
|
||||
|
||||
function configUrl(machineId?: string): string {
|
||||
return machineId === undefined ? "/api/config" : `${machinePrefix(machineId)}/config`;
|
||||
}
|
||||
|
||||
function pluginsUrl(machineId?: string): string {
|
||||
return machineId === undefined ? "/api/plugins" : `${machinePrefix(machineId)}/plugins`;
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
config: () => request("/api/config", parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues) => request("/api/config", parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
config: (machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse),
|
||||
saveConfig: (config: PiWebConfigValues, machineId?: string) => request(configUrl(machineId), parsePiWebConfigResponse, { method: "PUT", body: JSON.stringify({ config }) }),
|
||||
};
|
||||
|
||||
export const pluginsApi = {
|
||||
plugins: () => request("/api/plugins", parsePiWebPluginsResponse),
|
||||
plugins: (machineId?: string) => request(pluginsUrl(machineId), parsePiWebPluginsResponse),
|
||||
};
|
||||
|
||||
function piPackageUrl(endpoint = "", machineId?: string): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Workspace } from "../../../shared/apiTypes";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../../shared/federatedRoutes";
|
||||
import { activityApi, filesApi, gitApi, piPackagesApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { activityApi, configApi, filesApi, gitApi, piPackagesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
|
||||
import { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./sockets";
|
||||
import { workspaceImagePreviewUrl } from "./urls";
|
||||
|
||||
@@ -28,6 +28,9 @@ describe("federated route contract", () => {
|
||||
|
||||
await Promise.all([
|
||||
ignoreParseFailure(piWebApi.piWebStatus(machineId)),
|
||||
ignoreParseFailure(configApi.config(machineId)),
|
||||
ignoreParseFailure(configApi.saveConfig({ spawnSessions: true }, machineId)),
|
||||
ignoreParseFailure(pluginsApi.plugins(machineId)),
|
||||
ignoreParseFailure(piPackagesApi.packages(machineId)),
|
||||
ignoreParseFailure(piPackagesApi.install("npm:@acme/tools", machineId)),
|
||||
ignoreParseFailure(piPackagesApi.remove("npm:@acme/tools", "user", machineId)),
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import { configApi, pluginsApi, type Machine, type MachineRuntime, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginInfo, type PiWebPluginsResponse } from "../api";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("settings-dialog session daemon machine targeting", () => {
|
||||
it("keeps gateway settings loads on the gateway config/plugin endpoints", async () => {
|
||||
const config = configResponse({ host: "127.0.0.1" });
|
||||
const plugins: PiWebPluginsResponse = { plugins: [] };
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
|
||||
await callDialogPromise(dialog, "loadConfig");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([[]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([[]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "pluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "error")).toBe("");
|
||||
expect(getDialogProperty(dialog, "loading")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads session-daemon config from the selected machine", async () => {
|
||||
const config = configResponse({ spawnSessions: false, subsessions: true });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves local session-daemon config through the local machine alias and updates local daemon state", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({ host: "127.0.0.1", spawnSessions: false, subsessions: false });
|
||||
const savedConfig = configResponse({ spawnSessions: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ spawnSessions: true }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({ config: { host: "127.0.0.1", spawnSessions: true, subsessions: false } });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ spawnSessions: false }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale session-daemon save responses after the selected machine changes", async () => {
|
||||
stubWindowTimers();
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveSessiondConfig", { subsessions: true });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
save.resolve(configResponse({ subsessions: true }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips selected-machine settings loads when the remote runtime does not advertise support", async () => {
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", true)]));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy).not.toHaveBeenCalled();
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("does not save remote selected-machine settings when runtime support is missing", async () => {
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(configResponse({ spawnSessions: true }));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
dialog.machineRuntime = runtimeWithoutSelectedMachineSettings;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
await callDialogPromise(dialog, "saveSessiondConfig", { spawnSessions: true });
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] } });
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
});
|
||||
|
||||
it("shows selected-machine settings errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadSessiondConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "sessiondError")).toBe("Failed to load session-daemon config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "sessiondLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-dialog general settings machine targeting", () => {
|
||||
it("describes the General tab as gateway server plus selected-machine file/upload settings", () => {
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.section = "general";
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
expect(callDialogMethod(dialog, "settingsScopeMessage")).toBe("Gateway server config and selected machine file/upload config: Lab Mac (remote machine).");
|
||||
});
|
||||
|
||||
it("keeps gateway server config saves on the gateway config endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const savedConfig = configResponse({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
|
||||
await callDialogPromise(dialog, "saveConfig", { host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ host: "0.0.0.0", port: 9000, allowedHosts: true }]]);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBe(savedConfig);
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({ host: "0.0.0.0", port: 9000, allowedHosts: true });
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("loads file access and upload config from the selected machine", async () => {
|
||||
const config = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine file access and upload config through the selected-machine endpoint", async () => {
|
||||
stubWindowTimers();
|
||||
const patch = { pathAccess: { allowedPaths: ["/mnt/share", "~/SDKs"] }, uploads: { defaultFolder: "manual/uploads" } };
|
||||
const savedConfig = configResponse(patch);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine access saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
const patch = { pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {} };
|
||||
const savedConfig = configResponse({ pathAccess: { allowedPaths: ["~/SDKs"] }, uploads: {}, maxUploadBytes: 5678 });
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
|
||||
await callDialogPromise(dialog, "saveMachineAccessConfig", patch);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[patch, "local"]]);
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: {},
|
||||
maxUploadBytes: 5678,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale file access load responses after the selected machine changes", async () => {
|
||||
const load = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(load.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
load.resolve(configResponse({ pathAccess: { allowedPaths: ["/stale"] } }));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale file access save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "saveMachineAccessConfig", { pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } });
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual" } }));
|
||||
await savePromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "accessConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows selected-machine file access errors with the selected target name", async () => {
|
||||
vi.spyOn(configApi, "config").mockRejectedValue(new Error("Remote machine unavailable"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadAccessConfigForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "accessError")).toBe("Failed to load file access/upload config from Lab Mac (remote machine): Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(getDialogProperty(dialog, "accessLoading")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-dialog plugin settings machine targeting", () => {
|
||||
it("loads plugin config and plugin list from the selected machine", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
const plugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const configSpy = vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(plugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(configSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(plugins);
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps fulfilled plugin config when the selected machine plugin list is unsupported", async () => {
|
||||
const config = configResponse({ plugins: { info: { enabled: true } } });
|
||||
vi.spyOn(configApi, "config").mockResolvedValue(config);
|
||||
vi.spyOn(pluginsApi, "plugins").mockRejectedValue(new Error("route GET:/api/plugins not found"));
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
await callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(config);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("Failed to load PI WEB plugin settings from Lab Mac (remote machine): PI WEB plugins: Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("saves selected-machine plugin toggles as plugin-only patches and refreshes the selected machine plugin list", async () => {
|
||||
stubWindowTimers();
|
||||
const baseConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const savedConfig = configResponse({
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
});
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", false), pluginInfo("keep", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", baseConfig);
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[
|
||||
{
|
||||
plugins: {
|
||||
keep: { enabled: true, settings: { level: 1 } },
|
||||
info: { enabled: false, settings: { color: "blue" } },
|
||||
},
|
||||
},
|
||||
"remote-a",
|
||||
]]);
|
||||
expect(pluginsSpy.mock.calls).toEqual([["remote-a"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("Config saved.");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges local selected-machine plugin saves into gateway config without dropping gateway-only values", async () => {
|
||||
stubWindowTimers();
|
||||
const gatewayConfig = configResponse({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: false }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
const savedConfig = configResponse({ plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } } });
|
||||
const refreshedPlugins = pluginsResponse([pluginInfo("info", true)]);
|
||||
const saveSpy = vi.spyOn(configApi, "saveConfig").mockResolvedValue(savedConfig);
|
||||
vi.spyOn(pluginsApi, "plugins").mockResolvedValue(refreshedPlugins);
|
||||
const onConfigSaved = vi.fn();
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.onConfigSaved = onConfigSaved;
|
||||
setDialogProperty(dialog, "configResponse", gatewayConfig);
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: false } } }));
|
||||
|
||||
await callDialogPromise(dialog, "togglePlugin", "info", true);
|
||||
|
||||
expect(saveSpy.mock.calls).toEqual([[{ plugins: { info: { enabled: true } } }, "local"]]);
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBe(savedConfig);
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBe(refreshedPlugins);
|
||||
expect(getDialogProperty(dialog, "configResponse")).toMatchObject({
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
},
|
||||
});
|
||||
expect(onConfigSaved).toHaveBeenCalledWith({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, gateway: { settings: { theme: "dark" } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale plugin load responses after the selected machine changes", async () => {
|
||||
const configLoad = deferred<PiWebConfigResponse>();
|
||||
const pluginsLoad = deferred<PiWebPluginsResponse>();
|
||||
vi.spyOn(configApi, "config").mockReturnValue(configLoad.promise);
|
||||
vi.spyOn(pluginsApi, "plugins").mockReturnValue(pluginsLoad.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
|
||||
const loadPromise = callDialogPromise(dialog, "loadPluginsForTarget");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
configLoad.resolve(configResponse({ plugins: { info: { enabled: true } } }));
|
||||
pluginsLoad.resolve(pluginsResponse([pluginInfo("info", true)]));
|
||||
await loadPromise;
|
||||
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "pluginError")).toBe("");
|
||||
expect(getDialogProperty(dialog, "pluginLoading")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale plugin save responses after the selected machine changes", async () => {
|
||||
const save = deferred<PiWebConfigResponse>();
|
||||
const pluginsSpy = vi.spyOn(pluginsApi, "plugins").mockResolvedValue(pluginsResponse([pluginInfo("info", false)]));
|
||||
vi.spyOn(configApi, "saveConfig").mockReturnValue(save.promise);
|
||||
const dialog = new SettingsDialog();
|
||||
dialog.machine = remoteMachine;
|
||||
setDialogProperty(dialog, "selectedPluginConfigResponse", configResponse({ plugins: { info: { enabled: true } } }));
|
||||
|
||||
const savePromise = callDialogPromise(dialog, "togglePlugin", "info", false);
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(true);
|
||||
|
||||
dialog.machine = secondRemoteMachine;
|
||||
callDialogUpdated(dialog, new Map([["machine", remoteMachine]]));
|
||||
save.resolve(configResponse({ plugins: { info: { enabled: false } } }));
|
||||
await savePromise;
|
||||
|
||||
expect(pluginsSpy).not.toHaveBeenCalled();
|
||||
expect(getDialogProperty(dialog, "selectedPluginConfigResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "selectedPluginsResponse")).toBeUndefined();
|
||||
expect(getDialogProperty(dialog, "savedMessage")).toBe("");
|
||||
expect(getDialogProperty(dialog, "saving")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const remoteMachine: Machine = {
|
||||
id: "remote-a",
|
||||
name: "Lab Mac",
|
||||
kind: "remote",
|
||||
baseUrl: "https://lab.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const secondRemoteMachine: Machine = {
|
||||
id: "remote-b",
|
||||
name: "Build Box",
|
||||
kind: "remote",
|
||||
baseUrl: "https://build.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const runtimeWithoutSelectedMachineSettings: MachineRuntime = {
|
||||
machineId: "remote-a",
|
||||
ok: true,
|
||||
checkedAt: "2026-07-01T00:00:00.000Z",
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
};
|
||||
|
||||
function getDialogProperty(dialog: SettingsDialog, property: string): unknown {
|
||||
return Reflect.get(dialog, property);
|
||||
}
|
||||
|
||||
function setDialogProperty(dialog: SettingsDialog, property: string, value: unknown): void {
|
||||
if (!Reflect.set(dialog, property, value)) throw new Error(`Failed to set SettingsDialog property ${property}`);
|
||||
}
|
||||
|
||||
async function callDialogPromise(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||
const result = callDialogMethod(dialog, methodName, ...args);
|
||||
if (!(result instanceof Promise)) throw new Error(`SettingsDialog.${methodName} did not return a promise`);
|
||||
await result;
|
||||
}
|
||||
|
||||
function callDialogUpdated(dialog: SettingsDialog, changed: Map<string, unknown>): void {
|
||||
const result = callDialogMethod(dialog, "updated", changed);
|
||||
if (result !== undefined) throw new Error("SettingsDialog.updated returned an unexpected value");
|
||||
}
|
||||
|
||||
function callDialogMethod(dialog: SettingsDialog, methodName: string, ...args: readonly unknown[]): unknown {
|
||||
const method: unknown = Reflect.get(dialog, methodName);
|
||||
if (!isDialogMethod(method)) throw new Error(`SettingsDialog.${methodName} is not callable`);
|
||||
return method.call(dialog, ...args);
|
||||
}
|
||||
|
||||
function isDialogMethod(value: unknown): value is (this: SettingsDialog, ...args: readonly unknown[]) => unknown {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function pluginsResponse(plugins: PiWebPluginInfo[]): PiWebPluginsResponse {
|
||||
return { plugins };
|
||||
}
|
||||
|
||||
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
let rejectDeferred: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolveDeferred = resolve;
|
||||
rejectDeferred = reject;
|
||||
});
|
||||
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
|
||||
}
|
||||
|
||||
function stubWindowTimers(): void {
|
||||
vi.stubGlobal("window", {
|
||||
clearTimeout: vi.fn(),
|
||||
setTimeout: vi.fn(() => 1),
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,12 @@ import "./settings/SettingsSessiondPanel";
|
||||
import "./settings/SettingsPackagesPanel";
|
||||
import "./settings/SettingsPluginsPanel";
|
||||
import "./settings/SettingsShortcutsPanel";
|
||||
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
||||
import { friendlyPiPackageErrorMessage, isPiPackageManagementUnsupported, piPackageManagementSupport, piPackageManagementSupportKey, piPackageMutationFollowUpMessage, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageManagementSupport, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
||||
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
|
||||
import { mergeSelectedMachineAccessConfig } from "./settings/settingsMachineAccessConfig";
|
||||
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, settingsMachineTarget, settingsMachineTargetLabel, type SelectedMachineSettingsSupport, type SettingsMachineTarget } from "./settings/settingsMachineTarget";
|
||||
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settings/settingsPluginConfig";
|
||||
import { mergeSelectedMachineSessiondConfig } from "./settings/settingsSessiondConfig";
|
||||
|
||||
@customElement("settings-dialog")
|
||||
export class SettingsDialog extends LitElement {
|
||||
@@ -21,24 +25,40 @@ export class SettingsDialog extends LitElement {
|
||||
@property({ attribute: false }) onClose?: () => void;
|
||||
@property({ attribute: false }) onConfigSaved?: (config: PiWebConfigValues) => void;
|
||||
@state() private configResponse: PiWebConfigResponse | undefined;
|
||||
@state() private accessConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private sessiondConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
|
||||
@state() private selectedPluginConfigResponse: PiWebConfigResponse | undefined;
|
||||
@state() private selectedPluginsResponse: PiWebPluginsResponse | undefined;
|
||||
@state() private packagesResponse: PiPackagesResponse | undefined;
|
||||
@state() private loading = true;
|
||||
@state() private accessLoading = true;
|
||||
@state() private sessiondLoading = true;
|
||||
@state() private pluginLoading = true;
|
||||
@state() private packageLoading = true;
|
||||
@state() private saving = false;
|
||||
@state() private packageOperation: PiPackageOperationState | undefined;
|
||||
@state() private error = "";
|
||||
@state() private accessError = "";
|
||||
@state() private sessiondError = "";
|
||||
@state() private pluginError = "";
|
||||
@state() private packageError = "";
|
||||
@state() private savedMessage = "";
|
||||
@state() private packageMessage = "";
|
||||
private savedMessageTimer: number | undefined;
|
||||
private loadRequestSeq = 0;
|
||||
private accessLoadRequestSeq = 0;
|
||||
private sessiondLoadRequestSeq = 0;
|
||||
private pluginLoadRequestSeq = 0;
|
||||
private packageLoadRequestSeq = 0;
|
||||
private packageMutationSeq = 0;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
void this.loadConfig();
|
||||
void this.loadAccessConfigForTarget();
|
||||
void this.loadSessiondConfigForTarget();
|
||||
void this.loadPluginsForTarget();
|
||||
void this.loadPackagesForTarget();
|
||||
}
|
||||
|
||||
@@ -49,10 +69,16 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>): void {
|
||||
const currentTarget = this.packageTarget();
|
||||
const currentTarget = this.settingsTarget();
|
||||
if (changed.has("machine")) {
|
||||
const previousTarget = piPackageTargetContext(changed.get("machine"));
|
||||
const previousTarget = settingsMachineTarget(changed.get("machine"));
|
||||
if (previousTarget.id !== currentTarget.id) {
|
||||
this.resetAccessStateForTargetChange();
|
||||
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
|
||||
this.resetSessiondStateForTargetChange();
|
||||
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
|
||||
this.resetPluginStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
|
||||
this.resetPackageStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
|
||||
return;
|
||||
@@ -60,6 +86,14 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
|
||||
if (!changed.has("machineRuntime")) return;
|
||||
if (this.selectedMachineSettingsSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) {
|
||||
this.resetAccessStateForTargetChange();
|
||||
if (this.isConnected) void this.loadAccessConfigForTarget(currentTarget);
|
||||
this.resetSessiondStateForTargetChange();
|
||||
if (this.isConnected) void this.loadSessiondConfigForTarget(currentTarget);
|
||||
this.resetPluginStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPluginsForTarget(currentTarget);
|
||||
}
|
||||
if (!this.packageManagementSupportNeedsReload(changed.get("machineRuntime"), currentTarget)) return;
|
||||
this.resetPackageStateForTargetChange();
|
||||
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
|
||||
@@ -78,10 +112,10 @@ export class SettingsDialog extends LitElement {
|
||||
</header>
|
||||
<div class="settings-body">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
${this.renderNavButton("general", "General", "Gateway config")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Gateway runtime")}
|
||||
${this.renderNavButton("general", "General", "Gateway + selected machine")}
|
||||
${this.renderNavButton("sessiond", "Session daemon", "Selected machine")}
|
||||
${this.renderNavButton("packages", "Pi packages", "Selected machine")}
|
||||
${this.renderNavButton("plugins", "PI WEB plugins", "Gateway plugins")}
|
||||
${this.renderNavButton("plugins", "PI WEB plugins", "Selected machine")}
|
||||
${this.renderNavButton("shortcuts", "Keyboard", "Gateway shortcuts")}
|
||||
</nav>
|
||||
<main class="settings-content">
|
||||
@@ -98,13 +132,14 @@ export class SettingsDialog extends LitElement {
|
||||
if (this.section === "sessiond") {
|
||||
return html`
|
||||
<settings-sessiond-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.loading=${this.loading}
|
||||
.configResponse=${this.sessiondConfigResponse}
|
||||
.loading=${this.sessiondLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.error=${this.sessiondError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadSessiondConfigForTarget()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveSessiondConfig(config)}
|
||||
></settings-sessiond-panel>
|
||||
`;
|
||||
}
|
||||
@@ -142,13 +177,14 @@ export class SettingsDialog extends LitElement {
|
||||
if (this.section === "plugins") {
|
||||
return html`
|
||||
<settings-plugins-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.pluginsResponse=${this.pluginsResponse}
|
||||
.loading=${this.loading}
|
||||
.configResponse=${this.selectedPluginConfigResponse}
|
||||
.pluginsResponse=${this.selectedPluginsResponse}
|
||||
.loading=${this.pluginLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.error=${this.pluginError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadPluginsForTarget()}
|
||||
.onTogglePlugin=${(pluginId: string, enabled: boolean) => this.togglePlugin(pluginId, enabled)}
|
||||
></settings-plugins-panel>
|
||||
`;
|
||||
@@ -156,12 +192,18 @@ export class SettingsDialog extends LitElement {
|
||||
return html`
|
||||
<settings-general-panel
|
||||
.configResponse=${this.configResponse}
|
||||
.machineConfigResponse=${this.accessConfigResponse}
|
||||
.loading=${this.loading}
|
||||
.machineLoading=${this.accessLoading}
|
||||
.saving=${this.saving}
|
||||
.error=${this.error}
|
||||
.machineError=${this.accessError}
|
||||
.savedMessage=${this.savedMessage}
|
||||
.targetLabel=${settingsMachineTargetLabel(this.settingsTarget())}
|
||||
.onReload=${() => this.loadConfig()}
|
||||
.onReloadMachine=${() => this.loadAccessConfigForTarget()}
|
||||
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
|
||||
.onSaveMachineConfig=${(config: PiWebConfigValues) => this.saveMachineAccessConfig(config)}
|
||||
></settings-general-panel>
|
||||
`;
|
||||
}
|
||||
@@ -186,10 +228,10 @@ export class SettingsDialog extends LitElement {
|
||||
|
||||
private settingsScopeMessage(): string {
|
||||
if (this.section === "packages") return `Selected machine packages: ${piPackageTargetLabel(this.packageTarget())}.`;
|
||||
if (this.section === "sessiond") return "Local gateway session-daemon config.";
|
||||
if (this.section === "plugins") return "Local gateway PI WEB plugin enablement.";
|
||||
if (this.section === "sessiond") return `Selected machine session-daemon config: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
|
||||
if (this.section === "plugins") return `Selected machine PI WEB plugin enablement: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
|
||||
if (this.section === "shortcuts") return "Local gateway keyboard shortcuts.";
|
||||
return "Local gateway config.";
|
||||
return `Gateway server config and selected machine file/upload config: ${settingsMachineTargetLabel(this.settingsTarget())}.`;
|
||||
}
|
||||
|
||||
private navigate(section: SettingsSection): void {
|
||||
@@ -215,6 +257,83 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAccessConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.accessLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.accessConfigResponse = undefined;
|
||||
this.accessLoading = false;
|
||||
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.accessLoading = true;
|
||||
this.accessError = "";
|
||||
try {
|
||||
const response = await configApi.config(target.id);
|
||||
if (!this.isCurrentAccessLoad(requestSeq, target)) return;
|
||||
this.accessConfigResponse = response;
|
||||
} catch (error) {
|
||||
if (this.isCurrentAccessLoad(requestSeq, target)) {
|
||||
this.accessError = `Failed to load file access/upload config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
if (this.isCurrentAccessLoad(requestSeq, target)) this.accessLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSessiondConfigForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.sessiondLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.sessiondConfigResponse = undefined;
|
||||
this.sessiondLoading = false;
|
||||
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.sessiondLoading = true;
|
||||
this.sessiondError = "";
|
||||
try {
|
||||
const response = await configApi.config(target.id);
|
||||
if (!this.isCurrentSessiondLoad(requestSeq, target)) return;
|
||||
this.sessiondConfigResponse = response;
|
||||
} catch (error) {
|
||||
if (this.isCurrentSessiondLoad(requestSeq, target)) {
|
||||
this.sessiondError = `Failed to load session-daemon config from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
if (this.isCurrentSessiondLoad(requestSeq, target)) this.sessiondLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPluginsForTarget(target = this.settingsTarget()): Promise<void> {
|
||||
const requestSeq = ++this.pluginLoadRequestSeq;
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.selectedPluginConfigResponse = undefined;
|
||||
this.selectedPluginsResponse = undefined;
|
||||
this.pluginLoading = false;
|
||||
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.pluginLoading = true;
|
||||
this.pluginError = "";
|
||||
try {
|
||||
const [config, plugins] = await Promise.allSettled([configApi.config(target.id), pluginsApi.plugins(target.id)]);
|
||||
if (!this.isCurrentPluginLoad(requestSeq, target)) return;
|
||||
|
||||
const errors: string[] = [];
|
||||
if (config.status === "fulfilled") this.selectedPluginConfigResponse = config.value;
|
||||
else errors.push(`config: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(config.reason), target)}`);
|
||||
|
||||
if (plugins.status === "fulfilled") this.selectedPluginsResponse = plugins.value;
|
||||
else errors.push(`PI WEB plugins: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(plugins.reason), target)}`);
|
||||
|
||||
this.pluginError = errors.length === 0 ? "" : `Failed to load PI WEB plugin settings from ${settingsMachineTargetLabel(target)}: ${errors.join("; ")}`;
|
||||
} finally {
|
||||
if (this.isCurrentPluginLoad(requestSeq, target)) this.pluginLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPackagesForTarget(target = this.packageTarget()): Promise<void> {
|
||||
const requestSeq = ++this.packageLoadRequestSeq;
|
||||
this.packageLoading = true;
|
||||
@@ -232,18 +351,40 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
|
||||
private async togglePlugin(pluginId: string, enabled: boolean): Promise<void> {
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
const currentPlugins = baseConfig.plugins ?? {};
|
||||
const currentPluginConfig = currentPlugins[pluginId] ?? {};
|
||||
await this.saveConfig({
|
||||
...baseConfig,
|
||||
plugins: {
|
||||
...currentPlugins,
|
||||
[pluginId]: { ...currentPluginConfig, enabled },
|
||||
},
|
||||
});
|
||||
const pluginRefreshError = await this.refreshPlugins();
|
||||
if (pluginRefreshError !== undefined) this.error = pluginRefreshError;
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.pluginError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
if (this.selectedPluginConfigResponse === undefined) {
|
||||
this.pluginError = `Plugin config is not loaded for ${settingsMachineTargetLabel(target)}. Reload before changing plugin enablement.`;
|
||||
return;
|
||||
}
|
||||
const patch = pluginEnabledConfigPatch(this.selectedPluginConfigResponse.config, pluginId, enabled);
|
||||
this.saving = true;
|
||||
this.pluginError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(patch, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.selectedPluginConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) {
|
||||
this.configResponse = mergeSelectedMachinePluginConfig(this.configResponse, response);
|
||||
this.onConfigSaved?.(this.configResponse.effectiveConfig);
|
||||
}
|
||||
const pluginRefreshError = await this.refreshPluginsForTarget(target);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
if (pluginRefreshError !== undefined) this.pluginError = pluginRefreshError;
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.pluginError = `Failed to save PI WEB plugin config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveConfig(config: PiWebConfigValues): Promise<void> {
|
||||
@@ -263,6 +404,61 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async saveMachineAccessConfig(config: PiWebConfigValues): Promise<void> {
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.accessError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.accessError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(config, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.accessConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) {
|
||||
this.configResponse = mergeSelectedMachineAccessConfig(this.configResponse, response);
|
||||
this.onConfigSaved?.(this.configResponse.effectiveConfig);
|
||||
}
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.accessError = `Failed to save file access/upload config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSessiondConfig(config: PiWebConfigValues): Promise<void> {
|
||||
if (this.saving) return;
|
||||
const target = this.settingsTarget();
|
||||
const support = this.selectedMachineSettingsSupport(target);
|
||||
if (isSelectedMachineSettingsUnsupported(support)) {
|
||||
this.sessiondError = support.message ?? `Selected-machine settings are not available on ${settingsMachineTargetLabel(target)}.`;
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.sessiondError = "";
|
||||
this.savedMessage = "";
|
||||
try {
|
||||
const response = await configApi.saveConfig(config, target.id);
|
||||
if (!this.isCurrentSettingsTarget(target)) return;
|
||||
this.sessiondConfigResponse = response;
|
||||
if (target.kind === "local" && this.configResponse !== undefined) this.configResponse = mergeSelectedMachineSessiondConfig(this.configResponse, response);
|
||||
this.showSavedMessage();
|
||||
} catch (error) {
|
||||
if (this.isCurrentSettingsTarget(target)) {
|
||||
this.sessiondError = `Failed to save session-daemon config on ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async installPiPackage(source: string): Promise<void> {
|
||||
const target = this.packageTarget();
|
||||
await this.runPiPackageMutation({ kind: "install", source }, "install Pi package", target, () => piPackagesApi.install(source, target.id));
|
||||
@@ -296,7 +492,7 @@ export class SettingsDialog extends LitElement {
|
||||
const response = await mutate();
|
||||
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||
this.packagesResponse = { packages: response.packages };
|
||||
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshPlugins() : undefined;
|
||||
const pluginRefreshError = shouldRefreshGatewayPluginsAfterPiPackageMutation(target) ? await this.refreshGatewayPlugins() : undefined;
|
||||
if (!this.isCurrentPackageMutation(requestSeq, target)) return;
|
||||
if (pluginRefreshError !== undefined) this.packageError = pluginRefreshError;
|
||||
this.packageMessage = piPackageMutationFollowUpMessage(response.action, target);
|
||||
@@ -311,17 +507,41 @@ export class SettingsDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPlugins(): Promise<string | undefined> {
|
||||
private async refreshGatewayPlugins(): Promise<string | undefined> {
|
||||
try {
|
||||
this.pluginsResponse = await pluginsApi.plugins();
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return `Failed to refresh PI WEB plugins: ${errorMessage(error)}`;
|
||||
return `Failed to refresh gateway PI WEB plugins: ${errorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshPluginsForTarget(target: SettingsMachineTarget): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await pluginsApi.plugins(target.id);
|
||||
if (this.isCurrentSettingsTarget(target)) this.selectedPluginsResponse = response;
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return `Config saved, but failed to refresh PI WEB plugins from ${settingsMachineTargetLabel(target)}: ${friendlySelectedMachineSettingsErrorMessage(errorMessage(error), target)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private settingsTarget(): SettingsMachineTarget {
|
||||
return settingsMachineTarget(this.machine);
|
||||
}
|
||||
|
||||
private packageTarget(): PiPackageTargetContext {
|
||||
return piPackageTargetContext(this.machine);
|
||||
return this.settingsTarget();
|
||||
}
|
||||
|
||||
private selectedMachineSettingsSupport(target = this.settingsTarget()): SelectedMachineSettingsSupport {
|
||||
return selectedMachineSettingsSupport(target, this.machineRuntime);
|
||||
}
|
||||
|
||||
private selectedMachineSettingsSupportNeedsReload(previousRuntime: MachineRuntime | undefined, target: SettingsMachineTarget): boolean {
|
||||
const previousSupport = selectedMachineSettingsSupport(target, previousRuntime);
|
||||
const currentSupport = this.selectedMachineSettingsSupport(target);
|
||||
return selectedMachineSettingsSupportKey(previousSupport) !== selectedMachineSettingsSupportKey(currentSupport);
|
||||
}
|
||||
|
||||
private packageManagementSupport(target = this.packageTarget()): PiPackageManagementSupport {
|
||||
@@ -339,6 +559,18 @@ export class SettingsDialog extends LitElement {
|
||||
return requestSeq === this.loadRequestSeq;
|
||||
}
|
||||
|
||||
private isCurrentAccessLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.accessLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentSessiondLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.sessiondLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPluginLoad(requestSeq: number, target: SettingsMachineTarget): boolean {
|
||||
return requestSeq === this.pluginLoadRequestSeq && this.isCurrentSettingsTarget(target);
|
||||
}
|
||||
|
||||
private isCurrentPackageLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
|
||||
return requestSeq === this.packageLoadRequestSeq && this.isCurrentPackageTarget(target);
|
||||
}
|
||||
@@ -351,6 +583,35 @@ export class SettingsDialog extends LitElement {
|
||||
return this.packageTarget().id === target.id;
|
||||
}
|
||||
|
||||
private isCurrentSettingsTarget(target: SettingsMachineTarget): boolean {
|
||||
return this.settingsTarget().id === target.id;
|
||||
}
|
||||
|
||||
private resetAccessStateForTargetChange(): void {
|
||||
this.accessLoadRequestSeq += 1;
|
||||
this.accessLoading = false;
|
||||
this.accessError = "";
|
||||
this.accessConfigResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetSessiondStateForTargetChange(): void {
|
||||
this.sessiondLoadRequestSeq += 1;
|
||||
this.sessiondLoading = false;
|
||||
this.sessiondError = "";
|
||||
this.sessiondConfigResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetPluginStateForTargetChange(): void {
|
||||
this.pluginLoadRequestSeq += 1;
|
||||
this.pluginLoading = false;
|
||||
this.pluginError = "";
|
||||
this.selectedPluginConfigResponse = undefined;
|
||||
this.selectedPluginsResponse = undefined;
|
||||
this.savedMessage = "";
|
||||
}
|
||||
|
||||
private resetPackageStateForTargetChange(): void {
|
||||
const hadPackageOperation = this.packageOperation !== undefined;
|
||||
this.packageLoadRequestSeq += 1;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { SettingsGeneralPanel } from "./SettingsGeneralPanel";
|
||||
import type { GatewayServerConfigDraft, MachineAccessConfigDraft } from "./settingsConfigDraft";
|
||||
|
||||
describe("settings-general-panel copy", () => {
|
||||
it("uses factual scope copy for gateway and selected-machine settings", () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
panel.configResponse = configResponse({ host: "127.0.0.1" });
|
||||
panel.machineConfigResponse = configResponse({ pathAccess: { allowedPaths: ["/mnt/share"] }, uploads: { defaultFolder: "manual/uploads" } });
|
||||
|
||||
const template = panel.render();
|
||||
const strings = collectTemplateStrings(template).join("");
|
||||
const values = collectTemplateValues(template);
|
||||
|
||||
expect(strings).toContain("Gateway server fields edit this local gateway. File access and upload defaults edit ");
|
||||
expect(strings).toContain("Host, port, and allowed hosts are saved in the gateway config.");
|
||||
expect(strings).toContain("External filesystem roots and upload defaults are saved on ");
|
||||
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("shows reload copy when selected-machine access config is unavailable", () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
panel.configResponse = configResponse({ host: "127.0.0.1" });
|
||||
panel.machineError = "Failed to load file access/upload config from Lab Mac (remote machine): unsupported";
|
||||
|
||||
const template = panel.render();
|
||||
const values = collectTemplateValues(template);
|
||||
|
||||
expect(values).toContain("Selected-machine file access config is unavailable. Reload before saving file/upload settings.");
|
||||
expect(values).toContain("Failed to load file access/upload config from Lab Mac (remote machine): unsupported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-general-panel save payloads", () => {
|
||||
it("saves gateway server fields through the gateway save callback only", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSave = vi.fn();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
panel.configResponse = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["old.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/gateway"] },
|
||||
uploads: { defaultFolder: "gateway/uploads" },
|
||||
spawnSessions: true,
|
||||
});
|
||||
panel.onSave = onSave;
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "gatewayDraft", {
|
||||
host: " 0.0.0.0 ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "all",
|
||||
allowedHostsText: "ignored.local",
|
||||
} satisfies GatewayServerConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveGatewayConfig", event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onSave.mock.calls).toEqual([[
|
||||
{
|
||||
host: "0.0.0.0",
|
||||
port: 9000,
|
||||
allowedHosts: true,
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/gateway"] },
|
||||
uploads: { defaultFolder: "gateway/uploads" },
|
||||
spawnSessions: true,
|
||||
},
|
||||
]]);
|
||||
expect(onSaveMachineConfig).not.toHaveBeenCalled();
|
||||
expect(getPanelProperty(panel, "gatewayLocalError")).toBe("");
|
||||
});
|
||||
|
||||
it("saves external roots and upload defaults through the selected-machine save callback only", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSave = vi.fn();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
const event = new Event("submit", { cancelable: true });
|
||||
panel.onSave = onSave;
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "machineDraft", {
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
uploadDefaultFolder: " manual\\uploads/. ",
|
||||
} satisfies MachineAccessConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveMachineAccessConfig", event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(onSaveMachineConfig.mock.calls).toEqual([[
|
||||
{
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
},
|
||||
]]);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(getPanelProperty(panel, "machineLocalError")).toBe("");
|
||||
});
|
||||
|
||||
it("clears upload defaults with a selected-machine-safe patch", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "machineDraft", {
|
||||
allowedPathsText: "",
|
||||
uploadDefaultFolder: "",
|
||||
} satisfies MachineAccessConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
|
||||
|
||||
expect(onSaveMachineConfig.mock.calls).toEqual([[
|
||||
{
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
},
|
||||
]]);
|
||||
});
|
||||
|
||||
it("keeps invalid upload folders local and does not save selected-machine config", async () => {
|
||||
const panel = new SettingsGeneralPanel();
|
||||
const onSaveMachineConfig = vi.fn();
|
||||
panel.onSaveMachineConfig = onSaveMachineConfig;
|
||||
setPanelProperty(panel, "machineDraft", {
|
||||
allowedPathsText: "",
|
||||
uploadDefaultFolder: "/tmp/uploads",
|
||||
} satisfies MachineAccessConfigDraft);
|
||||
|
||||
await callPanelPromise(panel, "saveMachineAccessConfig", new Event("submit", { cancelable: true }));
|
||||
|
||||
expect(onSaveMachineConfig).not.toHaveBeenCalled();
|
||||
expect(getPanelProperty(panel, "machineLocalError")).toBe("Upload default folder must be workspace-relative.");
|
||||
});
|
||||
});
|
||||
|
||||
function collectTemplateStrings(template: TemplateResult): string[] {
|
||||
const strings: string[] = [];
|
||||
visitTemplate(template);
|
||||
return strings;
|
||||
|
||||
function visitTemplate(current: TemplateResult): void {
|
||||
strings.push(...templateStrings(current));
|
||||
for (const value of templateValues(current)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
|
||||
} else if (isTemplateResult(value)) {
|
||||
visitTemplate(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectTemplateValues(template: TemplateResult): unknown[] {
|
||||
const values: unknown[] = [];
|
||||
visit(template);
|
||||
return values;
|
||||
|
||||
function visit(current: unknown): void {
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(current)) return;
|
||||
for (const value of templateValues(current)) {
|
||||
values.push(value);
|
||||
visit(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
function setPanelProperty(panel: SettingsGeneralPanel, property: string, value: unknown): void {
|
||||
if (!Reflect.set(panel, property, value)) throw new Error(`Failed to set SettingsGeneralPanel property ${property}`);
|
||||
}
|
||||
|
||||
function getPanelProperty(panel: SettingsGeneralPanel, property: string): unknown {
|
||||
return Reflect.get(panel, property);
|
||||
}
|
||||
|
||||
async function callPanelPromise(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): Promise<void> {
|
||||
const result = callPanelMethod(panel, methodName, ...args);
|
||||
if (!(result instanceof Promise)) throw new Error(`SettingsGeneralPanel.${methodName} did not return a promise`);
|
||||
await result;
|
||||
}
|
||||
|
||||
function callPanelMethod(panel: SettingsGeneralPanel, methodName: string, ...args: readonly unknown[]): unknown {
|
||||
const method: unknown = Reflect.get(panel, methodName);
|
||||
if (!isPanelMethod(method)) throw new Error(`SettingsGeneralPanel.${methodName} is not callable`);
|
||||
return method.call(panel, ...args);
|
||||
}
|
||||
|
||||
function isPanelMethod(value: unknown): value is (this: SettingsGeneralPanel, ...args: readonly unknown[]) => unknown {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
@@ -1,99 +1,181 @@
|
||||
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { configFromDraft, draftFromConfig, emptyConfigDraft, type ConfigDraft } from "./settingsConfigDraft";
|
||||
import { DEFAULT_WORKSPACE_UPLOADS_FOLDER, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues } from "../../api";
|
||||
import {
|
||||
emptyGatewayServerConfigDraft,
|
||||
emptyMachineAccessConfigDraft,
|
||||
gatewayServerConfigFromDraft,
|
||||
gatewayServerDraftFromConfig,
|
||||
machineAccessConfigPatchFromDraft,
|
||||
machineAccessDraftFromConfig,
|
||||
type GatewayServerConfigDraft,
|
||||
type MachineAccessConfigDraft,
|
||||
} from "./settingsConfigDraft";
|
||||
|
||||
@customElement("settings-general-panel")
|
||||
export class SettingsGeneralPanel extends LitElement {
|
||||
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
|
||||
@property({ attribute: false }) machineConfigResponse: PiWebConfigResponse | undefined;
|
||||
@property({ type: Boolean }) loading = false;
|
||||
@property({ type: Boolean }) machineLoading = false;
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() machineError = "";
|
||||
@property() savedMessage = "";
|
||||
@property() targetLabel = "selected machine";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onReloadMachine?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private draft: ConfigDraft = emptyConfigDraft();
|
||||
@state() private localError = "";
|
||||
@property({ attribute: false }) onSaveMachineConfig?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
@state() private gatewayDraft: GatewayServerConfigDraft = emptyGatewayServerConfigDraft();
|
||||
@state() private machineDraft: MachineAccessConfigDraft = emptyMachineAccessConfigDraft();
|
||||
@state() private gatewayLocalError = "";
|
||||
@state() private machineLocalError = "";
|
||||
|
||||
protected override willUpdate(changed: PropertyValues<this>): void {
|
||||
if (changed.has("configResponse") && this.configResponse !== undefined) {
|
||||
this.draft = draftFromConfig(this.configResponse.config);
|
||||
this.localError = "";
|
||||
this.gatewayDraft = gatewayServerDraftFromConfig(this.configResponse.config);
|
||||
this.gatewayLocalError = "";
|
||||
}
|
||||
if (changed.has("machineConfigResponse") && this.machineConfigResponse !== undefined) {
|
||||
this.machineDraft = machineAccessDraftFromConfig(this.machineConfigResponse.config);
|
||||
this.machineLocalError = "";
|
||||
}
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const config = this.configResponse;
|
||||
return html`
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>General configuration</h2>
|
||||
<p>Update the JSON config file PI WEB is using. Host and port changes are saved immediately, but require the web service to restart before the running server binds to the new address.</p>
|
||||
<p>Gateway server fields edit this local gateway. File access and upload defaults edit ${this.targetLabel}.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
<button class="secondary" ?disabled=${this.loading || this.machineLoading} @click=${() => { this.reloadAll(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderSavedMessage()}
|
||||
<div class="settings-sections">
|
||||
${this.renderGatewayServerSettings()}
|
||||
${this.renderSelectedMachineAccessSettings()}
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Host</span>
|
||||
${this.renderOverrideBadge("host")}
|
||||
</span>
|
||||
<input .value=${this.draft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ host: inputValue(event) }); }}>
|
||||
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Port</span>
|
||||
${this.renderOverrideBadge("port")}
|
||||
</span>
|
||||
<input .value=${this.draft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateDraft({ port: inputValue(event) }); }}>
|
||||
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allowed hosts</span>
|
||||
${this.renderOverrideBadge("allowedHosts")}
|
||||
</span>
|
||||
<select .value=${this.draft.allowedHostsMode} @change=${(event: Event) => { this.updateDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
|
||||
<option value="list">Only listed hosts</option>
|
||||
<option value="all">Allow every host</option>
|
||||
</select>
|
||||
<textarea .value=${this.draft.allowedHostsText} ?disabled=${this.draft.allowedHostsMode === "all"} rows="4" placeholder="example.local 192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>External filesystem roots</span>
|
||||
</span>
|
||||
<textarea .value=${this.draft.allowedPathsText} rows="4" placeholder="~/SDKs /opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Global allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
|
||||
</label>
|
||||
|
||||
${this.renderEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessages(): TemplateResult | null {
|
||||
const error = this.localError || this.error;
|
||||
if (error !== "") return html`<div class="message error-message">${error}</div>`;
|
||||
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
return null;
|
||||
private renderGatewayServerSettings(): TemplateResult {
|
||||
const config = this.configResponse;
|
||||
return html`
|
||||
<section class="settings-card" aria-label="Gateway server settings">
|
||||
<div class="card-heading">
|
||||
<h3>Gateway server</h3>
|
||||
<p>Host, port, and allowed hosts are saved in the gateway config. Address changes require the web service to restart before the running server binds to the new address.</p>
|
||||
</div>
|
||||
${this.renderGatewayMessages()}
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading gateway configuration…</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Gateway config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
<small>${config?.exists === true ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveGatewayConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Host</span>
|
||||
${this.renderOverrideBadge("host")}
|
||||
</span>
|
||||
<input .value=${this.gatewayDraft.host} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ host: inputValue(event) }); }}>
|
||||
<small>Address the web server should bind to. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Port</span>
|
||||
${this.renderOverrideBadge("port")}
|
||||
</span>
|
||||
<input .value=${this.gatewayDraft.port} inputmode="numeric" pattern="[0-9]*" placeholder="8504" autocomplete="off" @input=${(event: Event) => { this.updateGatewayDraft({ port: inputValue(event) }); }}>
|
||||
<small>TCP port from 1 to 65535. Leave empty to use PI WEB's default.</small>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allowed hosts</span>
|
||||
${this.renderOverrideBadge("allowedHosts")}
|
||||
</span>
|
||||
<select .value=${this.gatewayDraft.allowedHostsMode} @change=${(event: Event) => { this.updateGatewayDraft({ allowedHostsMode: selectValue(event) === "all" ? "all" : "list" }); }}>
|
||||
<option value="list">Only listed hosts</option>
|
||||
<option value="all">Allow every host</option>
|
||||
</select>
|
||||
<textarea .value=${this.gatewayDraft.allowedHostsText} ?disabled=${this.gatewayDraft.allowedHostsMode === "all"} rows="4" placeholder="example.local 192.168.1.20" spellcheck="false" @input=${(event: Event) => { this.updateGatewayDraft({ allowedHostsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Enter one host per line, or choose “Allow every host” to write <code>true</code>.</small>
|
||||
</div>
|
||||
|
||||
${this.renderGatewayEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.loading || this.saving}>${this.saving ? "Saving…" : "Save gateway server config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSelectedMachineAccessSettings(): TemplateResult {
|
||||
const config = this.machineConfigResponse;
|
||||
return html`
|
||||
<section class="settings-card" aria-label="Selected machine file access and upload settings">
|
||||
<div class="card-heading">
|
||||
<h3>Selected machine file access and uploads</h3>
|
||||
<p>External filesystem roots and upload defaults are saved on ${this.targetLabel}.</p>
|
||||
</div>
|
||||
${this.renderMachineMessages()}
|
||||
${config === undefined ? html`<div class="loading-card">${this.machineLoading ? "Loading selected-machine file access config…" : "Selected-machine file access config is unavailable. Reload before saving file/upload settings."}</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Selected machine config file</span>
|
||||
<code>${config.path}</code>
|
||||
<small>${config.exists ? "Existing file" : "This file will be created on save"}</small>
|
||||
</div>
|
||||
<form class="config-form" @submit=${(event: Event) => { void this.saveMachineAccessConfig(event); }}>
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>External filesystem roots</span>
|
||||
</span>
|
||||
<textarea .value=${this.machineDraft.allowedPathsText} rows="4" placeholder="~/SDKs /opt/reference" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ allowedPathsText: textAreaValue(event) }); }}></textarea>
|
||||
<small>Allowlist for absolute <code>@</code> completions and file explorer reads outside a workspace on ${this.targetLabel}. Enter one absolute path, Windows absolute path, or <code>~</code>-prefixed path per line. Leave empty to deny external paths by default.</small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-heading">
|
||||
<span>Default upload folder</span>
|
||||
</span>
|
||||
<input .value=${this.machineDraft.uploadDefaultFolder} placeholder=${DEFAULT_WORKSPACE_UPLOADS_FOLDER} autocomplete="off" spellcheck="false" @input=${(event: Event) => { this.updateMachineDraft({ uploadDefaultFolder: inputValue(event) }); }}>
|
||||
<small>Workspace-relative folder for manual file uploads on ${this.targetLabel}. Leave empty to use PI WEB's default <code>${DEFAULT_WORKSPACE_UPLOADS_FOLDER}</code>.</small>
|
||||
</label>
|
||||
|
||||
${this.renderMachineEffectiveConfig()}
|
||||
|
||||
<footer class="form-actions">
|
||||
<button class="primary" ?disabled=${this.machineLoading || this.saving}>${this.saving ? "Saving…" : "Save file/upload config"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSavedMessage(): TemplateResult | null {
|
||||
if (this.savedMessage === "") return null;
|
||||
return html`<div class="message success-message">${this.savedMessage}</div>`;
|
||||
}
|
||||
|
||||
private renderGatewayMessages(): TemplateResult | null {
|
||||
const error = this.gatewayLocalError || this.error;
|
||||
if (error === "") return null;
|
||||
return html`<div class="message error-message">${error}</div>`;
|
||||
}
|
||||
|
||||
private renderMachineMessages(): TemplateResult | null {
|
||||
const error = this.machineLocalError || this.machineError;
|
||||
if (error === "") return null;
|
||||
return html`<div class="message error-message">${error}</div>`;
|
||||
}
|
||||
|
||||
private renderOverrideBadge(key: keyof PiWebConfigEnvOverrides): TemplateResult | null {
|
||||
@@ -101,40 +183,72 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
return html`<span class="override-badge">environment override</span>`;
|
||||
}
|
||||
|
||||
private renderEffectiveConfig(): TemplateResult {
|
||||
private renderGatewayEffectiveConfig(): TemplateResult {
|
||||
const effective = this.configResponse?.effectiveConfig ?? {};
|
||||
return html`
|
||||
<section class="effective-card" aria-label="Effective configuration summary">
|
||||
<h3>Effective after environment overrides</h3>
|
||||
<section class="effective-card" aria-label="Effective gateway configuration summary">
|
||||
<h3>Effective gateway settings after environment overrides</h3>
|
||||
<dl>
|
||||
<div><dt>Host</dt><dd>${effective.host ?? html`<span class="muted">127.0.0.1 default</span>`}</dd></div>
|
||||
<div><dt>Port</dt><dd>${effective.port ?? html`<span class="muted">8504 default</span>`}</dd></div>
|
||||
<div><dt>Allowed hosts</dt><dd>${formatAllowedHosts(effective.allowedHosts)}</dd></div>
|
||||
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveConfig(event: Event): Promise<void> {
|
||||
private renderMachineEffectiveConfig(): TemplateResult {
|
||||
const effective = this.machineConfigResponse?.effectiveConfig ?? {};
|
||||
return html`
|
||||
<section class="effective-card" aria-label="Effective selected machine file access and upload summary">
|
||||
<h3>Effective selected-machine settings</h3>
|
||||
<dl>
|
||||
<div><dt>External roots</dt><dd>${formatAllowedPaths(effective.pathAccess?.allowedPaths)}</dd></div>
|
||||
<div><dt>Upload folder</dt><dd>${effective.uploads?.defaultFolder ?? html`<span class="muted">${DEFAULT_WORKSPACE_UPLOADS_FOLDER} default</span>`}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private reloadAll(): void {
|
||||
void this.onReload?.();
|
||||
void this.onReloadMachine?.();
|
||||
}
|
||||
|
||||
private async saveGatewayConfig(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
this.localError = "";
|
||||
this.gatewayLocalError = "";
|
||||
try {
|
||||
await this.onSave?.(configFromDraft(this.draft, this.configResponse?.config ?? {}));
|
||||
await this.onSave?.(gatewayServerConfigFromDraft(this.gatewayDraft, this.configResponse?.config ?? {}));
|
||||
} catch (error) {
|
||||
this.localError = errorMessage(error);
|
||||
this.gatewayLocalError = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateDraft(patch: Partial<ConfigDraft>): void {
|
||||
this.draft = { ...this.draft, ...patch };
|
||||
this.localError = "";
|
||||
private async saveMachineAccessConfig(event: Event): Promise<void> {
|
||||
event.preventDefault();
|
||||
this.machineLocalError = "";
|
||||
try {
|
||||
await this.onSaveMachineConfig?.(machineAccessConfigPatchFromDraft(this.machineDraft));
|
||||
} catch (error) {
|
||||
this.machineLocalError = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
private updateGatewayDraft(patch: Partial<GatewayServerConfigDraft>): void {
|
||||
this.gatewayDraft = { ...this.gatewayDraft, ...patch };
|
||||
this.gatewayLocalError = "";
|
||||
}
|
||||
|
||||
private updateMachineDraft(patch: Partial<MachineAccessConfigDraft>): void {
|
||||
this.machineDraft = { ...this.machineDraft, ...patch };
|
||||
this.machineLocalError = "";
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.section-heading > div { display: grid; gap: 6px; min-width: 0; }
|
||||
.section-heading > div, .card-heading { display: grid; gap: 6px; min-width: 0; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: 17px; line-height: 1.25; }
|
||||
h3 { font-size: 13px; line-height: 1.3; }
|
||||
@@ -143,12 +257,15 @@ export class SettingsGeneralPanel extends LitElement {
|
||||
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.secondary { flex: 0 0 auto; }
|
||||
.message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.settings-sections { display: grid; gap: 14px; }
|
||||
.settings-card, .message, .loading-card, .config-path-card, .effective-card { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; }
|
||||
.settings-card { display: grid; gap: 14px; }
|
||||
.message { margin-bottom: 12px; }
|
||||
.settings-card .message { margin-bottom: 0; }
|
||||
.error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); }
|
||||
.success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); }
|
||||
.loading-card { color: var(--pi-muted); }
|
||||
.config-path-card { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.config-path-card { display: grid; gap: 5px; }
|
||||
.config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
||||
.config-path-card small, .field small { color: var(--pi-muted); }
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues, PiWebPluginInfo } from "../../api";
|
||||
import { SettingsPluginsPanel } from "./SettingsPluginsPanel";
|
||||
|
||||
describe("settings-plugins-panel copy", () => {
|
||||
it("names the selected machine in plugin scope copy", () => {
|
||||
const panel = new SettingsPluginsPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
|
||||
const template = panel.render();
|
||||
const strings = collectTemplateStrings(template).join("");
|
||||
const values = collectTemplateValues(template);
|
||||
|
||||
expect(strings).toContain("Enable or disable discovered PI WEB browser plugins on ");
|
||||
expect(strings).toContain("Config key on ");
|
||||
expect(strings).toContain("No PI WEB browser plugins discovered on ");
|
||||
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settings-plugins-panel state", () => {
|
||||
it("shows disabled remote plugins from the selected machine plugin list", () => {
|
||||
const panel = new SettingsPluginsPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
panel.configResponse = configResponse({ plugins: { "remote-disabled": { enabled: false } } });
|
||||
panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] };
|
||||
|
||||
const values = collectTemplateValues(panel.render());
|
||||
|
||||
expect(values).toContain("remote-disabled");
|
||||
expect(values).toContain("Config disabled");
|
||||
expect(values).toContain("Disabled");
|
||||
});
|
||||
|
||||
it("disables plugin toggles while selected-machine config is unavailable", () => {
|
||||
const panel = new SettingsPluginsPanel();
|
||||
panel.pluginsResponse = { plugins: [pluginInfo("remote-disabled", false)] };
|
||||
|
||||
expect(collectTemplateStrings(panel.render()).join("")).toContain("Configuration is unavailable. Reload to try again before changing plugin enablement.");
|
||||
expect(templateValues(renderPluginTemplate(panel, pluginInfo("remote-disabled", false))).filter(isBoolean)).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderPluginTemplate(panel: SettingsPluginsPanel, plugin: PiWebPluginInfo): TemplateResult {
|
||||
const renderPlugin: unknown = Reflect.get(panel, "renderPlugin");
|
||||
if (!isPanelRenderPlugin(renderPlugin)) throw new Error("SettingsPluginsPanel.renderPlugin is not callable");
|
||||
return renderPlugin.call(panel, plugin);
|
||||
}
|
||||
|
||||
function isPanelRenderPlugin(value: unknown): value is (this: SettingsPluginsPanel, plugin: PiWebPluginInfo) => TemplateResult {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function collectTemplateStrings(template: TemplateResult): string[] {
|
||||
const strings: string[] = [];
|
||||
visitTemplate(template);
|
||||
return strings;
|
||||
|
||||
function visitTemplate(current: TemplateResult): void {
|
||||
strings.push(...templateStrings(current));
|
||||
for (const value of templateValues(current)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) if (isTemplateResult(item)) visitTemplate(item);
|
||||
} else if (isTemplateResult(value)) {
|
||||
visitTemplate(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectTemplateValues(template: TemplateResult): unknown[] {
|
||||
const values: unknown[] = [];
|
||||
visit(template);
|
||||
return values;
|
||||
|
||||
function visit(current: unknown): void {
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(current)) return;
|
||||
for (const value of templateValues(current)) {
|
||||
values.push(value);
|
||||
visit(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
|
||||
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
|
||||
function pluginInfo(id: string, enabled: boolean): PiWebPluginInfo {
|
||||
return {
|
||||
id,
|
||||
module: `/pi-web-plugins/${id}/plugin.js`,
|
||||
source: "test",
|
||||
scope: "local",
|
||||
machineSpecific: false,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() savedMessage = "";
|
||||
@property() targetLabel = "local (local gateway)";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onTogglePlugin?: (pluginId: string, enabled: boolean) => void | Promise<void>;
|
||||
|
||||
@@ -19,14 +20,15 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>PI WEB plugins</h2>
|
||||
<p>Enable or disable discovered PI WEB browser plugins. This is separate from installing Pi packages. Changes apply after reloading the browser tab; already-loaded plugin code is not unloaded from the current page.</p>
|
||||
<p>Enable or disable discovered PI WEB browser plugins on <strong>${this.targetLabel}</strong>. This is separate from installing Pi packages. Reload the browser tab to apply plugin runtime changes.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
<div class="trust-warning"><strong>Trusted code warning:</strong> PI WEB plugins and Pi packages can run with your user permissions. Enable plugins only from sources you trust.</div>
|
||||
<div class="plugin-note">Config key: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
|
||||
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered.</div>` : html`
|
||||
<div class="plugin-note">Config key on ${this.targetLabel}: <code>plugins</code>. Plugins are enabled unless their entry sets <code>enabled</code> to <code>false</code>.</div>
|
||||
${this.configResponse === undefined && !this.loading ? html`<div class="loading-card">Configuration is unavailable. Reload to try again before changing plugin enablement.</div>` : null}
|
||||
${this.loading && plugins.length === 0 ? html`<div class="loading-card">Loading PI WEB plugins…</div>` : plugins.length === 0 ? html`<div class="loading-card">No PI WEB browser plugins discovered on ${this.targetLabel}.</div>` : html`
|
||||
<div class="plugin-list">
|
||||
${plugins.map((plugin) => this.renderPlugin(plugin))}
|
||||
</div>
|
||||
@@ -51,7 +53,7 @@ export class SettingsPluginsPanel extends LitElement {
|
||||
<small>${configuredState}</small>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
|
||||
<input type="checkbox" .checked=${plugin.enabled} ?disabled=${this.saving || this.configResponse === undefined} @change=${(event: Event) => { void this.togglePlugin(plugin, event); }}>
|
||||
<span>${plugin.enabled ? "Enabled" : "Disabled"}</span>
|
||||
</label>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
|
||||
|
||||
describe("settings-sessiond-panel copy", () => {
|
||||
it("names the selected machine in the scope and restart copy", () => {
|
||||
const panel = new SettingsSessiondPanel();
|
||||
panel.targetLabel = "Lab Mac (remote machine)";
|
||||
|
||||
const template = panel.render();
|
||||
const strings = templateStrings(template);
|
||||
const values = templateValues(template);
|
||||
|
||||
expect(values.filter((value) => value === "Lab Mac (remote machine)")).toHaveLength(2);
|
||||
expect(strings.join("")).toContain("These settings affect the long-lived session runtime on ");
|
||||
expect(strings.join("")).toContain("Restart required on ");
|
||||
expect(strings.join("")).toContain("run <code>pi-web restart</code> on that machine");
|
||||
});
|
||||
});
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||
|
||||
@customElement("settings-sessiond-panel")
|
||||
export class SettingsSessiondPanel extends LitElement {
|
||||
@@ -9,6 +10,7 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
@property({ type: Boolean }) saving = false;
|
||||
@property() error = "";
|
||||
@property() savedMessage = "";
|
||||
@property() targetLabel = "local (local gateway)";
|
||||
@property({ attribute: false }) onReload?: () => void | Promise<void>;
|
||||
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
|
||||
|
||||
@@ -25,16 +27,16 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>Session daemon</h2>
|
||||
<p>These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.</p>
|
||||
<p>These settings affect the long-lived session runtime on ${this.targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.</p>
|
||||
</div>
|
||||
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
|
||||
</div>
|
||||
${this.renderMessages()}
|
||||
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div>
|
||||
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
|
||||
<div class="restart-note" role="note">Restart required on ${this.targetLabel}: run <code>pi-web restart</code> on that machine (or restart its session daemon service) after changing these settings.</div>
|
||||
${config === undefined ? html`<div class="loading-card">${this.loading ? "Loading configuration…" : "Configuration is unavailable. Reload to try again."}</div>` : html`
|
||||
<div class="config-path-card">
|
||||
<span>Config file</span>
|
||||
<code>${config?.path ?? "Unknown"}</code>
|
||||
<code>${config.path}</code>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
@@ -88,14 +90,12 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
|
||||
private async toggleSpawnSessions(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
await this.onSave?.({ ...baseConfig, spawnSessions: enabled });
|
||||
await this.onSave?.(spawnSessionsConfigPatch(enabled));
|
||||
}
|
||||
|
||||
private async toggleSubsessions(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
const baseConfig = this.configResponse?.config ?? {};
|
||||
await this.onSave?.({ ...baseConfig, subsessions: enabled });
|
||||
await this.onSave?.(subsessionsConfigPatch(enabled));
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
|
||||
@@ -1,7 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { configFromDraft, draftFromConfig } from "./settingsConfigDraft";
|
||||
import {
|
||||
configFromDraft,
|
||||
draftFromConfig,
|
||||
gatewayServerConfigFromDraft,
|
||||
gatewayServerDraftFromConfig,
|
||||
machineAccessConfigPatchFromDraft,
|
||||
machineAccessDraftFromConfig,
|
||||
} from "./settingsConfigDraft";
|
||||
|
||||
describe("settings config drafts", () => {
|
||||
it("splits gateway server and selected-machine access drafts", () => {
|
||||
const config = {
|
||||
host: "0.0.0.0",
|
||||
port: 8504,
|
||||
allowedHosts: ["example.local", "192.168.1.20"],
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
};
|
||||
|
||||
expect(gatewayServerDraftFromConfig(config)).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: "8504",
|
||||
allowedHostsMode: "list",
|
||||
allowedHostsText: "example.local\n192.168.1.20",
|
||||
});
|
||||
expect(machineAccessDraftFromConfig(config)).toEqual({
|
||||
allowedPathsText: "/tmp\n~/SDKs",
|
||||
uploadDefaultFolder: "manual/uploads",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds gateway server saves without changing selected-machine-safe config values", () => {
|
||||
expect(gatewayServerConfigFromDraft({
|
||||
host: " gateway.local ",
|
||||
port: "9000",
|
||||
allowedHostsMode: "all",
|
||||
allowedHostsText: "ignored.local",
|
||||
}, {
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
spawnSessions: true,
|
||||
})).toEqual({
|
||||
host: "gateway.local",
|
||||
port: 9000,
|
||||
allowedHosts: true,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
spawnSessions: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds selected-machine access/upload patches only from selected-machine-safe fields", () => {
|
||||
const patch = machineAccessConfigPatchFromDraft({
|
||||
allowedPathsText: "/tmp\n~/SDKs\n",
|
||||
uploadDefaultFolder: " manual\\uploads/. ",
|
||||
});
|
||||
|
||||
expect(Object.keys(patch)).toEqual(["pathAccess", "uploads"]);
|
||||
expect(patch).toEqual({
|
||||
pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears selected-machine access/upload settings with safe default patches", () => {
|
||||
expect(machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "" })).toEqual({
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid selected-machine upload default folders before saving", () => {
|
||||
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "/tmp/uploads" })).toThrow("Upload default folder must be workspace-relative.");
|
||||
expect(() => machineAccessConfigPatchFromDraft({ allowedPathsText: "", uploadDefaultFolder: "../secret" })).toThrow("Upload default folder must not contain path traversal.");
|
||||
});
|
||||
|
||||
it("converts PI WEB config values to editable general settings drafts", () => {
|
||||
expect(draftFromConfig({ host: "0.0.0.0", port: 8504, allowedHosts: ["example.local", "192.168.1.20"], pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } })).toEqual({
|
||||
host: "0.0.0.0",
|
||||
|
||||
@@ -1,36 +1,51 @@
|
||||
import type { PiWebConfigValues } from "../../api";
|
||||
|
||||
export interface ConfigDraft {
|
||||
export interface GatewayServerConfigDraft {
|
||||
host: string;
|
||||
port: string;
|
||||
allowedHostsMode: "list" | "all";
|
||||
allowedHostsText: string;
|
||||
}
|
||||
|
||||
export interface MachineAccessConfigDraft {
|
||||
allowedPathsText: string;
|
||||
uploadDefaultFolder: string;
|
||||
}
|
||||
|
||||
export interface ConfigDraft extends GatewayServerConfigDraft {
|
||||
allowedPathsText: string;
|
||||
}
|
||||
|
||||
export function emptyConfigDraft(): ConfigDraft {
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "", allowedPathsText: "" };
|
||||
export function emptyGatewayServerConfigDraft(): GatewayServerConfigDraft {
|
||||
return { host: "", port: "", allowedHostsMode: "list", allowedHostsText: "" };
|
||||
}
|
||||
|
||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
export function emptyMachineAccessConfigDraft(): MachineAccessConfigDraft {
|
||||
return { allowedPathsText: "", uploadDefaultFolder: "" };
|
||||
}
|
||||
|
||||
export function gatewayServerDraftFromConfig(config: PiWebConfigValues): GatewayServerConfigDraft {
|
||||
return {
|
||||
host: config.host ?? "",
|
||||
port: config.port === undefined ? "" : String(config.port),
|
||||
allowedHostsMode: config.allowedHosts === true ? "all" : "list",
|
||||
allowedHostsText: Array.isArray(config.allowedHosts) ? config.allowedHosts.join("\n") : "",
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||
const config: PiWebConfigValues = {
|
||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
|
||||
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
export function machineAccessDraftFromConfig(config: PiWebConfigValues): MachineAccessConfigDraft {
|
||||
return {
|
||||
allowedPathsText: config.pathAccess?.allowedPaths?.join("\n") ?? "",
|
||||
uploadDefaultFolder: config.uploads?.defaultFolder ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function draftFromConfig(config: PiWebConfigValues): ConfigDraft {
|
||||
return { ...gatewayServerDraftFromConfig(config), allowedPathsText: machineAccessDraftFromConfig(config).allowedPathsText };
|
||||
}
|
||||
|
||||
export function gatewayServerConfigFromDraft(draft: GatewayServerConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||
const config = preservedGatewayConfigRemainder(baseConfig);
|
||||
const host = draft.host.trim();
|
||||
const port = draft.port.trim();
|
||||
if (host !== "") config.host = host;
|
||||
@@ -40,11 +55,38 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue
|
||||
config.port = parsed;
|
||||
}
|
||||
config.allowedHosts = draft.allowedHostsMode === "all" ? true : parseAllowedHostsText(draft.allowedHostsText);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function machineAccessConfigPatchFromDraft(draft: MachineAccessConfigDraft): PiWebConfigValues {
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
const uploadDefaultFolder = normalizeWorkspaceRelativeFolder(draft.uploadDefaultFolder);
|
||||
return {
|
||||
pathAccess: { allowedPaths },
|
||||
uploads: uploadDefaultFolder === "" ? {} : { defaultFolder: uploadDefaultFolder },
|
||||
};
|
||||
}
|
||||
|
||||
export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValues = {}): PiWebConfigValues {
|
||||
const config = gatewayServerConfigFromDraft(draft, baseConfig);
|
||||
const allowedPaths = parseAllowedPathsText(draft.allowedPathsText);
|
||||
if (allowedPaths.length > 0) config.pathAccess = { allowedPaths };
|
||||
else delete config.pathAccess;
|
||||
return config;
|
||||
}
|
||||
|
||||
function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebConfigValues {
|
||||
return {
|
||||
...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }),
|
||||
...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }),
|
||||
...(baseConfig.pathAccess === undefined ? {} : { pathAccess: baseConfig.pathAccess }),
|
||||
...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }),
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowedHostsText(value: string): string[] {
|
||||
return value.split(/[\n,]/u).map((host) => host.trim()).filter((host) => host !== "");
|
||||
}
|
||||
@@ -56,6 +98,21 @@ function parseAllowedPathsText(value: string): string[] {
|
||||
return paths;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceRelativeFolder(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return "";
|
||||
if (isAbsoluteLike(trimmed)) throw new Error("Upload default folder must be workspace-relative.");
|
||||
const parts = trimmed.split(/[\\/]+/u).filter((part) => part !== "" && part !== ".");
|
||||
if (parts.length === 0) return "";
|
||||
if (parts.some((part) => part === "..")) throw new Error("Upload default folder must not contain path traversal.");
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function isAbsoluteishAllowedPath(path: string): boolean {
|
||||
return path === "~" || path.startsWith("~/") || path.startsWith("~\\") || path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/u.test(path);
|
||||
}
|
||||
|
||||
function isAbsoluteLike(value: string): boolean {
|
||||
const withForwardSlashes = value.replace(/\\/g, "/");
|
||||
return withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//u.test(withForwardSlashes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { mergeSelectedMachineAccessConfig } from "./settingsMachineAccessConfig";
|
||||
|
||||
describe("selected-machine access config helpers", () => {
|
||||
it("merges local selected-machine file/upload config into gateway config without dropping gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
maxUploadBytes: 1234,
|
||||
});
|
||||
const selectedMachine = configResponse({
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
maxUploadBytes: 5678,
|
||||
});
|
||||
|
||||
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
pathAccess: { allowedPaths: ["~/SDKs"] },
|
||||
uploads: { defaultFolder: "manual/uploads" },
|
||||
maxUploadBytes: 5678,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges cleared selected-machine access/upload defaults without clearing gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
pathAccess: { allowedPaths: ["/old"] },
|
||||
uploads: { defaultFolder: "old/uploads" },
|
||||
});
|
||||
const selectedMachine = configResponse({ pathAccess: { allowedPaths: [] }, uploads: {} });
|
||||
|
||||
expect(mergeSelectedMachineAccessConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
pathAccess: { allowedPaths: [] },
|
||||
uploads: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
|
||||
export function mergeSelectedMachineAccessConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...base,
|
||||
config: mergeAccessConfig(base.config, selectedMachine.config),
|
||||
effectiveConfig: mergeAccessConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAccessConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
|
||||
return {
|
||||
...base,
|
||||
...(selectedMachine.pathAccess === undefined ? {} : { pathAccess: selectedMachine.pathAccess }),
|
||||
...(selectedMachine.uploads === undefined ? {} : { uploads: selectedMachine.uploads }),
|
||||
...(selectedMachine.maxUploadBytes === undefined ? {} : { maxUploadBytes: selectedMachine.maxUploadBytes }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Machine, MachineRuntime } from "../../api";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../../shared/capabilities";
|
||||
import { friendlySelectedMachineSettingsErrorMessage, isSelectedMachineSettingsUnsupported, selectedMachineSettingsSupport, selectedMachineSettingsSupportKey, selectedMachineSettingsUnavailableMessage, settingsMachineTarget, settingsMachineTargetLabel } from "./settingsMachineTarget";
|
||||
|
||||
const remoteMachine: Machine = {
|
||||
id: "remote-a",
|
||||
name: "Lab Mac",
|
||||
kind: "remote",
|
||||
baseUrl: "https://lab.example.test",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("selected-machine settings target helpers", () => {
|
||||
it("uses the selected machine when present and falls back to the local gateway", () => {
|
||||
expect(settingsMachineTarget(undefined)).toEqual({ id: "local", name: "local", kind: "local" });
|
||||
expect(settingsMachineTarget(remoteMachine)).toEqual({ id: "remote-a", name: "Lab Mac", kind: "remote" });
|
||||
});
|
||||
|
||||
it("labels local and remote settings targets factually", () => {
|
||||
expect(settingsMachineTargetLabel({ id: "local", name: "local", kind: "local" })).toBe("local (local gateway)");
|
||||
expect(settingsMachineTargetLabel(settingsMachineTarget(remoteMachine))).toBe("Lab Mac (remote machine)");
|
||||
});
|
||||
|
||||
it("gates remote selected-machine settings on advertised runtime support", () => {
|
||||
const target = settingsMachineTarget(remoteMachine);
|
||||
const supportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.selectedMachineSettings] };
|
||||
const unsupportedRuntime: MachineRuntime = { machineId: "remote-a", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] };
|
||||
|
||||
expect(selectedMachineSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
|
||||
expect(selectedMachineSettingsSupport(target, undefined)).toEqual({ state: "unknown" });
|
||||
expect(selectedMachineSettingsSupport(target, { ok: false })).toEqual({ state: "unknown" });
|
||||
expect(selectedMachineSettingsSupport(target, supportedRuntime)).toEqual({ state: "supported" });
|
||||
|
||||
const unsupported = selectedMachineSettingsSupport(target, unsupportedRuntime);
|
||||
expect(isSelectedMachineSettingsUnsupported(unsupported)).toBe(true);
|
||||
expect(unsupported.message).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(selectedMachineSettingsSupportKey(unsupported)).toBe(`unsupported:${selectedMachineSettingsUnavailableMessage(target)}`);
|
||||
});
|
||||
|
||||
it("turns older remote config route failures into selected-machine compatibility guidance", () => {
|
||||
const target = settingsMachineTarget(remoteMachine);
|
||||
|
||||
expect(selectedMachineSettingsUnavailableMessage(target)).toBe("Selected-machine settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/config not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Cannot PUT /api/config", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("route GET:/api/plugins not found", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Cannot GET /api/plugins", target)).toBe(selectedMachineSettingsUnavailableMessage(target));
|
||||
});
|
||||
|
||||
it("scopes remote reachability errors to selected-machine settings", () => {
|
||||
const target = settingsMachineTarget(remoteMachine);
|
||||
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine unavailable", target)).toBe("Could not reach Lab Mac for selected-machine settings. Check the machine connection and try again.");
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Remote machine timeout", target)).toBe("Timed out while contacting Lab Mac for selected-machine settings. The operation may still be running remotely; reload before retrying.");
|
||||
expect(friendlySelectedMachineSettingsErrorMessage("Not Found", { id: "local", name: "local", kind: "local" })).toBe("Not Found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Machine, MachineKind, MachineRuntime } from "../../api";
|
||||
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../../shared/capabilities";
|
||||
|
||||
export interface SettingsMachineTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: MachineKind;
|
||||
}
|
||||
|
||||
export type SelectedMachineSettingsSupportState = "supported" | "unsupported" | "unknown";
|
||||
|
||||
export interface SelectedMachineSettingsSupport {
|
||||
state: SelectedMachineSettingsSupportState;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function settingsMachineTarget(machine: Pick<Machine, "id" | "name" | "kind"> | undefined): SettingsMachineTarget {
|
||||
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
|
||||
return { id: "local", name: "local", kind: "local" };
|
||||
}
|
||||
|
||||
export function settingsMachineTargetLabel(target: SettingsMachineTarget): string {
|
||||
return target.kind === "local" ? `${target.name} (local gateway)` : `${target.name} (remote machine)`;
|
||||
}
|
||||
|
||||
export function selectedMachineSettingsSupport(target: SettingsMachineTarget, runtime: Pick<MachineRuntime, "ok" | "capabilities"> | undefined): SelectedMachineSettingsSupport {
|
||||
if (target.kind === "local") return { state: "supported" };
|
||||
if (runtime?.ok !== true) return { state: "unknown" };
|
||||
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.selectedMachineSettings)) return { state: "supported" };
|
||||
return { state: "unsupported", message: selectedMachineSettingsUnavailableMessage(target) };
|
||||
}
|
||||
|
||||
export function selectedMachineSettingsSupportKey(support: SelectedMachineSettingsSupport): string {
|
||||
return `${support.state}:${support.message ?? ""}`;
|
||||
}
|
||||
|
||||
export function isSelectedMachineSettingsUnsupported(support: SelectedMachineSettingsSupport | undefined): support is SelectedMachineSettingsSupport & { state: "unsupported" } {
|
||||
return support?.state === "unsupported";
|
||||
}
|
||||
|
||||
export function selectedMachineSettingsUnavailableMessage(target: SettingsMachineTarget): string {
|
||||
return `Selected-machine settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`;
|
||||
}
|
||||
|
||||
export function friendlySelectedMachineSettingsErrorMessage(message: string, target: SettingsMachineTarget): string {
|
||||
const normalized = message.trim();
|
||||
if (target.kind !== "remote") return normalized;
|
||||
if (isUnsupportedRemoteSelectedMachineSettingsRouteMessage(normalized)) {
|
||||
return selectedMachineSettingsUnavailableMessage(target);
|
||||
}
|
||||
if (normalized === "Remote machine timeout") {
|
||||
return `Timed out while contacting ${target.name} for selected-machine settings. The operation may still be running remotely; reload before retrying.`;
|
||||
}
|
||||
if (normalized === "Remote machine unavailable") {
|
||||
return `Could not reach ${target.name} for selected-machine settings. Check the machine connection and try again.`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isUnsupportedRemoteSelectedMachineSettingsRouteMessage(message: string): boolean {
|
||||
return message === "Not Found"
|
||||
|| /route\s+(GET|PUT):?\/api\/(config|plugins)\b.*not found/iu.test(message)
|
||||
|| /cannot\s+(GET|PUT)\s+.*\/api\/(config|plugins)\b/iu.test(message);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { mergeSelectedMachinePluginConfig, pluginEnabledConfigPatch } from "./settingsPluginConfig";
|
||||
|
||||
describe("plugin settings config helpers", () => {
|
||||
it("builds plugin-only save patches while preserving existing plugin config", () => {
|
||||
const patch = pluginEnabledConfigPatch(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
plugins: {
|
||||
info: { enabled: true, settings: { theme: "dark" }, custom: "keep" },
|
||||
metrics: { enabled: false },
|
||||
},
|
||||
},
|
||||
"info",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(Object.keys(patch)).toEqual(["plugins"]);
|
||||
expect(patch).toEqual({
|
||||
plugins: {
|
||||
info: { enabled: false, settings: { theme: "dark" }, custom: "keep" },
|
||||
metrics: { enabled: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("merges local selected-machine plugin config into gateway config without dropping gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: false } },
|
||||
});
|
||||
const selectedMachine = configResponse({ plugins: { info: { enabled: true }, metrics: { enabled: false } } });
|
||||
|
||||
expect(mergeSelectedMachinePluginConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, metrics: { enabled: false } },
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
spawnSessions: false,
|
||||
plugins: { info: { enabled: true }, metrics: { enabled: false } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
|
||||
export function pluginEnabledConfigPatch(baseConfig: PiWebConfigValues, pluginId: string, enabled: boolean): PiWebConfigValues {
|
||||
const currentPlugins = baseConfig.plugins ?? {};
|
||||
const currentPluginConfig = currentPlugins[pluginId] ?? {};
|
||||
return {
|
||||
plugins: {
|
||||
...currentPlugins,
|
||||
[pluginId]: { ...currentPluginConfig, enabled },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeSelectedMachinePluginConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...base,
|
||||
config: mergePluginConfig(base.config, selectedMachine.config),
|
||||
effectiveConfig: mergePluginConfig(base.effectiveConfig, selectedMachine.effectiveConfig),
|
||||
};
|
||||
}
|
||||
|
||||
function mergePluginConfig(base: PiWebConfigValues, selectedMachine: PiWebConfigValues): PiWebConfigValues {
|
||||
if (selectedMachine.plugins === undefined) return base;
|
||||
return { ...base, plugins: selectedMachine.plugins };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||
|
||||
describe("session daemon settings config helpers", () => {
|
||||
it("builds daemon-only save patches for the sessiond toggles", () => {
|
||||
expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false });
|
||||
expect(Object.keys(spawnSessionsConfigPatch(false))).toEqual(["spawnSessions"]);
|
||||
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
||||
expect(Object.keys(subsessionsConfigPatch(true))).toEqual(["subsessions"]);
|
||||
});
|
||||
|
||||
it("merges local selected-machine daemon config into gateway config without dropping gateway-only values", () => {
|
||||
const gateway = configResponse({
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
});
|
||||
const selectedMachine = configResponse({ spawnSessions: true, subsessions: true }, { spawnSessions: true, subsessions: false });
|
||||
|
||||
expect(mergeSelectedMachineSessiondConfig(gateway, selectedMachine)).toEqual({
|
||||
...gateway,
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: true,
|
||||
subsessions: true,
|
||||
},
|
||||
effectiveConfig: {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.local"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true } },
|
||||
spawnSessions: true,
|
||||
subsessions: true,
|
||||
},
|
||||
envOverrides: {
|
||||
host: false,
|
||||
port: false,
|
||||
allowedHosts: false,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function configResponse(config: PiWebConfigValues, overrides: Partial<PiWebConfigResponse["envOverrides"]> = {}): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, ...overrides },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
|
||||
export function spawnSessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
||||
return { spawnSessions: enabled };
|
||||
}
|
||||
|
||||
export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
||||
return { subsessions: enabled };
|
||||
}
|
||||
|
||||
export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, selectedMachine: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...base,
|
||||
config: { ...base.config, ...selectedMachine.config },
|
||||
effectiveConfig: { ...base.effectiveConfig, ...selectedMachine.effectiveConfig },
|
||||
envOverrides: {
|
||||
...base.envOverrides,
|
||||
spawnSessions: selectedMachine.envOverrides.spawnSessions,
|
||||
subsessions: selectedMachine.envOverrides.subsessions,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -160,6 +160,81 @@ describe("buildApp", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined);
|
||||
});
|
||||
|
||||
it("filters remote selected-machine config reads to machine-safe keys", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
|
||||
body: piWebConfigResponse(fullPiWebConfig()),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/config` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
expect(response.json<PiWebConfigResponse>()).toEqual({
|
||||
...piWebConfigResponse(fullPiWebConfig()),
|
||||
config: selectedMachinePiWebConfig(),
|
||||
effectiveConfig: selectedMachinePiWebConfig(),
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/api/config");
|
||||
});
|
||||
|
||||
it("merges remote selected-machine config updates into the target machine config", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>((method, _path, body) => {
|
||||
if (method === "GET") return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(fullPiWebConfig()) });
|
||||
return Promise.resolve({ statusCode: 200, headers: { "content-type": "application/json" }, body: piWebConfigResponse(configFromMachineConfigWriteBody(body)) });
|
||||
});
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/srv/remote"] }, uploads: { defaultFolder: "remote\\uploads" }, maxUploadBytes: 4096, spawnSessions: true } },
|
||||
});
|
||||
|
||||
const expectedMerged: PiWebConfigValues = {
|
||||
...fullPiWebConfig(),
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/remote"] },
|
||||
uploads: { defaultFolder: "remote/uploads" },
|
||||
maxUploadBytes: 4096,
|
||||
spawnSessions: true,
|
||||
};
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(requestJson).toHaveBeenNthCalledWith(1, "GET", "/api/config");
|
||||
expect(requestJson).toHaveBeenNthCalledWith(2, "PUT", "/api/config", { config: expectedMerged });
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual({
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/remote"] },
|
||||
uploads: { defaultFolder: "remote/uploads" },
|
||||
maxUploadBytes: 4096,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe remote selected-machine config keys before proxying", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const requestJson = vi.fn<MachineClient["requestJson"]>();
|
||||
remoteClient = fakeRemoteClient({ requestJson });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/machines/${remote.id}/config`,
|
||||
payload: { config: { host: "0.0.0.0", allowedHosts: true, shortcuts: { "core:view.chat": "mod+1" }, spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
|
||||
expect(requestJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proxies remote Pi package routes and gives package mutations a longer timeout", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -444,6 +519,10 @@ describe("buildApp", () => {
|
||||
expect(pluginsResponse.statusCode).toBe(200);
|
||||
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
|
||||
const localMachinePluginsResponse = await app.inject({ method: "GET", url: "/api/machines/local/plugins" });
|
||||
expect(localMachinePluginsResponse.statusCode).toBe(200);
|
||||
expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
|
||||
const assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
||||
expect(assetResponse.statusCode).toBe(200);
|
||||
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
|
||||
@@ -453,6 +532,24 @@ describe("buildApp", () => {
|
||||
expect(missingResponse.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("proxies remote machine plugin lists for settings", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json", "set-cookie": "secret=1" },
|
||||
body: Readable.from([JSON.stringify({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] })]),
|
||||
}));
|
||||
remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/plugins` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["set-cookie"]).toBeUndefined();
|
||||
expect(response.json()).toEqual({ plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/plugin.js", source: "local", scope: "local", machineSpecific: false, enabled: false }] });
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined);
|
||||
});
|
||||
|
||||
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
|
||||
const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
@@ -947,6 +1044,32 @@ function fakeConfigService() {
|
||||
};
|
||||
}
|
||||
|
||||
function fullPiWebConfig(): PiWebConfigValues {
|
||||
return {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.example.test"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true, settings: { note: "remote" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachinePiWebConfig(): PiWebConfigValues {
|
||||
return {
|
||||
plugins: { info: { enabled: true, settings: { note: "remote" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
return {
|
||||
path: join(tempDir, "config.json"),
|
||||
@@ -957,6 +1080,24 @@ function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface MachineConfigWriteBody {
|
||||
config: PiWebConfigValues;
|
||||
}
|
||||
|
||||
function configFromMachineConfigWriteBody(body: unknown): PiWebConfigValues {
|
||||
if (!isMachineConfigWriteBody(body)) throw new Error("Expected machine config write body");
|
||||
return body.config;
|
||||
}
|
||||
|
||||
function isMachineConfigWriteBody(value: unknown): value is MachineConfigWriteBody {
|
||||
if (!isRecord(value)) return false;
|
||||
return isRecord(value["config"]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function fakePiPackageService(): PiPackageService {
|
||||
const packages: PiPackageInfo[] = [{ source: "npm:@acme/tools", scope: "user", filtered: false, installedPath: "/tmp/pi-tools" }];
|
||||
return {
|
||||
|
||||
+3
-1
@@ -18,7 +18,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { createFilePiWebConfigService, registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { createDefaultPiPackageService, type PiPackageService } from "./piPackageService.js";
|
||||
import { registerPiPackageRoutes } from "./piPackageRoutes.js";
|
||||
@@ -149,9 +149,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
|
||||
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
|
||||
app.get("/api/plugins", async () => piWebPlugins.plugins());
|
||||
app.get("/api/machines/local/plugins", async () => piWebPlugins.plugins());
|
||||
registerPiPackageRoutes(app, piPackages);
|
||||
registerPiPackageRoutes(app, piPackages, "/api/machines/local");
|
||||
registerConfigRoutes(app, configService);
|
||||
registerLocalMachineConfigRoutes(app, configService);
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
registerMachinePluginProxyRoutes(app, machines);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import { registerConfigRoutes, registerLocalMachineConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
|
||||
import type { PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -18,6 +18,7 @@ beforeEach(async () => {
|
||||
};
|
||||
app = Fastify({ logger: false });
|
||||
registerConfigRoutes(app, service);
|
||||
registerLocalMachineConfigRoutes(app, service);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
@@ -92,8 +93,102 @@ describe("config routes", () => {
|
||||
expect(response.json()).toHaveProperty("error");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters local machine config reads to selected-machine-safe keys", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/machines/local/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json<PiWebConfigResponse>()).toEqual({
|
||||
...responseFor(savedConfig, true),
|
||||
config: selectedMachineConfig(),
|
||||
effectiveConfig: selectedMachineConfig(),
|
||||
});
|
||||
});
|
||||
|
||||
it("merges local selected-machine config updates without dropping gateway-only keys", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { plugins: { info: { enabled: false } }, uploads: { defaultFolder: "uploads\\manual" }, spawnSessions: true } },
|
||||
});
|
||||
|
||||
const expectedConfig: PiWebConfigValues = {
|
||||
...fullConfig(),
|
||||
plugins: { info: { enabled: false } },
|
||||
uploads: { defaultFolder: "uploads/manual" },
|
||||
spawnSessions: true,
|
||||
};
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(savedConfig).toEqual(expectedConfig);
|
||||
expect(service.write).toHaveBeenCalledWith(expectedConfig);
|
||||
expect(response.json<PiWebConfigResponse>().config).toEqual({
|
||||
plugins: { info: { enabled: false } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads/manual" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe local selected-machine config keys before writing", async () => {
|
||||
savedConfig = fullConfig();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { host: "0.0.0.0", spawnSessions: true } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config key is not allowed: host");
|
||||
expect(savedConfig).toEqual(fullConfig());
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid local selected-machine config values before writing", async () => {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/machines/local/config",
|
||||
payload: { config: { spawnSessions: "yes" } },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json<{ error: string }>().error).toContain("PI WEB selected-machine config spawnSessions must be a boolean");
|
||||
expect(service.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function fullConfig(): PiWebConfigValues {
|
||||
return {
|
||||
host: "127.0.0.1",
|
||||
port: 8504,
|
||||
allowedHosts: ["gateway.example.test"],
|
||||
shortcuts: { "core:view.chat": "mod+1" },
|
||||
plugins: { info: { enabled: true, settings: { note: "visible" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachineConfig(): PiWebConfigValues {
|
||||
return {
|
||||
plugins: { info: { enabled: true, settings: { note: "visible" } } },
|
||||
pathAccess: { allowedPaths: ["/srv/repos"] },
|
||||
uploads: { defaultFolder: "uploads" },
|
||||
maxUploadBytes: 1024,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
};
|
||||
}
|
||||
|
||||
function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
|
||||
+113
-1
@@ -8,6 +8,17 @@ export interface PiWebConfigService {
|
||||
write: (config: PiWebConfigValues) => PiWebConfigResponse | Promise<PiWebConfigResponse>;
|
||||
}
|
||||
|
||||
export const SELECTED_MACHINE_CONFIG_KEYS = [
|
||||
"plugins",
|
||||
"pathAccess",
|
||||
"uploads",
|
||||
"maxUploadBytes",
|
||||
"spawnSessions",
|
||||
"subsessions",
|
||||
] as const satisfies readonly (keyof PiWebConfigValues)[];
|
||||
|
||||
const SELECTED_MACHINE_CONFIG_KEY_SET = new Set<string>(SELECTED_MACHINE_CONFIG_KEYS);
|
||||
|
||||
export function createFilePiWebConfigService(options: LoadOptions = {}): PiWebConfigService {
|
||||
return {
|
||||
read: () => currentPiWebConfigResponse(options),
|
||||
@@ -50,6 +61,62 @@ export function registerConfigRoutes(app: FastifyInstance, service: PiWebConfigS
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLocalMachineConfigRoutes(app: FastifyInstance, service: PiWebConfigService = createFilePiWebConfigService()): void {
|
||||
app.get("/api/machines/local/config", async (_request, reply) => {
|
||||
try {
|
||||
return selectedMachineConfigResponse(await service.read());
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Body: { config?: unknown } | undefined }>("/api/machines/local/config", async (request, reply) => {
|
||||
try {
|
||||
const current = await service.read();
|
||||
const patch = parseSelectedMachineConfigRequest(request.body?.config);
|
||||
return selectedMachineConfigResponse(await service.write(mergeSelectedMachineConfig(current.config, patch)));
|
||||
} catch (error) {
|
||||
const status = isConfigValidationError(error) ? 400 : 500;
|
||||
return reply.code(status).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSelectedMachineConfigRequest(value: unknown): PiWebConfig {
|
||||
if (!isRecord(value)) throw new Error("PI WEB selected-machine config update must include a config object");
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!SELECTED_MACHINE_CONFIG_KEY_SET.has(key)) throw new Error(`PI WEB selected-machine config key is not allowed: ${key}`);
|
||||
}
|
||||
try {
|
||||
return pickSelectedMachineConfig(parseConfigRequest(value));
|
||||
} catch (error) {
|
||||
throw new Error(selectedMachineConfigErrorMessage(error), { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSelectedMachineConfig(current: PiWebConfigValues, patch: PiWebConfigValues): PiWebConfig {
|
||||
return { ...current, ...pickSelectedMachineConfig(patch) };
|
||||
}
|
||||
|
||||
export function selectedMachineConfigResponse(response: PiWebConfigResponse): PiWebConfigResponse {
|
||||
return {
|
||||
...response,
|
||||
config: pickSelectedMachineConfig(response.config),
|
||||
effectiveConfig: pickSelectedMachineConfig(response.effectiveConfig),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePiWebConfigResponseBody(value: unknown, source = "PI WEB config response"): PiWebConfigResponse {
|
||||
const record = requireResponseRecord(value, source);
|
||||
return {
|
||||
path: requireResponseString(record, "path", source),
|
||||
exists: requireResponseBoolean(record, "exists", source),
|
||||
config: parseConfigRequest(record["config"]),
|
||||
effectiveConfig: parseConfigRequest(record["effectiveConfig"]),
|
||||
envOverrides: parsePiWebConfigEnvOverridesResponse(record["envOverrides"], source),
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
if (!isRecord(value)) throw new Error("PI WEB config update must include a config object");
|
||||
const config: PiWebConfig = {};
|
||||
@@ -88,6 +155,23 @@ function parseConfigRequest(value: unknown): PiWebConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
|
||||
return {
|
||||
...(config.plugins !== undefined ? { plugins: config.plugins } : {}),
|
||||
...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}),
|
||||
...(config.uploads !== undefined ? { uploads: config.uploads } : {}),
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function selectedMachineConfigErrorMessage(error: unknown): string {
|
||||
const message = errorMessage(error);
|
||||
if (message.startsWith("PI WEB config ")) return `PI WEB selected-machine config ${message.slice("PI WEB config ".length)}`;
|
||||
return `PI WEB selected-machine config ${message}`;
|
||||
}
|
||||
|
||||
function parseAllowedHostsRequest(value: unknown): string[] | true {
|
||||
if (value === true) return true;
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
||||
@@ -141,6 +225,34 @@ function parsePluginsRequest(value: unknown): NonNullable<PiWebConfig["plugins"]
|
||||
}));
|
||||
}
|
||||
|
||||
function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): PiWebConfigEnvOverrides {
|
||||
const record = requireResponseRecord(value, `${source} envOverrides`);
|
||||
return {
|
||||
host: requireResponseBoolean(record, "host", source),
|
||||
port: requireResponseBoolean(record, "port", source),
|
||||
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
|
||||
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
|
||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||
};
|
||||
}
|
||||
|
||||
function requireResponseRecord(value: unknown, source: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`${source} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireResponseString(record: Record<string, unknown>, key: string, source: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") throw new Error(`${source} field must be a string: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireResponseBoolean(record: Record<string, unknown>, key: string, source: string): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${source} field must be a boolean: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverrides {
|
||||
return {
|
||||
host: isEnvSet(env["PI_WEB_HOST"]),
|
||||
@@ -156,7 +268,7 @@ function isEnvSet(value: string | undefined): boolean {
|
||||
}
|
||||
|
||||
function isConfigValidationError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("PI WEB config");
|
||||
return error instanceof Error && (error.message.startsWith("PI WEB config") || error.message.startsWith("PI WEB selected-machine config"));
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { WebSocket } from "ws";
|
||||
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES, type FederatedHttpRouteSpec } from "../../shared/federatedRoutes.js";
|
||||
import { mergeSelectedMachineConfig, parsePiWebConfigResponseBody, parseSelectedMachineConfigRequest, selectedMachineConfigResponse } from "../configRoutes.js";
|
||||
import { bridgeSockets } from "../webSocketBridge.js";
|
||||
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { RemoteMachineRequestError, type MachineClient, type MachineJsonResponse, type MachineRequestOptions } from "./machineClient.js";
|
||||
import { MachineService } from "./machineService.js";
|
||||
|
||||
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
|
||||
@@ -45,19 +46,62 @@ async function proxyHttpRequest(machines: MachineService, spec: FederatedHttpRou
|
||||
}
|
||||
|
||||
try {
|
||||
const remotePath = remoteApiPath(machineId, requestUrl);
|
||||
if (spec.path === "/config") return await proxySelectedMachineConfigRequest(client, machineId, method, remotePath, body, reply);
|
||||
|
||||
const requestOptions = proxyRequestOptions(spec, body, contentType);
|
||||
const upstream = requestOptions === undefined
|
||||
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
|
||||
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
|
||||
? await client.request(method, remotePath, body)
|
||||
: await client.request(method, remotePath, body, requestOptions);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
if (upstream.body === undefined) return await reply.send();
|
||||
return await reply.send(upstream.body);
|
||||
} catch (error) {
|
||||
if (isSelectedMachineConfigRequestError(error)) return reply.code(400).send({ error: errorMessage(error) });
|
||||
return sendGatewayError(reply, machineId, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function proxySelectedMachineConfigRequest(client: MachineClient, machineId: string, method: string, remotePath: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
|
||||
if (method === "GET") {
|
||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("GET", remotePath), machineId);
|
||||
}
|
||||
|
||||
if (method === "PUT") {
|
||||
const patch = parseSelectedMachineConfigRequest(configPayload(body));
|
||||
const currentResponse = await client.requestJson("GET", remotePath);
|
||||
if (!isSuccessfulStatus(currentResponse.statusCode)) return sendUpstreamJsonResponse(reply, currentResponse, machineId);
|
||||
|
||||
const current = parsePiWebConfigResponseBody(currentResponse.body, "Remote machine config response");
|
||||
const merged = mergeSelectedMachineConfig(current.config, patch);
|
||||
return sendSelectedMachineConfigResponse(reply, await client.requestJson("PUT", remotePath, { config: merged }), machineId);
|
||||
}
|
||||
|
||||
return reply.code(405).send({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
function configPayload(body: unknown): unknown {
|
||||
return isRecord(body) ? body["config"] : undefined;
|
||||
}
|
||||
|
||||
function sendSelectedMachineConfigResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
if (!isSuccessfulStatus(upstream.statusCode)) return sendUpstreamJsonResponse(reply, upstream, machineId);
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(selectedMachineConfigResponse(parsePiWebConfigResponseBody(upstream.body, "Remote machine config response")));
|
||||
}
|
||||
|
||||
function sendUpstreamJsonResponse(reply: FastifyReply, upstream: MachineJsonResponse, machineId: string): FastifyReply {
|
||||
reply.code(upstream.statusCode);
|
||||
applySafeHeaders(reply, upstream.headers);
|
||||
return reply.send(upstream.body ?? { error: "Remote machine config request failed", machineId, statusCode: upstream.statusCode });
|
||||
}
|
||||
|
||||
function isSuccessfulStatus(statusCode: number): boolean {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
async function proxyWebSocket(machines: MachineService, machineId: string, requestUrl: string, socket: WebSocket): Promise<void> {
|
||||
if (machineId === "local") {
|
||||
socket.close(1011, "Local machine route is not registered for this endpoint");
|
||||
@@ -110,6 +154,18 @@ function applySafeHeaders(reply: FastifyReply, headers: Record<string, string |
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectedMachineConfigRequestError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("PI WEB selected-machine config");
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
|
||||
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
|
||||
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
|
||||
@@ -117,6 +173,6 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown
|
||||
error: label,
|
||||
machineId,
|
||||
statusCode,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
detail: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("PI WEB status", () => {
|
||||
expect(status).not.toHaveProperty("release");
|
||||
});
|
||||
|
||||
it("reports Pi package management as a web runtime capability", async () => {
|
||||
it("reports web-only capabilities from the web runtime", async () => {
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
@@ -53,9 +53,10 @@ describe("PI WEB status", () => {
|
||||
|
||||
const runtime = await getPiWebRuntime(daemon);
|
||||
|
||||
expect(runtime.components.web.capabilities).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(runtime.components.web.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(runtime.capabilities).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(runtime.components.sessiond.capabilities).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
expect(runtime.capabilities).toEqual(expect.arrayContaining([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]));
|
||||
});
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const PI_WEB_CAPABILITIES = {
|
||||
promptAttachments: "prompt.attachments",
|
||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||
piPackagesManage: "piPackages.manage",
|
||||
selectedMachineSettings: "settings.selectedMachine",
|
||||
} as const;
|
||||
|
||||
export type PiWebCapability = typeof PI_WEB_CAPABILITIES[keyof typeof PI_WEB_CAPABILITIES];
|
||||
|
||||
@@ -2,18 +2,20 @@ import { describe, expect, it } from "vitest";
|
||||
import { effectivePiWebCapabilities, PI_WEB_CAPABILITIES, SESSIOND_RUNTIME_CAPABILITIES, WEB_RUNTIME_CAPABILITIES, parseKnownPiWebCapabilities } from "./capabilities";
|
||||
|
||||
describe("PI WEB capabilities", () => {
|
||||
it("advertises Pi package management from the web runtime only", () => {
|
||||
it("advertises web-only capabilities without requiring session daemon support", () => {
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.piPackagesManage);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).not.toContain(PI_WEB_CAPABILITIES.selectedMachineSettings);
|
||||
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] },
|
||||
web: { available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
|
||||
sessiond: { available: false, capabilities: [] },
|
||||
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage]);
|
||||
})).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||
});
|
||||
|
||||
it("keeps only known string capabilities when parsing runtime data", () => {
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage]);
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
||||
PI_WEB_CAPABILITIES.piPackagesManage,
|
||||
PI_WEB_CAPABILITIES.selectedMachineSettings,
|
||||
] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
export const SESSIOND_RUNTIME_CAPABILITIES = [
|
||||
@@ -32,6 +33,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
|
||||
[PI_WEB_CAPABILITIES.selectedMachineSettings]: ["web"],
|
||||
} as const satisfies Record<PiWebCapability, readonly PiWebServiceComponent[]>;
|
||||
|
||||
export function isPiWebCapability(value: unknown): value is PiWebCapability {
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface FederatedHttpRouteSpec {
|
||||
|
||||
export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "GET", path: "/pi-web/status" },
|
||||
{ method: "GET", path: "/config" },
|
||||
{ method: "PUT", path: "/config" },
|
||||
{ method: "GET", path: "/plugins" },
|
||||
{ method: "GET", path: "/pi-packages" },
|
||||
{ method: "POST", path: "/pi-packages/install", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
|
||||
{ method: "POST", path: "/pi-packages/remove", timeoutMs: PI_PACKAGE_MUTATION_PROXY_TIMEOUT_MS },
|
||||
|
||||
@@ -3,21 +3,21 @@ import { PI_WEB_CAPABILITIES } from "./capabilities";
|
||||
import { parsePiWebRuntimeResponse } from "./piWebStatusParsing";
|
||||
|
||||
describe("PI WEB status parsing", () => {
|
||||
it("parses package-management runtime capabilities and ignores unknown string capabilities", () => {
|
||||
it("parses known runtime capabilities and ignores unknown string capabilities", () => {
|
||||
expect(parsePiWebRuntimeResponse({
|
||||
packageName: "@jmfederico/pi-web",
|
||||
generatedAt: "now",
|
||||
components: {
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"] },
|
||||
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"] },
|
||||
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: ["future.sessiondCapability"] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, "future.capability"],
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"],
|
||||
})).toMatchObject({
|
||||
components: {
|
||||
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage] },
|
||||
web: { capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings] },
|
||||
sessiond: { capabilities: [] },
|
||||
},
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage],
|
||||
capabilities: [PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user