fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+6 -2
View File
@@ -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]
+6
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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;
+5 -7
View File
@@ -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;
+52
View File
@@ -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;
+34 -34
View File
@@ -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
+14
View File
@@ -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;
+6
View File
@@ -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;
+34 -34
View File
@@ -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
+14
View File
@@ -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
}
]
}
+5 -7
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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: [
+179
View File
@@ -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",
+2
View File
@@ -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",
+138 -31
View File
@@ -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
+51 -584
View File
@@ -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",
};
});
},
};
}
+166 -14
View File
@@ -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"
}
}
]
}
}
}
}
}
},
+28 -3
View File
@@ -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>;
}
+147
View File
@@ -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();
}
+161 -1
View File
@@ -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`,
);
}
}
});
}
}
+89 -10
View File
@@ -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;
}
+25
View File
@@ -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 -2
View File
@@ -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,
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+4 -2
View File
@@ -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,
+7 -1
View File
@@ -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. */
+1 -1
View File
@@ -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,
+47 -1
View File
@@ -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:
+3 -1
View File
@@ -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>;
/**
+2 -9
View File
@@ -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();
});
});
+4 -1
View File
@@ -116,7 +116,10 @@
"descriptionKey": "permissions.run.description",
"defaultRoles": ["user"]
}
]
],
"http": {
"legacyPaths": ["/automations/webhook"]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
+1 -1
View File
@@ -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": {
+16 -1
View File
@@ -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",
+1 -1
View File
@@ -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] =
-7
View File
@@ -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"
}
+5 -2
View File
@@ -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",
+1 -1
View File
@@ -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 -1
View File
@@ -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);
}
+4 -11
View File
@@ -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(),
}));
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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
+14 -2
View File
@@ -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"]
+1 -1
View File
@@ -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 -2
View File
@@ -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,
},
};
}
+2 -1
View File
@@ -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");
}
+2 -6
View File
@@ -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);
}
}
+25
View File
@@ -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