mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -81,11 +81,15 @@ jobs:
|
|||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run 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
|
- name: Run unit tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
- name: Build
|
- name: Run plugin tests
|
||||||
run: npm run build
|
run: npm run test:plugins
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
needs: [prep, verify]
|
needs: [prep, verify]
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Run plugin tests
|
||||||
|
run: npm run test:plugins
|
||||||
|
|
||||||
database-dialects:
|
database-dialects:
|
||||||
name: Postgres and MySQL
|
name: Postgres and MySQL
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ jobs:
|
|||||||
npm run lint:fix || true
|
npm run lint:fix || true
|
||||||
npm run format
|
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
|
- name: Run unit tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -87,8 +87,17 @@ RUN npm ci --omit=dev --ignore-scripts && \
|
|||||||
FROM node:26-slim
|
FROM node:26-slim
|
||||||
WORKDIR /app
|
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 \
|
ENV DATA_DIR=/app/data \
|
||||||
OPKSSH_BUNDLED_DIR=/app/opkssh-bundled \
|
OPKSSH_BUNDLED_DIR=/app/opkssh-bundled \
|
||||||
|
OPKSSH_VERSION=$OPKSSH_VERSION \
|
||||||
|
OPKSSH_SHA256_AMD64=$OPKSSH_SHA256_AMD64 \
|
||||||
|
OPKSSH_SHA256_ARM64=$OPKSSH_SHA256_ARM64 \
|
||||||
PORT=8080 \
|
PORT=8080 \
|
||||||
NODE_ENV=production \
|
NODE_ENV=production \
|
||||||
POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd
|
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=production-deps /app/node_modules /app/node_modules
|
||||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||||
# Bundled first-party plugins (ssh-terminal). Resolved as dist/plugins by
|
# Bundled first-party plugins, resolved as dist/plugins by
|
||||||
# getBundledPluginsDir(); without this the terminal never starts.
|
# getBundledPluginsDir().
|
||||||
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
|
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.
|
# 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
|
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
+1
-1
@@ -144,7 +144,7 @@ http {
|
|||||||
root /app/html;
|
root /app/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
expires off;
|
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 X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
||||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. A real DROP
|
-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four
|
||||||
-- TABLE here would run before the plugin's adoption migration and delete
|
-- ssh_data proxmox columns are copied into the plugin's host settings after
|
||||||
-- every row.
|
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox`;--> statement-breakpoint
|
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_config`;--> statement-breakpoint
|
SELECT 1;
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox_stats`;--> statement-breakpoint
|
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_stats_config`;
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- The dialect generator now decides varchar or text per table. Columns that
|
||||||
|
-- were varchar(255) only because the same name was a key in another table
|
||||||
|
-- become text, and the timestamps and labels B18's regeneration left as
|
||||||
|
-- varchar(255) on real databases follow. varchar to text keeps every value.
|
||||||
|
ALTER TABLE `api_keys` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `api_keys` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `api_keys` MODIFY COLUMN `expires_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` MODIFY COLUMN `expires_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `credential_sidebar_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `folder_access` MODIFY COLUMN `expires_at` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `folder_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `host_access` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `host_sidebar_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` MODIFY COLUMN `name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` MODIFY COLUMN `folder` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `notification_channels` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `notification_channels` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `plugin_install_counts` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `plugin_registries` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `plugin_settings` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `plugin_storage` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `plugins` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `plugins` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `rbac_known_permissions` MODIFY COLUMN `plugin_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `roles` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `roles` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `sessions` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_credential_secrets` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_host_auth_overrides` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_host_auth_overrides` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_host_secrets` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_host_secrets` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_credentials` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_credentials` MODIFY COLUMN `folder` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_credentials` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_credentials` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_folders` MODIFY COLUMN `name` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_folders` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_folders` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `sync_tombstones` MODIFY COLUMN `sync_id` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `trusted_devices` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `trusted_devices` MODIFY COLUMN `expires_at` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ui_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_external_identities` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_open_tabs` MODIFY COLUMN `label` text NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_open_tabs` MODIFY COLUMN `created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_open_tabs` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_preferences` MODIFY COLUMN `updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session
|
||||||
|
-- sharing and session recording host columns, and the dead show_*_in_sidebar
|
||||||
|
-- columns, moved to their plugins' host settings. The copies run after
|
||||||
|
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||||
|
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||||
|
SELECT 1;
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -318,7 +318,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -596,7 +596,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -733,7 +733,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -869,7 +869,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -1491,7 +1491,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -1499,7 +1499,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -1675,7 +1675,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -1751,7 +1751,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2083,7 +2083,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2169,7 +2169,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2284,7 +2284,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2580,7 +2580,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2588,7 +2588,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2676,7 +2676,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2861,7 +2861,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2869,7 +2869,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2987,7 +2987,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -2995,7 +2995,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3163,7 +3163,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3171,7 +3171,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3503,7 +3503,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3511,7 +3511,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3621,7 +3621,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3629,7 +3629,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3798,7 +3798,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3874,7 +3874,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -3948,7 +3948,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -4031,7 +4031,7 @@
|
|||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"name": "label",
|
"name": "label",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -4053,7 +4053,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -4061,7 +4061,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -4297,7 +4297,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -4669,4 +4669,4 @@
|
|||||||
"tables": {},
|
"tables": {},
|
||||||
"indexes": {}
|
"indexes": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -393,6 +393,20 @@
|
|||||||
"when": 1790300572937,
|
"when": 1790300572937,
|
||||||
"tag": "0055_icy_patch",
|
"tag": "0055_icy_patch",
|
||||||
"breakpoints": true
|
"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
|
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
||||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. A real DROP
|
-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four
|
||||||
-- TABLE here would run before the plugin's adoption migration and delete
|
-- ssh_data proxmox columns are copied into the plugin's host settings after
|
||||||
-- every row.
|
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||||
ALTER TABLE "ssh_data" DROP COLUMN "enable_proxmox";--> statement-breakpoint
|
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||||
ALTER TABLE "ssh_data" DROP COLUMN "proxmox_config";--> statement-breakpoint
|
SELECT 1;
|
||||||
ALTER TABLE "ssh_data" DROP COLUMN "enable_proxmox_stats";--> statement-breakpoint
|
|
||||||
ALTER TABLE "ssh_data" DROP COLUMN "proxmox_stats_config";
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- The dialect generator now decides varchar or text per table. Columns that
|
||||||
|
-- were varchar(255) only because the same name was a key in another table
|
||||||
|
-- become text, and the timestamps and labels B18's regeneration left as
|
||||||
|
-- varchar(255) on real databases follow. varchar to text keeps every value.
|
||||||
|
ALTER TABLE "api_keys" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_keys" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_keys" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "api_keys" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_sidebar_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "credential_sidebar_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "folder_access" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "folder_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "folder_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_access" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_access" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_sidebar_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_sidebar_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ALTER COLUMN "folder" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_channels" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_channels" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_channels" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_install_counts" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_install_counts" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_registries" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_settings" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_settings" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_storage" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugin_storage" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugins" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "rbac_known_permissions" ALTER COLUMN "plugin_id" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "roles" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "roles" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "roles" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "roles" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_credential_secrets" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_auth_overrides" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_secrets" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_secrets" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_secrets" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_host_secrets" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_credentials" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_credentials" ALTER COLUMN "folder" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_credentials" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_credentials" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_credentials" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_credentials" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_folders" ALTER COLUMN "name" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_folders" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_folders" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_folders" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_folders" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sync_tombstones" ALTER COLUMN "sync_id" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trusted_devices" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trusted_devices" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "trusted_devices" ALTER COLUMN "expires_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ui_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ui_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_external_identities" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_external_identities" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_open_tabs" ALTER COLUMN "label" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_open_tabs" ALTER COLUMN "created_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_open_tabs" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_open_tabs" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_open_tabs" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DATA TYPE text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_preferences" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session
|
||||||
|
-- sharing and session recording host columns, and the dead show_*_in_sidebar
|
||||||
|
-- columns, moved to their plugins' host settings. The copies run after
|
||||||
|
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||||
|
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||||
|
SELECT 1;
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -619,7 +619,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -757,7 +757,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -916,7 +916,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -1453,14 +1453,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -1650,7 +1650,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -1716,7 +1716,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -2044,7 +2044,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -2157,7 +2157,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -2271,7 +2271,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -2570,14 +2570,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -2653,7 +2653,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -2829,14 +2829,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -2970,14 +2970,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -3145,14 +3145,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -3483,14 +3483,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -3596,14 +3596,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -3762,7 +3762,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -3837,7 +3837,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -3901,7 +3901,7 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -3996,7 +3996,7 @@
|
|||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"name": "label",
|
"name": "label",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
@@ -4015,14 +4015,14 @@
|
|||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -4236,7 +4236,7 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "text",
|
"type": "varchar(255)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "CURRENT_TIMESTAMP"
|
"default": "CURRENT_TIMESTAMP"
|
||||||
@@ -4603,4 +4603,4 @@
|
|||||||
"schemas": {},
|
"schemas": {},
|
||||||
"tables": {}
|
"tables": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -400,6 +400,20 @@
|
|||||||
"when": 1790300572231,
|
"when": 1790300572231,
|
||||||
"tag": "0056_chilly_vindicator",
|
"tag": "0056_chilly_vindicator",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 57,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790304002011,
|
||||||
|
"tag": "0057_married_dragon_lord",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 58,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790304465181,
|
||||||
|
"tag": "0058_goofy_ma_gnuci",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
-- proxmox_node_history and proxmox_stats_preferences moved to the proxmox
|
||||||
-- plugin (p_proxmox_*), adopted by rename rather than dropped. A real DROP
|
-- plugin (p_proxmox_*), adopted by rename rather than dropped. The four
|
||||||
-- TABLE here would run before the plugin's adoption migration and delete
|
-- ssh_data proxmox columns are copied into the plugin's host settings after
|
||||||
-- every row.
|
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox`;--> statement-breakpoint
|
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_config`;--> statement-breakpoint
|
SELECT 1;
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `enable_proxmox_stats`;--> statement-breakpoint
|
|
||||||
ALTER TABLE `ssh_data` DROP COLUMN `proxmox_stats_config`;
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- The file manager, tunnels, web endpoint, terminal, tmux monitor, session
|
||||||
|
-- sharing and session recording host columns, and the dead show_*_in_sidebar
|
||||||
|
-- columns, moved to their plugins' host settings. The copies run after
|
||||||
|
-- plugins load, which is after this migration, so a real DROP COLUMN here
|
||||||
|
-- would delete the data first. The columns stay in place, unused, until 3.0.0.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -372,6 +372,13 @@
|
|||||||
"when": 1790300571504,
|
"when": 1790300571504,
|
||||||
"tag": "0052_public_professor_monster",
|
"tag": "0052_public_professor_monster",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790304468233,
|
||||||
|
"tag": "0053_bent_absorbing_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+6
-1
@@ -7,7 +7,12 @@ import tseslint from "typescript-eslint";
|
|||||||
import { globalIgnores } from "eslint/config";
|
import { globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default tseslint.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}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
Generated
+179
@@ -95,6 +95,7 @@
|
|||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/ssh2": "^1.15.6",
|
"@types/ssh2": "^1.15.6",
|
||||||
|
"@types/supertest": "^7.2.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||||
"@uiw/codemirror-theme-github": "^4.25.11",
|
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||||
@@ -143,6 +144,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
|
"supertest": "^7.3.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
@@ -3853,6 +3855,29 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@peculiar/asn1-android": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
|
||||||
@@ -7360,6 +7385,13 @@
|
|||||||
"@types/express": "*"
|
"@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": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@@ -7512,6 +7544,13 @@
|
|||||||
"@types/unist": "*"
|
"@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": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
@@ -7661,6 +7700,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
@@ -8693,6 +8756,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/asn1": {
|
||||||
"version": "0.2.6",
|
"version": "0.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
@@ -9583,6 +9653,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/compressible": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
@@ -9886,6 +9966,13 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"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"
|
"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": {
|
"node_modules/dijkstrajs": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
@@ -11253,6 +11351,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-sha256": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
@@ -11510,6 +11615,24 @@
|
|||||||
"node": ">=0.4.x"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"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": {
|
"node_modules/micromark": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||||
@@ -17146,6 +17279,52 @@
|
|||||||
"node": ">= 8.0"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "10.2.2",
|
"version": "10.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
|||||||
@@ -137,6 +137,7 @@
|
|||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@types/ssh2": "^1.15.6",
|
"@types/ssh2": "^1.15.6",
|
||||||
|
"@types/supertest": "^7.2.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||||
"@uiw/codemirror-theme-github": "^4.25.11",
|
"@uiw/codemirror-theme-github": "^4.25.11",
|
||||||
@@ -185,6 +186,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.8",
|
"sonner": "^2.0.8",
|
||||||
|
"supertest": "^7.3.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
@@ -342,13 +342,30 @@ attached after the sanitizers, because the connect-level projection of a shared
|
|||||||
host reduces it to an allowlist.
|
host reduces it to an allowlist.
|
||||||
|
|
||||||
`ctx.settings` is `get/set`, `getUser/setUser`, `getHost/setHost`, `getAll`,
|
`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
|
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
|
every field, and a plugin that had to ask permission to read its own
|
||||||
configuration would be useless. Only `readCore`, which reaches outside the
|
configuration would be useless. Only `readCore`, which reaches outside the
|
||||||
plugin's namespace, is gated on `settings:read-core`, and it serves a short
|
plugin's namespace, is gated on `settings:read-core`, and it serves a short
|
||||||
allowlist (`CORE_SETTINGS_ALLOWLIST`) rather than the whole settings table.
|
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
|
### 8. UI
|
||||||
|
|
||||||
A plugin frontend is a built ESM bundle, `dist/frontend.js`, exporting
|
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
|
from `src/ui/lib/terminal-look/`; keyboard handling shared with the shell
|
||||||
(`findMatchingKeybinding`, `globalShortcutHandler`, `isTabJumpHotkey`); the
|
(`findMatchingKeybinding`, `globalShortcutHandler`, `isTabJumpHotkey`); the
|
||||||
connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
|
connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
|
||||||
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`); and a few core APIs
|
`hydrateLocalSharedHostAuth`, `useConnectionDefaults`). **D0** moved the core
|
||||||
(`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`,
|
calls a plugin makes for the signed-in user onto the typed host bridge, as
|
||||||
`parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a
|
`@termix/plugin-sdk/frontend` functions: `logActivity`, `getHostPassword`,
|
||||||
stopgap in the right place: D1 turns them into typed bridge members.
|
`patchOpenTab`, `getCustomKeybindings`, `setHostAutoTmux` and
|
||||||
|
`getClientPreference`.
|
||||||
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
|
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
|
||||||
Appearance density and chart options), and the homepage widget pieces
|
Appearance density and chart options), and the homepage widget pieces
|
||||||
`WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**,
|
`WidgetTitle` and `runVisibleInterval` (moved to `src/ui/lib/` in **B19**,
|
||||||
once a plugin rather than a core feature became their biggest caller), for
|
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
|
host-metrics's cards and its metrics chart widget.
|
||||||
Docker manager uses to remember the card or table layout the user picked.
|
|
||||||
The `docker` and `hostMetrics` areas are still declared in core's
|
**D0** gave plugins their own Appearance area. A manifest's
|
||||||
`types/ui-preferences.ts`; a plugin cannot contribute its own area yet. The frontend SDK gained `useHostStatus(hostId)`: core's
|
`contributes.uiPresets` names a value per interface preset (simple, balanced,
|
||||||
status for a host, from the shell's own polling, or null outside the shell.
|
advanced), and `usePluginUiPreferences()` returns the one for the user's
|
||||||
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
|
level with their changes on top, plus a `set` that saves a change. Changes
|
||||||
`ConnectionStage` type, for remote desktop's display socket and its remote
|
are stored in the UI preferences under `plugin:<id>` and listed, with the
|
||||||
host lookup. **B17** added `NotificationChannelDialog` and the channel API
|
plugin's name, where Appearance lets a user revert them. Docker (list or
|
||||||
(`getNotificationChannels`, `deleteNotificationChannel`,
|
detail view, card or table layout) and host-metrics (column count) use it;
|
||||||
`testNotificationChannel`, the `NotificationChannel` type): channels are core,
|
version 2 of the UI preferences moved their 2.8 `docker` and `hostMetrics`
|
||||||
so a plugin that manages them shows core's dialog. `useConnectionDefaults` now carries terminal defaults only; the
|
overrides across. The terminal toolbar density and the file manager's view
|
||||||
RDP half became remote desktop's user settings. Publishing its
|
mode are still preset-seeded localStorage keys core writes, as in 2.8.
|
||||||
`.d.ts` for plugins outside this repo is a follow-up for the repo split.
|
|
||||||
**B19** added `PluginViewPlaceholder`, for the homepage plugin's widget
|
**D0** also added:
|
||||||
shell to render in place of a widget type whose owning plugin (docker,
|
|
||||||
tunnels, host-metrics, file-manager) is off, and moved `WidgetTitle` and
|
- `usePluginApiFor(origin)`: the plugin's client for a resolved connection
|
||||||
`runVisibleInterval` here from the deleted homepage feature directory
|
origin, as `app.apiFor` but usable in a component. The terminal toolbar's
|
||||||
(above), unchanged for their existing callers.
|
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
|
#### 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`,
|
carry every plugin's host settings (secrets redacted) under `pluginSettings`,
|
||||||
which is what makes the round trip work without core naming a plugin.
|
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,
|
**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
|
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
|
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
|
`collab_room_members`, and reaches sessions only through `sessions.live`. Its
|
||||||
two public routes, `/resolve/:linkToken` and `/guest/:token`, check the token
|
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
|
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
|
`terminal.toolbar` and `remote-desktop.toolbar`, and (**D0**) to the tab
|
||||||
the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
|
bar's `tab.menu` slot, which reads the terminal handle's `getShareTarget`. The
|
||||||
`TabOptions`/`TabHandle` are gone. Core's active connections list gets 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.
|
sessions shared with a user from the `sessions.sharedWithMe` action.
|
||||||
|
|
||||||
**Automations (B17)** imports nothing from core. It reaches snippets
|
**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
|
The preset supplies both projects, the shared setup file
|
||||||
(`@termix/plugin-sdk/testing/setup`, which core's `vitest.setup.ts` re-exports
|
(`@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` 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`)
|
`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
|
rename, which it cannot do without a terminal, so B16 generated two
|
||||||
migrations: the added columns first, then the no-op drop.
|
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
|
## 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.registerSecretResolver` | `auth:provide` | **B20** |
|
||||||
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
|
| `ctx.credentials.listSshKeys` | `credentials:use` | **C6** |
|
||||||
| `ctx.credentials.createSshKey` | `credentials:write` | **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.schedule.every` / `.after` | none | **B16** |
|
||||||
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
|
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** |
|
||||||
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
|
| `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
|
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
|
`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
|
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
|
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
|
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
|
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 | - |
|
| Core importing a plugin backend | **Error** | 0 | - |
|
||||||
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
|
||||||
| The shell importing plugin code | **Error** | 0 | - |
|
| The shell importing plugin code | **Error** | 0 | - |
|
||||||
| A plugin frontend importing core through `@/` | Warning | 53 files | D1 |
|
| A plugin frontend importing core through `@/` | Warning | 51 files | D1 |
|
||||||
| A plugin importing core by relative path | Warning | 4 files | D1 |
|
| A plugin importing core by relative path | Warning | 3 files | D1 |
|
||||||
| A plugin importing another plugin's source | Warning | 0 files | - |
|
| A plugin importing another plugin's source | Warning | 0 files | - |
|
||||||
|
|
||||||
A warning does not fail a build, so the counts are held by
|
A warning does not fail a build, so the counts are held by
|
||||||
|
|||||||
@@ -1,559 +1,7 @@
|
|||||||
# Finish list
|
# Finish list
|
||||||
|
|
||||||
Running list of things intentionally left unfinished by a Phase B/C step,
|
Everything a Phase B/C step left for D0 is done. What is left needs a person
|
||||||
so D0 (or an earlier owner named below) can pick them up. Remove a line once
|
with a real install, a real device or a repo outside this one.
|
||||||
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.
|
|
||||||
|
|
||||||
## Manual checks after 2.9.0
|
## 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
|
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
|
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.
|
and a Vault host says it needs it, then enable it again.
|
||||||
- **C6 (termix-identity):** only the public resolver URLs under
|
- Termix-Mobile: the 2.8 termix-id management routes (`/termix-id/me`,
|
||||||
`/termix-id/u/` redirect to the plugin. The 2.8 management routes
|
`/termix-id/keys`, `/termix-id/ca` and the rest) now 404, only the
|
||||||
(`/termix-id/me`, `/termix-id/keys`, `/termix-id/ca` and the rest) now 404.
|
`/termix-id/u/` resolver redirects. Confirm the mobile app does not call
|
||||||
Nothing in this repo calls them; confirm Termix-Mobile does not before
|
them, and that it still reads a host's metrics settings (`statsConfig` is
|
||||||
2.9.0 ships, and add redirects if it does. Owner: D0.
|
put back on the host payload by host-metrics) and the renamed Warpgate
|
||||||
- **C6 (termix-identity):** on MySQL the adopted `p_termix_identity_keys` has
|
fields. Once it reads `pluginSettings` and `GET /host/status`, drop
|
||||||
no `idx_termix_identity_keys_identity` (core never created one there and
|
host-metrics's `hostPayloadLegacy` hook.
|
||||||
an adoption writes no MySQL indexes), so the public resolver scans the
|
- Docs site: point these at the plugins: the OPKSSH config path and redirect
|
||||||
keys table. Add it in a later plugin migration if a MySQL install needs
|
URI, the step-ca settings, the Vault redirect URI, the Termix ID resolver
|
||||||
it. Owner: D0.
|
URL (`/plugin-api/termix-identity/u/<handle>`, the old one still 308s) and
|
||||||
- **C6 (termix-identity):** the docs site still shows the
|
the HTTPS certificate page (`docs.termix.site/features/networking/ssl`,
|
||||||
`/termix-id/u/<handle>` resolver URL. It keeps working through the 308,
|
which still describes certbot and the webroot challenge).
|
||||||
but the docs should show `/plugin-api/termix-identity/u/<handle>`. Owner:
|
- Upgrade a real 2.8 install on Postgres and on MySQL (SQLite is covered by
|
||||||
D0.
|
the upgrade test): proxmox host settings, tunnels, web endpoints, the file
|
||||||
- **C7 (acme-ssl):** the 2.8 leftovers stay on disk and in the database for
|
manager's default path and SCP switch, and the terminal, tmux, sharing and
|
||||||
one release: the `acme_ssl_settings` settings row (the Cloudflare token is
|
recording switches all survive. Postgres was checked by hand against a
|
||||||
already stripped from it), `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`.
|
scratch database; MySQL only runs in CI.
|
||||||
Delete them in the release after 2.9.0. Owner: after D4.
|
- Host settings round trip: filter the Hosts panel by a plugin feature, bulk
|
||||||
- **C7 (acme-ssl):** the docs page linked from Admin Settings > HTTPS
|
enable and disable a plugin on a few hosts, export hosts to JSON and import
|
||||||
certificate (`docs.termix.site/features/networking/ssl`) still describes
|
them on another install (plugin settings come across, secrets do not), the
|
||||||
certbot and the webroot challenge. Point it at the plugin. Owner: D0.
|
raw SQLite export and import, and remote sync of a host with a Vault
|
||||||
- **C7, pre-existing:** `AutoSSLSetup.setupEnvironmentVariables` in
|
profile selected (the profile follows it).
|
||||||
`src/backend/tls/self-signed.ts` rewrites `SSL_CERT_PATH`, `SSL_KEY_PATH`
|
- Desktop app with a remote server: tunnels on a remote host show their
|
||||||
and `SSL_DOMAIN` to the defaults in `DATA_DIR/.env` on every boot, so custom
|
status next to local ones, and the terminal toolbar's metrics bars work
|
||||||
certificate paths set in the environment are lost after the first start.
|
for a host on the remote server.
|
||||||
Owner: D0.
|
- Shared hosts: a remote desktop host shared at "connect" does not show its
|
||||||
- **C7, pre-existing:** `src/backend/scripts/setup-ssl.sh` and
|
gateway settings; a shared editor cannot change a Vault host's profile; the
|
||||||
`enable-ssl.sh` are not called by anything and write a `JWT_SECRET` and
|
host list status dot works for a host someone shared with you.
|
||||||
`DATABASE_KEY` to `.env`, which SystemCrypto now owns. Delete or rewrite
|
- The PDF preview in the file manager, in the browser and in the desktop app.
|
||||||
them. Owner: D0.
|
- 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 * as esbuild from "esbuild";
|
||||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||||
import { legacyCoreImports } from "../lib/legacy-core-imports.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";
|
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||||
|
|
||||||
const BACKEND_ENTRIES = [
|
const BACKEND_ENTRIES = [
|
||||||
@@ -75,6 +76,7 @@ export async function build({ cwd }) {
|
|||||||
logLevel: "warning",
|
logLevel: "warning",
|
||||||
external: FRONTEND_EXTERNALS,
|
external: FRONTEND_EXTERNALS,
|
||||||
plugins: [
|
plugins: [
|
||||||
|
staticUrlImports({ outDir }),
|
||||||
legacyCoreImports({
|
legacyCoreImports({
|
||||||
pluginId,
|
pluginId,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
|
|||||||
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
|
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
|
||||||
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
|
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
|
||||||
const CORE_UI = /^(?:\.\.\/)+src\/ui\//;
|
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/... */
|
/** dist/plugins/<id>/dist/backend.js -> dist/backend/... */
|
||||||
const COMPILED_CORE = "../../../backend/backend/";
|
const COMPILED_CORE = "../../../backend/backend/";
|
||||||
const COMPILED_TYPES = "../../../backend/types/";
|
const COMPILED_TYPES = "../../../backend/types/";
|
||||||
@@ -68,12 +63,6 @@ export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
|
|||||||
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
|
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
|
||||||
external: true,
|
external: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
|
|
||||||
const [, other] = CROSS_PLUGIN.exec(args.path);
|
|
||||||
if (other === "src" || other === pluginId) return null;
|
|
||||||
return { path: `../../${other}/dist/backend.js`, external: true };
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `import url from "some/file.js?url"` in a plugin frontend, as core's Vite
|
||||||
|
* build allows: the file is copied next to the bundle under assets/ and the
|
||||||
|
* import becomes its URL, resolved against the bundle's own URL so it works
|
||||||
|
* under /plugin-assets/<id>/. Only scripts, since that is all the asset
|
||||||
|
* route serves (a pdfjs or Monaco worker, say).
|
||||||
|
*/
|
||||||
|
export function staticUrlImports({ outDir }) {
|
||||||
|
return {
|
||||||
|
name: "static-url-imports",
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /\?url$/ }, async (args) => {
|
||||||
|
const target = args.path.slice(0, -"?url".length);
|
||||||
|
const resolved = await build.resolve(target, {
|
||||||
|
kind: args.kind,
|
||||||
|
resolveDir: args.resolveDir,
|
||||||
|
importer: args.importer,
|
||||||
|
});
|
||||||
|
if (resolved.errors.length > 0) return { errors: resolved.errors };
|
||||||
|
return { path: resolved.path, namespace: "static-url" };
|
||||||
|
});
|
||||||
|
|
||||||
|
build.onLoad({ filter: /.*/, namespace: "static-url" }, (args) => {
|
||||||
|
if (!/\.(m?js|cjs)$/.test(args.path)) {
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
text: `${args.path}: only script files can be imported with ?url`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bytes = fs.readFileSync(args.path);
|
||||||
|
const hash = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(bytes)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 8);
|
||||||
|
const base = path.basename(args.path).replace(/\.(m?js|cjs)$/, "");
|
||||||
|
const name = `${base}-${hash}.js`;
|
||||||
|
fs.mkdirSync(path.join(outDir, "assets"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(outDir, "assets", name), bytes);
|
||||||
|
return {
|
||||||
|
contents: `export default new URL("./assets/${name}", import.meta.url).href;`,
|
||||||
|
loader: "js",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -170,7 +170,10 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"uniqueItems": true,
|
"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": {
|
"guestViews": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]*$"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -286,23 +294,38 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"sshAuthTypes": {
|
"sshAuthTypes": {
|
||||||
"type": "array",
|
"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": {
|
"loginMethods": {
|
||||||
"type": "array",
|
"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": {
|
"secondFactors": {
|
||||||
"type": "array",
|
"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": {
|
"secretSchemes": {
|
||||||
"type": "array",
|
"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": {
|
"keyboardInteractive": {
|
||||||
"type": "array",
|
"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,
|
"additionalProperties": false,
|
||||||
"required": ["id", "titleKey"],
|
"required": ["id", "titleKey"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "minLength": 1 },
|
"id": {
|
||||||
"titleKey": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
"icon": { "type": "string", "minLength": 1 }
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"titleKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -326,9 +358,18 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["id", "titleKey"],
|
"required": ["id", "titleKey"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "minLength": 1 },
|
"id": {
|
||||||
"titleKey": { "type": "string", "minLength": 1 },
|
"type": "string",
|
||||||
"icon": { "type": "string", "minLength": 1 }
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"titleKey": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -727,9 +768,31 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"description": "Registered component id. Required when type is \"custom\"."
|
"description": "Registered component id. Required when type is \"custom\"."
|
||||||
|
},
|
||||||
|
"hidden": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Stored and validated, but not drawn by the generic settings form."
|
||||||
|
},
|
||||||
|
"defaultFrom": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "A non-secret admin field whose value a new host starts with."
|
||||||
|
},
|
||||||
|
"shareRead": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["connect", "view", "edit", "manage"],
|
||||||
|
"description": "Lowest share level whose recipients see the value in the host payload."
|
||||||
|
},
|
||||||
|
"ownerOnly": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Only the host's owner may change it."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"enableDefault": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "What the enable switch reads before a host saves it. Off by default."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,6 +812,95 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "HTTP options for the plugin's /plugin-api/<id>/ router.",
|
||||||
|
"properties": {
|
||||||
|
"legacyPaths": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Old URLs under /<plugin id>/ served by the plugin's router with that prefix removed.",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^/[a-z][a-z0-9-]*(/[A-Za-z0-9_.-]+)+$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uiPresets": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["simple", "balanced", "advanced"],
|
||||||
|
"description": "The plugin's Appearance defaults for each interface preset, read with usePluginUiPreferences.",
|
||||||
|
"properties": {
|
||||||
|
"simple": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"balanced": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -456,6 +456,16 @@ export interface PluginSettings {
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
) => Promise<void>;
|
) => 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. */
|
/** Every value in one scope, with declared defaults merged in. */
|
||||||
getAll: (
|
getAll: (
|
||||||
scope: "admin" | "user" | "host",
|
scope: "admin" | "user" | "host",
|
||||||
@@ -468,6 +478,19 @@ export interface PluginSettings {
|
|||||||
*/
|
*/
|
||||||
onChange: (key: string, listener: (value: unknown) => void) => () => void;
|
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.
|
* Reads a core server setting from a small documented allowlist.
|
||||||
* Requires the settings:read-core capability.
|
* Requires the settings:read-core capability.
|
||||||
@@ -513,9 +536,6 @@ export interface PluginHostRecord {
|
|||||||
folder: string | null;
|
folder: string | null;
|
||||||
jumpHosts?: unknown;
|
jumpHosts?: unknown;
|
||||||
enableSsh?: boolean | null;
|
enableSsh?: boolean | null;
|
||||||
enableTerminal?: boolean | null;
|
|
||||||
enableFileManager?: boolean | null;
|
|
||||||
enableTunnel?: boolean | null;
|
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -1454,6 +1474,11 @@ export interface PluginAudit {
|
|||||||
details?: string;
|
details?: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
/**
|
||||||
|
* The HTTP request or WebSocket upgrade this happened on, for the line's
|
||||||
|
* IP address and user agent.
|
||||||
|
*/
|
||||||
|
request?: unknown;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -644,6 +644,16 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
tabs: TabsApi;
|
tabs: TabsApi;
|
||||||
/** The desktop app, when the frontend runs in it. */
|
/** The desktop app, when the frontend runs in it. */
|
||||||
desktop: DesktopApi;
|
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. */
|
/** Registered cleanup, run when the plugin is disabled. */
|
||||||
onDispose: (dispose: Disposer) => void;
|
onDispose: (dispose: Disposer) => void;
|
||||||
}
|
}
|
||||||
@@ -722,6 +732,7 @@ export interface PluginHostBridge {
|
|||||||
useTheme: () => { theme: "light" | "dark" };
|
useTheme: () => { theme: "light" | "dark" };
|
||||||
toast: ToastApi;
|
toast: ToastApi;
|
||||||
getApi: (pluginId: string) => PluginApiClient;
|
getApi: (pluginId: string) => PluginApiClient;
|
||||||
|
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||||
useTabs: () => TabsApi;
|
useTabs: () => TabsApi;
|
||||||
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
|
||||||
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
|
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
|
||||||
@@ -739,6 +750,81 @@ export interface PluginHostBridge {
|
|||||||
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
||||||
useHomepageWidgetTypes: () => HomepageWidgetContribution[];
|
useHomepageWidgetTypes: () => HomepageWidgetContribution[];
|
||||||
homepageWidgetType: (id: string) => HomepageWidgetContribution | undefined;
|
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. */
|
/** What a recent-activity entry opens and how it is labelled. */
|
||||||
@@ -849,11 +935,72 @@ export function useToast(): ToastApi {
|
|||||||
return requireHost().toast;
|
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 {
|
export function usePluginApi(): PluginApiClient {
|
||||||
const bridge = requireHost();
|
const bridge = requireHost();
|
||||||
return bridge.getApi(bridge.usePluginId());
|
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 {
|
export function useTabs(): TabsApi {
|
||||||
return requireHost().useTabs();
|
return requireHost().useTabs();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,19 @@ export interface PluginSettingsField {
|
|||||||
group?: string;
|
group?: string;
|
||||||
/** Registered component id. Required when type is "custom". */
|
/** Registered component id. Required when type is "custom". */
|
||||||
component?: string;
|
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
|
* 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
|
* 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. */
|
/** Boolean field rendered first, gating the rest of the section. */
|
||||||
enableKey?: string;
|
enableKey?: string;
|
||||||
enableLabelKey?: string;
|
enableLabelKey?: string;
|
||||||
|
/** What the enable switch reads before a host saves it. Off by default. */
|
||||||
|
enableDefault?: boolean;
|
||||||
fields: PluginSettingsField[];
|
fields: PluginSettingsField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +303,27 @@ export interface PluginContributions {
|
|||||||
*/
|
*/
|
||||||
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
|
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
|
||||||
auth?: PluginAuthContribution;
|
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 {
|
export interface PluginManifest {
|
||||||
@@ -375,6 +411,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"permissions",
|
"permissions",
|
||||||
"settings",
|
"settings",
|
||||||
"hostCapability",
|
"hostCapability",
|
||||||
|
"http",
|
||||||
|
"uiPresets",
|
||||||
"auth",
|
"auth",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -393,8 +431,13 @@ const ALLOWED_SETTINGS_FIELD = [
|
|||||||
"group",
|
"group",
|
||||||
"component",
|
"component",
|
||||||
"hidden",
|
"hidden",
|
||||||
|
"defaultFrom",
|
||||||
|
"shareRead",
|
||||||
|
"ownerOnly",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
|
||||||
|
|
||||||
/** Settings keys are stored as-is, so they stay short and index-safe. */
|
/** 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}$/;
|
const SETTINGS_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
@@ -782,6 +825,78 @@ function validateContributes(
|
|||||||
validateSettings(contributes.settings, errors);
|
validateSettings(contributes.settings, errors);
|
||||||
validateHostCapability(contributes.hostCapability, errors);
|
validateHostCapability(contributes.hostCapability, errors);
|
||||||
validateAuthContribution(contributes.auth, 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}$/;
|
const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
||||||
@@ -861,7 +976,7 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
|||||||
}
|
}
|
||||||
rejectUnknown(
|
rejectUnknown(
|
||||||
host,
|
host,
|
||||||
["enableKey", "enableLabelKey", "fields"],
|
["enableKey", "enableLabelKey", "enableDefault", "fields"],
|
||||||
`"${at}"`,
|
`"${at}"`,
|
||||||
errors,
|
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.
|
// An enable switch with no label is a blank row in the host editor.
|
||||||
requireString(host.enableLabelKey, `${at}.enableLabelKey`, errors);
|
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)) {
|
if (!Array.isArray(host.fields)) {
|
||||||
errors.push(`${at}.fields must be an array`);
|
errors.push(`${at}.fields must be an array`);
|
||||||
@@ -887,6 +1005,48 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
|||||||
errors,
|
errors,
|
||||||
typeof host.enableKey === "string" ? host.enableKey : undefined,
|
typeof host.enableKey === "string" ? host.enableKey : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const adminKeys = new Set(
|
||||||
|
(Array.isArray(settings.admin) ? settings.admin : [])
|
||||||
|
.filter(isPlainObject)
|
||||||
|
// A secret never becomes a default the host editor can read.
|
||||||
|
.filter((field) => field.type !== "secret")
|
||||||
|
.map((field) => field.key),
|
||||||
|
);
|
||||||
|
host.fields.forEach((raw, index) => {
|
||||||
|
if (!isPlainObject(raw)) return;
|
||||||
|
const fieldAt = `${at}.fields[${index}]`;
|
||||||
|
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
|
||||||
|
errors.push(
|
||||||
|
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"shareRead" in raw &&
|
||||||
|
!SHARE_LEVELS.includes(raw.shareRead as string)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`${fieldAt}.shareRead must be one of: ${SHARE_LEVELS.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") {
|
||||||
|
errors.push(`${fieldAt}.ownerOnly must be a boolean`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const scope of ["admin", "user"] as const) {
|
||||||
|
const fields = settings[scope];
|
||||||
|
if (!Array.isArray(fields)) continue;
|
||||||
|
fields.forEach((raw, index) => {
|
||||||
|
if (!isPlainObject(raw)) return;
|
||||||
|
for (const key of ["defaultFrom", "shareRead", "ownerOnly"]) {
|
||||||
|
if (key in raw) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.${scope}[${index}].${key} is only valid on host fields`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,12 @@ export interface FakeAuthRegistrations {
|
|||||||
|
|
||||||
export interface FakePluginContext {
|
export interface FakePluginContext {
|
||||||
ctx: PluginContext;
|
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. */
|
/** Everything the plugin registered through ctx.disposables. */
|
||||||
disposals: Array<() => void | Promise<void>>;
|
disposals: Array<() => void | Promise<void>>;
|
||||||
/** Topics emitted through ctx.events.emit, in order. */
|
/** Topics emitted through ctx.events.emit, in order. */
|
||||||
@@ -417,6 +423,10 @@ export function createFakeContext(
|
|||||||
Object.entries(options.coreSettings ?? {}),
|
Object.entries(options.coreSettings ?? {}),
|
||||||
);
|
);
|
||||||
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
|
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 sshConnections: FakePluginContext["sshConnections"] = [];
|
||||||
const hostShares: FakePluginContext["hostShares"] = [];
|
const hostShares: FakePluginContext["hostShares"] = [];
|
||||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||||
@@ -499,6 +509,15 @@ export function createFakeContext(
|
|||||||
: null;
|
: null;
|
||||||
const holds = (permission: string) =>
|
const holds = (permission: string) =>
|
||||||
held === null || held.has(qualifyPermission(pluginId, permission));
|
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 ?? {})) {
|
for (const [key, value] of Object.entries(options.settings ?? {})) {
|
||||||
settings.set(settingsKey("admin", undefined, key), value);
|
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.
|
// other plugins' namespaces is covered by core's tests, not here.
|
||||||
has: async (permission) => holds(permission),
|
has: async (permission) => holds(permission),
|
||||||
hasFor: async (_userId, permission) => holds(permission),
|
hasFor: async (_userId, permission) => holds(permission),
|
||||||
require: (permission) =>
|
require: (permission) => {
|
||||||
((_req, res, next) => {
|
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)) {
|
if (holds(permission)) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
@@ -704,7 +731,8 @@ export function createFakeContext(
|
|||||||
error: "Insufficient permissions",
|
error: "Insufficient permissions",
|
||||||
required: qualifyPermission(pluginId, permission),
|
required: qualifyPermission(pluginId, permission),
|
||||||
});
|
});
|
||||||
}) as PluginMiddleware,
|
}) as PluginMiddleware;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Nothing here is capability-checked, per the module doc; createMockCtx
|
// Nothing here is capability-checked, per the module doc; createMockCtx
|
||||||
@@ -729,6 +757,22 @@ export function createFakeContext(
|
|||||||
setHost: async (hostId, key, value) =>
|
setHost: async (hostId, key, value) =>
|
||||||
writeSetting("host", String(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) => {
|
getAll: async (scope, scopeId) => {
|
||||||
const prefix = `${scope}:${scopeId ?? ""}:`;
|
const prefix = `${scope}:${scopeId ?? ""}:`;
|
||||||
const all: Record<string, unknown> = {};
|
const all: Record<string, unknown> = {};
|
||||||
@@ -754,6 +798,16 @@ export function createFakeContext(
|
|||||||
return unsubscribe;
|
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,
|
readCore: async (key) => coreSettings.get(key) ?? null,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -919,12 +973,17 @@ export function createFakeContext(
|
|||||||
),
|
),
|
||||||
requiresSecret: (authType) =>
|
requiresSecret: (authType) =>
|
||||||
["password", "key", "credential", "agent"].includes(authType),
|
["password", "key", "credential", "agent"].includes(authType),
|
||||||
supportsBackground: (authType) =>
|
// Matches core: the built-in types, plus any registered provider that
|
||||||
authType !== "none" &&
|
// does not opt out. An unknown type cannot connect unattended.
|
||||||
!auth.sshAuthProviders.some(
|
supportsBackground: (authType) => {
|
||||||
(provider) =>
|
if (["password", "key", "credential", "agent"].includes(authType)) {
|
||||||
provider.type === authType && provider.supportsBackground === false,
|
return true;
|
||||||
),
|
}
|
||||||
|
const provider = auth.sshAuthProviders.find(
|
||||||
|
(candidate) => candidate.type === authType,
|
||||||
|
);
|
||||||
|
return !!provider && provider.supportsBackground !== false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
@@ -1150,6 +1209,14 @@ export function createFakeContext(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ctx,
|
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,
|
disposals,
|
||||||
emitted,
|
emitted,
|
||||||
kv,
|
kv,
|
||||||
@@ -1385,7 +1452,15 @@ export function createMockCtx(
|
|||||||
}
|
}
|
||||||
ctx.events.emit(topic, payload);
|
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: {
|
http: {
|
||||||
@@ -1411,6 +1486,10 @@ export function createMockCtx(
|
|||||||
// Reading and writing a plugin's OWN settings is ungated, exactly as in
|
// Reading and writing a plugin's OWN settings is ungated, exactly as in
|
||||||
// the real runtime. Only readCore reaches outside the plugin.
|
// the real runtime. Only readCore reaches outside the plugin.
|
||||||
...ctx.settings,
|
...ctx.settings,
|
||||||
|
listHostValues: async (key) => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.settings.listHostValues(key);
|
||||||
|
},
|
||||||
readCore: async (key) => {
|
readCore: async (key) => {
|
||||||
require("settings:read-core");
|
require("settings:read-core");
|
||||||
return ctx.settings.readCore(key);
|
return ctx.settings.readCore(key);
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Keeps a test server's random port off the fetch spec's blocked list.
|
||||||
|
*
|
||||||
|
* Tests start servers on port 0 and call them with fetch, which refuses
|
||||||
|
* ports like 6000 or 6667 with "bad port". Linux never hands those out as
|
||||||
|
* ephemeral ports, but Windows can (its dynamic range may start at 1024), so
|
||||||
|
* a server that lands on one closes and listens again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
|
export const FETCH_BLOCKED_PORTS = new Set([
|
||||||
|
1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79,
|
||||||
|
87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137,
|
||||||
|
139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
|
||||||
|
540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723,
|
||||||
|
2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
|
||||||
|
6679, 6697, 10080,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function wantsRandomPort(args: unknown[]): boolean {
|
||||||
|
const first = args[0];
|
||||||
|
if (first === 0 || first === "0") return true;
|
||||||
|
if (first && typeof first === "object" && !Array.isArray(first)) {
|
||||||
|
const port = (first as { port?: unknown }).port;
|
||||||
|
return port === 0 || port === "0";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATCHED = Symbol.for("termix.safePorts");
|
||||||
|
|
||||||
|
export function installSafePorts(): void {
|
||||||
|
const proto = net.Server.prototype as net.Server & { [PATCHED]?: boolean };
|
||||||
|
if (proto[PATCHED]) return;
|
||||||
|
proto[PATCHED] = true;
|
||||||
|
|
||||||
|
const listen = proto.listen as (
|
||||||
|
this: net.Server,
|
||||||
|
...args: unknown[]
|
||||||
|
) => net.Server;
|
||||||
|
proto.listen = function (this: net.Server, ...args: unknown[]) {
|
||||||
|
if (wantsRandomPort(args)) retryOnBlockedPort(this, args);
|
||||||
|
return listen.apply(this, args);
|
||||||
|
} as typeof proto.listen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryOnBlockedPort(server: net.Server, args: unknown[]): void {
|
||||||
|
const emit = server.emit;
|
||||||
|
// The caller's callback is already a pending "listening" listener, so a
|
||||||
|
// retry leaves it out and it fires once, on the good port.
|
||||||
|
const retryArgs = args.filter((arg) => typeof arg !== "function");
|
||||||
|
server.emit = ((event: string | symbol, ...rest: unknown[]) => {
|
||||||
|
if (event === "listening") {
|
||||||
|
const address = server.address();
|
||||||
|
if (
|
||||||
|
address &&
|
||||||
|
typeof address === "object" &&
|
||||||
|
FETCH_BLOCKED_PORTS.has(address.port)
|
||||||
|
) {
|
||||||
|
server.close(() => server.listen(...(retryArgs as [])));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
server.emit = emit;
|
||||||
|
}
|
||||||
|
return emit.call(server, event, ...rest);
|
||||||
|
}) as typeof server.emit;
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, vi } from "vitest";
|
import { afterEach, vi } from "vitest";
|
||||||
|
import { installSafePorts } from "./safe-ports.js";
|
||||||
|
|
||||||
|
installSafePorts();
|
||||||
|
|
||||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
@@ -114,6 +117,28 @@ const offline = () =>
|
|||||||
}),
|
}),
|
||||||
useSlotContributions: () => [],
|
useSlotContributions: () => [],
|
||||||
usePluginComponent: () => undefined,
|
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(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2023",
|
"target": "ES2023",
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
"module": "nodenext",
|
"module": "esnext",
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function registerAiRoutes(
|
|||||||
notify: ctx.notify,
|
notify: ctx.notify,
|
||||||
rbac: ctx.rbac,
|
rbac: ctx.rbac,
|
||||||
ssh: ctx.ssh,
|
ssh: ctx.ssh,
|
||||||
|
audit: ctx.audit,
|
||||||
};
|
};
|
||||||
|
|
||||||
const logError = (message: string, error: unknown) =>
|
const logError = (message: string, error: unknown) =>
|
||||||
@@ -728,8 +729,9 @@ export function registerAiRoutes(
|
|||||||
).aiAccess ?? { allowReadOnlyCommands: false };
|
).aiAccess ?? { allowReadOnlyCommands: false };
|
||||||
const hosts = await ctx.hosts.list();
|
const hosts = await ctx.hosts.list();
|
||||||
const config = await providerConfig(provider);
|
const config = await providerConfig(provider);
|
||||||
const tools = availableTools((service) =>
|
const tools = availableTools(
|
||||||
serviceAvailable(ctx.services, service),
|
(service) => serviceAvailable(ctx.services, service),
|
||||||
|
{ allowReadOnlyCommands: access.allowReadOnlyCommands },
|
||||||
);
|
);
|
||||||
|
|
||||||
// no-transform keeps core's compression from buffering the stream,
|
// no-transform keeps core's compression from buffering the stream,
|
||||||
|
|||||||
@@ -64,8 +64,14 @@ export const FORBIDDEN_DOMAINS = [
|
|||||||
*/
|
*/
|
||||||
export function availableTools(
|
export function availableTools(
|
||||||
isAvailable: (service: string) => boolean,
|
isAvailable: (service: string) => boolean,
|
||||||
|
options: { allowReadOnlyCommands?: boolean } = {},
|
||||||
): AiTool[] {
|
): 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. */
|
/** Tool definitions in the shape the provider adapters expect. */
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export async function applyProposal(
|
|||||||
* Runs one approved command over this plugin's SSH pool, mirroring how an
|
* Runs one approved command over this plugin's SSH pool, mirroring how an
|
||||||
* automation run_command step executes.
|
* automation run_command step executes.
|
||||||
*/
|
*/
|
||||||
async function runCommandOnHost(
|
export async function runCommandOnHost(
|
||||||
deps: ToolDeps,
|
deps: ToolDeps,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
command: string,
|
command: string,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
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 { 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
|
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||||
@@ -30,6 +35,47 @@ function toHostSummary(host: PluginHostSummary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const readTools: AiTool[] = [
|
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",
|
name: "list_hosts",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type ToolCategory = "read" | "propose";
|
|||||||
/** What tools and the proposal executor reach core and other plugins through. */
|
/** What tools and the proposal executor reach core and other plugins through. */
|
||||||
export type ToolDeps = Pick<
|
export type ToolDeps = Pick<
|
||||||
PluginContext,
|
PluginContext,
|
||||||
"hosts" | "services" | "notify" | "rbac" | "ssh"
|
"hosts" | "services" | "notify" | "rbac" | "ssh" | "audit"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
@@ -26,6 +26,8 @@ export interface AiTool {
|
|||||||
* not offered to the model at all.
|
* not offered to the model at all.
|
||||||
*/
|
*/
|
||||||
service?: string;
|
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. */
|
/** JSON Schema for the arguments, sent to the provider verbatim. */
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -184,18 +184,11 @@ export function activate(app: TermixApp): void {
|
|||||||
applyStatus();
|
applyStatus();
|
||||||
refresh();
|
refresh();
|
||||||
// The admin switch and the user's own choice are settings, saved from
|
// 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.
|
// the settings screen.
|
||||||
let lastFocusRefresh = Date.now();
|
app.onSettingsChanged(() => refresh());
|
||||||
const onFocus = () => {
|
|
||||||
if (Date.now() - lastFocusRefresh < 30_000) return;
|
|
||||||
lastFocusRefresh = Date.now();
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
|
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
|
||||||
for (const event of events) window.addEventListener(event, refresh);
|
for (const event of events) window.addEventListener(event, refresh);
|
||||||
window.addEventListener("focus", onFocus);
|
|
||||||
app.onDispose(() => {
|
app.onDispose(() => {
|
||||||
for (const event of events) window.removeEventListener(event, refresh);
|
for (const event of events) window.removeEventListener(event, refresh);
|
||||||
window.removeEventListener("focus", onFocus);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { availableTools, getTool } from "../../../src/backend/tools/catalog.js";
|
||||||
|
import type { ToolContext } from "../../../src/backend/tools/types.js";
|
||||||
|
|
||||||
|
const tool = getTool("run_readonly_command")!;
|
||||||
|
|
||||||
|
function contextWith(overrides: Partial<ToolContext> = {}) {
|
||||||
|
const withConnection = vi.fn(async () => ({
|
||||||
|
stdout: "Filesystem Size",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
}));
|
||||||
|
const record = vi.fn(async () => {});
|
||||||
|
const context = {
|
||||||
|
userId: "user-1",
|
||||||
|
conversationId: 1,
|
||||||
|
allowReadOnlyCommands: true,
|
||||||
|
deps: {
|
||||||
|
hosts: {
|
||||||
|
checkAccess: vi.fn(async () => ({ hasAccess: true })),
|
||||||
|
},
|
||||||
|
ssh: { withConnection },
|
||||||
|
audit: { record },
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} as unknown as ToolContext;
|
||||||
|
return { context, withConnection, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run_readonly_command", () => {
|
||||||
|
it("is only offered to a user who opted in", () => {
|
||||||
|
const names = (allow: boolean) =>
|
||||||
|
availableTools(() => true, { allowReadOnlyCommands: allow }).map(
|
||||||
|
(entry) => entry.name,
|
||||||
|
);
|
||||||
|
expect(names(false)).not.toContain("run_readonly_command");
|
||||||
|
expect(names(true)).toContain("run_readonly_command");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs an allowlisted command and audits it", async () => {
|
||||||
|
const { context, withConnection, record } = contextWith();
|
||||||
|
const result = await tool.handler({ hostId: 4, command: "df -h" }, context);
|
||||||
|
expect(result).toEqual({ output: "Filesystem Size" });
|
||||||
|
expect(withConnection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "ai_readonly_command", success: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a command that is not read-only", async () => {
|
||||||
|
const { context, withConnection } = contextWith();
|
||||||
|
const result = (await tool.handler(
|
||||||
|
{ hostId: 4, command: "rm -rf /tmp/x" },
|
||||||
|
context,
|
||||||
|
)) as { error?: string };
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
expect(withConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the user has not opted in, or cannot reach the host", async () => {
|
||||||
|
const off = contextWith({ allowReadOnlyCommands: false });
|
||||||
|
expect(
|
||||||
|
await tool.handler({ hostId: 4, command: "uptime" }, off.context),
|
||||||
|
).toEqual({ error: "The user has not allowed read-only commands" });
|
||||||
|
|
||||||
|
const denied = contextWith();
|
||||||
|
(
|
||||||
|
denied.context.deps.hosts.checkAccess as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({ hasAccess: false });
|
||||||
|
expect(
|
||||||
|
await tool.handler({ hostId: 4, command: "uptime" }, denied.context),
|
||||||
|
).toEqual({ error: "Host not found" });
|
||||||
|
expect(denied.withConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -116,7 +116,10 @@
|
|||||||
"descriptionKey": "permissions.run.description",
|
"descriptionKey": "permissions.run.description",
|
||||||
"defaultRoles": ["user"]
|
"defaultRoles": ["user"]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"http": {
|
||||||
|
"legacyPaths": ["/automations/webhook"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
"platforms": ["linux", "win32", "darwin"],
|
"platforms": ["linux", "win32", "darwin"],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"credentials:use",
|
"credentials:use",
|
||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
"network:serve",
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface",
|
||||||
|
"events:core"
|
||||||
],
|
],
|
||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
@@ -75,6 +76,20 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"uiPresets": {
|
||||||
|
"simple": {
|
||||||
|
"viewMode": "list",
|
||||||
|
"containerLayout": "card"
|
||||||
|
},
|
||||||
|
"balanced": {
|
||||||
|
"viewMode": "list",
|
||||||
|
"containerLayout": "card"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"viewMode": "detail",
|
||||||
|
"containerLayout": "table"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
useConnectionRetry,
|
useConnectionRetry,
|
||||||
useHost,
|
useHost,
|
||||||
useTranslation,
|
useTranslation,
|
||||||
|
logActivity,
|
||||||
|
usePluginUiPreferences,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -27,12 +29,9 @@ import {
|
|||||||
Separator,
|
Separator,
|
||||||
TOTPDialog,
|
TOTPDialog,
|
||||||
BrowserSignInDialog,
|
BrowserSignInDialog,
|
||||||
logActivity,
|
|
||||||
useAdaptivePolling,
|
useAdaptivePolling,
|
||||||
useAreaPreferences,
|
|
||||||
useConnectionLog,
|
useConnectionLog,
|
||||||
useTabsSafe,
|
useTabsSafe,
|
||||||
useUiPreferencesContext,
|
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
|
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
|
||||||
import {
|
import {
|
||||||
@@ -82,8 +81,10 @@ function DockerManagerInner({
|
|||||||
const docker = useDockerApi();
|
const docker = useDockerApi();
|
||||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||||
const { currentTab, removeTab } = useTabsSafe();
|
const { currentTab, removeTab } = useTabsSafe();
|
||||||
const dockerPrefs = useAreaPreferences("docker");
|
const { values: dockerPrefs, set: setDockerPref } = usePluginUiPreferences<{
|
||||||
const uiPrefsCtx = useUiPreferencesContext();
|
viewMode: "list" | "detail";
|
||||||
|
containerLayout: "card" | "table";
|
||||||
|
}>();
|
||||||
|
|
||||||
// The shell's host list keeps the host current after an edit; a
|
// 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.
|
// standalone window has no shell, so it keeps the host it was given.
|
||||||
@@ -113,9 +114,9 @@ function DockerManagerInner({
|
|||||||
const handleSetContainerLayout = React.useCallback(
|
const handleSetContainerLayout = React.useCallback(
|
||||||
(layout: "card" | "table") => {
|
(layout: "card" | "table") => {
|
||||||
setContainerLayout(layout);
|
setContainerLayout(layout);
|
||||||
uiPrefsCtx?.setOverride("docker", "containerLayout", layout);
|
setDockerPref("containerLayout", layout);
|
||||||
},
|
},
|
||||||
[uiPrefsCtx],
|
[setDockerPref],
|
||||||
);
|
);
|
||||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||||
const [dockerValidation, setDockerValidation] =
|
const [dockerValidation, setDockerValidation] =
|
||||||
|
|||||||
@@ -401,13 +401,6 @@
|
|||||||
},
|
},
|
||||||
"hosts": {
|
"hosts": {
|
||||||
"fileManager": "File Manager",
|
"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",
|
"fileManagerUrlCopied": "File Manager URL copied",
|
||||||
"copyFileManagerUrlAction": "Copy File Manager URL"
|
"copyFileManagerUrlAction": "Copy File Manager URL"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
"db:own",
|
"db:own",
|
||||||
"network:serve",
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface",
|
||||||
|
"events:core",
|
||||||
|
"kv:own"
|
||||||
],
|
],
|
||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
@@ -70,6 +72,7 @@
|
|||||||
"host": {
|
"host": {
|
||||||
"enableKey": "enableFileManager",
|
"enableKey": "enableFileManager",
|
||||||
"enableLabelKey": "settings.host.enable.label",
|
"enableLabelKey": "settings.host.enable.label",
|
||||||
|
"enableDefault": true,
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"key": "defaultPath",
|
"key": "defaultPath",
|
||||||
@@ -89,7 +92,7 @@
|
|||||||
"hostCapability": {
|
"hostCapability": {
|
||||||
"key": "enableFileManager",
|
"key": "enableFileManager",
|
||||||
"labelKey": "hostCapability.label",
|
"labelKey": "hostCapability.label",
|
||||||
"editorTab": "files"
|
"editorTab": "plugins"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
|||||||
import { getErrorMessage } from "./error-message.js";
|
import { getErrorMessage } from "./error-message.js";
|
||||||
import { setPluginSsh, pluginSsh } from "./ssh.js";
|
import { setPluginSsh, pluginSsh } from "./ssh.js";
|
||||||
import { setPluginCtx } from "./plugin-ctx.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 { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||||
import {
|
import {
|
||||||
@@ -113,6 +119,14 @@ export async function activate(ctx: PluginContext) {
|
|||||||
setPluginCtx(ctx);
|
setPluginCtx(ctx);
|
||||||
ctx.disposables.add(() => setPluginSsh(null));
|
ctx.disposables.add(() => setPluginSsh(null));
|
||||||
ctx.disposables.add(() => setPluginCtx(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 */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
// ctx.db.define hands back an untyped table object; typed at this module's
|
// 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
|
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
|
||||||
Record<string, unknown> | undefined;
|
Record<string, unknown> | undefined;
|
||||||
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
resolvedScpLegacy =
|
||||||
|
(await ctx.settings.getHost<boolean>(
|
||||||
|
Number(resolvedHost.id),
|
||||||
|
"scpLegacy",
|
||||||
|
)) === true;
|
||||||
if (resolvedHost.useSocks5) {
|
if (resolvedHost.useSocks5) {
|
||||||
resolvedUseSocks5 = resolvedHost.useSocks5;
|
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||||
resolvedSocks5Host = resolvedHost.socks5Host;
|
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 { createHash } from "node:crypto";
|
||||||
import { promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export interface TransferPerformanceProfile {
|
export interface TransferPerformanceProfile {
|
||||||
throughputBps: number;
|
throughputBps: number;
|
||||||
@@ -32,15 +29,32 @@ const MB = 1024 * 1024;
|
|||||||
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const MAX_PROFILES = 128;
|
const MAX_PROFILES = 128;
|
||||||
const STORE_VERSION = 1;
|
const STORE_VERSION = 1;
|
||||||
const STORE_FILENAME = "adaptive-transfer-profiles.json";
|
|
||||||
const profiles = new Map<string, TransferPerformanceProfile>();
|
const profiles = new Map<string, TransferPerformanceProfile>();
|
||||||
const directRoutes = new Map<string, DirectRouteProfile>();
|
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;
|
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 persistTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let persistPromise = Promise.resolve();
|
let persistPromise = Promise.resolve();
|
||||||
|
|
||||||
interface PersistedProfiles {
|
export interface PersistedProfiles {
|
||||||
version: typeof STORE_VERSION;
|
version: typeof STORE_VERSION;
|
||||||
profiles: Record<string, TransferPerformanceProfile>;
|
profiles: Record<string, TransferPerformanceProfile>;
|
||||||
directRoutes: Record<string, DirectRouteProfile>;
|
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 {
|
function profileId(key: string): string {
|
||||||
return createHash("sha256").update(key).digest("hex");
|
return createHash("sha256").update(key).digest("hex");
|
||||||
}
|
}
|
||||||
@@ -117,22 +125,15 @@ function trimProfiles(now = Date.now()): void {
|
|||||||
|
|
||||||
async function persistProfiles(): Promise<void> {
|
async function persistProfiles(): Promise<void> {
|
||||||
trimProfiles();
|
trimProfiles();
|
||||||
const target = storePath();
|
if (!store) return;
|
||||||
const temporary = `${target}.${process.pid}.tmp`;
|
|
||||||
const payload: PersistedProfiles = {
|
|
||||||
version: STORE_VERSION,
|
|
||||||
profiles: Object.fromEntries(profiles),
|
|
||||||
directRoutes: Object.fromEntries(directRoutes),
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
await store.write({
|
||||||
await fs.writeFile(temporary, JSON.stringify(payload), {
|
version: STORE_VERSION,
|
||||||
encoding: "utf8",
|
profiles: Object.fromEntries(profiles),
|
||||||
mode: 0o600,
|
directRoutes: Object.fromEntries(directRoutes),
|
||||||
});
|
});
|
||||||
await fs.rename(temporary, target);
|
|
||||||
} catch {
|
} 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> {
|
export async function initializeTransferProfiles(): Promise<void> {
|
||||||
const target = storePath();
|
if (loadPromise && loadedStore === store) return loadPromise;
|
||||||
if (loadedPath === target) return loadPromise;
|
loadedStore = store;
|
||||||
loadedPath = target;
|
const current = store;
|
||||||
loadPromise = (async () => {
|
loadPromise = (async () => {
|
||||||
profiles.clear();
|
profiles.clear();
|
||||||
directRoutes.clear();
|
directRoutes.clear();
|
||||||
|
if (!current) return;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(
|
const parsed = ((await current.read()) ??
|
||||||
await fs.readFile(target, "utf8"),
|
{}) as Partial<PersistedProfiles>;
|
||||||
) as Partial<PersistedProfiles>;
|
|
||||||
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
|
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
|
||||||
for (const [key, profile] of Object.entries(parsed.profiles)) {
|
for (const [key, profile] of Object.entries(parsed.profiles)) {
|
||||||
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
|
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
|
||||||
@@ -381,7 +382,7 @@ export function getRecentDirectRouteDecision(
|
|||||||
export function clearTransferProfiles(): void {
|
export function clearTransferProfiles(): void {
|
||||||
if (persistTimer) clearTimeout(persistTimer);
|
if (persistTimer) clearTimeout(persistTimer);
|
||||||
persistTimer = undefined;
|
persistTimer = undefined;
|
||||||
loadedPath = undefined;
|
loadedStore = null;
|
||||||
loadPromise = undefined;
|
loadPromise = undefined;
|
||||||
profiles.clear();
|
profiles.clear();
|
||||||
directRoutes.clear();
|
directRoutes.clear();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fileManagerHostSetting } from "./host-settings";
|
||||||
import { getErrorMessage } from "@/lib/error-message.js";
|
import { getErrorMessage } from "@/lib/error-message.js";
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
import React, {
|
import React, {
|
||||||
@@ -44,7 +45,11 @@ import {
|
|||||||
ConnectionScreen,
|
ConnectionScreen,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
|
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 { copyToClipboard } from "@/lib/clipboard.ts";
|
||||||
import {
|
import {
|
||||||
listSSHFiles,
|
listSSHFiles,
|
||||||
@@ -77,7 +82,6 @@ import {
|
|||||||
getPinnedFiles,
|
getPinnedFiles,
|
||||||
} from "./api/file-manager-data-api";
|
} from "./api/file-manager-data-api";
|
||||||
import {
|
import {
|
||||||
logActivity,
|
|
||||||
transferToHost,
|
transferToHost,
|
||||||
addTransferRecent,
|
addTransferRecent,
|
||||||
type TransferMethodPreference,
|
type TransferMethodPreference,
|
||||||
@@ -145,15 +149,21 @@ function FileManagerContent({
|
|||||||
|
|
||||||
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
||||||
const [currentPath, setCurrentPath] = useState(
|
const [currentPath, setCurrentPath] = useState(
|
||||||
initialPath || initialHost?.defaultPath || "/",
|
initialPath ||
|
||||||
|
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||||
|
"/",
|
||||||
);
|
);
|
||||||
const currentPathRef = useRef(currentPath);
|
const currentPathRef = useRef(currentPath);
|
||||||
currentPathRef.current = currentPath;
|
currentPathRef.current = currentPath;
|
||||||
const lastSuccessfulPathRef = useRef(
|
const lastSuccessfulPathRef = useRef(
|
||||||
initialPath || initialHost?.defaultPath || "/",
|
initialPath ||
|
||||||
|
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||||
|
"/",
|
||||||
);
|
);
|
||||||
const [navHistory, setNavHistory] = useState<string[]>([
|
const [navHistory, setNavHistory] = useState<string[]>([
|
||||||
initialPath || initialHost?.defaultPath || "/",
|
initialPath ||
|
||||||
|
fileManagerHostSetting(initialHost, "defaultPath", "") ||
|
||||||
|
"/",
|
||||||
]);
|
]);
|
||||||
const [navIndex, setNavIndex] = useState(0);
|
const [navIndex, setNavIndex] = useState(0);
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
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 {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -372,11 +373,11 @@ function RemotePane({
|
|||||||
...current,
|
...current,
|
||||||
connectionState: "ready",
|
connectionState: "ready",
|
||||||
sessionId: result.sessionId || String(host.id),
|
sessionId: result.sessionId || String(host.id),
|
||||||
path: host.defaultPath || "/",
|
path: fileManagerHostSetting(host, "defaultPath", "") || "/",
|
||||||
}));
|
}));
|
||||||
await loadRemotePath(
|
await loadRemotePath(
|
||||||
result.sessionId || String(host.id),
|
result.sessionId || String(host.id),
|
||||||
host.defaultPath || "/",
|
fileManagerHostSetting(host, "defaultPath", "") || "/",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[loadRemotePath, setPane],
|
[loadRemotePath, setPane],
|
||||||
@@ -585,7 +586,7 @@ export function SftpTransferTab() {
|
|||||||
setHosts(
|
setHosts(
|
||||||
data.filter(
|
data.filter(
|
||||||
(host) =>
|
(host) =>
|
||||||
host.enableFileManager !== false &&
|
fileManagerHostSetting(host, "enableFileManager", true) &&
|
||||||
host.connectionType !== "rdp" &&
|
host.connectionType !== "rdp" &&
|
||||||
host.connectionType !== "vnc",
|
host.connectionType !== "vnc",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React, { useState } from "react";
|
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 { AlertCircle, Download } from "lucide-react";
|
||||||
import { Button } from "@termix/plugin-sdk/ui";
|
import { Button } from "@termix/plugin-sdk/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
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
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
|
||||||
// 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).
|
|
||||||
|
|
||||||
interface PdfPreviewProps {
|
interface PdfPreviewProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fileManagerHostSetting } from "../host-settings";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -236,7 +237,7 @@ export function TransferToHostDialog({
|
|||||||
const candidates = hosts.filter(
|
const candidates = hosts.filter(
|
||||||
(h) =>
|
(h) =>
|
||||||
h.id !== sourceHost.id &&
|
h.id !== sourceHost.id &&
|
||||||
h.enableFileManager !== false &&
|
fileManagerHostSetting(h, "enableFileManager", true) &&
|
||||||
h.connectionType !== "rdp" &&
|
h.connectionType !== "rdp" &&
|
||||||
h.connectionType !== "vnc",
|
h.connectionType !== "vnc",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fileManagerHostSetting } from "../host-settings";
|
||||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||||
import type {
|
import type {
|
||||||
@@ -12,7 +13,9 @@ export function FileManagerWidgetEditForm({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { hosts } = useHosts();
|
const { hosts } = useHosts();
|
||||||
const options = hosts.filter(
|
const options = hosts.filter(
|
||||||
(host) => host.enableSsh !== false && host.enableFileManager !== false,
|
(host) =>
|
||||||
|
host.enableSsh !== false &&
|
||||||
|
fileManagerHostSetting(host, "enableFileManager", true),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** One of this plugin's host settings, from the host payload. */
|
||||||
|
export function fileManagerHostSetting<T>(
|
||||||
|
host: object | null | undefined,
|
||||||
|
key: "enableFileManager" | "defaultPath" | "scpLegacy",
|
||||||
|
fallback: T,
|
||||||
|
): T {
|
||||||
|
const settings = (
|
||||||
|
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
|
||||||
|
)?.pluginSettings;
|
||||||
|
const value = settings?.["file-manager"]?.[key];
|
||||||
|
return value === undefined || value === null || value === ""
|
||||||
|
? fallback
|
||||||
|
: (value as T);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fileManagerHostSetting } from "./host-settings";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { FolderSearch, ArrowLeftRight } from "lucide-react";
|
import { FolderSearch, ArrowLeftRight } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +12,6 @@ import { GRID_SIZE } from "./homepage/homepage.js";
|
|||||||
import { FileManager } from "./FileManager.tsx";
|
import { FileManager } from "./FileManager.tsx";
|
||||||
import FileManagerApp from "./FileManagerApp.tsx";
|
import FileManagerApp from "./FileManagerApp.tsx";
|
||||||
import { SftpTransferTab } from "./SftpTransferTab.tsx";
|
import { SftpTransferTab } from "./SftpTransferTab.tsx";
|
||||||
import { HostFilesTab } from "./HostFilesTab.tsx";
|
|
||||||
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
|
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
|
||||||
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
|
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
|
||||||
import { startTransferMonitor } from "./TransferMonitor.tsx";
|
import { startTransferMonitor } from "./TransferMonitor.tsx";
|
||||||
@@ -104,16 +104,9 @@ export function activate(app: TermixApp): void {
|
|||||||
order: 20,
|
order: 20,
|
||||||
tabType: "files",
|
tabType: "files",
|
||||||
copyUrlView: "file-manager",
|
copyUrlView: "file-manager",
|
||||||
when: (host) => !!host.enableSsh && host.enableFileManager !== false,
|
when: (host) =>
|
||||||
});
|
!!host.enableSsh &&
|
||||||
|
fileManagerHostSetting(host, "enableFileManager", true),
|
||||||
app.registerHostEditorSection({
|
|
||||||
id: "files",
|
|
||||||
group: "ssh",
|
|
||||||
titleKey: "hosts.tabFiles",
|
|
||||||
icon: FolderSearch,
|
|
||||||
order: 60,
|
|
||||||
component: HostFilesTab as never,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.registerHomepageWidget({
|
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,
|
id: 1,
|
||||||
name: "prod",
|
name: "prod",
|
||||||
ip: "10.0.0.1",
|
ip: "10.0.0.1",
|
||||||
enableFileManager: true,
|
|
||||||
connectionType: "ssh",
|
connectionType: "ssh",
|
||||||
defaultPath: "/srv",
|
pluginSettings: {
|
||||||
|
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "backup",
|
name: "backup",
|
||||||
ip: "10.0.0.2",
|
ip: "10.0.0.2",
|
||||||
enableFileManager: true,
|
|
||||||
connectionType: "ssh",
|
connectionType: "ssh",
|
||||||
defaultPath: "/srv",
|
pluginSettings: {
|
||||||
|
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
|
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 });
|
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 () => {
|
it("registers files.openHost and files.openEditor actions", async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ vi.mock("sonner", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/main-axios.ts", () => ({}));
|
vi.mock("@/main-axios.ts", () => ({}));
|
||||||
vi.mock("../../../features/file-manager/transferNotificationStore.ts", () => ({
|
vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
|
||||||
markTransferNotified: vi.fn(),
|
markTransferNotified: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function SshTerminalEditForm({
|
|||||||
<SingleHostEditForm
|
<SingleHostEditForm
|
||||||
hostId={config.hostId}
|
hostId={config.hostId}
|
||||||
onChange={(hostId) => onChange({ ...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">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"ssh:connect",
|
"ssh:connect",
|
||||||
"db:own",
|
"db:own",
|
||||||
"network:serve",
|
"network:serve",
|
||||||
"ui:surface"
|
"ui:surface",
|
||||||
|
"events:core"
|
||||||
],
|
],
|
||||||
"provides": [
|
"provides": [
|
||||||
{
|
{
|
||||||
@@ -136,7 +137,18 @@
|
|||||||
"id": "host-metrics.managers",
|
"id": "host-metrics.managers",
|
||||||
"accepts": ["component"]
|
"accepts": ["component"]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"uiPresets": {
|
||||||
|
"simple": {
|
||||||
|
"columns": 1
|
||||||
|
},
|
||||||
|
"balanced": {
|
||||||
|
"columns": 3
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"columns": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"locales": "locales",
|
||||||
"platforms": ["linux", "win32", "darwin"]
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "termix-plugin build",
|
"build": "termix-plugin build",
|
||||||
"test": "termix-plugin test",
|
"test": "termix-plugin test",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
|
||||||
"validate": "termix-plugin validate"
|
"validate": "termix-plugin validate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -98,7 +98,12 @@ export async function collectMetrics(
|
|||||||
onAuthenticated?.();
|
onAuthenticated?.();
|
||||||
const platform = await detectHostPlatform(client);
|
const platform = await detectHostPlatform(client);
|
||||||
const result: CollectedMetrics = {
|
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),
|
memory: await collectMemoryMetrics(client, platform),
|
||||||
disk: await collectDiskMetrics(
|
disk: await collectDiskMetrics(
|
||||||
client,
|
client,
|
||||||
@@ -106,7 +111,12 @@ export async function collectMetrics(
|
|||||||
mounts.monitoredMounts,
|
mounts.monitoredMounts,
|
||||||
platform,
|
platform,
|
||||||
),
|
),
|
||||||
network: await collectNetworkMetrics(client, platform, host.id),
|
network: await collectNetworkMetrics(
|
||||||
|
client,
|
||||||
|
platform,
|
||||||
|
host.id,
|
||||||
|
state.rateSamples.network,
|
||||||
|
),
|
||||||
uptime: await collectUptimeMetrics(client, platform),
|
uptime: await collectUptimeMetrics(client, platform),
|
||||||
processes: await collectProcessesMetrics(client),
|
processes: await collectProcessesMetrics(client),
|
||||||
system: await collectSystemMetrics(client, platform),
|
system: await collectSystemMetrics(client, platform),
|
||||||
|
|||||||
@@ -30,3 +30,32 @@ export function hostImportNormalizer(
|
|||||||
});
|
});
|
||||||
return { ...settings };
|
return { ...settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered as "host-metrics.hostPayloadLegacy": the 2.8 statsConfig shape
|
||||||
|
* Termix-Mobile still reads. Remove once the mobile app reads
|
||||||
|
* pluginSettings["host-metrics"] and GET /host/status.
|
||||||
|
*/
|
||||||
|
export function hostPayloadLegacy(
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
host: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const settings = readHostMetricsSettings(values);
|
||||||
|
const statusInterval =
|
||||||
|
typeof host.statusCheckInterval === "number"
|
||||||
|
? host.statusCheckInterval
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
statsConfig: {
|
||||||
|
enabledWidgets: settings.enabledWidgets,
|
||||||
|
statusCheckEnabled: host.statusCheckEnabled !== false,
|
||||||
|
statusCheckInterval: statusInterval ?? 30,
|
||||||
|
useGlobalStatusInterval: statusInterval === null,
|
||||||
|
metricsEnabled: settings.metricsEnabled,
|
||||||
|
metricsInterval: settings.metricsInterval ?? 30,
|
||||||
|
useGlobalMetricsInterval: settings.metricsInterval == null,
|
||||||
|
excludedMounts: settings.excludedMounts,
|
||||||
|
monitoredMounts: settings.monitoredMounts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { MetricsSessions } from "./sessions.js";
|
|||||||
import { CollectorRegistry } from "./collectors.js";
|
import { CollectorRegistry } from "./collectors.js";
|
||||||
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
|
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
|
||||||
import { registerRoutes } from "./routes.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";
|
import { newSessionId, supportsMetrics } from "./helpers.js";
|
||||||
|
|
||||||
export type { MetricsCollectorV1 } from "./collectors.js";
|
export type { MetricsCollectorV1 } from "./collectors.js";
|
||||||
@@ -101,6 +101,7 @@ export async function activate(ctx: PluginContext) {
|
|||||||
"host-metrics.hostImportNormalizer",
|
"host-metrics.hostImportNormalizer",
|
||||||
hostImportNormalizer,
|
hostImportNormalizer,
|
||||||
);
|
);
|
||||||
|
ctx.registry.provide("host-metrics.hostPayloadLegacy", hostPayloadLegacy);
|
||||||
|
|
||||||
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
|
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import {
|
|||||||
metricsConcurrencyFor,
|
metricsConcurrencyFor,
|
||||||
type MetricsState,
|
type MetricsState,
|
||||||
} from "./state.js";
|
} 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_METRICS_INTERVAL = 30;
|
||||||
export const DEFAULT_RETENTION_DAYS = 7;
|
export const DEFAULT_RETENTION_DAYS = 7;
|
||||||
@@ -201,8 +199,7 @@ export class MetricsPoller {
|
|||||||
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
|
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
|
||||||
this.viewers.clear();
|
this.viewers.clear();
|
||||||
this.viewerDetails.clear();
|
this.viewerDetails.clear();
|
||||||
clearCpuSampleCache();
|
this.state.rateSamples.clear();
|
||||||
clearNetworkSampleCache();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The host as `userId` may reach it, cached for a few minutes. */
|
/** The host as `userId` may reach it, cached for a few minutes. */
|
||||||
@@ -274,8 +271,7 @@ export class MetricsPoller {
|
|||||||
if (clearData) {
|
if (clearData) {
|
||||||
this.metrics.delete(hostId);
|
this.metrics.delete(hostId);
|
||||||
this.state.hostCache.invalidate(hostId);
|
this.state.hostCache.invalidate(hostId);
|
||||||
clearCpuSampleCache(hostId);
|
this.state.rateSamples.clear(hostId);
|
||||||
clearNetworkSampleCache(hostId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { CpuSamples } from "./widgets/cpu-collector.js";
|
||||||
|
import { createNetworkSamples } from "./widgets/network-collector.js";
|
||||||
|
|
||||||
export type HostStatus = "online" | "reachable" | "offline";
|
export type HostStatus = "online" | "reachable" | "offline";
|
||||||
|
|
||||||
export class RequestQueue {
|
export class RequestQueue {
|
||||||
@@ -422,8 +425,30 @@ export function canStartInitialMetrics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Everything one activation of the plugin polls with. */
|
/** 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() {
|
export function createMetricsState() {
|
||||||
return {
|
return {
|
||||||
|
rateSamples: createRateSamples(),
|
||||||
/** SSH metrics execs are expensive; resized with the fleet. */
|
/** SSH metrics execs are expensive; resized with the fleet. */
|
||||||
metricsLimiter: new ConcurrentLimiter(5),
|
metricsLimiter: new ConcurrentLimiter(5),
|
||||||
/** Viewer registration is bursty; admit only two first samples at a time. */
|
/** 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
|
* 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
|
* /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,
|
number,
|
||||||
{ total: number; idle: number; timestamp: 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(
|
export function parseCpuLine(
|
||||||
cpuLine: string,
|
cpuLine: string,
|
||||||
@@ -149,6 +142,7 @@ export async function collectCpuMetrics(
|
|||||||
client: Client,
|
client: Client,
|
||||||
platform?: HostPlatform,
|
platform?: HostPlatform,
|
||||||
hostId?: number,
|
hostId?: number,
|
||||||
|
previousStat: CpuSamples = new Map(),
|
||||||
): Promise<{
|
): Promise<{
|
||||||
percent: number | null;
|
percent: number | null;
|
||||||
cores: 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.
|
* 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.
|
* Keyed by host, since counters from one host are meaningless against another.
|
||||||
*/
|
*/
|
||||||
const previousSamples = new Map<
|
export interface NetworkSamples {
|
||||||
number,
|
counters: Map<
|
||||||
{ counters: Map<string, NetworkCounters>; timestamp: number }
|
number,
|
||||||
>();
|
{ counters: Map<string, NetworkCounters>; timestamp: number }
|
||||||
|
>;
|
||||||
|
windows: Map<number, { rows: WindowsAdapterRow[]; timestamp: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
const previousWindowsSamples = new Map<
|
export function createNetworkSamples(): NetworkSamples {
|
||||||
number,
|
return { counters: new Map(), windows: new Map() };
|
||||||
{ 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 parseDarwinIfconfig(
|
export function parseDarwinIfconfig(
|
||||||
@@ -112,7 +104,8 @@ export function parseDarwinNetstat(
|
|||||||
|
|
||||||
async function collectDarwinNetworkMetrics(
|
async function collectDarwinNetworkMetrics(
|
||||||
client: Client,
|
client: Client,
|
||||||
hostId?: number,
|
hostId: number | undefined,
|
||||||
|
samples: NetworkSamples,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
interfaces: Array<{
|
interfaces: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -143,7 +136,7 @@ async function collectDarwinNetworkMetrics(
|
|||||||
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
|
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
|
||||||
const current = parseDarwinNetstat(netstatOut.stdout);
|
const current = parseDarwinNetstat(netstatOut.stdout);
|
||||||
const previous =
|
const previous =
|
||||||
hostId !== undefined ? previousSamples.get(hostId) : undefined;
|
hostId !== undefined ? samples.counters.get(hostId) : undefined;
|
||||||
const elapsedSeconds = previous
|
const elapsedSeconds = previous
|
||||||
? (readAt - previous.timestamp) / 1000
|
? (readAt - previous.timestamp) / 1000
|
||||||
: 0;
|
: 0;
|
||||||
@@ -166,7 +159,7 @@ async function collectDarwinNetworkMetrics(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (hostId !== undefined) {
|
if (hostId !== undefined) {
|
||||||
previousSamples.set(hostId, { counters: current, timestamp: readAt });
|
samples.counters.set(hostId, { counters: current, timestamp: readAt });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
for (const [name, data] of ifMap.entries()) {
|
for (const [name, data] of ifMap.entries()) {
|
||||||
@@ -225,7 +218,8 @@ export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
|
|||||||
|
|
||||||
async function collectWindowsNetworkMetrics(
|
async function collectWindowsNetworkMetrics(
|
||||||
client: Client,
|
client: Client,
|
||||||
hostId?: number,
|
hostId: number | undefined,
|
||||||
|
samples: NetworkSamples,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
interfaces: Array<{
|
interfaces: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -252,7 +246,7 @@ async function collectWindowsNetworkMetrics(
|
|||||||
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
|
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
|
||||||
const currentRows = parseWindowsAdapterJson(result.stdout);
|
const currentRows = parseWindowsAdapterJson(result.stdout);
|
||||||
const previous =
|
const previous =
|
||||||
hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined;
|
hostId !== undefined ? samples.windows.get(hostId) : undefined;
|
||||||
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
|
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
|
||||||
const previousMap = new Map(
|
const previousMap = new Map(
|
||||||
(previous?.rows ?? []).map((row) => [row.name, row]),
|
(previous?.rows ?? []).map((row) => [row.name, row]),
|
||||||
@@ -276,7 +270,7 @@ async function collectWindowsNetworkMetrics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hostId !== undefined) {
|
if (hostId !== undefined) {
|
||||||
previousWindowsSamples.set(hostId, {
|
samples.windows.set(hostId, {
|
||||||
rows: currentRows,
|
rows: currentRows,
|
||||||
timestamp: readAt,
|
timestamp: readAt,
|
||||||
});
|
});
|
||||||
@@ -292,6 +286,7 @@ export async function collectNetworkMetrics(
|
|||||||
client: Client,
|
client: Client,
|
||||||
platform?: HostPlatform,
|
platform?: HostPlatform,
|
||||||
hostId?: number,
|
hostId?: number,
|
||||||
|
samples: NetworkSamples = createNetworkSamples(),
|
||||||
): Promise<{
|
): Promise<{
|
||||||
interfaces: Array<{
|
interfaces: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -304,10 +299,10 @@ export async function collectNetworkMetrics(
|
|||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
return collectDarwinNetworkMetrics(client, hostId);
|
return collectDarwinNetworkMetrics(client, hostId, samples);
|
||||||
}
|
}
|
||||||
if (platform === "windows") {
|
if (platform === "windows") {
|
||||||
return collectWindowsNetworkMetrics(client, hostId);
|
return collectWindowsNetworkMetrics(client, hostId, samples);
|
||||||
}
|
}
|
||||||
|
|
||||||
const interfaces: Array<{
|
const interfaces: Array<{
|
||||||
@@ -368,7 +363,7 @@ export async function collectNetworkMetrics(
|
|||||||
const procNet = await execCommand(client, "cat /proc/net/dev");
|
const procNet = await execCommand(client, "cat /proc/net/dev");
|
||||||
const rxTxMap = parseNetworkCounters(procNet.stdout);
|
const rxTxMap = parseNetworkCounters(procNet.stdout);
|
||||||
const previous =
|
const previous =
|
||||||
hostId !== undefined ? previousSamples.get(hostId) : undefined;
|
hostId !== undefined ? samples.counters.get(hostId) : undefined;
|
||||||
const elapsedSeconds = previous
|
const elapsedSeconds = previous
|
||||||
? (readAt - previous.timestamp) / 1000
|
? (readAt - previous.timestamp) / 1000
|
||||||
: 0;
|
: 0;
|
||||||
@@ -395,7 +390,7 @@ export async function collectNetworkMetrics(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (hostId !== undefined) {
|
if (hostId !== undefined) {
|
||||||
previousSamples.set(hostId, {
|
samples.counters.set(hostId, {
|
||||||
counters: rxTxMap,
|
counters: rxTxMap,
|
||||||
timestamp: readAt,
|
timestamp: readAt,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Separator,
|
Separator,
|
||||||
Button,
|
Button,
|
||||||
useAreaPreferences,
|
|
||||||
useConfirmation,
|
useConfirmation,
|
||||||
TOTPDialog,
|
TOTPDialog,
|
||||||
useTabsSafe,
|
useTabsSafe,
|
||||||
@@ -12,7 +11,6 @@ import {
|
|||||||
CardGridCanvas,
|
CardGridCanvas,
|
||||||
ColumnCountStepper,
|
ColumnCountStepper,
|
||||||
type GridCardCatalogEntry,
|
type GridCardCatalogEntry,
|
||||||
logActivity,
|
|
||||||
SnippetVariablesDialog,
|
SnippetVariablesDialog,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||||
@@ -52,6 +50,8 @@ import {
|
|||||||
useHost,
|
useHost,
|
||||||
useHostStatus,
|
useHostStatus,
|
||||||
invokeAction,
|
invokeAction,
|
||||||
|
logActivity,
|
||||||
|
usePluginUiPreferences,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
|
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
|
||||||
@@ -161,7 +161,9 @@ function HostMetricsInner({
|
|||||||
const hostId = currentHostConfig?.id ?? null;
|
const hostId = currentHostConfig?.id ?? null;
|
||||||
const { layout, setLayout } = useHostMetricsPreferences(hostId);
|
const { layout, setLayout } = useHostMetricsPreferences(hostId);
|
||||||
|
|
||||||
const metricsPrefs = useAreaPreferences("hostMetrics");
|
const { values: metricsPrefs } = usePluginUiPreferences<{
|
||||||
|
columns: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
|
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
|
||||||
// A saved layout is user-authored, so the preset never rewrites it -- it
|
// A saved layout is user-authored, so the preset never rewrites it -- it
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
import {
|
||||||
|
usePluginApi,
|
||||||
|
usePluginApiFor,
|
||||||
|
useTranslation,
|
||||||
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
getPollingEnvironmentMultiplier,
|
getPollingEnvironmentMultiplier,
|
||||||
|
resolveConnectionOrigin,
|
||||||
|
resolveRemoteHostId,
|
||||||
runAdaptivePolling,
|
runAdaptivePolling,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
/** What the ssh-terminal plugin hands a "terminal.toolbarStatus" component. */
|
/** What the ssh-terminal plugin hands a "terminal.toolbarStatus" component. */
|
||||||
interface TerminalToolbarStatusProps {
|
interface TerminalToolbarStatusProps {
|
||||||
host: { id: number | string };
|
host: {
|
||||||
|
id: number | string;
|
||||||
|
connectionOrigin?: "local" | "remote" | null;
|
||||||
|
syncId?: string | null;
|
||||||
|
};
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/** Visible on a desktop viewport and connected: poll only while true. */
|
/** Visible on a desktop viewport and connected: poll only while true. */
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -59,7 +69,8 @@ function StatBar({
|
|||||||
export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
||||||
const { host, active } = props as unknown as TerminalToolbarStatusProps;
|
const { host, active } = props as unknown as TerminalToolbarStatusProps;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const api = usePluginApi();
|
const localApi = usePluginApi();
|
||||||
|
const remoteApi = usePluginApiFor("remote");
|
||||||
const [metrics, setMetrics] = useState<Sample | null>(null);
|
const [metrics, setMetrics] = useState<Sample | null>(null);
|
||||||
const [available, setAvailable] = useState(true);
|
const [available, setAvailable] = useState(true);
|
||||||
const hostId = host?.id ? Number(host.id) : null;
|
const hostId = host?.id ? Number(host.id) : null;
|
||||||
@@ -73,6 +84,9 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
|||||||
let stopPolling: (() => void) | undefined;
|
let stopPolling: (() => void) | undefined;
|
||||||
let viewerSessionId: string | undefined;
|
let viewerSessionId: string | undefined;
|
||||||
let previous: Sample | null = null;
|
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);
|
setAvailable(true);
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@@ -81,7 +95,7 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
|||||||
cpu?: { percent?: number };
|
cpu?: { percent?: number };
|
||||||
memory?: { percent?: number };
|
memory?: { percent?: number };
|
||||||
disk?: { percent?: number };
|
disk?: { percent?: number };
|
||||||
} | null>(`/metrics/${hostId}`, {
|
} | null>(`/metrics/${targetId}`, {
|
||||||
validateStatus: (status: number) => status === 200 || status === 404,
|
validateStatus: (status: number) => status === 200 || status === 404,
|
||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
@@ -109,14 +123,24 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
|||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
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<{
|
const { data } = await api.post<{
|
||||||
requires_totp?: boolean;
|
requires_totp?: boolean;
|
||||||
viewerSessionId?: string;
|
viewerSessionId?: string;
|
||||||
}>(`/metrics/start/${hostId}`);
|
}>(`/metrics/start/${targetId}`);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
if (data.viewerSessionId) {
|
if (data.viewerSessionId) {
|
||||||
void api
|
void api
|
||||||
.post(`/metrics/stop/${hostId}`, {
|
.post(`/metrics/stop/${targetId}`, {
|
||||||
viewerSessionId: data.viewerSessionId,
|
viewerSessionId: data.viewerSessionId,
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -148,10 +172,10 @@ export function TerminalMetricsStatus(props: Record<string, unknown>) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
stopPolling?.();
|
stopPolling?.();
|
||||||
void api
|
void api
|
||||||
.post(`/metrics/stop/${hostId}`, { viewerSessionId })
|
.post(`/metrics/stop/${targetId}`, { viewerSessionId })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
};
|
};
|
||||||
}, [active, hostId, api]);
|
}, [active, hostId, localApi, remoteApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (!available) {
|
if (!available) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { useManagerData, extractError } from "./useManagerData";
|
import { useManagerData, extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
import { ManagerSearch } from "./ManagerToolbar";
|
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface CronEntry {
|
interface CronEntry {
|
||||||
raw: string;
|
raw: string;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { useManagerData, extractError } from "./useManagerData";
|
import { useManagerData, extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
import { ManagerSearch } from "./ManagerToolbar";
|
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface FirewallData {
|
interface FirewallData {
|
||||||
type: "iptables" | "nftables" | "none";
|
type: "iptables" | "nftables" | "none";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { useManagerData, extractError } from "./useManagerData";
|
import { useManagerData, extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface HealthCheck {
|
interface HealthCheck {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ScrollText } from "lucide-react";
|
|||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { extractError } from "./useManagerData";
|
import { extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface LogFiles {
|
interface LogFiles {
|
||||||
common: string[];
|
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 { toast } from "sonner";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { useManagerData, extractError } from "./useManagerData";
|
import { useManagerData, extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
import { ManagerSearch } from "./ManagerToolbar";
|
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface UpgradablePackage {
|
interface UpgradablePackage {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { useManagerData, extractError } from "./useManagerData";
|
import { useManagerData, extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
import { ManagerSearch } from "./ManagerToolbar";
|
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface ProcessRow {
|
interface ProcessRow {
|
||||||
pid: number;
|
pid: number;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { useManagerData, extractError } from "./useManagerData";
|
import { useManagerData, extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
import { ManagerSearch } from "./ManagerToolbar";
|
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface SystemdService {
|
interface SystemdService {
|
||||||
unit: string;
|
unit: string;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useMemo, useState } from "react";
|
|||||||
import { MemoryStick, Timer, HardDrive } from "lucide-react";
|
import { MemoryStick, Timer, HardDrive } from "lucide-react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { useManagerData } from "./useManagerData";
|
import { useManagerData } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
import { ManagerSearch } from "./ManagerToolbar";
|
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface MemProc {
|
interface MemProc {
|
||||||
pid: number;
|
pid: number;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useManagerAction,
|
useManagerAction,
|
||||||
extractError,
|
extractError,
|
||||||
} from "./useManagerData";
|
} from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface CertInfo {
|
interface CertInfo {
|
||||||
client: string;
|
client: string;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useHostMetricsApi } from "../../host-metrics-api";
|
import { useHostMetricsApi } from "../../host-metrics-api";
|
||||||
import { useManagerData, extractError } from "./useManagerData";
|
import { useManagerData, extractError } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
import { ManagerSearch } from "./ManagerToolbar";
|
import { ManagerSearch } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface SystemUser {
|
interface SystemUser {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||||
import { useManagerData, useManagerAction } from "./useManagerData";
|
import { useManagerData, useManagerAction } from "./useManagerData";
|
||||||
import { ManagerCardShell } from "./ManagerCardShell";
|
import { ManagerCardShell } from "@termix/plugin-sdk/ui";
|
||||||
|
|
||||||
interface WireGuardPeer {
|
interface WireGuardPeer {
|
||||||
publicKey: string;
|
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) => ({
|
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
|
||||||
...(await importOriginal<object>()),
|
...(await importOriginal<object>()),
|
||||||
useAreaPreferences: () => ({ columns: 3 }),
|
|
||||||
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
|
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
|
||||||
logActivity: vi.fn(async () => undefined),
|
logActivity: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user