mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -81,11 +81,15 @@ jobs:
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
# Before the tests: the plugin boot tests load the built bundles.
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Run plugin tests
|
||||
run: npm run test:plugins
|
||||
|
||||
create-release:
|
||||
needs: [prep, verify]
|
||||
|
||||
@@ -38,6 +38,12 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test
|
||||
|
||||
- name: Run plugin tests
|
||||
run: npm run test:plugins
|
||||
|
||||
database-dialects:
|
||||
name: Postgres and MySQL
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
|
||||
@@ -105,6 +105,10 @@ jobs:
|
||||
npm run lint:fix || true
|
||||
npm run format
|
||||
|
||||
# The plugin boot tests load the built bundles.
|
||||
- name: Build plugins
|
||||
run: npm run build:sdk && npm run build:plugins
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test
|
||||
|
||||
|
||||
+11
-2
@@ -87,8 +87,17 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
||||
FROM node:26-slim
|
||||
WORKDIR /app
|
||||
|
||||
# The release the opkssh-downloader stage baked in, so the plugin trusts that
|
||||
# binary when the build picks a version other than its pinned one.
|
||||
ARG OPKSSH_VERSION=v0.16.0
|
||||
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
|
||||
ARG OPKSSH_SHA256_ARM64=9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
OPKSSH_BUNDLED_DIR=/app/opkssh-bundled \
|
||||
OPKSSH_VERSION=$OPKSSH_VERSION \
|
||||
OPKSSH_SHA256_AMD64=$OPKSSH_SHA256_AMD64 \
|
||||
OPKSSH_SHA256_ARM64=$OPKSSH_SHA256_ARM64 \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production \
|
||||
POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd
|
||||
@@ -107,8 +116,8 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html
|
||||
|
||||
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
# Bundled first-party plugins (ssh-terminal). Resolved as dist/plugins by
|
||||
# getBundledPluginsDir(); without this the terminal never starts.
|
||||
# Bundled first-party plugins, resolved as dist/plugins by
|
||||
# getBundledPluginsDir().
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
|
||||
# Target of node_modules/@termix/plugin-sdk, which core and the plugins import.
|
||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
||||
|
||||
@@ -174,7 +174,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-iJpOhOpxvXh7OKmYZ0WQBD4t4cx55Fo/ofGeEAVwflY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-q2DmZRcdiLWe4+k149nTr+u9AGPLVk+Bx0aWPFnz5bU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-iJpOhOpxvXh7OKmYZ0WQBD4t4cx55Fo/ofGeEAVwflY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-q2DmZRcdiLWe4+k149nTr+u9AGPLVk+Bx0aWPFnz5bU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. A real DROP
|
||||
-- TABLE here would run before the plugin's adoption migration and delete
|
||||
-- every row.
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_config`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox_stats`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_stats_config`;
|
||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four
|
||||
-- ssh_data proxmox columns are copied into the plugin's host settings after
|
||||
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- The dialect generator now decides varchar or text per table. Columns that
|
||||
-- were varchar(255) only because the same name was a key in another table
|
||||
-- become text, and the timestamps and labels B18's regeneration left as
|
||||
-- varchar(255) on real databases follow. varchar to text keeps every value.
|
||||
ALTER TABLE `api_keys` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `api_keys` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `api_keys` MODIFY COLUMN `expires_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `credential_access` MODIFY COLUMN `expires_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `credential_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `credential_sidebar_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `folder_access` MODIFY COLUMN `expires_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `folder_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `host_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `host_sidebar_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` MODIFY COLUMN `name` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` MODIFY COLUMN `folder` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `notification_channels` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `notification_channels` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `plugin_install_counts` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `plugin_registries` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `plugin_settings` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `plugin_storage` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `plugins` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `plugins` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `rbac_known_permissions` MODIFY COLUMN `plugin_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `roles` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `roles` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `sessions` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `shared_credential_secrets` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `shared_credential_secrets` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `shared_host_auth_overrides` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `shared_host_auth_overrides` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `shared_host_secrets` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `shared_host_secrets` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_credentials` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` text;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_credentials` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_credentials` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_folders` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_folders` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `ssh_folders` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `sync_tombstones` MODIFY COLUMN `sync_id` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `trusted_devices` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `trusted_devices` MODIFY COLUMN `expires_at` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `ui_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `user_external_identities` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `user_open_tabs` MODIFY COLUMN `label` text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `user_open_tabs` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `user_open_tabs` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session
|
||||
-- sharing and session recording host columns, and the dead show_*_in_sidebar
|
||||
-- columns, moved to their plugins' host settings. The copies run after
|
||||
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -318,7 +318,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -432,7 +432,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -596,7 +596,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -733,7 +733,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -869,7 +869,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -1491,7 +1491,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -1499,7 +1499,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -1675,7 +1675,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -1751,7 +1751,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2083,7 +2083,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2169,7 +2169,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2284,7 +2284,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2580,7 +2580,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2588,7 +2588,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2676,7 +2676,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2861,7 +2861,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2869,7 +2869,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2987,7 +2987,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -2995,7 +2995,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3163,7 +3163,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3171,7 +3171,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3503,7 +3503,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3511,7 +3511,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3621,7 +3621,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3629,7 +3629,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3798,7 +3798,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3874,7 +3874,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -3948,7 +3948,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -4031,7 +4031,7 @@
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
@@ -4053,7 +4053,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -4061,7 +4061,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -4297,7 +4297,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
@@ -4669,4 +4669,4 @@
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -393,6 +393,20 @@
|
||||
"when": 1790300572937,
|
||||
"tag": "0055_icy_patch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "5",
|
||||
"when": 1790304003222,
|
||||
"tag": "0056_supreme_toad_men",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 57,
|
||||
"version": "5",
|
||||
"when": 1790304466729,
|
||||
"tag": "0057_serious_shatterstar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. A real DROP
|
||||
-- TABLE here would run before the plugin's adoption migration and delete
|
||||
-- every row.
|
||||
ALTER TABLE "ssh_data" DROP COLUMN "enable_proxmox";--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" DROP COLUMN "proxmox_config";--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" DROP COLUMN "enable_proxmox_stats";--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" DROP COLUMN "proxmox_stats_config";
|
||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four
|
||||
-- ssh_data proxmox columns are copied into the plugin's host settings after
|
||||
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- The dialect generator now decides varchar or text per table. Columns that
|
||||
-- were varchar(255) only because the same name was a key in another table
|
||||
-- become text, and the timestamps and labels B18's regeneration left as
|
||||
-- varchar(255) on real databases follow. varchar to text keeps every value.
|
||||
ALTER TABLE "api_keys" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "api_keys" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "credential_access" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "credential_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "credential_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "credential_sidebar_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "credential_sidebar_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "folder_access" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "folder_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "folder_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "host_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "host_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "host_sidebar_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "host_sidebar_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_data" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "notification_channels" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "notification_channels" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "notification_channels" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_install_counts" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_install_counts" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_registries" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_settings" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_settings" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_storage" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_storage" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "plugins" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "rbac_known_permissions" ALTER COLUMN "plugin_id" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "roles" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "roles" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "roles" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "roles" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_secrets" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_secrets" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_secrets" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "shared_host_secrets" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_credentials" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_folders" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_folders" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_folders" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_folders" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ssh_folders" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "sync_tombstones" ALTER COLUMN "sync_id" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "trusted_devices" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "trusted_devices" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "trusted_devices" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ui_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "ui_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "user_external_identities" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "user_external_identities" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "user_open_tabs" ALTER COLUMN "label" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "user_open_tabs" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "user_open_tabs" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "user_open_tabs" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "user_open_tabs" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session
|
||||
-- sharing and session recording host columns, and the dead show_*_in_sidebar
|
||||
-- columns, moved to their plugins' host settings. The copies run after
|
||||
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -337,7 +337,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -468,7 +468,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -619,7 +619,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -757,7 +757,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -916,7 +916,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -1453,14 +1453,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -1650,7 +1650,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -1716,7 +1716,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -2044,7 +2044,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -2157,7 +2157,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -2271,7 +2271,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -2570,14 +2570,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -2653,7 +2653,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -2829,14 +2829,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -2970,14 +2970,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -3145,14 +3145,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -3483,14 +3483,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -3596,14 +3596,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -3762,7 +3762,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -3837,7 +3837,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -3901,7 +3901,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -3996,7 +3996,7 @@
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
@@ -4015,14 +4015,14 @@
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -4236,7 +4236,7 @@
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "CURRENT_TIMESTAMP"
|
||||
@@ -4603,4 +4603,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -400,6 +400,20 @@
|
||||
"when": 1790300572231,
|
||||
"tag": "0056_chilly_vindicator",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 57,
|
||||
"version": "7",
|
||||
"when": 1790304002011,
|
||||
"tag": "0057_married_dragon_lord",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 58,
|
||||
"version": "7",
|
||||
"when": 1790304465181,
|
||||
"tag": "0058_goofy_ma_gnuci",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. A real DROP
|
||||
-- TABLE here would run before the plugin's adoption migration and delete
|
||||
-- every row.
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_config`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox_stats`;--> statement-breakpoint
|
||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_stats_config`;
|
||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four
|
||||
-- ssh_data proxmox columns are copied into the plugin's host settings after
|
||||
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session
|
||||
-- sharing and session recording host columns, and the dead show_*_in_sidebar
|
||||
-- columns, moved to their plugins' host settings. The copies run after
|
||||
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -372,6 +372,13 @@
|
||||
"when": 1790300571504,
|
||||
"tag": "0052_public_professor_monster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "6",
|
||||
"when": 1790304468233,
|
||||
"tag": "0053_bent_absorbing_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-1
@@ -7,7 +7,12 @@ import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(["dist", "release", "Mobile", "src/mcp-server/node_modules"]),
|
||||
globalIgnores([
|
||||
"**/dist",
|
||||
"release",
|
||||
"Mobile",
|
||||
"src/mcp-server/node_modules",
|
||||
]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
|
||||
Generated
+179
@@ -95,6 +95,7 @@
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/ssh2": "^1.15.6",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||
@@ -143,6 +144,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.8",
|
||||
"supertest": "^7.3.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -3853,6 +3855,29 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2/node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
|
||||
@@ -7360,6 +7385,13 @@
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -7512,6 +7544,13 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
@@ -7661,6 +7700,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz",
|
||||
"integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz",
|
||||
"integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -8693,6 +8756,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
@@ -9583,6 +9653,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
@@ -9886,6 +9966,13 @@
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -10205,6 +10292,17 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
@@ -11253,6 +11351,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
@@ -11510,6 +11615,24 @@
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -13694,6 +13817,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -17146,6 +17279,52 @@
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.4.1.tgz",
|
||||
"integrity": "sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.1",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.3.0.tgz",
|
||||
"integrity": "sha512-UwwmWq3xLhyU96c521wYNaBg7IqX78Wpj4FKs6Katp6vdklX42zU5ESvX3KeZRLFyO44RwFp4yvxYMNZzCcX9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-signature": "^1.2.2",
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/ssh2": "^1.15.6",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||
@@ -185,6 +186,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.8",
|
||||
"supertest": "^7.3.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -342,13 +342,30 @@ attached after the sanitizers, because the connect-level projection of a shared
|
||||
host reduces it to an allowlist.
|
||||
|
||||
`ctx.settings` is `get/set`, `getUser/setUser`, `getHost/setHost`, `getAll`,
|
||||
`onChange` (disposed with the plugin) and `readCore`. **Reading and writing a
|
||||
`onChange` (disposed with the plugin), `onValidate`, `listHostValues` and
|
||||
`readCore`. **Reading and writing a
|
||||
plugin's own settings needs no capability**: the manifest already declares
|
||||
every field, and a plugin that had to ask permission to read its own
|
||||
configuration would be useless. Only `readCore`, which reaches outside the
|
||||
plugin's namespace, is gated on `settings:read-core`, and it serves a short
|
||||
allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
|
||||
|
||||
**D0** added three pieces:
|
||||
|
||||
- `onValidate(scope, validator)` checks a save from the settings screen or the
|
||||
host editor before anything is written. The validator gets every value in
|
||||
the save (and the host id for host scope) and returns field key to message
|
||||
for what is wrong. ssh-terminal checks that its image paths are absolute,
|
||||
step-ca that its CA URL is https and its fingerprint a SHA-256 digest.
|
||||
- `listHostValues(key)` lists every host that saved a value for one of the
|
||||
plugin's host fields, across all users, with the host's owner, for a
|
||||
background job that must know which hosts to act on and as whom before any
|
||||
request exists (proxmox's auto-sync). It needs `hosts:read` and refuses a
|
||||
secret field.
|
||||
- Host fields take `enableDefault` (on the host section), `defaultFrom`,
|
||||
`shareRead` and `ownerOnly`; see the manifest reference. A host settings
|
||||
save also bumps the host's `updated_at`, so remote sync carries it.
|
||||
|
||||
### 8. UI
|
||||
|
||||
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
|
||||
@@ -606,32 +623,56 @@ dark), shared by the SSH and local terminals, the docker console and serial
|
||||
from `src/ui/lib/terminal-look/`; keyboard handling shared with the shell
|
||||
(`findMatchingKeybinding`, `globalShortcutHandler`, `isTabJumpHotkey`); the
|
||||
connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
|
||||
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`); and a few core APIs
|
||||
(`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`,
|
||||
`parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a
|
||||
stopgap in the right place: D1 turns them into typed bridge members.
|
||||
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`). **D0** moved the core
|
||||
calls a plugin makes for the signed-in user onto the typed host bridge, as
|
||||
`@termix/plugin-sdk/frontend` functions: `logActivity`, `getHostPassword`,
|
||||
`patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and
|
||||
`getClientPreference`.
|
||||
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
|
||||
Appearance density and chart options), and the homepage widget pieces
|
||||
`WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**,
|
||||
once a plugin rather than a core feature became their biggest caller), for
|
||||
host-metrics's cards and its metrics chart widget. **B15** added `useUiPreferencesContext`, whose `setOverride` the
|
||||
Docker manager uses to remember the card or table layout the user picked.
|
||||
The `docker` and `hostMetrics` areas are still declared in core's
|
||||
`types/ui-preferences.ts`; a plugin cannot contribute its own area yet. The frontend SDK gained `useHostStatus(hostId)`: core's
|
||||
status for a host, from the shell's own polling, or null outside the shell.
|
||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
||||
host lookup. **B17** added `NotificationChannelDialog` and the channel API
|
||||
(`getNotificationChannels`, `deleteNotificationChannel`,
|
||||
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
|
||||
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
RDP half became remote desktop's user settings. Publishing its
|
||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
||||
**B19** added `PluginViewPlaceholder`, for the homepage plugin's widget
|
||||
shell to render in place of a widget type whose owning plugin (docker,
|
||||
tunnels, host-metrics, file-manager) is off, and moved `WidgetTitle` and
|
||||
`runVisibleInterval` here from the deleted homepage feature directory
|
||||
(above), unchanged for their existing callers.
|
||||
host-metrics's cards and its metrics chart widget.
|
||||
|
||||
**D0** gave plugins their own Appearance area. A manifest's
|
||||
`contributes.uiPresets` names a value per interface preset (simple, balanced,
|
||||
advanced), and `usePluginUiPreferences()` returns the one for the user's
|
||||
level with their changes on top, plus a `set` that saves a change. Changes
|
||||
are stored in the UI preferences under `plugin:<id>` and listed, with the
|
||||
plugin's name, where Appearance lets a user revert them. Docker (list or
|
||||
detail view, card or table layout) and host-metrics (column count) use it;
|
||||
version 2 of the UI preferences moved their 2.8 `docker` and `hostMetrics`
|
||||
overrides across. The terminal toolbar density and the file manager's view
|
||||
mode are still preset-seeded localStorage keys core writes, as in 2.8.
|
||||
|
||||
**D0** also added:
|
||||
|
||||
- `usePluginApiFor(origin)`: the plugin's client for a resolved connection
|
||||
origin, as `app.apiFor` but usable in a component. The terminal toolbar's
|
||||
metrics bars use it for a host on the desktop app's remote server, and
|
||||
tunnels merges the remote server's statuses through `app.apiFor("remote")`.
|
||||
- `app.onSettingsChanged(listener)`: fires after the settings screen or host
|
||||
editor saved one of the plugin's settings, instead of polling on focus.
|
||||
- A `tab.menu` slot: entries in a tab's right-click menu, invoked with the
|
||||
tab's surface handle; `when` sees `{ tab, handle }`. Session sharing adds its
|
||||
share entry there, for a terminal whose toolbar is hidden.
|
||||
- The terminal toolbar lists every plugin's `open` host actions for its host
|
||||
(tmux monitor, docker, tunnels, host metrics) as quick links, so no plugin
|
||||
has to contribute a toolbar button to be reachable from a terminal. The frontend SDK gained `useHostStatus(hostId)`: core's
|
||||
status for a host, from the shell's own polling, or null outside the shell.
|
||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
||||
host lookup. **B17** added `NotificationChannelDialog` and the channel API
|
||||
(`getNotificationChannels`, `deleteNotificationChannel`,
|
||||
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
|
||||
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
|
||||
RDP half became remote desktop's user settings. Publishing its
|
||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
||||
**B19** added `PluginViewPlaceholder`, for the homepage plugin's widget
|
||||
shell to render in place of a widget type whose owning plugin (docker,
|
||||
tunnels, host-metrics, file-manager) is off, and moved `WidgetTitle` and
|
||||
`runVisibleInterval` here from the deleted homepage feature directory
|
||||
(above), unchanged for their existing callers.
|
||||
|
||||
#### Strings
|
||||
|
||||
@@ -686,6 +727,30 @@ register one; remote desktop's (**B14**) also reads a Termix export's
|
||||
carry every plugin's host settings (secrets redacted) under `pluginSettings`,
|
||||
which is what makes the round trip work without core naming a plugin.
|
||||
|
||||
**D0** finished the generic host settings paths, all keyed off manifests:
|
||||
|
||||
- Import writes an export's `pluginSettings` (non-secret declared fields,
|
||||
validated like any save) before the plugin's normalizer runs. The raw
|
||||
SQLite export carries host-scope `plugin_settings` rows in its own table,
|
||||
and its import reads them back, plus the 2.8 columns through the
|
||||
normalizers.
|
||||
- The Hosts panel's feature filter and the bulk enable and disable menu list
|
||||
every plugin whose host section has an `enableKey`; the bulk route takes
|
||||
`pluginEnable: { [pluginId]: boolean }`.
|
||||
- Remote sync carries a host's non-secret plugin values. A value that is a
|
||||
local row id (vault's `profileId`) is translated with
|
||||
`ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue })`.
|
||||
- `ctx.registry.provide("<id>.hostPayloadLegacy", fn)` puts fields back on
|
||||
the host payload in their 2.8 shape for clients outside Termix.
|
||||
host-metrics uses it for the `statsConfig` Termix-Mobile reads; remove it
|
||||
once the mobile app reads `pluginSettings` and `GET /host/status`.
|
||||
- A host field's `shareRead` hides it from shared recipients below that
|
||||
level, and `ownerOnly` keeps a shared editor from changing it. Remote
|
||||
desktop's `guacamoleConfig` (which can hold a gateway password) is
|
||||
`shareRead: "edit"`, vault's `profileId` is `ownerOnly`.
|
||||
- The bulk import accepts any auth type core or a plugin registers or
|
||||
declares in `contributes.auth.sshAuthTypes`.
|
||||
|
||||
**B9**'s terminal provides `sessions.live` (live SSH sessions: look up, end,
|
||||
remove a share's participants, hand a room share's control over, subscribe to
|
||||
output, write) and `terminal.history` (the actor's command history, which the
|
||||
@@ -758,9 +823,9 @@ adopts `session_shares`, `session_share_participants`, `collab_rooms` and
|
||||
`collab_room_members`, and reaches sessions only through `sessions.live`. Its
|
||||
two public routes, `/resolve/:linkToken` and `/guest/:token`, check the token
|
||||
themselves and are rate limited per IP. The share button is a contribution to
|
||||
`terminal.toolbar` and `remote-desktop.toolbar`; the tab bar's share entry and
|
||||
the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
|
||||
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
|
||||
`terminal.toolbar` and `remote-desktop.toolbar`, and (**D0**) to the tab
|
||||
bar's `tab.menu` slot, which reads the terminal handle's `getShareTarget`. The
|
||||
`shareable`, `canShare` and `openShareModal` members of `TabOptions` are gone. Core's active connections list gets the
|
||||
sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||
|
||||
**Automations (B17)** imports nothing from core. It reaches snippets
|
||||
@@ -949,7 +1014,9 @@ export default pluginVitestConfig(import.meta.url);
|
||||
|
||||
The preset supplies both projects, the shared setup file
|
||||
(`@termix/plugin-sdk/testing/setup`, which core's `vitest.setup.ts` re-exports
|
||||
so there is one copy) and the `@/` alias the frontends still use.
|
||||
so there is one copy) and the `@/` alias the frontends still use. The setup
|
||||
also keeps a test server started on port 0 off the ports `fetch` refuses,
|
||||
which Windows can hand out.
|
||||
|
||||
`npm run test` runs core only, `npm run test:plugins` runs every plugin and
|
||||
`npm run test:all` runs both. Core tests (`src/backend/tests`, `src/ui/tests`)
|
||||
@@ -1500,6 +1567,46 @@ dropping one in the same drizzle-kit run makes it ask whether that is a
|
||||
rename, which it cannot do without a terminal, so B16 generated two
|
||||
migrations: the added columns first, then the no-op drop.
|
||||
|
||||
**D0** dropped the rest this way (the file manager, tunnels, web endpoint,
|
||||
terminal, tmux monitor, session sharing and session recording columns and the
|
||||
dead `show_*_in_sidebar` ones), rewrote the two proxmox drops that really ran
|
||||
`DROP COLUMN` on Postgres and MySQL, and moved every copy onto
|
||||
`selectLegacyRows`, which reads a column schema.ts no longer declares on all
|
||||
three engines and answers nothing on a database that never had it. A 2.8
|
||||
boolean read raw goes through `legacyFlag`, since SQLite and MySQL hand back
|
||||
0 and 1. The copies live in `src/backend/plugins/boot-migrations.ts`
|
||||
(`runPluginDataMigrations`), which the starter runs after the plugins
|
||||
activate and which runs again when an admin enables a plugin without a
|
||||
restart. The core half of boot (keys, auth, core data migrations) is
|
||||
`runCoreBootMigrations` in `src/backend/boot.ts`; the boot integration tests
|
||||
share both with the starter.
|
||||
|
||||
A host switch whose 2.8 column defaulted to on declares `enableDefault: true`
|
||||
(file manager, terminal), because a copy only writes the hosts that changed
|
||||
it, and every other host has to keep reading on.
|
||||
|
||||
**Left for 3.0.0.** These stay in 2.9.0 so a downgrade still works, and go
|
||||
once every install has booted it:
|
||||
|
||||
- The moved `ssh_data` columns (every plugin host column above, the proxmox,
|
||||
remote desktop, docker, warpgate, vault and stats columns) and
|
||||
`user_preferences.rdp_defaults`.
|
||||
- `users.totp_secret`, `totp_enabled` and `totp_backup_codes`; `users.is_oidc`,
|
||||
`oidc_identifier`, `sso_provider_id` and the 2.8 per-user OIDC columns, and
|
||||
`sessions.sso_provider_id`, `oidc_sub` and `oidc_sid`, once nothing needs the
|
||||
2.8 shape.
|
||||
- The legacy settings rows the plugins copied: the terminal's
|
||||
(`terminal_session_*`, `command_history_enabled`, `touch_input_settings`,
|
||||
`terminal_image_*`), `session_sharing_globally_enabled`, `guac_enabled`,
|
||||
`guac_url`, the `step_ca_*` rows and `acme_ssl_settings`.
|
||||
- `DATA_DIR/.opk`, `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`.
|
||||
- The 2.8 URLs kept for identity providers and Termix-Mobile:
|
||||
`auth-compat-routes.ts` (`/users/oidc/*`, `/users/ldap/login` and the rest),
|
||||
`host-compat-routes.ts` (`/host/opkssh-callback`, `/host/step-ca-callback`)
|
||||
and the `/termix-id/u/` and `/vault` redirects.
|
||||
- The `@termix/plugin-sdk/ui` typings for plugins built outside this repo,
|
||||
which arrive with the split into separate repositories.
|
||||
|
||||
---
|
||||
|
||||
## Manifest v2 reference
|
||||
@@ -1796,7 +1903,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
|
||||
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
|
||||
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
|
||||
| `ctx.credentials.createSshKey` | `credentials:write` | **C6** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
|
||||
| `ctx.audit.record` | none, the actor is the runtime's | **B9**, request **D0** |
|
||||
| `ctx.schedule.every` / `.after` | none | **B16** |
|
||||
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
|
||||
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
|
||||
@@ -2322,7 +2429,7 @@ free. A **community plugin installed from a tarball has none of this**: there
|
||||
is no repo-root `npm install` to hoist its dependency into, no shared
|
||||
`node_modules` its bundle can walk up to. Shipping a native dependency in a
|
||||
plugin nobody's build system already vendors is an open problem this step
|
||||
does not solve - see `packages/plugin-sdk/FINISH-LIST.md`. A community plugin
|
||||
does not solve, and it waits for the split into separate repositories. A community plugin
|
||||
author who hits this today has three honest options, worst to best: ask the
|
||||
person installing it to `npm install` the native package into the server's
|
||||
own `node_modules` by hand (fragile, easy to get wrong on upgrade); avoid a
|
||||
@@ -2675,8 +2782,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
|
||||
| Core importing a plugin backend | **Error** | 0 | - |
|
||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||
| The shell importing plugin code | **Error** | 0 | - |
|
||||
| A plugin frontend importing core through `@/` | Warning | 53 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 4 files | D1 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 51 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 3 files | D1 |
|
||||
| A plugin importing another plugin's source | Warning | 0 files | - |
|
||||
|
||||
A warning does not fail a build, so the counts are held by
|
||||
|
||||
@@ -1,559 +1,7 @@
|
||||
# Finish list
|
||||
|
||||
Running list of things intentionally left unfinished by a Phase B/C step,
|
||||
so D0 (or an earlier owner named below) can pick them up. Remove a line once
|
||||
it's done.
|
||||
|
||||
- **B3 (tailscale):** `ManagerCardShell`/`ManagerSearch`-equivalent presentation
|
||||
is duplicated as `TailscaleManagerShell`/`TailscaleManagerSearch` in
|
||||
`plugins/tailscale/src/frontend/TailscaleManagerShell.tsx` rather than
|
||||
shared with host-metrics's originals. Promote a shared version into
|
||||
`@termix/plugin-sdk/ui` once a second manager-card plugin needs one, per the
|
||||
contract's "add to the SDK when a second plugin needs it" rule. Owner: D1.
|
||||
- **B5 (proxmox):** `runAdaptivePolling` and `cn` are duplicated in
|
||||
`plugins/proxmox/src/frontend/stats/` rather than shared with core's
|
||||
`src/ui/lib/` originals. `useConnectionRetry` (the third duplicate B5 left
|
||||
here) is resolved: **B6** promoted it into `@termix/plugin-sdk/frontend`
|
||||
once file-manager became its fourth caller (after proxmox, docker,
|
||||
host-metrics, remote-desktop) and switched proxmox's own copy over too.
|
||||
`runAdaptivePolling` and `cn` are exported from `@termix/plugin-sdk/ui`
|
||||
now (B9), and host-metrics uses them there (B16); only proxmox's copies are
|
||||
left to switch. Owner: D1.
|
||||
- **B5 (proxmox):** `runDueProxmoxAutoSyncs` in
|
||||
`plugins/proxmox/src/backend/routes.ts` still reaches
|
||||
`createCurrentPluginSettingsRepository`/`createCurrentHostRepository` by
|
||||
relative import instead of `ctx`, because it needs to scan every host's
|
||||
Proxmox settings across every user before it knows which actor to run each
|
||||
sync as, and neither `ctx.settings` nor `ctx.hosts` expose a cross-user
|
||||
bulk read today. A `ctx.settings.listHostsWithKey` or similar SDK addition
|
||||
would close this, if a second plugin's background scan ever needs the same
|
||||
shape. Owner: D1.
|
||||
- **B6 (file-manager):** `plugins/file-manager/src/frontend/components/PdfPreview.tsx`
|
||||
runs pdfjs without a worker (main-thread parsing) because `termix-plugin
|
||||
build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
build resolves a `?url` import to a hashed `/assets/` file. Fine for the
|
||||
small files this preview usually opens; restoring a real worker needs the
|
||||
CLI (`packages/plugin-sdk/cli/commands/build.mjs`) to gain a way to emit an
|
||||
extra file into `dist/` and have the frontend reference it by its served
|
||||
`/plugin-assets/<id>/` path. Owner: D1, or whichever step first needs a
|
||||
worker or other static asset from a plugin bundle.
|
||||
- **B6 (file-manager):** `transfer-tuning.ts`'s adaptive-tuning profile still
|
||||
persists to a JSON file under `DATA_DIR` rather than `ctx.kv`, and
|
||||
`HostFilesTab.tsx`'s `t()` calls plus the manifest's `contributes.settings.host`
|
||||
`labelKey`s deliberately point at two different key paths (`hosts.*` and
|
||||
`settings.host.*`) for the same three fields, both carrying the same English
|
||||
text - a minor duplication from keeping the host editor's exact existing
|
||||
section verbatim (`registerHostEditorSection`) instead of switching it to
|
||||
the schema-driven settings form. Neither blocks anything; flagging for
|
||||
whoever next touches this plugin's settings UI.
|
||||
- **B6 (file-manager):** `TabContext.tsx`'s `computeUniqueTitle` compares
|
||||
`tabType === "file_manager"` (an activity-log type string, not a tab type -
|
||||
the real tab type has always been `"files"`), so that branch has never
|
||||
matched and silently falls through to the terminal title. Pre-existing,
|
||||
unrelated to the conversion; noted rather than fixed per the lean-mode
|
||||
rules. Owner: whoever next touches tab title logic.
|
||||
- **B6 (file-manager):** `enable_file_manager`, `default_path` and
|
||||
`scp_legacy` are still live `ssh_data` columns; only the copy-into-plugin-
|
||||
settings migration shipped (`file-manager-settings-migration.ts`). Every
|
||||
core read site the earlier exploration found (`host.ts`,
|
||||
`host-normalizers.ts`, `host-bulk-routes.ts`, `credentials.ts`,
|
||||
`host-internal-routes.ts`, `database.ts`'s encrypt/decrypt round trip,
|
||||
`plugins/host-view.ts`, `ctx-hosts.ts`, plus the frontend's
|
||||
`HostEditorData.ts`/`HostManagerData.ts`/`SidebarTree.tsx`/quick-connect
|
||||
mapping) still reads the column directly rather than
|
||||
`ctx.settings.getHost`. Switching every one of those and then dropping the
|
||||
column in lockstep (`schema.ts`, `db/index.ts`, a drizzle migration per
|
||||
dialect, `schema:generate`) is real, separate work this step didn't
|
||||
attempt, since none of those reads block the plugin conversion itself.
|
||||
Owner: a dedicated follow-up step, or D0.
|
||||
- **B7 (tunnels):** `enable_tunnel` and `tunnel_connections` are still live
|
||||
`ssh_data` columns; only the copy-into-plugin-settings migration shipped
|
||||
(`tunnels-settings-migration.ts`), and the plugin reads its host settings
|
||||
now. Core still carries the columns through the host routes, the
|
||||
encrypt/decrypt round trip, `SidebarTree.tsx`'s host duplicate payload, the
|
||||
quick-connect mapping and the `Host`/`SSHHost` types, none of which the
|
||||
plugin reads any more. Dropping them in lockstep (`schema.ts`, `db/index.ts`,
|
||||
a drizzle migration per dialect, `schema:generate`) is the same follow-up the
|
||||
B6 file-manager line above describes. Owner: a dedicated follow-up step, or D0.
|
||||
- **B7 (tunnels):** host export/import, host duplicate, the Hosts panel
|
||||
feature filter and the bulk enable/disable menu all work off `ssh_data`
|
||||
columns, so none of them can see a plugin's host settings. For tunnels the
|
||||
"Tunnel" filter and the bulk enable/disable entries were removed, and the
|
||||
export's "Tunnels" group became "Proxy" (SOCKS5 fields only) since
|
||||
`tunnelConnections` there is now stale. **B15** removed the "Docker" filter
|
||||
and the bulk Docker toggles the same way. Bringing these back needs a way for
|
||||
core to read and write host-scope plugin settings generically (keyed off
|
||||
`contributes.settings.host` or `hostCapability`) rather than per column.
|
||||
Owner: D1.
|
||||
- **B7 (tunnels):** the tunnel tab no longer writes a recent-activity entry:
|
||||
`logActivity` is a core `@/main-axios` call and the plugin imports nothing
|
||||
from `@/`. Old "tunnel" entries still reopen the tab through the plugin's
|
||||
`activityTypes`. An `app.logActivity` (or equivalent bridge member) would
|
||||
restore it for this plugin and let file-manager drop its own
|
||||
`@/main-axios` import for it too (host-metrics and docker use the
|
||||
`logActivity` the ui entry exports). Owner: D1.
|
||||
- **B7 (tunnels):** on the desktop app, tunnel statuses from a connected
|
||||
remote server are no longer merged into the tab: the old code polled the
|
||||
remote tunnel service through `getRemoteTunnelApi()`, and the SDK has no
|
||||
remote-origin plugin API client. `subscribeTunnelStatuses` already takes a
|
||||
`fetchRemote` and merges with local statuses winning, so it only needs a
|
||||
client for `/plugin-api/tunnels/status` on the remote server. Owner: D1.
|
||||
- **B8 (web-endpoint):** `enable_web_ui` and `web_ui_config` are still live
|
||||
`ssh_data` columns; only the copy-into-plugin-settings migration shipped
|
||||
(`web-endpoint-settings-migration.ts`). Unlike B6/B7, this step DID convert
|
||||
every core read/write of those two fields (`host.ts`'s create/update and
|
||||
export routes, `host-normalizers.ts`, `host-bulk-routes.ts`,
|
||||
`database.ts`'s encrypt/decrypt round trip is the one exception - it copies
|
||||
whatever is already in the column for the raw DB export/backup tool, which
|
||||
needs no change since nothing reads that copy through `pluginSettings`).
|
||||
Dropping the columns in lockstep (`schema.ts`, `db/index.ts`, a drizzle
|
||||
migration per dialect, `schema:generate`) is the only remaining piece.
|
||||
Owner: a dedicated follow-up step, or D0.
|
||||
- **B8 (web-endpoint):** added the first generic cross-plugin hook for core
|
||||
routes that touch host-scope plugin settings without importing a plugin:
|
||||
`ctx.registry.provide("<id>.hostImportNormalizer", fn)` plus
|
||||
`applyPluginHostImportSettings` in `host-plugin-settings.ts`, called from
|
||||
`host-bulk-routes.ts`'s Termix-JSON import path. Only import validation is
|
||||
covered; B7's TODO above about the Hosts panel feature filter and the bulk
|
||||
enable/disable menu (`hostCapability`-driven, not import-driven) is
|
||||
unrelated and still open. Owner: D1 for extending the same pattern there.
|
||||
- **B8 (web-endpoint):** noticed while sanity-checking with `npm run lint`
|
||||
(not required by this step): `eslint.config.mjs`'s
|
||||
`globalIgnores(["dist", ...])` only matches a top-level `dist/` folder, not
|
||||
nested `packages/*/dist` or `plugins/*/dist`, so `npm run lint` fails on
|
||||
bundled output repo-wide with "Definition for rule X was not found"
|
||||
errors. Pre-existing, not introduced by this or any single plugin step.
|
||||
Owner: whoever next needs a clean `npm run lint`, or D0.
|
||||
- **B8 (web-endpoint):** the isolated-window Electron IPC bridge
|
||||
(`src/backend/utils/electron-ipc-bridge.ts`, `ctx.desktop.openIsolatedWindow`)
|
||||
is a single request/response channel keyed by a random id with one
|
||||
registered backend-request handler (`open-isolated-window`) in
|
||||
`electron/main.cjs`. Fine for today's one caller; if a second plugin needs
|
||||
to ask Electron's main process for something, extend
|
||||
`BACKEND_REQUEST_HANDLERS` there rather than building a parallel channel.
|
||||
No owner needed unless a second caller appears.
|
||||
- **B9 (ssh-terminal), for D2:** the terminal socket is a public route with
|
||||
`optionalAuth`, because share-link and room guests authenticate inside the
|
||||
handler with their token. Guest auth itself is unchanged; review it with the
|
||||
rest of the public routes.
|
||||
- **B9 (ssh-terminal):** recording has no provider yet. The terminal calls the
|
||||
optional `recordings.writer` v1 `open(meta)` and gets a sink with
|
||||
`append(chunk)` (one call per 300 ms batch, the first starting with the
|
||||
asciicast header), `persist(summary)` and `discard()`. Without it nothing is
|
||||
recorded and no `session_recordings` row is written. Owner: B13, which
|
||||
writes the file under `DATA_DIR/session_logs/<user>/<session>.cast` (mkdir
|
||||
and writeFile on the first append, appendFile after, never per chunk, see
|
||||
issue #1049) and creates or updates the row as the old session manager did.
|
||||
- **B9 (ssh-terminal):** `enable_terminal`, `enable_terminal_toolbar` and
|
||||
`enable_command_history` are still live `ssh_data` columns. The copy into
|
||||
ssh-terminal host settings shipped (`ssh-terminal-settings-migration.ts`)
|
||||
and the terminal reads its settings, but core host routes, types, the
|
||||
export/import sample and `HostEditorData` still carry the columns.
|
||||
**B14** gave remote desktop its own `enableToolbar` host setting, copied
|
||||
from `enable_terminal_toolbar`, so nothing else holds the column up. Drop
|
||||
all three the B14 way (see "Order at boot" in ARCHITECTURE.md). Owner: D0.
|
||||
- **B9 (ssh-terminal):** the legacy `settings` rows for the terminal
|
||||
(`terminal_session_timeout_minutes`, `terminal_session_persistence_enabled`,
|
||||
`command_history_enabled`, `touch_input_settings`, `terminal_image_*`) are
|
||||
left in place for a release after the move; nothing reads them. Delete them
|
||||
in 3.0.0. `sessionPersistence` is carried over but, as before, nothing acts
|
||||
on it. Owner: D0 to decide.
|
||||
- **B9 (ssh-terminal):** the image storage paths used to be rejected at save
|
||||
when not absolute; the schema-driven settings form accepts any string and
|
||||
the resolver ignores a bad one with a warning. A custom field or a validate
|
||||
hook on plugin settings would restore the save-time error. Owner: D1.
|
||||
- **B9 (ssh-terminal):** `@termix/plugin-sdk/ui` now re-exports a handful of
|
||||
core APIs the terminal needs (`logActivity`, `getHostPassword`,
|
||||
`patchOpenTab`, `getUserPreferences`, `parseCustomKeybindings`,
|
||||
`setHostAutoTmux`, `getCookie`) and the connection helpers. They belong on
|
||||
the typed host bridge, which is also where B7's `app.logActivity` line
|
||||
above lands. Owner: D1.
|
||||
- **B9 (host-metrics):** the terminal toolbar's CPU/memory/disk bars
|
||||
(`TerminalMetricsStatus`) poll the local backend only; on the desktop app a
|
||||
host that lives on a connected remote server shows no bars. Same fix as the
|
||||
B7 remote-origin line above. Owner: D1.
|
||||
- **B9:** the Hosts panel's "Terminal" feature filter was removed along with
|
||||
the column reads, the same as B7's tunnel filter. Owner: D1, with the
|
||||
generic host-settings filter.
|
||||
- **B9:** the startup purge of expired open tabs in `db/index.ts` read
|
||||
`terminal_session_timeout_minutes` from core settings; it was removed and
|
||||
expired tabs are dropped when the tab list is read, against the terminal's
|
||||
own timeout (`GET /open-tabs/session-timeout`, 30 while the terminal is
|
||||
off). No owner unless the table growth matters.
|
||||
- **Pre-existing (B8), found in B9:** `node scripts/check-shell-plugin-ids.cjs`
|
||||
fails on `src/ui/lib/host-to-ssh-host.ts` spelling `web-endpoint` (the
|
||||
`pluginSettings` lookup B8 added). Not allowlisted and not introduced here.
|
||||
Owner: D0 (read it through a registry or allowlist it with a reason).
|
||||
- **B10 (tmux-monitor):** `enable_tmux_monitor` is still a live `ssh_data`
|
||||
column; only the copy-into-plugin-settings migration shipped
|
||||
(`tmux-monitor-settings-migration.ts`), the same as B6/B7's own columns.
|
||||
Every core read site (`host.ts`, `host-normalizers.ts`,
|
||||
`host-bulk-routes.ts`, `database.ts`'s encrypt/decrypt round trip) still
|
||||
reads the column directly. Dropping it in lockstep (`schema.ts`,
|
||||
`db/index.ts`, a drizzle migration per dialect, `schema:generate`) is the
|
||||
same follow-up the B6/B7/B8 lines above describe. Owner: a dedicated
|
||||
follow-up step, or D0.
|
||||
- **B10 (tmux-monitor):** `terminalToolbar.openTmuxMonitor` and
|
||||
`terminalToolbar.tmuxDetach` are dead locale keys (nothing calls
|
||||
`t("terminalToolbar.openTmuxMonitor")` or `t("terminalToolbar.tmuxDetach")`
|
||||
anywhere in the tree; the terminal's own live detach button uses
|
||||
`terminalToolbar.detachTmux`/`detachTmuxDescription` instead). Pre-existing,
|
||||
not introduced by this step; sibling dead keys
|
||||
(`copyTerminalUrlAction`/`copyDockerUrlAction`/`copyHostMetricsUrlAction`,
|
||||
`terminalUrlCopied`/`dockerUrlCopied`/`hostMetricsUrlCopied`) are the same
|
||||
vintage, from before the generic `hosts.copiedToClipboard`/
|
||||
`copyViewUrlAction` replaced them. Owner: whoever next cleans up
|
||||
`src/ui/locales/en.json`, or D0.
|
||||
- **B10 (tmux-monitor):** no plugin contributes an "Open Tmux Monitor" (or
|
||||
"Open Docker" / "Open Tunnel" / "Open Host Metrics") quick link into
|
||||
`terminal.toolbar` today; that whole quick-link row was dropped when the
|
||||
toolbar was rebuilt as a generic action slot (`ai` is the only real
|
||||
`terminal.toolbar` contributor, for its assistant button) and never
|
||||
replaced per-feature. Adding tmux-monitor's own link is straightforward
|
||||
(`app.registerSlotContribution("terminal.toolbar", ...)` plus
|
||||
`app.registerAction` opening the singleton tab) but is a shared gap across
|
||||
four plugins, not specific to this step. Owner: D1, or whoever revisits the
|
||||
toolbar's quick-link row.
|
||||
- **B11 (serial):** `plugins/serial/src/frontend/Serial.tsx` uses a small
|
||||
hardcoded light/dark xterm palette and a generic monospace font stack
|
||||
instead of the app's real terminal theme system
|
||||
(`resolveTermixThemeColors`, `DEFAULT_TERMINAL_CONFIG`, `TERMINAL_FONTS`,
|
||||
font loading), which is still core-only (`src/ui/features/terminal/`,
|
||||
`src/ui/lib/terminal-themes.ts`) and not yet exposed through the SDK. The
|
||||
serial console therefore does not match a user's chosen terminal theme or
|
||||
font. Promoting that system into `@termix/plugin-sdk/ui` is really
|
||||
ssh-terminal's own Phase B step's call to make (it is the plugin that
|
||||
currently owns the terminal component itself, per the "Known specifics"
|
||||
note above), so this line just points at the gap rather than building a
|
||||
premature shared surface for one caller. Owner: ssh-terminal's own Phase B
|
||||
step, or D1.
|
||||
- **B11 (serial):** shipping a plugin's own native dependency (`serialport`)
|
||||
works for this repo's three bundled deployments only because they all
|
||||
install the whole npm workspace at once and copy/rebuild one shared
|
||||
`node_modules` - see "Native dependencies" in ARCHITECTURE.md for the full
|
||||
reasoning. A community plugin installed later from a tarball has no such
|
||||
shared `node_modules` to land in, so it cannot ship a native dependency the
|
||||
same way. No fix attempted here; the honest workarounds are listed in that
|
||||
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
|
||||
packaging, or a documented "avoid native code" rule for community plugins).
|
||||
|
||||
- **B12 (session-sharing):** `allow_session_sharing` is still a live `ssh_data`
|
||||
column. The copy into the plugin's host setting shipped
|
||||
(`session-sharing-settings-migration.ts`) and nothing in core reads or
|
||||
writes it any more; drop it in lockstep (`schema.ts`, `db/index.ts`, a
|
||||
drizzle migration per dialect, `schema:generate`). Owner: D0.
|
||||
- **B12 (session-sharing):** the legacy `session_sharing_globally_enabled`
|
||||
settings row is left in place for a release; nothing reads it. Delete it in
|
||||
3.0.0. Owner: D0 to decide.
|
||||
- **B12 (session-sharing):** room audit lines go through `ctx.audit.record`,
|
||||
which records the actor but not the request's IP address and user agent the
|
||||
old `logAudit` calls carried. Adding those to `ctx.audit.record` would
|
||||
restore them. Owner: D1.
|
||||
- **B12 (session-sharing):** the share button lives in the terminal and remote
|
||||
desktop toolbars now; the tab bar's share button is gone, so a host with the
|
||||
terminal toolbar switched off has no share button. A generic tab-menu slot
|
||||
would bring it back if that matters. Owner: D1, if wanted.
|
||||
- **B13 (session-recording):** `enable_session_logging` is still a live
|
||||
`ssh_data` column; only the copy-into-plugin-settings migration shipped
|
||||
(`session-recording-settings-migration.ts`). The legacy default was `true`;
|
||||
the plugin's enable switch defaults to `false` like every other plugin's, so
|
||||
the migration copies every host's value rather than only the ones that
|
||||
turned it off - a fresh host created after 2.9.0 defaults to recording off,
|
||||
a real behaviour change from the old column default, made once here rather
|
||||
than silently. `ssh-terminal/src/backend/terminal-socket.ts` still reads
|
||||
`resolvedHostData?.enableSessionLogging ?? hostConfig.enableSessionLogging`
|
||||
off the resolved SSH host instead of `ctx.settings.getHost` (remote desktop
|
||||
asks `recordings.writer.enabledFor` since B14), and `HostEditorData.ts`,
|
||||
`HostManagerData.ts`, `quick-connect-host.ts` and the export/import sample
|
||||
all still read the column directly. The General tab's checkbox was removed
|
||||
from `HostEditor.tsx` (the schema-driven Plugins group replaces it); the
|
||||
data plumbing above was not switched over. Dropping the column in lockstep
|
||||
(`schema.ts`, `db/index.ts`, a drizzle migration per dialect,
|
||||
`schema:generate`) needs every one of those switched first. Owner: a
|
||||
dedicated follow-up step, or D0.
|
||||
- **B13 (session-recording):** `accessId` (the legacy `access_id` column,
|
||||
referencing the now-core-only `host_access` table) is carried in the
|
||||
adopted table's shape as a plain unreferenced integer, since the SDK's
|
||||
`defineTable` has no builder for a foreign key into an arbitrary core
|
||||
table beyond `refUser()`/`refHost()`. Nothing has ever populated it (true
|
||||
in core before this step too). Owner: none needed unless a future step
|
||||
wants to actually use it.
|
||||
|
||||
- **B14 (remote-desktop):** the moved columns stay in the database, unused:
|
||||
`ssh_data.enable_rdp`, `enable_vnc`, `enable_telnet`, `rdp_port`,
|
||||
`vnc_port`, `telnet_port`, `rdp_security`, `rdp_ignore_cert`, `security`,
|
||||
`ignore_cert`, `guacamole_config`, and `user_preferences.rdp_defaults`.
|
||||
Their drizzle migrations are `SELECT 1;` on purpose (the copy runs after
|
||||
drizzle on Postgres and MySQL). Drop them physically in 3.0.0, once every
|
||||
install has booted 2.9.0. The legacy `guac_enabled` and `guac_url` rows in
|
||||
`settings` are left too. **B15** did the same with `ssh_data.enable_docker`
|
||||
and `docker_config` (copied by `docker-settings-migration.ts`). Owner: 3.0.0.
|
||||
- **B14, found in B5 (proxmox):** `drizzle/postgres/0036_redundant_ender_wiggin.sql`
|
||||
and `drizzle/mysql/0035_clumsy_leech.sql` really `DROP COLUMN` the four
|
||||
proxmox host columns. On Postgres and MySQL that runs at boot before
|
||||
`runProxmoxSettingsMigration` copies them, so an upgrade there loses every
|
||||
host's Proxmox settings. Since nothing has shipped, rewrite those two files
|
||||
to the `SELECT 1;` pattern (keeping their journal entries). Owner: D0.
|
||||
- **B14, found in B5 to B13:** every earlier host-column copy
|
||||
(`proxmox`, `file-manager`, `tunnels`, `web-endpoint`, `ssh-terminal`,
|
||||
`tmux-monitor`, `session-sharing`, `session-recording` settings migrations)
|
||||
reads the old columns with `getDb().all(sql...)`, which only exists on
|
||||
SQLite. On Postgres and MySQL the call throws, the migration's catch logs a
|
||||
warning and nothing is copied. Switch them to `selectRows` from
|
||||
`utils/crypto-migration/raw-rows.ts`, which B14 added and uses. Owner: D0.
|
||||
- **B14, found in B13:** `session-recording-settings-migration.ts` writes
|
||||
`row.enable_session_logging !== false`, but SQLite returns `0`, so a host
|
||||
that had recording off migrates as on. Its test only uses real booleans.
|
||||
Owner: D0.
|
||||
- **B14 (remote-desktop), for D2:** host payloads carry every plugin's host
|
||||
settings after the sharing sanitizers, so a connect-level recipient now
|
||||
receives remote desktop's `guacamoleConfig`, which can hold an RDP gateway
|
||||
password in plain text. A view-level recipient saw it before B14 too.
|
||||
Decide whether plugin host settings need a per-field sharing level, or make
|
||||
the gateway password its own secret field. Owner: D2.
|
||||
- **B14 (remote-desktop):** core still holds some locale keys that look dead
|
||||
after this step but may be built dynamically: `hosts.guac.noCredential`,
|
||||
`authType*`, `recording*`, `saveHostFirst`, `sharingOptionsAfterSave`,
|
||||
`permissionLevel`, `typeHeader`, `targetHeader`, `permissionHeader`,
|
||||
`hosts.connectRdp/Vnc/Telnet`, `hosts.copy*UrlAction`, `hosts.telnet` and
|
||||
`homepage.connType_*`. Owner: whoever next cleans up `src/ui/locales/en.json`,
|
||||
or D0.
|
||||
- **Pre-existing, found in B14:** `npm run test -w plugins/web-endpoint`
|
||||
fails "answers 503 while the tunnels plugin is not available": the route
|
||||
expects `ctx.services.get` to throw for a missing service, but since B12
|
||||
the runtime and the test doubles hand back an empty handle, so the route
|
||||
calls an undefined `forward` and answers 500. Check `typeof forward` the
|
||||
way remote desktop does. Owner: D0.
|
||||
- **Pre-existing, found in B14:** each plugin's own `typecheck` script
|
||||
(`tsc -p tsconfig.json`) fails with the SDK's `tsconfig.plugin.json`
|
||||
(node16 resolution wants `.js` on relative imports and cannot resolve
|
||||
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
|
||||
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
|
||||
|
||||
- **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
|
||||
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the
|
||||
metrics API. The host payload now carries `statusCheckEnabled` and
|
||||
`statusCheckInterval` at the top level, the metrics options under
|
||||
`pluginSettings["host-metrics"]`, and statuses come from `GET /host/status`.
|
||||
The mobile app needs the matching change, or the host payload a
|
||||
`statsConfig` built from those fields for one release. Owner: D1.
|
||||
- **B16 (host-metrics):** a host's status is only checked once its owner has
|
||||
asked for statuses (opened the app) since the server started: a user who
|
||||
only has a host shared with them sees no dot for it until then. This was
|
||||
already true before B16 (the first user to load the host list started
|
||||
polling for their own hosts only). Starting checks for the owners of every
|
||||
host a requester can see would fix it. Owner: D1.
|
||||
- **B16 (host-metrics):** the host JSON export does not carry host-metrics's
|
||||
host settings (it did carry `statsConfig`); import reads both the old
|
||||
`statsConfig` and `pluginSettings["host-metrics"]` through the plugin's
|
||||
import normalizer. Same gap as the B7 line about exporting plugin host
|
||||
settings generically. Owner: D1.
|
||||
- **B16 (host-metrics):** the CPU and network collectors keep their last
|
||||
sample per host in module-level maps (`widgets/cpu-collector.ts`,
|
||||
`widgets/network-collector.ts`) to compute rates. The poller clears them on
|
||||
deactivate and per host, so nothing leaks, but they are still module state
|
||||
rather than per-activation. Owner: D1.
|
||||
- **B15 (docker), for D2:** the console socket `/plugin-ws/docker/console`
|
||||
is a public route with `optionalAuth`. The handler refuses a socket with no
|
||||
signed-in user or without `docker.use` and checks the data key per connect
|
||||
message, but review it with the rest of the public routes.
|
||||
- **B15 (docker):** `ssh_data.show_docker_in_sidebar` (and its siblings for
|
||||
terminal, files, tunnels and server stats) are dead columns nothing reads;
|
||||
only the raw database export still copies them. Drop them the B14 way.
|
||||
Owner: D0.
|
||||
- **B15 (docker):** the Docker manager's card or table layout is still an
|
||||
override on core's Appearance presets (`types/ui-preferences.ts` declares a
|
||||
`docker` area, as it does `hostMetrics`), reached through the ui entry's
|
||||
`useAreaPreferences` and `useUiPreferencesContext`. A plugin cannot declare
|
||||
its own area yet. Owner: D1.
|
||||
- **B15 (docker):** the raw SQLite database import (`database.ts`) no longer
|
||||
brings `enable_docker` across, the same gap every earlier column move left
|
||||
there: it writes host rows only and never plugin settings. Owner: D1, with
|
||||
the generic host-settings export and import.
|
||||
|
||||
- **B17 (automations), for D2:** the ctx table says `ctx.events.on` needs
|
||||
`events:core` for core topics, but the runtime only checks `emit`.
|
||||
docker, file-manager, host-metrics, session-recording and snippets
|
||||
subscribe to core topics (`host.*`, `user.*`) without declaring it.
|
||||
Either gate `on` and add `events:core` to those five, or change the doc to
|
||||
say subscribing is ungated. Owner: D2.
|
||||
- **B17 (automations):** the 2.8 webhook URL `/automations/webhook/<token>`
|
||||
is kept only by a rewrite in `docker/nginx.conf` and
|
||||
`docker/nginx-https.conf`. An install that serves the backend without
|
||||
nginx (the desktop app's embedded backend, a hand-rolled proxy) answers
|
||||
404 there. Decide whether that matters enough for a generic legacy-path
|
||||
alias in the plugin HTTP layer. Owner: D0.
|
||||
- **B17 (automations):** there are two notification senders now:
|
||||
`deliverNotification` (ctx.notify, per-channel private opt-in, throws) and
|
||||
the older `sendWebhook`/`sendNtfy`/`sendDiscord` that the channel "test"
|
||||
route in `notification-channels-routes.ts` still uses (always applies the
|
||||
allowlist, retries once). Point the test route at `deliverNotification`
|
||||
and delete the old ones. Owner: D1.
|
||||
- **B17 (automations):** the scheduler no longer skips a user whose data key
|
||||
cannot be resolved (`DataCrypto.canUserAccessData` has no SDK equivalent).
|
||||
With system-wrapped DEKs that check was effectively always true; if it
|
||||
ever is not, the run now fails on its first SSH step instead of being
|
||||
skipped silently. Owner: none unless a background "is this user unlocked"
|
||||
check is added to the SDK.
|
||||
- **B18 (ai):** the "allow read-only diagnostic commands" user setting only
|
||||
adds a line to the system prompt. No tool runs a command directly;
|
||||
`tools/command-allowlist.ts` (`isReadOnlyCommand`) is tested but unused, so
|
||||
every command is still a proposal. Either add the direct tool or drop the
|
||||
setting. Owner: D0.
|
||||
- **B18 (ai):** provider requests now go through `ctx.fetch`, which has no
|
||||
outbound proxy support. Before, an admin-allowlisted private provider went
|
||||
through `fetchWithProxy` (public ones never did). Decide whether
|
||||
`ctx.fetch` should honour the proxy settings. Owner: D0.
|
||||
- **B18:** `scripts/generate-dialect-schema.cjs` decides varchar or text by
|
||||
column name across every table, so removing the ai indexes on
|
||||
`created_at`, `updated_at` and `label` turned those columns into `text`
|
||||
everywhere in `schema.pg.ts` and `schema.mysql.ts`. drizzle-kit then wrote
|
||||
about a hundred type changes, which B18 left out of
|
||||
`drizzle/postgres/0048` and `drizzle/mysql/0047` (both a no-op), so real
|
||||
databases keep `varchar(255)` while the snapshots say `text`. Key the
|
||||
generator per table, regenerate, and check `verify:dialect`. Owner: D0.
|
||||
- **B18:** saving a plugin setting from the Settings screen tells nobody. The
|
||||
ai frontend re-reads its status on `termix:plugins-changed` and on window
|
||||
focus (throttled to 30 seconds), so an admin turning it on shows up on the
|
||||
next focus. A settings-changed hook on the app object would be cleaner.
|
||||
Owner: D1.
|
||||
- **B18:** tunnels builds its own SSE URL off the axios base instead of the
|
||||
new `app.fetch`. Owner: D1.
|
||||
|
||||
- **Pre-existing, found in B19:** `createMockCtx`'s `wsRoutes` double (B15)
|
||||
records `{ path, raw, handler, options }` per route, but
|
||||
`plugins/serial/tests/backend/activate.test.ts`,
|
||||
`plugins/ssh-terminal/tests/backend/activate.test.ts` and
|
||||
`plugins/tunnels/tests/backend/activate.test.ts` still assert the old
|
||||
two-field shape (`{ path, raw }`) and fail on `toEqual`. Not touched by
|
||||
this step; update each assertion to match or to check only the fields it
|
||||
cares about. Owner: D0.
|
||||
- **Pre-existing, found in B19:** `src/backend/hosts/file-manager/` (21
|
||||
files) is dead code left over from before the file-manager plugin (B6):
|
||||
nothing in `src/backend/starter.ts` or elsewhere imports
|
||||
`hosts/file-manager/index.ts`, so its own port (30004, still listed in
|
||||
`src/backend/utils/swagger.ts`'s server list) never actually starts and
|
||||
its `axios.post("http://localhost:30006/activity/log", ...)` calls (now a
|
||||
dead port after this step) never run. Delete the directory once confirmed
|
||||
nothing else references it. Owner: D0.
|
||||
- **Pre-existing, found in B19:** `src/ui/main-axios.ts`'s `tmuxMonitorApi`
|
||||
still points at `getApiUrl("/tmux_monitor", 30010)`, a port tmux-monitor's
|
||||
own B10 step moved off of (per `plugins/tmux-monitor/CHANGELOG.md`) without
|
||||
updating this frontend constant to `/plugin-api/tmux-monitor`. Not
|
||||
something this step touched or verified further. Owner: D0.
|
||||
- **Pre-existing, found in B20:** `npm run test:plugins` fails on three tests
|
||||
unrelated to wake-on-lan or secret-sources: `ssh-terminal` and `tunnels`
|
||||
both assert `mock.wsRoutes` equals `[{ path, raw }]`, but the mock now also
|
||||
carries `handler` and `options` (added for **B15**'s doubles, per the
|
||||
contract's "Tests" section), so the exact-equality assertion fails; and
|
||||
`web-endpoint`'s "answers 503 while the tunnels plugin is not available"
|
||||
test gets 502. Confirmed present on `dev-2.9.0` before this step (checked
|
||||
by stashing B20's changes and re-running). Owner: D0.
|
||||
- **C1 (totp):** `totp-migration.ts` moves a user's TOTP secret only when
|
||||
their data key opens at boot. A 2.8 user whose key is still a pre-2.5.1
|
||||
password wrap keeps their enrolment row (fail closed) but has no secret in
|
||||
`p_totp_enrollments` until a boot after their next password login migrates
|
||||
the key, so that one login fails at the TOTP step. Likewise, enabling the
|
||||
totp plugin for the first time without a restart does not move secrets
|
||||
until the next boot. Decide whether either needs handling (a migration run
|
||||
on plugin activation, or an admin reset message). Owner: D0.
|
||||
- **C1 (totp/webauthn):** the `users` columns `totp_secret`, `totp_enabled`
|
||||
and `totp_backup_codes` stay in the database (dropped from `schema.ts`, the
|
||||
drizzle drop is a no-op) until 3.0.0 removes them, after the boot
|
||||
migration has run everywhere. Owner: 3.0.0.
|
||||
- **C2 (sso/ldap):** `users.is_oidc`, `oidc_identifier`, `sso_provider_id`
|
||||
and the 2.8 per-user OIDC columns (`client_id`, `issuer_url`, ...), and
|
||||
`sessions.sso_provider_id`, `oidc_sub`, `oidc_sid`, stay in core. Core
|
||||
still reads `is_oidc`/`oidc_identifier` for account linking, `/users/me` and
|
||||
the `$oidc.preferred_username` placeholder, and still writes them from an
|
||||
identity's `legacy` field so a downgrade works. Rename or drop them once
|
||||
nothing needs the 2.8 shape. Owner: 3.0.0.
|
||||
- **C2 (sso/ldap):** `database/routes/auth-compat-routes.ts` keeps the 2.8
|
||||
URLs (`/users/oidc/*`, `/users/oidc-config`, `/users/sso-providers`,
|
||||
`/users/ldap/login`) and so names the `oidc`/`ldap` method ids and the sso
|
||||
plugin's `/plugin-api/sso/` paths. Keep until identity providers have moved
|
||||
to the new redirect URI and Termix-Mobile uses `/users/auth/*`. Owner:
|
||||
3.0.0.
|
||||
- **C2 (sso):** the `oidc_auto_provision` and `oidc_silent_login_default`
|
||||
settings, their routes and admin toggles are generic (every external
|
||||
method, every redirect method) but still carry OIDC in their names and
|
||||
i18n. Rename with a settings migration if wanted. Owner: D0.
|
||||
- **Unrelated, found in C2:** `node scripts/check-shell-plugin-ids.cjs`
|
||||
fails on `src/ui/lib/host-to-ssh-host.ts` naming `"web-endpoint"`
|
||||
(`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before
|
||||
C2. Owner: D0.
|
||||
|
||||
- **C3 (opkssh), C4 (step-ca):** `database/routes/host-compat-routes.ts`
|
||||
answers the 2.8 redirect URIs `/host/opkssh-callback` and
|
||||
`/host/step-ca-callback` with a 307 to the plugins and so names their
|
||||
paths. Keep it until identity providers have moved to
|
||||
`/plugin-api/opkssh/callback` and `/plugin-api/step-ca/callback` (each
|
||||
plugin's admin page shows it, and its `legacyCallback` setting switches
|
||||
over). Owner: 3.0.0.
|
||||
- **C3 (opkssh/warpgate):** `ssh_data.use_warpgate` and the `.opk` folder
|
||||
under `DATA_DIR` stay on disk, unused (the drizzle drop is `SELECT 1;`, the
|
||||
config is copied, not moved). Remove them in 3.0.0. Owner: 3.0.0.
|
||||
- **C3 (opkssh):** the Termix docs still describe `DATA_DIR/.opk/config.yml`
|
||||
and `/host/opkssh-callback`. They need the new config path
|
||||
(`DATA_DIR/plugins/opkssh/config.yml`), the new redirect URI and the
|
||||
`legacyCallback` setting. Owner: D0 (docs repo).
|
||||
- **C3 (warpgate):** Termix-Mobile's file manager and docker clients call
|
||||
`/ssh/connect-warpgate` and read `requiresWarpgate`; the plugins answer
|
||||
`connect-browser-sign-in` and `requires_browser_sign_in` now (both clients
|
||||
were already on paths B6 and B15 moved). The terminal socket keeps
|
||||
`warpgate_auth_required` / `warpgate_auth_continue`. Owner: D1, with the
|
||||
rest of the mobile client changes.
|
||||
- **C3:** `host-bulk-routes.ts` still validates imported auth types against a
|
||||
fixed list that names plugin types (`opkssh`, `stepca`, `tailscale`,
|
||||
`vault`), and the host create/update routes no longer accept a
|
||||
`useWarpgate` field inline (the host editor sends plugin settings). The
|
||||
bulk list should come from the registered providers plus
|
||||
`contributes.auth.sshAuthTypes`. Owner: D1.
|
||||
- **C4 (step-ca):** the 2.8 `step_ca_url`, `step_ca_fingerprint`,
|
||||
`step_ca_provisioner` and `step_ca_private_endpoint_allowlist` rows stay in
|
||||
core `settings`, unused, after `step-ca-settings-migration.ts` copies them
|
||||
(kept for one release so a downgrade works). Delete them in 3.0.0.
|
||||
Owner: 3.0.0.
|
||||
- **C4 (step-ca):** the admin fields are plain settings now, so a bad CA URL
|
||||
or fingerprint is reported when someone signs in (in the terminal dialog)
|
||||
instead of when the admin saves, as the 2.8 route did. A settings
|
||||
validation hook for plugins would bring the save-time check back.
|
||||
Owner: D0.
|
||||
- **C4 (step-ca):** the Termix docs still describe Step CA under Admin
|
||||
Settings and the `/host/step-ca-callback` redirect URI. They need the
|
||||
plugin settings page, the new redirect URI and the `legacyCallback`
|
||||
setting. Owner: D0 (docs repo).
|
||||
- **Pre-existing, found in C3:** the Docker `opkssh-downloader` stage's
|
||||
`OPKSSH_VERSION` build arg is not passed on at runtime, so building with
|
||||
another version gives a prebaked binary the plugin's pinned checksum
|
||||
rejects (it then downloads the pinned version instead). Pass
|
||||
`OPKSSH_VERSION` and the matching `OPKSSH_SHA256` as `ENV` in the final
|
||||
stage. Owner: D0.
|
||||
- **C5 (vault):** the Termix docs still describe Vault profiles with the
|
||||
`/vault/oidc/callback` redirect URI. They need the plugin settings page,
|
||||
the new redirect URI (`/plugin-api/vault/oidc/callback`) and the
|
||||
`legacyCallback` setting. Owner: D0 (docs repo).
|
||||
- **C5 (vault):** a host's Vault profile is the plugin's `profileId` host
|
||||
setting now, and host-scope plugin settings do not travel over remote sync
|
||||
(the host entity used to carry `vaultProfileSyncId`). Profiles still sync
|
||||
as `vaultProfiles`; the host on the other side has to pick its profile
|
||||
again. Needs a generic way to sync host-scope plugin settings, the same gap
|
||||
the B7 export/import line describes. Owner: D1.
|
||||
- **C5 (vault), for D2:** `vaultProfileId` was an owner-private auth field a
|
||||
shared-host recipient with edit access could not change. As a host setting
|
||||
it is written through `/plugins/vault/settings/host/:hostId`, which only
|
||||
checks edit access, so such a recipient can now point the host at another
|
||||
profile. Same question as the B14 line on per-field sharing levels.
|
||||
Owner: D2.
|
||||
- **C5 (vault):** `ssh_data.vault_profile_id` stays in the database,
|
||||
unused, after `vault-settings-migration.ts` copies it (kept so a downgrade
|
||||
works, and the pg/mysql foreign key on it now points at
|
||||
`p_vault_profiles`). Drop it in 3.0.0. Owner: 3.0.0.
|
||||
- **Pre-existing, found in C5:** `npm run test -w plugins/ssh-terminal`
|
||||
fails "ssh-terminal activate > serves the terminal socket": it expects
|
||||
`mock.wsRoutes` to equal `[{ path, raw }]`, but `FakeWsRoute` now also
|
||||
records `handler` and `options`. Use `toMatchObject` or
|
||||
`expect.objectContaining`. Owner: D0.
|
||||
Everything a Phase B/C step left for D0 is done. What is left needs a person
|
||||
with a real install, a real device or a repo outside this one.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
@@ -657,33 +105,52 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
editor (sharing only offered with "Share Vault profiles"); switch to the new
|
||||
redirect URI after allowing it in the Vault role; disable the vault plugin
|
||||
and a Vault host says it needs it, then enable it again.
|
||||
- **C6 (termix-identity):** only the public resolver URLs under
|
||||
`/termix-id/u/` redirect to the plugin. The 2.8 management routes
|
||||
(`/termix-id/me`, `/termix-id/keys`, `/termix-id/ca` and the rest) now 404.
|
||||
Nothing in this repo calls them; confirm Termix-Mobile does not before
|
||||
2.9.0 ships, and add redirects if it does. Owner: D0.
|
||||
- **C6 (termix-identity):** on MySQL the adopted `p_termix_identity_keys` has
|
||||
no `idx_termix_identity_keys_identity` (core never created one there and
|
||||
an adoption writes no MySQL indexes), so the public resolver scans the
|
||||
keys table. Add it in a later plugin migration if a MySQL install needs
|
||||
it. Owner: D0.
|
||||
- **C6 (termix-identity):** the docs site still shows the
|
||||
`/termix-id/u/<handle>` resolver URL. It keeps working through the 308,
|
||||
but the docs should show `/plugin-api/termix-identity/u/<handle>`. Owner:
|
||||
D0.
|
||||
- **C7 (acme-ssl):** the 2.8 leftovers stay on disk and in the database for
|
||||
one release: the `acme_ssl_settings` settings row (the Cloudflare token is
|
||||
already stripped from it), `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`.
|
||||
Delete them in the release after 2.9.0. Owner: after D4.
|
||||
- **C7 (acme-ssl):** the docs page linked from Admin Settings > HTTPS
|
||||
certificate (`docs.termix.site/features/networking/ssl`) still describes
|
||||
certbot and the webroot challenge. Point it at the plugin. Owner: D0.
|
||||
- **C7, pre-existing:** `AutoSSLSetup.setupEnvironmentVariables` in
|
||||
`src/backend/tls/self-signed.ts` rewrites `SSL_CERT_PATH`, `SSL_KEY_PATH`
|
||||
and `SSL_DOMAIN` to the defaults in `DATA_DIR/.env` on every boot, so custom
|
||||
certificate paths set in the environment are lost after the first start.
|
||||
Owner: D0.
|
||||
- **C7, pre-existing:** `src/backend/scripts/setup-ssl.sh` and
|
||||
`enable-ssl.sh` are not called by anything and write a `JWT_SECRET` and
|
||||
`DATABASE_KEY` to `.env`, which SystemCrypto now owns. Delete or rewrite
|
||||
them. Owner: D0.
|
||||
- Termix-Mobile: the 2.8 termix-id management routes (`/termix-id/me`,
|
||||
`/termix-id/keys`, `/termix-id/ca` and the rest) now 404, only the
|
||||
`/termix-id/u/` resolver redirects. Confirm the mobile app does not call
|
||||
them, and that it still reads a host's metrics settings (`statsConfig` is
|
||||
put back on the host payload by host-metrics) and the renamed Warpgate
|
||||
fields. Once it reads `pluginSettings` and `GET /host/status`, drop
|
||||
host-metrics's `hostPayloadLegacy` hook.
|
||||
- Docs site: point these at the plugins: the OPKSSH config path and redirect
|
||||
URI, the step-ca settings, the Vault redirect URI, the Termix ID resolver
|
||||
URL (`/plugin-api/termix-identity/u/<handle>`, the old one still 308s) and
|
||||
the HTTPS certificate page (`docs.termix.site/features/networking/ssl`,
|
||||
which still describes certbot and the webroot challenge).
|
||||
- Upgrade a real 2.8 install on Postgres and on MySQL (SQLite is covered by
|
||||
the upgrade test): proxmox host settings, tunnels, web endpoints, the file
|
||||
manager's default path and SCP switch, and the terminal, tmux, sharing and
|
||||
recording switches all survive. Postgres was checked by hand against a
|
||||
scratch database; MySQL only runs in CI.
|
||||
- Host settings round trip: filter the Hosts panel by a plugin feature, bulk
|
||||
enable and disable a plugin on a few hosts, export hosts to JSON and import
|
||||
them on another install (plugin settings come across, secrets do not), the
|
||||
raw SQLite export and import, and remote sync of a host with a Vault
|
||||
profile selected (the profile follows it).
|
||||
- Desktop app with a remote server: tunnels on a remote host show their
|
||||
status next to local ones, and the terminal toolbar's metrics bars work
|
||||
for a host on the remote server.
|
||||
- Shared hosts: a remote desktop host shared at "connect" does not show its
|
||||
gateway settings; a shared editor cannot change a Vault host's profile; the
|
||||
host list status dot works for a host someone shared with you.
|
||||
- The PDF preview in the file manager, in the browser and in the desktop app.
|
||||
- The tab right-click menu's share entry on a terminal whose toolbar is
|
||||
hidden, and the terminal toolbar's quick links (tmux monitor, docker,
|
||||
tunnels, host metrics) for a host with those plugins on.
|
||||
- Appearance: switching the interface preset changes the Docker layout and
|
||||
the host metrics column count, a change made in either shows under
|
||||
Appearance with the plugin's name and reverts from there, and a 2.8 user's
|
||||
Docker and host metrics overrides are still applied.
|
||||
- A new host takes the admin "command history for new hosts" setting (a 2.8
|
||||
install that had it off in host defaults keeps it off), and the file
|
||||
manager and terminal are on for a new host.
|
||||
- Settings saves with a bad value are refused with a message: a relative
|
||||
terminal background image path, a step-ca URL that is not https.
|
||||
- Custom `SSL_CERT_PATH` and `SSL_KEY_PATH` in the environment survive a
|
||||
restart and no self-signed certificate is generated over them.
|
||||
- A LAN ntfy or webhook target in the admin allowlist is reached through the
|
||||
configured outbound proxy.
|
||||
- An admin enables a plugin that was off since the upgrade: its 2.8 data is
|
||||
copied in without a restart.
|
||||
- A password user who signs in for the first time after the upgrade keeps
|
||||
their TOTP (the copy runs once their keys unlock).
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { legacyCoreImports } from "../lib/legacy-core-imports.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
|
||||
const BACKEND_ENTRIES = [
|
||||
@@ -75,6 +76,7 @@ export async function build({ cwd }) {
|
||||
logLevel: "warning",
|
||||
external: FRONTEND_EXTERNALS,
|
||||
plugins: [
|
||||
staticUrlImports({ outDir }),
|
||||
legacyCoreImports({
|
||||
pluginId,
|
||||
platform: "browser",
|
||||
|
||||
@@ -20,11 +20,6 @@ const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
|
||||
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
|
||||
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
|
||||
const CORE_UI = /^(?:\.\.\/)+src\/ui\//;
|
||||
// "../../../<other-plugin>/backend/" -- only plugins/ai/.../executor.ts today.
|
||||
// esbuild filters are Go RE2, which has no lookahead, so "src" is excluded in
|
||||
// the callback rather than the pattern.
|
||||
const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//;
|
||||
|
||||
/** dist/plugins/<id>/dist/backend.js -> dist/backend/... */
|
||||
const COMPILED_CORE = "../../../backend/backend/";
|
||||
const COMPILED_TYPES = "../../../backend/types/";
|
||||
@@ -68,12 +63,6 @@ export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
|
||||
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
|
||||
external: true,
|
||||
}));
|
||||
|
||||
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
|
||||
const [, other] = CROSS_PLUGIN.exec(args.path);
|
||||
if (other === "src" || other === pluginId) return null;
|
||||
return { path: `../../${other}/dist/backend.js`, external: true };
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* `import url from "some/file.js?url"` in a plugin frontend, as core's Vite
|
||||
* build allows: the file is copied next to the bundle under assets/ and the
|
||||
* import becomes its URL, resolved against the bundle's own URL so it works
|
||||
* under /plugin-assets/<id>/. Only scripts, since that is all the asset
|
||||
* route serves (a pdfjs or Monaco worker, say).
|
||||
*/
|
||||
export function staticUrlImports({ outDir }) {
|
||||
return {
|
||||
name: "static-url-imports",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /\?url$/ }, async (args) => {
|
||||
const target = args.path.slice(0, -"?url".length);
|
||||
const resolved = await build.resolve(target, {
|
||||
kind: args.kind,
|
||||
resolveDir: args.resolveDir,
|
||||
importer: args.importer,
|
||||
});
|
||||
if (resolved.errors.length > 0) return { errors: resolved.errors };
|
||||
return { path: resolved.path, namespace: "static-url" };
|
||||
});
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: "static-url" }, (args) => {
|
||||
if (!/\.(m?js|cjs)$/.test(args.path)) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `${args.path}: only script files can be imported with ?url`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const bytes = fs.readFileSync(args.path);
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(bytes)
|
||||
.digest("hex")
|
||||
.slice(0, 8);
|
||||
const base = path.basename(args.path).replace(/\.(m?js|cjs)$/, "");
|
||||
const name = `${base}-${hash}.js`;
|
||||
fs.mkdirSync(path.join(outDir, "assets"), { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, "assets", name), bytes);
|
||||
return {
|
||||
contents: `export default new URL("./assets/${name}", import.meta.url).href;`,
|
||||
loader: "js",
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -170,7 +170,10 @@
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,12 +276,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"guest": { "type": "boolean" },
|
||||
"guest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"guestViews": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]*$"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
@@ -286,23 +294,38 @@
|
||||
"properties": {
|
||||
"sshAuthTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"loginMethods": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"secondFactors": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"secretSchemes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
},
|
||||
"keyboardInteractive": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{0,63}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -313,9 +336,18 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 }
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"titleKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -326,9 +358,18 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "titleKey"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1 },
|
||||
"titleKey": { "type": "string", "minLength": 1 },
|
||||
"icon": { "type": "string", "minLength": 1 }
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"titleKey": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -727,9 +768,31 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Registered component id. Required when type is \"custom\"."
|
||||
},
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Stored and validated, but not drawn by the generic settings form."
|
||||
},
|
||||
"defaultFrom": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "A non-secret admin field whose value a new host starts with."
|
||||
},
|
||||
"shareRead": {
|
||||
"type": "string",
|
||||
"enum": ["connect", "view", "edit", "manage"],
|
||||
"description": "Lowest share level whose recipients see the value in the host payload."
|
||||
},
|
||||
"ownerOnly": {
|
||||
"type": "boolean",
|
||||
"description": "Only the host's owner may change it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableDefault": {
|
||||
"type": "boolean",
|
||||
"description": "What the enable switch reads before a host saves it. Off by default."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -749,6 +812,95 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "HTTP options for the plugin's /plugin-api/<id>/ router.",
|
||||
"properties": {
|
||||
"legacyPaths": {
|
||||
"type": "array",
|
||||
"description": "Old URLs under /<plugin id>/ served by the plugin's router with that prefix removed.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiPresets": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["simple", "balanced", "advanced"],
|
||||
"description": "The plugin's Appearance defaults for each interface preset, read with usePluginUiPreferences.",
|
||||
"properties": {
|
||||
"simple": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"balanced": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -456,6 +456,16 @@ export interface PluginSettings {
|
||||
value: unknown,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Every host that saved a value for one of this plugin's host fields, across
|
||||
* all users, with the host's owner. For a background job that must know
|
||||
* which hosts to act on, and as whom, before any request is in flight.
|
||||
* Requires hosts:read. Secret fields cannot be listed.
|
||||
*/
|
||||
listHostValues: <T = unknown>(
|
||||
key: string,
|
||||
) => Promise<Array<{ hostId: number; userId: string; value: T }>>;
|
||||
|
||||
/** Every value in one scope, with declared defaults merged in. */
|
||||
getAll: (
|
||||
scope: "admin" | "user" | "host",
|
||||
@@ -468,6 +478,19 @@ export interface PluginSettings {
|
||||
*/
|
||||
onChange: (key: string, listener: (value: unknown) => void) => () => void;
|
||||
|
||||
/**
|
||||
* Checks a save from the settings screen or host editor before anything is
|
||||
* written. Return field key to message for what is wrong; nothing means the
|
||||
* save goes ahead. Disposed automatically on deactivate.
|
||||
*/
|
||||
onValidate: (
|
||||
scope: "admin" | "user" | "host",
|
||||
validator: (
|
||||
values: Record<string, unknown>,
|
||||
context: { hostId?: number },
|
||||
) => Record<string, string> | void | Promise<Record<string, string> | void>,
|
||||
) => () => void;
|
||||
|
||||
/**
|
||||
* Reads a core server setting from a small documented allowlist.
|
||||
* Requires the settings:read-core capability.
|
||||
@@ -513,9 +536,6 @@ export interface PluginHostRecord {
|
||||
folder: string | null;
|
||||
jumpHosts?: unknown;
|
||||
enableSsh?: boolean | null;
|
||||
enableTerminal?: boolean | null;
|
||||
enableFileManager?: boolean | null;
|
||||
enableTunnel?: boolean | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
[key: string]: unknown;
|
||||
@@ -1454,6 +1474,11 @@ export interface PluginAudit {
|
||||
details?: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
/**
|
||||
* The HTTP request or WebSocket upgrade this happened on, for the line's
|
||||
* IP address and user agent.
|
||||
*/
|
||||
request?: unknown;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -644,6 +644,16 @@ export interface TermixApp extends TermixAppInfo {
|
||||
tabs: TabsApi;
|
||||
/** The desktop app, when the frontend runs in it. */
|
||||
desktop: DesktopApi;
|
||||
/**
|
||||
* Fires after this plugin's settings are saved from the settings screen or
|
||||
* the host editor. Disposed automatically.
|
||||
*/
|
||||
onSettingsChanged: (
|
||||
listener: (change: {
|
||||
scope: "admin" | "user" | "host";
|
||||
hostId?: number;
|
||||
}) => void,
|
||||
) => Disposer;
|
||||
/** Registered cleanup, run when the plugin is disabled. */
|
||||
onDispose: (dispose: Disposer) => void;
|
||||
}
|
||||
@@ -722,6 +732,7 @@ export interface PluginHostBridge {
|
||||
useTheme: () => { theme: "light" | "dark" };
|
||||
toast: ToastApi;
|
||||
getApi: (pluginId: string) => PluginApiClient;
|
||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||
useTabs: () => TabsApi;
|
||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
|
||||
@@ -739,6 +750,81 @@ export interface PluginHostBridge {
|
||||
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
||||
useHomepageWidgetTypes: () => HomepageWidgetContribution[];
|
||||
homepageWidgetType: (id: string) => HomepageWidgetContribution | undefined;
|
||||
core: PluginCoreApi;
|
||||
usePluginUiPreferences: (pluginId: string) => {
|
||||
values: Record<string, unknown>;
|
||||
set: (key: string, value: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** A key combination a user bound in Appearance > Keybindings. */
|
||||
export interface KeyCombo {
|
||||
key: string;
|
||||
isCode: boolean;
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
export type KeybindingActionType =
|
||||
| "copy"
|
||||
| "paste"
|
||||
| "sendControlCode"
|
||||
| "sendText"
|
||||
| "runSnippet"
|
||||
| "nextTab"
|
||||
| "previousTab"
|
||||
| "openCommandPalette"
|
||||
| "reconnectSession";
|
||||
|
||||
export interface KeybindingAction {
|
||||
type: KeybindingActionType;
|
||||
text?: string;
|
||||
controlCode?: string;
|
||||
snippetId?: string;
|
||||
appendEnter?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomKeybinding {
|
||||
id: string;
|
||||
combo: KeyCombo;
|
||||
action: KeybindingAction;
|
||||
enabled: boolean;
|
||||
overridesDefaultId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Core calls a plugin frontend makes for the signed-in user. */
|
||||
export interface PluginCoreApi {
|
||||
/** Adds a recent-activity entry. `type` is one of the plugin's activityTypes. */
|
||||
logActivity: (
|
||||
type: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
) => Promise<void>;
|
||||
/** A host's stored password or sudo password, for autofill. Null when unset. */
|
||||
getHostPassword: (
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword",
|
||||
) => Promise<string | null>;
|
||||
/** Updates the saved open-tab record a page reload reattaches from. */
|
||||
patchOpenTab: (
|
||||
instanceId: string,
|
||||
updates: {
|
||||
hostId?: number;
|
||||
label?: string;
|
||||
tabOrder?: number;
|
||||
backendSessionId?: string | null;
|
||||
},
|
||||
) => Promise<void>;
|
||||
/** The user's custom keybindings, enabled or not. */
|
||||
getCustomKeybindings: () => Promise<CustomKeybinding[]>;
|
||||
/** Turns auto tmux on or off in a host's terminal options. */
|
||||
setHostAutoTmux: (hostId: number, autoTmux: boolean) => Promise<void>;
|
||||
/** A browser-side UI preference (a cookie, or the desktop app's store). */
|
||||
getClientPreference: (name: string) => string | undefined;
|
||||
}
|
||||
|
||||
/** What a recent-activity entry opens and how it is labelled. */
|
||||
@@ -849,11 +935,72 @@ export function useToast(): ToastApi {
|
||||
return requireHost().toast;
|
||||
}
|
||||
|
||||
export function logActivity(
|
||||
type: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
): Promise<void> {
|
||||
return requireHost().core.logActivity(type, hostId, hostName);
|
||||
}
|
||||
|
||||
export function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword",
|
||||
): Promise<string | null> {
|
||||
return requireHost().core.getHostPassword(hostId, field);
|
||||
}
|
||||
|
||||
export function patchOpenTab(
|
||||
instanceId: string,
|
||||
updates: Parameters<PluginCoreApi["patchOpenTab"]>[1],
|
||||
): Promise<void> {
|
||||
return requireHost().core.patchOpenTab(instanceId, updates);
|
||||
}
|
||||
|
||||
export function getCustomKeybindings(): Promise<CustomKeybinding[]> {
|
||||
return requireHost().core.getCustomKeybindings();
|
||||
}
|
||||
|
||||
export function setHostAutoTmux(
|
||||
hostId: number,
|
||||
autoTmux: boolean,
|
||||
): Promise<void> {
|
||||
return requireHost().core.setHostAutoTmux(hostId, autoTmux);
|
||||
}
|
||||
|
||||
export function getClientPreference(name: string): string | undefined {
|
||||
return requireHost().core.getClientPreference(name);
|
||||
}
|
||||
|
||||
export function usePluginApi(): PluginApiClient {
|
||||
const bridge = requireHost();
|
||||
return bridge.getApi(bridge.usePluginId());
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin's client for a resolved connection origin: "remote" reaches the
|
||||
* desktop app's connected remote server, anything else is usePluginApi's.
|
||||
*/
|
||||
export function usePluginApiFor(origin: unknown): PluginApiClient {
|
||||
const bridge = requireHost();
|
||||
return bridge.getApiFor(bridge.usePluginId(), origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin's Appearance area: the preset it declared in
|
||||
* contributes.uiPresets for the user's level, with the user's changes on top.
|
||||
* `set` saves one change; null clears it back to the preset.
|
||||
*/
|
||||
export function usePluginUiPreferences<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(): {
|
||||
values: T;
|
||||
set: <K extends keyof T & string>(key: K, value: T[K] | null) => void;
|
||||
} {
|
||||
const bridge = requireHost();
|
||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
||||
}
|
||||
|
||||
export function useTabs(): TabsApi {
|
||||
return requireHost().useTabs();
|
||||
}
|
||||
|
||||
@@ -224,6 +224,19 @@ export interface PluginSettingsField {
|
||||
group?: string;
|
||||
/** Registered component id. Required when type is "custom". */
|
||||
component?: string;
|
||||
/**
|
||||
* Host scope only: the admin field whose value a new host starts with,
|
||||
* instead of `default`. Hosts that never saved the field still read
|
||||
* `default`.
|
||||
*/
|
||||
defaultFrom?: string;
|
||||
/**
|
||||
* Host scope only: the lowest share level whose recipients see this value
|
||||
* in the host payload. Defaults to "connect", everyone who sees the host.
|
||||
*/
|
||||
shareRead?: "connect" | "view" | "edit" | "manage";
|
||||
/** Host scope only: only the host's owner may change it. */
|
||||
ownerOnly?: boolean;
|
||||
/**
|
||||
* Stored and validated like any field, but not drawn by the generic
|
||||
* settings form, because the plugin edits it in its own UI (a host editor
|
||||
@@ -236,6 +249,8 @@ export interface PluginHostSettingsContribution {
|
||||
/** Boolean field rendered first, gating the rest of the section. */
|
||||
enableKey?: string;
|
||||
enableLabelKey?: string;
|
||||
/** What the enable switch reads before a host saves it. Off by default. */
|
||||
enableDefault?: boolean;
|
||||
fields: PluginSettingsField[];
|
||||
}
|
||||
|
||||
@@ -288,6 +303,27 @@ export interface PluginContributions {
|
||||
*/
|
||||
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
|
||||
auth?: PluginAuthContribution;
|
||||
http?: PluginHttpContribution;
|
||||
/**
|
||||
* This plugin's Appearance defaults for each interface preset, read in the
|
||||
* frontend with usePluginUiPreferences. Every preset names the same keys;
|
||||
* values are booleans, numbers, strings or string arrays.
|
||||
*/
|
||||
uiPresets?: Record<
|
||||
"simple" | "balanced" | "advanced",
|
||||
Record<string, unknown>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface PluginHttpContribution {
|
||||
/**
|
||||
* Old URLs something outside Termix still calls (a webhook a third party
|
||||
* posts to). Each must start with "/<plugin id>/"; a request under it is
|
||||
* served by this plugin's router with that prefix removed, so
|
||||
* "/automations/webhook/x" reaches the route "/webhook/x". Core routes win
|
||||
* a clash.
|
||||
*/
|
||||
legacyPaths?: string[];
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
@@ -375,6 +411,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"permissions",
|
||||
"settings",
|
||||
"hostCapability",
|
||||
"http",
|
||||
"uiPresets",
|
||||
"auth",
|
||||
]);
|
||||
|
||||
@@ -393,8 +431,13 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"group",
|
||||
"component",
|
||||
"hidden",
|
||||
"defaultFrom",
|
||||
"shareRead",
|
||||
"ownerOnly",
|
||||
];
|
||||
|
||||
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||
|
||||
/** Settings keys are stored as-is, so they stay short and index-safe. */
|
||||
const SETTINGS_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
||||
|
||||
@@ -782,6 +825,78 @@ function validateContributes(
|
||||
validateSettings(contributes.settings, errors);
|
||||
validateHostCapability(contributes.hostCapability, errors);
|
||||
validateAuthContribution(contributes.auth, errors);
|
||||
validateHttpContribution(contributes.http, pluginId, errors);
|
||||
validateUiPresets(contributes.uiPresets, errors);
|
||||
}
|
||||
|
||||
function isPresetValue(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "boolean" ||
|
||||
(typeof value === "number" && Number.isFinite(value)) ||
|
||||
typeof value === "string" ||
|
||||
(Array.isArray(value) && value.every((item) => typeof item === "string"))
|
||||
);
|
||||
}
|
||||
|
||||
function validateUiPresets(value: unknown, errors: string[]): void {
|
||||
if (value === undefined) return;
|
||||
const where = "contributes.uiPresets";
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`Field "${where}" must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(value, ["simple", "balanced", "advanced"], where, errors);
|
||||
let keys: string | undefined;
|
||||
for (const level of ["simple", "balanced", "advanced"]) {
|
||||
const preset = value[level];
|
||||
if (!isPlainObject(preset)) {
|
||||
errors.push(`${where}.${level} must be an object`);
|
||||
continue;
|
||||
}
|
||||
for (const [key, entry] of Object.entries(preset)) {
|
||||
if (!isPresetValue(entry)) {
|
||||
errors.push(
|
||||
`${where}.${level}.${key} must be a boolean, number, string or string array`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const names = Object.keys(preset).sort().join(",");
|
||||
if (keys === undefined) keys = names;
|
||||
else if (keys !== names) {
|
||||
errors.push(`${where} presets must all name the same keys`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/;
|
||||
|
||||
function validateHttpContribution(
|
||||
value: unknown,
|
||||
pluginId: string | undefined,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push('Field "contributes.http" must be an object');
|
||||
return;
|
||||
}
|
||||
rejectUnknown(value, ["legacyPaths"], "contributes.http", errors);
|
||||
const paths = value.legacyPaths;
|
||||
if (paths === undefined) return;
|
||||
if (!Array.isArray(paths)) {
|
||||
errors.push('Field "contributes.http.legacyPaths" must be an array');
|
||||
return;
|
||||
}
|
||||
paths.forEach((path, index) => {
|
||||
const at = `contributes.http.legacyPaths[${index}]`;
|
||||
if (typeof path !== "string" || !LEGACY_PATH_PATTERN.test(path)) {
|
||||
errors.push(`${at} must be an absolute path like "/<plugin id>/name"`);
|
||||
return;
|
||||
}
|
||||
if (pluginId && !path.startsWith(`/${pluginId}/`)) {
|
||||
errors.push(`${at} must start with "/${pluginId}/"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
@@ -861,7 +976,7 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
}
|
||||
rejectUnknown(
|
||||
host,
|
||||
["enableKey", "enableLabelKey", "fields"],
|
||||
["enableKey", "enableLabelKey", "enableDefault", "fields"],
|
||||
`"${at}"`,
|
||||
errors,
|
||||
);
|
||||
@@ -876,6 +991,9 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
// An enable switch with no label is a blank row in the host editor.
|
||||
requireString(host.enableLabelKey, `${at}.enableLabelKey`, errors);
|
||||
}
|
||||
if ("enableDefault" in host && typeof host.enableDefault !== "boolean") {
|
||||
errors.push(`${at}.enableDefault must be a boolean`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(host.fields)) {
|
||||
errors.push(`${at}.fields must be an array`);
|
||||
@@ -887,6 +1005,48 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
errors,
|
||||
typeof host.enableKey === "string" ? host.enableKey : undefined,
|
||||
);
|
||||
|
||||
const adminKeys = new Set(
|
||||
(Array.isArray(settings.admin) ? settings.admin : [])
|
||||
.filter(isPlainObject)
|
||||
// A secret never becomes a default the host editor can read.
|
||||
.filter((field) => field.type !== "secret")
|
||||
.map((field) => field.key),
|
||||
);
|
||||
host.fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
const fieldAt = `${at}.fields[${index}]`;
|
||||
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
|
||||
errors.push(
|
||||
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
"shareRead" in raw &&
|
||||
!SHARE_LEVELS.includes(raw.shareRead as string)
|
||||
) {
|
||||
errors.push(
|
||||
`${fieldAt}.shareRead must be one of: ${SHARE_LEVELS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
|
||||
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const scope of ["admin", "user"] as const) {
|
||||
const fields = settings[scope];
|
||||
if (!Array.isArray(fields)) continue;
|
||||
fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
for (const key of ["defaultFrom", "shareRead", "ownerOnly"]) {
|
||||
if (key in raw) {
|
||||
errors.push(
|
||||
`${where}.${scope}[${index}].${key} is only valid on host fields`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,12 @@ export interface FakeAuthRegistrations {
|
||||
|
||||
export interface FakePluginContext {
|
||||
ctx: PluginContext;
|
||||
/** Runs what ctx.settings.onValidate registered, as a settings save would. */
|
||||
validateSettings: (
|
||||
scope: "admin" | "user" | "host",
|
||||
values: Record<string, unknown>,
|
||||
context?: { hostId?: number },
|
||||
) => Promise<Record<string, string>>;
|
||||
/** Everything the plugin registered through ctx.disposables. */
|
||||
disposals: Array<() => void | Promise<void>>;
|
||||
/** Topics emitted through ctx.events.emit, in order. */
|
||||
@@ -417,6 +423,10 @@ export function createFakeContext(
|
||||
Object.entries(options.coreSettings ?? {}),
|
||||
);
|
||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
const settingsValidators = new Set<{
|
||||
scope: "admin" | "user" | "host";
|
||||
validator: Parameters<PluginContext["settings"]["onValidate"]>[1];
|
||||
}>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
@@ -499,6 +509,15 @@ export function createFakeContext(
|
||||
: null;
|
||||
const holds = (permission: string) =>
|
||||
held === null || held.has(qualifyPermission(pluginId, permission));
|
||||
// Like the runtime, a route can only require a permission the manifest
|
||||
// declares. Checked when the test hands in a manifest.
|
||||
const declaredPermissions = options.manifest?.contributes?.permissions
|
||||
? new Set(
|
||||
options.manifest.contributes.permissions.map((entry) =>
|
||||
qualifyPermission(pluginId, entry.name),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
for (const [key, value] of Object.entries(options.settings ?? {})) {
|
||||
settings.set(settingsKey("admin", undefined, key), value);
|
||||
@@ -694,8 +713,16 @@ export function createFakeContext(
|
||||
// other plugins' namespaces is covered by core's tests, not here.
|
||||
has: async (permission) => holds(permission),
|
||||
hasFor: async (_userId, permission) => holds(permission),
|
||||
require: (permission) =>
|
||||
((_req, res, next) => {
|
||||
require: (permission) => {
|
||||
if (
|
||||
declaredPermissions &&
|
||||
!declaredPermissions.has(qualifyPermission(pluginId, permission))
|
||||
) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot require permission "${permission}": it is not declared in contributes.permissions`,
|
||||
);
|
||||
}
|
||||
return ((_req, res, next) => {
|
||||
if (holds(permission)) {
|
||||
next();
|
||||
return;
|
||||
@@ -704,7 +731,8 @@ export function createFakeContext(
|
||||
error: "Insufficient permissions",
|
||||
required: qualifyPermission(pluginId, permission),
|
||||
});
|
||||
}) as PluginMiddleware,
|
||||
}) as PluginMiddleware;
|
||||
},
|
||||
},
|
||||
|
||||
// Nothing here is capability-checked, per the module doc; createMockCtx
|
||||
@@ -729,6 +757,22 @@ export function createFakeContext(
|
||||
setHost: async (hostId, key, value) =>
|
||||
writeSetting("host", String(hostId), key, value),
|
||||
|
||||
listHostValues: async (key) => {
|
||||
const found: Array<{ hostId: number; userId: string; value: never }> =
|
||||
[];
|
||||
for (const [storedKey, value] of settings) {
|
||||
const [scope, scopeId, name] = storedKey.split(":");
|
||||
if (scope !== "host" || name !== key || value === undefined) continue;
|
||||
const hostId = Number(scopeId);
|
||||
found.push({
|
||||
hostId,
|
||||
userId: hostRecordsById.get(hostId)?.userId ?? actor ?? "",
|
||||
value: value as never,
|
||||
});
|
||||
}
|
||||
return found;
|
||||
},
|
||||
|
||||
getAll: async (scope, scopeId) => {
|
||||
const prefix = `${scope}:${scopeId ?? ""}:`;
|
||||
const all: Record<string, unknown> = {};
|
||||
@@ -754,6 +798,16 @@ export function createFakeContext(
|
||||
return unsubscribe;
|
||||
},
|
||||
|
||||
onValidate: (scope, validator) => {
|
||||
const entry = { scope, validator };
|
||||
settingsValidators.add(entry);
|
||||
const unsubscribe = () => {
|
||||
settingsValidators.delete(entry);
|
||||
};
|
||||
disposals.push(unsubscribe);
|
||||
return unsubscribe;
|
||||
},
|
||||
|
||||
readCore: async (key) => coreSettings.get(key) ?? null,
|
||||
},
|
||||
|
||||
@@ -919,12 +973,17 @@ export function createFakeContext(
|
||||
),
|
||||
requiresSecret: (authType) =>
|
||||
["password", "key", "credential", "agent"].includes(authType),
|
||||
supportsBackground: (authType) =>
|
||||
authType !== "none" &&
|
||||
!auth.sshAuthProviders.some(
|
||||
(provider) =>
|
||||
provider.type === authType && provider.supportsBackground === false,
|
||||
),
|
||||
// Matches core: the built-in types, plus any registered provider that
|
||||
// does not opt out. An unknown type cannot connect unattended.
|
||||
supportsBackground: (authType) => {
|
||||
if (["password", "key", "credential", "agent"].includes(authType)) {
|
||||
return true;
|
||||
}
|
||||
const provider = auth.sshAuthProviders.find(
|
||||
(candidate) => candidate.type === authType,
|
||||
);
|
||||
return !!provider && provider.supportsBackground !== false;
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
@@ -1150,6 +1209,14 @@ export function createFakeContext(
|
||||
|
||||
return {
|
||||
ctx,
|
||||
validateSettings: async (scope, values, context = {}) => {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of settingsValidators) {
|
||||
if (entry.scope !== scope) continue;
|
||||
Object.assign(errors, (await entry.validator(values, context)) ?? {});
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
disposals,
|
||||
emitted,
|
||||
kv,
|
||||
@@ -1385,7 +1452,15 @@ export function createMockCtx(
|
||||
}
|
||||
ctx.events.emit(topic, payload);
|
||||
},
|
||||
on: (topic, listener) => ctx.events.on(topic, listener),
|
||||
on: (topic, listener) => {
|
||||
if (!topic.startsWith("plugin.") && !granted.has("events:core")) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} may only listen to "plugin.*" topics. ` +
|
||||
`Declare the events:core capability to listen to core topics.`,
|
||||
);
|
||||
}
|
||||
return ctx.events.on(topic, listener);
|
||||
},
|
||||
},
|
||||
|
||||
http: {
|
||||
@@ -1411,6 +1486,10 @@ export function createMockCtx(
|
||||
// Reading and writing a plugin's OWN settings is ungated, exactly as in
|
||||
// the real runtime. Only readCore reaches outside the plugin.
|
||||
...ctx.settings,
|
||||
listHostValues: async (key) => {
|
||||
require("hosts:read");
|
||||
return ctx.settings.listHostValues(key);
|
||||
},
|
||||
readCore: async (key) => {
|
||||
require("settings:read-core");
|
||||
return ctx.settings.readCore(key);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Keeps a test server's random port off the fetch spec's blocked list.
|
||||
*
|
||||
* Tests start servers on port 0 and call them with fetch, which refuses
|
||||
* ports like 6000 or 6667 with "bad port". Linux never hands those out as
|
||||
* ephemeral ports, but Windows can (its dynamic range may start at 1024), so
|
||||
* a server that lands on one closes and listens again.
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
export const FETCH_BLOCKED_PORTS = new Set([
|
||||
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
|
||||
87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
|
||||
139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
|
||||
540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
|
||||
2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
|
||||
6679, 6697, 10080,
|
||||
]);
|
||||
|
||||
function wantsRandomPort(args: unknown[]): boolean {
|
||||
const first = args[0];
|
||||
if (first === 0 || first === "0") return true;
|
||||
if (first && typeof first === "object" && !Array.isArray(first)) {
|
||||
const port = (first as { port?: unknown }).port;
|
||||
return port === 0 || port === "0";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const PATCHED = Symbol.for("termix.safePorts");
|
||||
|
||||
export function installSafePorts(): void {
|
||||
const proto = net.Server.prototype as net.Server & { [PATCHED]?: boolean };
|
||||
if (proto[PATCHED]) return;
|
||||
proto[PATCHED] = true;
|
||||
|
||||
const listen = proto.listen as (
|
||||
this: net.Server,
|
||||
...args: unknown[]
|
||||
) => net.Server;
|
||||
proto.listen = function (this: net.Server, ...args: unknown[]) {
|
||||
if (wantsRandomPort(args)) retryOnBlockedPort(this, args);
|
||||
return listen.apply(this, args);
|
||||
} as typeof proto.listen;
|
||||
}
|
||||
|
||||
function retryOnBlockedPort(server: net.Server, args: unknown[]): void {
|
||||
const emit = server.emit;
|
||||
// The caller's callback is already a pending "listening" listener, so a
|
||||
// retry leaves it out and it fires once, on the good port.
|
||||
const retryArgs = args.filter((arg) => typeof arg !== "function");
|
||||
server.emit = ((event: string | symbol, ...rest: unknown[]) => {
|
||||
if (event === "listening") {
|
||||
const address = server.address();
|
||||
if (
|
||||
address &&
|
||||
typeof address === "object" &&
|
||||
FETCH_BLOCKED_PORTS.has(address.port)
|
||||
) {
|
||||
server.close(() => server.listen(...(retryArgs as [])));
|
||||
return true;
|
||||
}
|
||||
server.emit = emit;
|
||||
}
|
||||
return emit.call(server, event, ...rest);
|
||||
}) as typeof server.emit;
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { installSafePorts } from "./safe-ports.js";
|
||||
|
||||
installSafePorts();
|
||||
|
||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
@@ -114,6 +117,28 @@ const offline = () =>
|
||||
}),
|
||||
useSlotContributions: () => [],
|
||||
usePluginComponent: () => undefined,
|
||||
getApiFor: () => ({
|
||||
get: offline,
|
||||
delete: offline,
|
||||
post: offline,
|
||||
put: offline,
|
||||
patch: offline,
|
||||
}),
|
||||
hostProtocols: () => ["ssh"],
|
||||
useHostActions: () => [],
|
||||
useActivityTypes: () => [],
|
||||
activityTarget: () => undefined,
|
||||
useHomepageWidgetTypes: () => [],
|
||||
homepageWidgetType: () => undefined,
|
||||
usePluginUiPreferences: () => ({ values: {}, set: noop }),
|
||||
core: {
|
||||
logActivity: async () => {},
|
||||
getHostPassword: async () => null,
|
||||
patchOpenTab: async () => {},
|
||||
getCustomKeybindings: async () => [],
|
||||
setHostAutoTmux: async () => {},
|
||||
getClientPreference: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -56,6 +56,7 @@ export function registerAiRoutes(
|
||||
notify: ctx.notify,
|
||||
rbac: ctx.rbac,
|
||||
ssh: ctx.ssh,
|
||||
audit: ctx.audit,
|
||||
};
|
||||
|
||||
const logError = (message: string, error: unknown) =>
|
||||
@@ -728,8 +729,9 @@ export function registerAiRoutes(
|
||||
).aiAccess ?? { allowReadOnlyCommands: false };
|
||||
const hosts = await ctx.hosts.list();
|
||||
const config = await providerConfig(provider);
|
||||
const tools = availableTools((service) =>
|
||||
serviceAvailable(ctx.services, service),
|
||||
const tools = availableTools(
|
||||
(service) => serviceAvailable(ctx.services, service),
|
||||
{ allowReadOnlyCommands: access.allowReadOnlyCommands },
|
||||
);
|
||||
|
||||
// no-transform keeps core's compression from buffering the stream,
|
||||
|
||||
@@ -64,8 +64,14 @@ export const FORBIDDEN_DOMAINS = [
|
||||
*/
|
||||
export function availableTools(
|
||||
isAvailable: (service: string) => boolean,
|
||||
options: { allowReadOnlyCommands?: boolean } = {},
|
||||
): AiTool[] {
|
||||
return AI_TOOLS.filter((tool) => !tool.service || isAvailable(tool.service));
|
||||
return AI_TOOLS.filter(
|
||||
(tool) =>
|
||||
(!tool.service || isAvailable(tool.service)) &&
|
||||
(!tool.requiresReadOnlyCommands ||
|
||||
options.allowReadOnlyCommands === true),
|
||||
);
|
||||
}
|
||||
|
||||
/** Tool definitions in the shape the provider adapters expect. */
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function applyProposal(
|
||||
* Runs one approved command over this plugin's SSH pool, mirroring how an
|
||||
* automation run_command step executes.
|
||||
*/
|
||||
async function runCommandOnHost(
|
||||
export async function runCommandOnHost(
|
||||
deps: ToolDeps,
|
||||
hostId: number,
|
||||
command: string,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
import { num, objectSchema, str, type AiTool } from "./types.js";
|
||||
import { readService, SERVICE } from "../services.js";
|
||||
import { isReadOnlyCommand } from "./command-allowlist.js";
|
||||
import { runCommandOnHost } from "./executor.js";
|
||||
|
||||
/** Output handed back to the model is capped; a log can be huge. */
|
||||
const MAX_COMMAND_OUTPUT = 8000;
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
@@ -30,6 +35,47 @@ function toHostSummary(host: PluginHostSummary) {
|
||||
}
|
||||
|
||||
export const readTools: AiTool[] = [
|
||||
{
|
||||
name: "run_readonly_command",
|
||||
description:
|
||||
"Run a read-only diagnostic command on a host and get its output, for example df -h, uptime, free -m or systemctl status nginx. Only commands on a fixed read-only allowlist run; anything else must be proposed with propose_run_command.",
|
||||
category: "read",
|
||||
requiresReadOnlyCommands: true,
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id, as returned by list_hosts"),
|
||||
command: str("The command to run"),
|
||||
},
|
||||
["hostId", "command"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
if (!context.allowReadOnlyCommands) {
|
||||
return { error: "The user has not allowed read-only commands" };
|
||||
}
|
||||
const hostId = Number(args.hostId);
|
||||
const command = typeof args.command === "string" ? args.command : "";
|
||||
const check = isReadOnlyCommand(command);
|
||||
if (!check.allowed) {
|
||||
return { error: check.reason ?? "Command is not read-only" };
|
||||
}
|
||||
const access = await context.deps.hosts.checkAccess(hostId, "connect");
|
||||
if (!access.hasAccess) return { error: "Host not found" };
|
||||
|
||||
const result = await runCommandOnHost(context.deps, hostId, command);
|
||||
await context.deps.audit
|
||||
.record({
|
||||
action: "ai_readonly_command",
|
||||
resourceType: "host",
|
||||
resourceId: String(hostId),
|
||||
details: JSON.stringify({ command }),
|
||||
success: !result.error,
|
||||
errorMessage: result.error,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (result.error) return { error: result.error };
|
||||
return { output: (result.output ?? "").slice(0, MAX_COMMAND_OUTPUT) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_hosts",
|
||||
description:
|
||||
|
||||
@@ -5,7 +5,7 @@ export type ToolCategory = "read" | "propose";
|
||||
/** What tools and the proposal executor reach core and other plugins through. */
|
||||
export type ToolDeps = Pick<
|
||||
PluginContext,
|
||||
"hosts" | "services" | "notify" | "rbac" | "ssh"
|
||||
"hosts" | "services" | "notify" | "rbac" | "ssh" | "audit"
|
||||
>;
|
||||
|
||||
export interface ToolContext {
|
||||
@@ -26,6 +26,8 @@ export interface AiTool {
|
||||
* not offered to the model at all.
|
||||
*/
|
||||
service?: string;
|
||||
/** Offered only to a user who turned on read-only commands. */
|
||||
requiresReadOnlyCommands?: boolean;
|
||||
/** JSON Schema for the arguments, sent to the provider verbatim. */
|
||||
parameters: Record<string, unknown>;
|
||||
/**
|
||||
|
||||
@@ -184,18 +184,11 @@ export function activate(app: TermixApp): void {
|
||||
applyStatus();
|
||||
refresh();
|
||||
// The admin switch and the user's own choice are settings, saved from
|
||||
// the settings screen, so re-read when plugin state or focus changes too.
|
||||
let lastFocusRefresh = Date.now();
|
||||
const onFocus = () => {
|
||||
if (Date.now() - lastFocusRefresh < 30_000) return;
|
||||
lastFocusRefresh = Date.now();
|
||||
refresh();
|
||||
};
|
||||
// the settings screen.
|
||||
app.onSettingsChanged(() => refresh());
|
||||
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
|
||||
for (const event of events) window.addEventListener(event, refresh);
|
||||
window.addEventListener("focus", onFocus);
|
||||
app.onDispose(() => {
|
||||
for (const event of events) window.removeEventListener(event, refresh);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { availableTools, getTool } from "../../../src/backend/tools/catalog.js";
|
||||
import type { ToolContext } from "../../../src/backend/tools/types.js";
|
||||
|
||||
const tool = getTool("run_readonly_command")!;
|
||||
|
||||
function contextWith(overrides: Partial<ToolContext> = {}) {
|
||||
const withConnection = vi.fn(async () => ({
|
||||
stdout: "Filesystem Size",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
}));
|
||||
const record = vi.fn(async () => {});
|
||||
const context = {
|
||||
userId: "user-1",
|
||||
conversationId: 1,
|
||||
allowReadOnlyCommands: true,
|
||||
deps: {
|
||||
hosts: {
|
||||
checkAccess: vi.fn(async () => ({ hasAccess: true })),
|
||||
},
|
||||
ssh: { withConnection },
|
||||
audit: { record },
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as ToolContext;
|
||||
return { context, withConnection, record };
|
||||
}
|
||||
|
||||
describe("run_readonly_command", () => {
|
||||
it("is only offered to a user who opted in", () => {
|
||||
const names = (allow: boolean) =>
|
||||
availableTools(() => true, { allowReadOnlyCommands: allow }).map(
|
||||
(entry) => entry.name,
|
||||
);
|
||||
expect(names(false)).not.toContain("run_readonly_command");
|
||||
expect(names(true)).toContain("run_readonly_command");
|
||||
});
|
||||
|
||||
it("runs an allowlisted command and audits it", async () => {
|
||||
const { context, withConnection, record } = contextWith();
|
||||
const result = await tool.handler({ hostId: 4, command: "df -h" }, context);
|
||||
expect(result).toEqual({ output: "Filesystem Size" });
|
||||
expect(withConnection).toHaveBeenCalledTimes(1);
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: "ai_readonly_command", success: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a command that is not read-only", async () => {
|
||||
const { context, withConnection } = contextWith();
|
||||
const result = (await tool.handler(
|
||||
{ hostId: 4, command: "rm -rf /tmp/x" },
|
||||
context,
|
||||
)) as { error?: string };
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(withConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the user has not opted in, or cannot reach the host", async () => {
|
||||
const off = contextWith({ allowReadOnlyCommands: false });
|
||||
expect(
|
||||
await tool.handler({ hostId: 4, command: "uptime" }, off.context),
|
||||
).toEqual({ error: "The user has not allowed read-only commands" });
|
||||
|
||||
const denied = contextWith();
|
||||
(
|
||||
denied.context.deps.hosts.checkAccess as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({ hasAccess: false });
|
||||
expect(
|
||||
await tool.handler({ hostId: 4, command: "uptime" }, denied.context),
|
||||
).toEqual({ error: "Host not found" });
|
||||
expect(denied.withConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,10 @@
|
||||
"descriptionKey": "permissions.run.description",
|
||||
"defaultRoles": ["user"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"http": {
|
||||
"legacyPaths": ["/automations/webhook"]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -75,6 +76,20 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"uiPresets": {
|
||||
"simple": {
|
||||
"viewMode": "list",
|
||||
"containerLayout": "card"
|
||||
},
|
||||
"balanced": {
|
||||
"viewMode": "list",
|
||||
"containerLayout": "card"
|
||||
},
|
||||
"advanced": {
|
||||
"viewMode": "detail",
|
||||
"containerLayout": "table"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
useConnectionRetry,
|
||||
useHost,
|
||||
useTranslation,
|
||||
logActivity,
|
||||
usePluginUiPreferences,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Alert,
|
||||
@@ -27,12 +29,9 @@ import {
|
||||
Separator,
|
||||
TOTPDialog,
|
||||
BrowserSignInDialog,
|
||||
logActivity,
|
||||
useAdaptivePolling,
|
||||
useAreaPreferences,
|
||||
useConnectionLog,
|
||||
useTabsSafe,
|
||||
useUiPreferencesContext,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
|
||||
import {
|
||||
@@ -82,8 +81,10 @@ function DockerManagerInner({
|
||||
const docker = useDockerApi();
|
||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const dockerPrefs = useAreaPreferences("docker");
|
||||
const uiPrefsCtx = useUiPreferencesContext();
|
||||
const { values: dockerPrefs, set: setDockerPref } = usePluginUiPreferences<{
|
||||
viewMode: "list" | "detail";
|
||||
containerLayout: "card" | "table";
|
||||
}>();
|
||||
|
||||
// The shell's host list keeps the host current after an edit; a
|
||||
// standalone window has no shell, so it keeps the host it was given.
|
||||
@@ -113,9 +114,9 @@ function DockerManagerInner({
|
||||
const handleSetContainerLayout = React.useCallback(
|
||||
(layout: "card" | "table") => {
|
||||
setContainerLayout(layout);
|
||||
uiPrefsCtx?.setOverride("docker", "containerLayout", layout);
|
||||
setDockerPref("containerLayout", layout);
|
||||
},
|
||||
[uiPrefsCtx],
|
||||
[setDockerPref],
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [dockerValidation, setDockerValidation] =
|
||||
|
||||
@@ -401,13 +401,6 @@
|
||||
},
|
||||
"hosts": {
|
||||
"fileManager": "File Manager",
|
||||
"tabFiles": "Files",
|
||||
"enableFileManagerMonitor": "Enable File Manager",
|
||||
"enableFileManagerMonitorDesc": "Browse and manage files on this host over SFTP",
|
||||
"scpLegacyLabel": "SCP Legacy Mode",
|
||||
"scpLegacyDesc": "Use legacy file transfer for servers that do not support the SFTP subsystem (e.g. embedded or minimal SSH servers).",
|
||||
"defaultPathLabel": "Default Path",
|
||||
"fileManagerPathHint": "The directory to open when the file manager launches for this host.",
|
||||
"fileManagerUrlCopied": "File Manager URL copied",
|
||||
"copyFileManagerUrlAction": "Copy File Manager URL"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"events:core",
|
||||
"kv:own"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -70,6 +72,7 @@
|
||||
"host": {
|
||||
"enableKey": "enableFileManager",
|
||||
"enableLabelKey": "settings.host.enable.label",
|
||||
"enableDefault": true,
|
||||
"fields": [
|
||||
{
|
||||
"key": "defaultPath",
|
||||
@@ -89,7 +92,7 @@
|
||||
"hostCapability": {
|
||||
"key": "enableFileManager",
|
||||
"labelKey": "hostCapability.label",
|
||||
"editorTab": "files"
|
||||
"editorTab": "plugins"
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,6 +9,12 @@ import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import { setPluginSsh, pluginSsh } from "./ssh.js";
|
||||
import { setPluginCtx } from "./plugin-ctx.js";
|
||||
import {
|
||||
flushTransferProfiles,
|
||||
setTransferProfileStore,
|
||||
} from "./transfer-tuning.js";
|
||||
|
||||
const TRANSFER_PROFILES_KEY = "transfer-profiles";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
@@ -113,6 +119,14 @@ export async function activate(ctx: PluginContext) {
|
||||
setPluginCtx(ctx);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
ctx.disposables.add(() => setPluginCtx(null));
|
||||
setTransferProfileStore({
|
||||
read: () => ctx.kv.get(TRANSFER_PROFILES_KEY),
|
||||
write: (value) => ctx.kv.set(TRANSFER_PROFILES_KEY, value),
|
||||
});
|
||||
ctx.disposables.add(async () => {
|
||||
await flushTransferProfiles();
|
||||
setTransferProfileStore(null);
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands back an untyped table object; typed at this module's
|
||||
@@ -605,7 +619,11 @@ export async function activate(ctx: PluginContext) {
|
||||
};
|
||||
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
|
||||
Record<string, unknown> | undefined;
|
||||
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
||||
resolvedScpLegacy =
|
||||
(await ctx.settings.getHost<boolean>(
|
||||
Number(resolvedHost.id),
|
||||
"scpLegacy",
|
||||
)) === true;
|
||||
if (resolvedHost.useSocks5) {
|
||||
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||
resolvedSocks5Host = resolvedHost.socks5Host;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// TODO(file-manager): move this to ctx.kv, see FINISH-LIST.md
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface TransferPerformanceProfile {
|
||||
throughputBps: number;
|
||||
@@ -32,15 +29,32 @@ const MB = 1024 * 1024;
|
||||
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_PROFILES = 128;
|
||||
const STORE_VERSION = 1;
|
||||
const STORE_FILENAME = "adaptive-transfer-profiles.json";
|
||||
const profiles = new Map<string, TransferPerformanceProfile>();
|
||||
const directRoutes = new Map<string, DirectRouteProfile>();
|
||||
let loadedPath: string | undefined;
|
||||
|
||||
/** Where the learned profiles live: ctx.kv, set on activate. */
|
||||
export interface TransferProfileStore {
|
||||
read: () => Promise<unknown>;
|
||||
write: (value: PersistedProfiles) => Promise<void>;
|
||||
}
|
||||
|
||||
let store: TransferProfileStore | null = null;
|
||||
let loadedStore: TransferProfileStore | null = null;
|
||||
let loadPromise: Promise<void> | undefined;
|
||||
|
||||
export function setTransferProfileStore(
|
||||
next: TransferProfileStore | null,
|
||||
): void {
|
||||
store = next;
|
||||
loadedStore = null;
|
||||
loadPromise = undefined;
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
}
|
||||
let persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let persistPromise = Promise.resolve();
|
||||
|
||||
interface PersistedProfiles {
|
||||
export interface PersistedProfiles {
|
||||
version: typeof STORE_VERSION;
|
||||
profiles: Record<string, TransferPerformanceProfile>;
|
||||
directRoutes: Record<string, DirectRouteProfile>;
|
||||
@@ -68,12 +82,6 @@ function validDirectRoute(value: unknown): value is DirectRouteProfile {
|
||||
);
|
||||
}
|
||||
|
||||
function storePath(): string {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
return path.join(dataDir, STORE_FILENAME);
|
||||
}
|
||||
|
||||
function profileId(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
@@ -117,22 +125,15 @@ function trimProfiles(now = Date.now()): void {
|
||||
|
||||
async function persistProfiles(): Promise<void> {
|
||||
trimProfiles();
|
||||
const target = storePath();
|
||||
const temporary = `${target}.${process.pid}.tmp`;
|
||||
const payload: PersistedProfiles = {
|
||||
version: STORE_VERSION,
|
||||
profiles: Object.fromEntries(profiles),
|
||||
directRoutes: Object.fromEntries(directRoutes),
|
||||
};
|
||||
if (!store) return;
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(temporary, JSON.stringify(payload), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
await store.write({
|
||||
version: STORE_VERSION,
|
||||
profiles: Object.fromEntries(profiles),
|
||||
directRoutes: Object.fromEntries(directRoutes),
|
||||
});
|
||||
await fs.rename(temporary, target);
|
||||
} catch {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
// Learning data is a nicety; a failed write must not affect transfers.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,16 +147,16 @@ function queuePersist(): void {
|
||||
}
|
||||
|
||||
export async function initializeTransferProfiles(): Promise<void> {
|
||||
const target = storePath();
|
||||
if (loadedPath === target) return loadPromise;
|
||||
loadedPath = target;
|
||||
if (loadPromise && loadedStore === store) return loadPromise;
|
||||
loadedStore = store;
|
||||
const current = store;
|
||||
loadPromise = (async () => {
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
if (!current) return;
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(target, "utf8"),
|
||||
) as Partial<PersistedProfiles>;
|
||||
const parsed = ((await current.read()) ??
|
||||
{}) as Partial<PersistedProfiles>;
|
||||
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
|
||||
for (const [key, profile] of Object.entries(parsed.profiles)) {
|
||||
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
|
||||
@@ -381,7 +382,7 @@ export function getRecentDirectRouteDecision(
|
||||
export function clearTransferProfiles(): void {
|
||||
if (persistTimer) clearTimeout(persistTimer);
|
||||
persistTimer = undefined;
|
||||
loadedPath = undefined;
|
||||
loadedStore = null;
|
||||
loadPromise = undefined;
|
||||
profiles.clear();
|
||||
directRoutes.clear();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileManagerHostSetting } from "./host-settings";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, {
|
||||
@@ -44,7 +45,11 @@ import {
|
||||
ConnectionScreen,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
|
||||
import { invokeAction, useConnectionRetry } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
invokeAction,
|
||||
useConnectionRetry,
|
||||
logActivity,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { copyToClipboard } from "@/lib/clipboard.ts";
|
||||
import {
|
||||
listSSHFiles,
|
||||
@@ -77,7 +82,6 @@ import {
|
||||
getPinnedFiles,
|
||||
} from "./api/file-manager-data-api";
|
||||
import {
|
||||
logActivity,
|
||||
transferToHost,
|
||||
addTransferRecent,
|
||||
type TransferMethodPreference,
|
||||
@@ -145,15 +149,21 @@ function FileManagerContent({
|
||||
|
||||
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
||||
const [currentPath, setCurrentPath] = useState(
|
||||
initialPath || initialHost?.defaultPath || "/",
|
||||
initialPath ||
|
||||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||
"/",
|
||||
);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
const lastSuccessfulPathRef = useRef(
|
||||
initialPath || initialHost?.defaultPath || "/",
|
||||
initialPath ||
|
||||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||
"/",
|
||||
);
|
||||
const [navHistory, setNavHistory] = useState<string[]>([
|
||||
initialPath || initialHost?.defaultPath || "/",
|
||||
initialPath ||
|
||||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||
"/",
|
||||
]);
|
||||
const [navIndex, setNavIndex] = useState(0);
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Input,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
FakeSwitch,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { FolderSearch } from "lucide-react";
|
||||
import type { HostEditorForm } from "@/sidebar/HostEditorData";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostFilesTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.fileManager")}
|
||||
icon={<FolderSearch className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableFileManagerMonitor")}
|
||||
description={t("hosts.enableFileManagerMonitorDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableFileManager}
|
||||
onChange={(v) => setField("enableFileManager", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.scpLegacyLabel")}
|
||||
description={t("hosts.scpLegacyDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.scpLegacy}
|
||||
onChange={(v) => setField("scpLegacy", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.defaultPathLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={form.defaultPath}
|
||||
onChange={(e) => setField("defaultPath", e.target.value)}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.fileManagerPathHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileManagerHostSetting } from "./host-settings";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -372,11 +373,11 @@ function RemotePane({
|
||||
...current,
|
||||
connectionState: "ready",
|
||||
sessionId: result.sessionId || String(host.id),
|
||||
path: host.defaultPath || "/",
|
||||
path: fileManagerHostSetting(host, "defaultPath", "") || "/",
|
||||
}));
|
||||
await loadRemotePath(
|
||||
result.sessionId || String(host.id),
|
||||
host.defaultPath || "/",
|
||||
fileManagerHostSetting(host, "defaultPath", "") || "/",
|
||||
);
|
||||
},
|
||||
[loadRemotePath, setPane],
|
||||
@@ -585,7 +586,7 @@ export function SftpTransferTab() {
|
||||
setHosts(
|
||||
data.filter(
|
||||
(host) =>
|
||||
host.enableFileManager !== false &&
|
||||
fileManagerHostSetting(host, "enableFileManager", true) &&
|
||||
host.connectionType !== "rdp" &&
|
||||
host.connectionType !== "vnc",
|
||||
),
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import React, { useState } from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
|
||||
// No workerSrc: the termix-plugin CLI's esbuild step has no asset-copy
|
||||
// pipeline for a worker file the way core's Vite build does with `?url`, so
|
||||
// pdfjs falls back to parsing on the main thread instead. Fine for the
|
||||
// config/log-sized files this preview usually opens; a real worker needs the
|
||||
// CLI to gain static-asset output first (see FINISH-LIST.md).
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
|
||||
|
||||
interface PdfPreviewProps {
|
||||
content: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileManagerHostSetting } from "../host-settings";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -236,7 +237,7 @@ export function TransferToHostDialog({
|
||||
const candidates = hosts.filter(
|
||||
(h) =>
|
||||
h.id !== sourceHost.id &&
|
||||
h.enableFileManager !== false &&
|
||||
fileManagerHostSetting(h, "enableFileManager", true) &&
|
||||
h.connectionType !== "rdp" &&
|
||||
h.connectionType !== "vnc",
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileManagerHostSetting } from "../host-settings";
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
@@ -12,7 +13,9 @@ export function FileManagerWidgetEditForm({
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const options = hosts.filter(
|
||||
(host) => host.enableSsh !== false && host.enableFileManager !== false,
|
||||
(host) =>
|
||||
host.enableSsh !== false &&
|
||||
fileManagerHostSetting(host, "enableFileManager", true),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** One of this plugin's host settings, from the host payload. */
|
||||
export function fileManagerHostSetting<T>(
|
||||
host: object | null | undefined,
|
||||
key: "enableFileManager" | "defaultPath" | "scpLegacy",
|
||||
fallback: T,
|
||||
): T {
|
||||
const settings = (
|
||||
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
|
||||
)?.pluginSettings;
|
||||
const value = settings?.["file-manager"]?.[key];
|
||||
return value === undefined || value === null || value === ""
|
||||
? fallback
|
||||
: (value as T);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileManagerHostSetting } from "./host-settings";
|
||||
import type { ComponentType } from "react";
|
||||
import { FolderSearch, ArrowLeftRight } from "lucide-react";
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import { GRID_SIZE } from "./homepage/homepage.js";
|
||||
import { FileManager } from "./FileManager.tsx";
|
||||
import FileManagerApp from "./FileManagerApp.tsx";
|
||||
import { SftpTransferTab } from "./SftpTransferTab.tsx";
|
||||
import { HostFilesTab } from "./HostFilesTab.tsx";
|
||||
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
|
||||
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
|
||||
import { startTransferMonitor } from "./TransferMonitor.tsx";
|
||||
@@ -104,16 +104,9 @@ export function activate(app: TermixApp): void {
|
||||
order: 20,
|
||||
tabType: "files",
|
||||
copyUrlView: "file-manager",
|
||||
when: (host) => !!host.enableSsh && host.enableFileManager !== false,
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "files",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabFiles",
|
||||
icon: FolderSearch,
|
||||
order: 60,
|
||||
component: HostFilesTab as never,
|
||||
when: (host) =>
|
||||
!!host.enableSsh &&
|
||||
fileManagerHostSetting(host, "enableFileManager", true),
|
||||
});
|
||||
|
||||
app.registerHomepageWidget({
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearTransferProfiles,
|
||||
flushTransferProfiles,
|
||||
getTransferProfile,
|
||||
initializeTransferProfiles,
|
||||
recordTransferProfile,
|
||||
setTransferProfileStore,
|
||||
type PersistedProfiles,
|
||||
} from "../../src/backend/transfer-tuning.js";
|
||||
|
||||
afterEach(() => {
|
||||
setTransferProfileStore(null);
|
||||
clearTransferProfiles();
|
||||
});
|
||||
|
||||
function memoryStore() {
|
||||
let saved: PersistedProfiles | undefined;
|
||||
return {
|
||||
store: {
|
||||
read: async () => saved,
|
||||
write: async (value: PersistedProfiles) => {
|
||||
saved = JSON.parse(JSON.stringify(value));
|
||||
},
|
||||
},
|
||||
saved: () => saved,
|
||||
};
|
||||
}
|
||||
|
||||
describe("transfer profile store", () => {
|
||||
it("saves learned profiles and reads them back after a restart", async () => {
|
||||
const memory = memoryStore();
|
||||
setTransferProfileStore(memory.store);
|
||||
await initializeTransferProfiles();
|
||||
|
||||
recordTransferProfile("host-a:host-b", {
|
||||
bytes: 50 * 1024 * 1024,
|
||||
durationMs: 5000,
|
||||
lanes: 4,
|
||||
pipelineConcurrency: 8,
|
||||
failed: false,
|
||||
now: Date.now(),
|
||||
});
|
||||
await flushTransferProfiles();
|
||||
expect(Object.keys(memory.saved()?.profiles ?? {})).toHaveLength(1);
|
||||
|
||||
// A fresh activation starts from what the store holds.
|
||||
setTransferProfileStore(memory.store);
|
||||
await initializeTransferProfiles();
|
||||
expect(getTransferProfile("host-a:host-b")).toBeDefined();
|
||||
});
|
||||
|
||||
it("works without a store", async () => {
|
||||
await initializeTransferProfiles();
|
||||
expect(getTransferProfile("nothing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -59,17 +59,19 @@ beforeEach(() => {
|
||||
id: 1,
|
||||
name: "prod",
|
||||
ip: "10.0.0.1",
|
||||
enableFileManager: true,
|
||||
connectionType: "ssh",
|
||||
defaultPath: "/srv",
|
||||
pluginSettings: {
|
||||
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "backup",
|
||||
ip: "10.0.0.2",
|
||||
enableFileManager: true,
|
||||
connectionType: "ssh",
|
||||
defaultPath: "/srv",
|
||||
pluginSettings: {
|
||||
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
|
||||
|
||||
@@ -52,9 +52,9 @@ describe(`${manifest.id} activate`, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the files host editor section", async () => {
|
||||
it("leaves its host fields to the schema-driven Plugins tab", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.hostEditorSections()).toContain("files");
|
||||
expect(rendered.registered.hostEditorSections()).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers files.openHost and files.openEditor actions", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios.ts", () => ({}));
|
||||
vi.mock("../../../features/file-manager/transferNotificationStore.ts", () => ({
|
||||
vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
|
||||
markTransferNotified: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function SshTerminalEditForm({
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableTerminal)}
|
||||
filter={(h) => !!h.enableSsh}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -136,7 +137,18 @@
|
||||
"id": "host-metrics.managers",
|
||||
"accepts": ["component"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"uiPresets": {
|
||||
"simple": {
|
||||
"columns": 1
|
||||
},
|
||||
"balanced": {
|
||||
"columns": 3
|
||||
},
|
||||
"advanced": {
|
||||
"columns": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -98,7 +98,12 @@ export async function collectMetrics(
|
||||
onAuthenticated?.();
|
||||
const platform = await detectHostPlatform(client);
|
||||
const result: CollectedMetrics = {
|
||||
cpu: await collectCpuMetrics(client, platform, host.id),
|
||||
cpu: await collectCpuMetrics(
|
||||
client,
|
||||
platform,
|
||||
host.id,
|
||||
state.rateSamples.cpu,
|
||||
),
|
||||
memory: await collectMemoryMetrics(client, platform),
|
||||
disk: await collectDiskMetrics(
|
||||
client,
|
||||
@@ -106,7 +111,12 @@ export async function collectMetrics(
|
||||
mounts.monitoredMounts,
|
||||
platform,
|
||||
),
|
||||
network: await collectNetworkMetrics(client, platform, host.id),
|
||||
network: await collectNetworkMetrics(
|
||||
client,
|
||||
platform,
|
||||
host.id,
|
||||
state.rateSamples.network,
|
||||
),
|
||||
uptime: await collectUptimeMetrics(client, platform),
|
||||
processes: await collectProcessesMetrics(client),
|
||||
system: await collectSystemMetrics(client, platform),
|
||||
|
||||
@@ -30,3 +30,32 @@ export function hostImportNormalizer(
|
||||
});
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "host-metrics.hostPayloadLegacy": the 2.8 statsConfig shape
|
||||
* Termix-Mobile still reads. Remove once the mobile app reads
|
||||
* pluginSettings["host-metrics"] and GET /host/status.
|
||||
*/
|
||||
export function hostPayloadLegacy(
|
||||
values: Record<string, unknown>,
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const settings = readHostMetricsSettings(values);
|
||||
const statusInterval =
|
||||
typeof host.statusCheckInterval === "number"
|
||||
? host.statusCheckInterval
|
||||
: null;
|
||||
return {
|
||||
statsConfig: {
|
||||
enabledWidgets: settings.enabledWidgets,
|
||||
statusCheckEnabled: host.statusCheckEnabled !== false,
|
||||
statusCheckInterval: statusInterval ?? 30,
|
||||
useGlobalStatusInterval: statusInterval === null,
|
||||
metricsEnabled: settings.metricsEnabled,
|
||||
metricsInterval: settings.metricsInterval ?? 30,
|
||||
useGlobalMetricsInterval: settings.metricsInterval == null,
|
||||
excludedMounts: settings.excludedMounts,
|
||||
monitoredMounts: settings.monitoredMounts,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { MetricsSessions } from "./sessions.js";
|
||||
import { CollectorRegistry } from "./collectors.js";
|
||||
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
|
||||
import { newSessionId, supportsMetrics } from "./helpers.js";
|
||||
|
||||
export type { MetricsCollectorV1 } from "./collectors.js";
|
||||
@@ -101,6 +101,7 @@ export async function activate(ctx: PluginContext) {
|
||||
"host-metrics.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
ctx.registry.provide("host-metrics.hostPayloadLegacy", hostPayloadLegacy);
|
||||
|
||||
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
metricsConcurrencyFor,
|
||||
type MetricsState,
|
||||
} from "./state.js";
|
||||
import { clearCpuSampleCache } from "./widgets/cpu-collector.js";
|
||||
import { clearNetworkSampleCache } from "./widgets/network-collector.js";
|
||||
|
||||
export const DEFAULT_METRICS_INTERVAL = 30;
|
||||
export const DEFAULT_RETENTION_DAYS = 7;
|
||||
@@ -201,8 +199,7 @@ export class MetricsPoller {
|
||||
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
|
||||
this.viewers.clear();
|
||||
this.viewerDetails.clear();
|
||||
clearCpuSampleCache();
|
||||
clearNetworkSampleCache();
|
||||
this.state.rateSamples.clear();
|
||||
}
|
||||
|
||||
/** The host as `userId` may reach it, cached for a few minutes. */
|
||||
@@ -274,8 +271,7 @@ export class MetricsPoller {
|
||||
if (clearData) {
|
||||
this.metrics.delete(hostId);
|
||||
this.state.hostCache.invalidate(hostId);
|
||||
clearCpuSampleCache(hostId);
|
||||
clearNetworkSampleCache(hostId);
|
||||
this.state.rateSamples.clear(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { CpuSamples } from "./widgets/cpu-collector.js";
|
||||
import { createNetworkSamples } from "./widgets/network-collector.js";
|
||||
|
||||
export type HostStatus = "online" | "reachable" | "offline";
|
||||
|
||||
export class RequestQueue {
|
||||
@@ -422,8 +425,30 @@ export function canStartInitialMetrics(
|
||||
}
|
||||
|
||||
/** Everything one activation of the plugin polls with. */
|
||||
/** The last CPU and network sample per host, for rates between polls. */
|
||||
export function createRateSamples() {
|
||||
const cpu: CpuSamples = new Map();
|
||||
const network = createNetworkSamples();
|
||||
return {
|
||||
cpu,
|
||||
network,
|
||||
clear(hostId?: number) {
|
||||
if (hostId === undefined) {
|
||||
cpu.clear();
|
||||
network.counters.clear();
|
||||
network.windows.clear();
|
||||
return;
|
||||
}
|
||||
cpu.delete(hostId);
|
||||
network.counters.delete(hostId);
|
||||
network.windows.delete(hostId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMetricsState() {
|
||||
return {
|
||||
rateSamples: createRateSamples(),
|
||||
/** SSH metrics execs are expensive; resized with the fleet. */
|
||||
metricsLimiter: new ConcurrentLimiter(5),
|
||||
/** Viewer registration is bursty; admit only two first samples at a time. */
|
||||
|
||||
@@ -9,20 +9,13 @@ import type { Client } from "ssh2";
|
||||
/**
|
||||
* CPU percent is derived from the delta against the previous poll's
|
||||
* /proc/stat sample rather than a same-poll double-read, so a poll no longer
|
||||
* has to block for a fixed settle window. Keyed by host.
|
||||
* has to block for a fixed settle window. Keyed by host, and owned by the
|
||||
* poller so it goes away with the plugin.
|
||||
*/
|
||||
const previousStat = new Map<
|
||||
export type CpuSamples = Map<
|
||||
number,
|
||||
{ total: number; idle: number; timestamp: number }
|
||||
>();
|
||||
|
||||
export function clearCpuSampleCache(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
previousStat.clear();
|
||||
return;
|
||||
}
|
||||
previousStat.delete(hostId);
|
||||
}
|
||||
>;
|
||||
|
||||
export function parseCpuLine(
|
||||
cpuLine: string,
|
||||
@@ -149,6 +142,7 @@ export async function collectCpuMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
hostId?: number,
|
||||
previousStat: CpuSamples = new Map(),
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
|
||||
@@ -49,24 +49,16 @@ export function counterRate(
|
||||
* double-sample, so a poll no longer has to block for a fixed settle window.
|
||||
* Keyed by host, since counters from one host are meaningless against another.
|
||||
*/
|
||||
const previousSamples = new Map<
|
||||
number,
|
||||
{ counters: Map<string, NetworkCounters>; timestamp: number }
|
||||
>();
|
||||
export interface NetworkSamples {
|
||||
counters: Map<
|
||||
number,
|
||||
{ counters: Map<string, NetworkCounters>; timestamp: number }
|
||||
>;
|
||||
windows: Map<number, { rows: WindowsAdapterRow[]; timestamp: number }>;
|
||||
}
|
||||
|
||||
const previousWindowsSamples = new Map<
|
||||
number,
|
||||
{ rows: WindowsAdapterRow[]; timestamp: number }
|
||||
>();
|
||||
|
||||
export function clearNetworkSampleCache(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
previousSamples.clear();
|
||||
previousWindowsSamples.clear();
|
||||
return;
|
||||
}
|
||||
previousSamples.delete(hostId);
|
||||
previousWindowsSamples.delete(hostId);
|
||||
export function createNetworkSamples(): NetworkSamples {
|
||||
return { counters: new Map(), windows: new Map() };
|
||||
}
|
||||
|
||||
export function parseDarwinIfconfig(
|
||||
@@ -112,7 +104,8 @@ export function parseDarwinNetstat(
|
||||
|
||||
async function collectDarwinNetworkMetrics(
|
||||
client: Client,
|
||||
hostId?: number,
|
||||
hostId: number | undefined,
|
||||
samples: NetworkSamples,
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
@@ -143,7 +136,7 @@ async function collectDarwinNetworkMetrics(
|
||||
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
|
||||
const current = parseDarwinNetstat(netstatOut.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? previousSamples.get(hostId) : undefined;
|
||||
hostId !== undefined ? samples.counters.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous
|
||||
? (readAt - previous.timestamp) / 1000
|
||||
: 0;
|
||||
@@ -166,7 +159,7 @@ async function collectDarwinNetworkMetrics(
|
||||
});
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
previousSamples.set(hostId, { counters: current, timestamp: readAt });
|
||||
samples.counters.set(hostId, { counters: current, timestamp: readAt });
|
||||
}
|
||||
} catch {
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
@@ -225,7 +218,8 @@ export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
|
||||
|
||||
async function collectWindowsNetworkMetrics(
|
||||
client: Client,
|
||||
hostId?: number,
|
||||
hostId: number | undefined,
|
||||
samples: NetworkSamples,
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
@@ -252,7 +246,7 @@ async function collectWindowsNetworkMetrics(
|
||||
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
|
||||
const currentRows = parseWindowsAdapterJson(result.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined;
|
||||
hostId !== undefined ? samples.windows.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
|
||||
const previousMap = new Map(
|
||||
(previous?.rows ?? []).map((row) => [row.name, row]),
|
||||
@@ -276,7 +270,7 @@ async function collectWindowsNetworkMetrics(
|
||||
}
|
||||
|
||||
if (hostId !== undefined) {
|
||||
previousWindowsSamples.set(hostId, {
|
||||
samples.windows.set(hostId, {
|
||||
rows: currentRows,
|
||||
timestamp: readAt,
|
||||
});
|
||||
@@ -292,6 +286,7 @@ export async function collectNetworkMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
hostId?: number,
|
||||
samples: NetworkSamples = createNetworkSamples(),
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
@@ -304,10 +299,10 @@ export async function collectNetworkMetrics(
|
||||
}>;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinNetworkMetrics(client, hostId);
|
||||
return collectDarwinNetworkMetrics(client, hostId, samples);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsNetworkMetrics(client, hostId);
|
||||
return collectWindowsNetworkMetrics(client, hostId, samples);
|
||||
}
|
||||
|
||||
const interfaces: Array<{
|
||||
@@ -368,7 +363,7 @@ export async function collectNetworkMetrics(
|
||||
const procNet = await execCommand(client, "cat /proc/net/dev");
|
||||
const rxTxMap = parseNetworkCounters(procNet.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? previousSamples.get(hostId) : undefined;
|
||||
hostId !== undefined ? samples.counters.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous
|
||||
? (readAt - previous.timestamp) / 1000
|
||||
: 0;
|
||||
@@ -395,7 +390,7 @@ export async function collectNetworkMetrics(
|
||||
});
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
previousSamples.set(hostId, {
|
||||
samples.counters.set(hostId, {
|
||||
counters: rxTxMap,
|
||||
timestamp: readAt,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Separator,
|
||||
Button,
|
||||
useAreaPreferences,
|
||||
useConfirmation,
|
||||
TOTPDialog,
|
||||
useTabsSafe,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
CardGridCanvas,
|
||||
ColumnCountStepper,
|
||||
type GridCardCatalogEntry,
|
||||
logActivity,
|
||||
SnippetVariablesDialog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
@@ -52,6 +50,8 @@ import {
|
||||
useHost,
|
||||
useHostStatus,
|
||||
invokeAction,
|
||||
logActivity,
|
||||
usePluginUiPreferences,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
|
||||
@@ -161,7 +161,9 @@ function HostMetricsInner({
|
||||
const hostId = currentHostConfig?.id ?? null;
|
||||
const { layout, setLayout } = useHostMetricsPreferences(hostId);
|
||||
|
||||
const metricsPrefs = useAreaPreferences("hostMetrics");
|
||||
const { values: metricsPrefs } = usePluginUiPreferences<{
|
||||
columns: number;
|
||||
}>();
|
||||
|
||||
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
|
||||
// A saved layout is user-authored, so the preset never rewrites it -- it
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
usePluginApi,
|
||||
usePluginApiFor,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
cn,
|
||||
getPollingEnvironmentMultiplier,
|
||||
resolveConnectionOrigin,
|
||||
resolveRemoteHostId,
|
||||
runAdaptivePolling,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
|
||||
/** What the ssh-terminal plugin hands a "terminal.toolbarStatus" component. */
|
||||
interface TerminalToolbarStatusProps {
|
||||
host: { id: number | string };
|
||||
host: {
|
||||
id: number | string;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
syncId?: string | null;
|
||||
};
|
||||
isConnected: boolean;
|
||||
/** Visible on a desktop viewport and connected: poll only while true. */
|
||||
active: boolean;
|
||||
@@ -59,7 +69,8 @@ function StatBar({
|
||||
export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
||||
const { host, active } = props as unknown as TerminalToolbarStatusProps;
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const localApi = usePluginApi();
|
||||
const remoteApi = usePluginApiFor("remote");
|
||||
const [metrics, setMetrics] = useState<Sample | null>(null);
|
||||
const [available, setAvailable] = useState(true);
|
||||
const hostId = host?.id ? Number(host.id) : null;
|
||||
@@ -73,6 +84,9 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
||||
let stopPolling: (() => void) | undefined;
|
||||
let viewerSessionId: string | undefined;
|
||||
let previous: Sample | null = null;
|
||||
// A host on the desktop app's remote server has its own id there.
|
||||
let api = localApi;
|
||||
let targetId: number = hostId;
|
||||
setAvailable(true);
|
||||
|
||||
const poll = async () => {
|
||||
@@ -81,7 +95,7 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
||||
cpu?: { percent?: number };
|
||||
memory?: { percent?: number };
|
||||
disk?: { percent?: number };
|
||||
} | null>(`/metrics/${hostId}`, {
|
||||
} | null>(`/metrics/${targetId}`, {
|
||||
validateStatus: (status: number) => status === 200 || status === 404,
|
||||
});
|
||||
const data = response.data;
|
||||
@@ -109,14 +123,24 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
if ((await resolveConnectionOrigin(host)) === "remote") {
|
||||
const remoteId = await resolveRemoteHostId(host.syncId);
|
||||
if (cancelled) return;
|
||||
if (remoteId === null) {
|
||||
setAvailable(false);
|
||||
return;
|
||||
}
|
||||
api = remoteApi;
|
||||
targetId = remoteId;
|
||||
}
|
||||
const { data } = await api.post<{
|
||||
requires_totp?: boolean;
|
||||
viewerSessionId?: string;
|
||||
}>(`/metrics/start/${hostId}`);
|
||||
}>(`/metrics/start/${targetId}`);
|
||||
if (cancelled) {
|
||||
if (data.viewerSessionId) {
|
||||
void api
|
||||
.post(`/metrics/stop/${hostId}`, {
|
||||
.post(`/metrics/stop/${targetId}`, {
|
||||
viewerSessionId: data.viewerSessionId,
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -148,10 +172,10 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
||||
cancelled = true;
|
||||
stopPolling?.();
|
||||
void api
|
||||
.post(`/metrics/stop/${hostId}`, { viewerSessionId })
|
||||
.post(`/metrics/stop/${targetId}`, { viewerSessionId })
|
||||
.catch(() => {});
|
||||
};
|
||||
}, [active, hostId, api]);
|
||||
}, [active, hostId, localApi, remoteApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!available) {
|
||||
return (
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface CronEntry {
|
||||
raw: string;
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface FirewallData {
|
||||
type: "iptables" | "nftables" | "none";
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface HealthCheck {
|
||||
id: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ScrollText } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface LogFiles {
|
||||
common: string[];
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Button, MetricCard } from "@termix/plugin-sdk/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { RefreshCw, AlertTriangle } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { ManagerError } from "./useManagerData";
|
||||
|
||||
export function ManagerCardShell({
|
||||
title,
|
||||
icon,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
empty,
|
||||
emptyMessage,
|
||||
children,
|
||||
headerExtra,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
loading: boolean;
|
||||
error: ManagerError | null;
|
||||
onRefresh: () => void;
|
||||
empty?: boolean;
|
||||
emptyMessage?: string;
|
||||
children: ReactNode;
|
||||
headerExtra?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MetricCard
|
||||
title={title}
|
||||
icon={icon}
|
||||
scroll
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
{headerExtra}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title={t("hostMetrics.refresh")}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<AlertTriangle className="size-6 text-yellow-500" />
|
||||
<span className="text-xs text-muted-foreground">{error.message}</span>
|
||||
{error.code === "SUDO_REQUIRED" && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hostMetrics.managers.sudoHint")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="mt-1"
|
||||
>
|
||||
{t("hostMetrics.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : empty ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-muted-foreground/50">
|
||||
{emptyMessage ?? t("hostMetrics.managers.noData")}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* Compact search row shared by manager cards. Optional `extra` renders to the
|
||||
* right (filters, action buttons) and `count` shows a result tally.
|
||||
*/
|
||||
export function ManagerSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
count,
|
||||
extra,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
count?: number;
|
||||
extra?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? t("hostMetrics.managers.filter")}
|
||||
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{count != null && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface UpgradablePackage {
|
||||
name: string;
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface ProcessRow {
|
||||
pid: number;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface SystemdService {
|
||||
unit: string;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useMemo, useState } from "react";
|
||||
import { MemoryStick, Timer, HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useManagerData } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface MemProc {
|
||||
pid: number;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useManagerAction,
|
||||
extractError,
|
||||
} from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface CertInfo {
|
||||
client: string;
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface SystemUser {
|
||||
name: string;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useManagerData, useManagerAction } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface WireGuardPeer {
|
||||
publicKey: string;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hostPayloadLegacy } from "../../src/backend/host-import.js";
|
||||
|
||||
describe("hostPayloadLegacy", () => {
|
||||
it("rebuilds the 2.8 statsConfig Termix-Mobile reads", () => {
|
||||
const { statsConfig } = hostPayloadLegacy(
|
||||
{ metricsEnabled: false, metricsInterval: 45, enabledWidgets: ["cpu"] },
|
||||
{ statusCheckEnabled: true, statusCheckInterval: null },
|
||||
) as { statsConfig: Record<string, unknown> };
|
||||
|
||||
expect(statsConfig).toMatchObject({
|
||||
enabledWidgets: ["cpu"],
|
||||
statusCheckEnabled: true,
|
||||
useGlobalStatusInterval: true,
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
useGlobalMetricsInterval: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a host's own status interval", () => {
|
||||
const { statsConfig } = hostPayloadLegacy(
|
||||
{},
|
||||
{ statusCheckEnabled: false, statusCheckInterval: 120 },
|
||||
) as { statsConfig: Record<string, unknown> };
|
||||
expect(statsConfig).toMatchObject({
|
||||
statusCheckEnabled: false,
|
||||
statusCheckInterval: 120,
|
||||
useGlobalStatusInterval: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,6 @@ vi.mock("../../src/frontend/host-metrics-api", async (importOriginal) => {
|
||||
|
||||
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
useAreaPreferences: () => ({ columns: 3 }),
|
||||
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user