Compare commits

..
23 changed files with 253 additions and 10 deletions

No files matched your search

+2 -1
View File
@@ -5,7 +5,8 @@
### Added ### Added
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals - Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
- API keys can be limited to reading or running actions - API keys can be limited to reading or running actions, when an admin makes them
- A click on a host can open its host page, when a plugin offers one
### Changed ### Changed
+1
View File
@@ -82,6 +82,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | | [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. | | [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | | [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. | | [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | | [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | | [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.11.0", "version": "26.11.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.11.0", "version": "26.11.1",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.11.0", "version": "26.11.1",
"description": "Self-hosted, plugin-based server management.", "description": "Self-hosted, plugin-based server management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
+18
View File
@@ -1,5 +1,23 @@
# Changelog # Changelog
## 1.0.25
Plugin API 1.14.0.
### Added
- Host click behavior `hostPage` opens a host page when a plugin offers one
- `createApiKey` takes scopes, to limit a key to actions
## 1.0.24
Plugin API 1.14.0.
### Added
- `app.registerHostSection` adds a card to the host page
- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes
## 1.0.23 ## 1.0.23
Plugin API 1.14.0. Plugin API 1.14.0.
+1 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.23 | Plugin API 1.14.0 SDK 1.0.25 | Plugin API 1.14.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.23", "version": "1.0.25",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+21
View File
@@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
/** A control in the host editor's toolbar. */ /** A control in the host editor's toolbar. */
export type HostEditorToolbarItemContribution = HostBadgeContribution; export type HostEditorToolbarItemContribution = HostBadgeContribution;
export interface HostSectionProps {
host: PluginHostRecord;
isVisible: boolean;
shell: ShellApi;
}
export interface HostSectionContribution {
/** Declare it in manifest contributes.hostSections too. */
id: string;
titleKey: string;
/** Lower sorts first. */
order?: number;
when?: (host: PluginHostRecord) => boolean;
component: ComponentType<HostSectionProps>;
}
export interface HostContextMenuItemContribution { export interface HostContextMenuItemContribution {
id: string; id: string;
titleKey: string; titleKey: string;
@@ -1172,6 +1188,11 @@ export interface TermixApp extends TermixAppInfo {
registerHostContextMenuItem: ( registerHostContextMenuItem: (
item: HostContextMenuItemContribution, item: HostContextMenuItemContribution,
) => Disposer; ) => Disposer;
/**
* A card on the host page. Core 26.11.1 and newer; call it as
* app.registerHostSection?.(...) if your plugin allows older cores.
*/
registerHostSection: (section: HostSectionContribution) => Disposer;
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer; registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer; registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer; registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
+7 -1
View File
@@ -159,8 +159,10 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields HostRowFields (type): HostRowFields
HostSectionDef (type): HostSectionDef
HostSidebarFilterState (type): HostSidebarFilterState HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences HostSidebarPreferences (type): HostSidebarPreferences
HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record<string, unknown>; isVisible?: boolean; shell: TabShellCallbacks; }) => Element
HostTrayTrigger (type): HostTrayTrigger HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings HttpsSettings (type): HttpsSettings
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
@@ -354,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
clearLocalAdaptivePreferences (value): () => void clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean> copyToClipboard (value): (text: string) => Promise<boolean>
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey> createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>> createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host createQuickConnectHost (value): (input: QuickConnectInput) => Host
@@ -457,6 +459,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[] hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[]
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T> hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
invalidateHostsAndStatusCaches (value): () => void invalidateHostsAndStatusCaches (value): () => void
@@ -475,6 +478,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
linkedServerUrl (value): () => Promise<string> linkedServerUrl (value): () => Promise<string>
listHostActions (value): () => HostActionDef[] listHostActions (value): () => HostActionDef[]
listHostProtocols (value): () => HostProtocolDef[] listHostProtocols (value): () => HostProtocolDef[]
listHostSections (value): () => HostSectionDef[]
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]> loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
logoutUser (value): () => Promise<{ success: boolean; message: string; }> logoutUser (value): () => Promise<{ success: boolean; message: string; }>
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[] makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
@@ -488,6 +492,7 @@ moveSplitDrag (value): (x: number, y: number) => void
normalizeLanguageCode (value): (language?: string) => string normalizeLanguageCode (value): (language?: string) => string
notifySyncChanged (value): () => void notifySyncChanged (value): () => void
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
operationsApi (value): AxiosInstance
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date parseServerTime (value): (ts: string) => Date
@@ -598,6 +603,7 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[] useHostEditorSections (value): () => HostEditorSectionDef[]
useHostEditorToolbarItems (value): () => HostBadgeDef[] useHostEditorToolbarItems (value): () => HostBadgeDef[]
useHostProtocols (value): () => HostProtocolDef[] useHostProtocols (value): () => HostProtocolDef[]
useHostSections (value): () => HostSectionDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useHostStatusIndicators (value): () => HostBadgeDef[] useHostStatusIndicators (value): () => HostBadgeDef[]
useInstalledPlugins (value): () => PluginSummary[] useInstalledPlugins (value): () => PluginSummary[]
+6
View File
@@ -19,6 +19,12 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [ const ALLOWED = [
"useHostSections",
"operationsApi",
"listHostSections",
"hostSectionsFor",
"HostTabEmbed",
"HostSectionDef",
"ACCENT_PRESET_COLORS", "ACCENT_PRESET_COLORS",
"APP_VERSION", "APP_VERSION",
"AccessRecord", "AccessRecord",
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
isActive: boolean; isActive: boolean;
/** JSON list, or null for full access. */
scopes: string | null;
} }
export class ApiKeyRepository { export class ApiKeyRepository {
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
expiresAt: apiKeys.expiresAt, expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt, lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive, isActive: apiKeys.isActive,
scopes: apiKeys.scopes,
}) })
.from(apiKeys) .from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id)) .leftJoin(users, eq(apiKeys.userId, users.id))
@@ -1,5 +1,6 @@
import type { RequestHandler, Router } from "express"; import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
import crypto from "crypto"; import crypto from "crypto";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
* type: string * type: string
* format: date-time * format: date-time
* description: Optional expiration date. Null means the key never expires. * description: Optional expiration date. Null means the key never expires.
* scopes:
* type: array
* items:
* type: string
* enum: [operations:read, operations:run]
* description: Limits the key to actions only. Leave out for full access.
* responses: * responses:
* 201: * 201:
* description: API key created. Contains the full token (shown only once). * description: API key created. Contains the full token (shown only once).
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
*/ */
router.post("/api-keys", requireAdmin, async (req, res) => { router.post("/api-keys", requireAdmin, async (req, res) => {
try { try {
const { name, userId: targetUserId, expiresAt } = req.body; const { name, userId: targetUserId, expiresAt, scopes } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository(); const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository(); const userRepository = createCurrentUserRepository();
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
expiresAtValue = parsed.toISOString(); expiresAtValue = parsed.toISOString();
} }
let scopesValue: string[] | null = null;
if (scopes !== undefined && scopes !== null) {
if (
!Array.isArray(scopes) ||
scopes.some(
(scope) =>
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
)
) {
return res.status(400).json({ error: "Unknown API key scope" });
}
scopesValue =
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
}
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex"); const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12); const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10); const tokenHash = await bcrypt.hash(rawToken, 10);
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
lastUsedAt: null, lastUsedAt: null,
isActive: true, isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
}); });
const actorId = (req as AuthenticatedRequest).userId; const actorId = (req as AuthenticatedRequest).userId;
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
details: JSON.stringify({ details: JSON.stringify({
targetUserId, targetUserId,
targetUsername: targetUser.username, targetUsername: targetUser.username,
scopes: scopesValue,
}), }),
ipAddress, ipAddress,
userAgent, userAgent,
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
tokenPrefix, tokenPrefix,
createdAt: now, createdAt: now,
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken, token: rawToken,
}); });
} catch (err) { } catch (err) {
+2 -1
View File
@@ -69,7 +69,8 @@ export function clearApprovalPolicies(): void {
} }
const RANK = { allow: 0, approve: 1, deny: 2 } as const; const RANK = { allow: 0, approve: 1, deny: 2 } as const;
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]); // approved: a person approved this exact request in the plugin's own UI.
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet", "approved"]);
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]); const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
/** A person is the check when the request came from something else. */ /** A person is the check when the request came from something else. */
@@ -451,6 +451,15 @@ describe("policy", () => {
expect(outcome.status).toBe("success"); expect(outcome.status).toBe("success");
}); });
it("lets a request a person approved in a plugin go by RBAC", async () => {
register();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "approved" },
});
expect(outcome.status).toBe("success");
});
it("treats a community read as a change", async () => { it("treats a community read as a change", async () => {
state.plugin.source = "user"; state.plugin.source = "user";
state.plugin.signedTier = "community"; state.plugin.signedTier = "community";
+2 -1
View File
@@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
/** What clicking a host does when it already has an open tab. */ /** What clicking a host does when it already has an open tab. */
export type HostClickBehavior = export type HostClickBehavior =
"newTab" | "focusExisting" | "focusExistingDoubleClickNew"; "newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
export interface HostSidebarFilterState { export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[]; status: ("online" | "offline" | "pinned")[];
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
"newTab", "newTab",
"focusExisting", "focusExisting",
"focusExistingDoubleClickNew", "focusExistingDoubleClickNew",
"hostPage",
]; ];
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"]; const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
const FILTER_STATUS: HostSidebarFilterState["status"] = [ const FILTER_STATUS: HostSidebarFilterState["status"] = [
+4
View File
@@ -87,6 +87,8 @@ export interface ApiKey {
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
isActive: boolean; isActive: boolean;
/** Null for full access. */
scopes?: string[] | null;
} }
export interface CreatedApiKey extends ApiKey { export interface CreatedApiKey extends ApiKey {
@@ -97,12 +99,14 @@ export async function createApiKey(
name: string, name: string,
userId: string, userId: string,
expiresAt?: string, expiresAt?: string,
scopes?: string[] | null,
): Promise<CreatedApiKey> { ): Promise<CreatedApiKey> {
try { try {
const response = await authApi.post("/users/api-keys", { const response = await authApi.post("/users/api-keys", {
name, name,
userId, userId,
expiresAt: expiresAt ?? null, expiresAt: expiresAt ?? null,
scopes: scopes?.length ? scopes : null,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
+28
View File
@@ -79,6 +79,34 @@ export function createPluginApi(pluginId: string): AxiosInstance {
}) as AxiosInstance; }) as AxiosInstance;
} }
/**
* An axios client rooted at core's /operations routes: the action catalog,
* runs, approvals, events, signals and cross host resources.
*/
export function createOperationsApi(): AxiosInstance {
return new Proxy(authApi, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") return value;
if (
property === "get" ||
property === "delete" ||
property === "post" ||
property === "put" ||
property === "patch"
) {
return (url: string, ...rest: unknown[]) =>
(value as (...args: unknown[]) => unknown).call(
target,
`/operations${url.startsWith("/") ? url : `/${url}`}`,
...rest,
);
}
return value.bind(target);
},
}) as AxiosInstance;
}
/** /**
* A raw fetch on this plugin's /plugin-api mount point, for a response the * A raw fetch on this plugin's /plugin-api mount point, for a response the
* caller reads as a stream (server-sent events). axios buffers the whole * caller reads as a stream (server-sent events). axios buffers the whole
+1
View File
@@ -609,6 +609,7 @@
"hostClickBehavior": "Clicking a host", "hostClickBehavior": "Clicking a host",
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.", "hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
"hostClickNewTab": "Always open a new tab", "hostClickNewTab": "Always open a new tab",
"hostClickHostPage": "Open the host page, when a plugin offers one",
"hostClickFocusExisting": "Switch to the open tab", "hostClickFocusExisting": "Switch to the open tab",
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab", "hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
"openInNewTab": "Open in new tab", "openInNewTab": "Open in new tab",
+19
View File
@@ -41,6 +41,10 @@ import {
type KeybindingEditorProps, type KeybindingEditorProps,
} from "@/shell/keybinding-registry"; } from "@/shell/keybinding-registry";
import { registerHostProtocol } from "@/sidebar/host-protocols"; import { registerHostProtocol } from "@/sidebar/host-protocols";
import {
registerHostSection,
type HostSectionRenderProps,
} from "@/shell/host-sections";
import { import {
registerDashboardCard, registerDashboardCard,
type DashboardCardRenderProps, type DashboardCardRenderProps,
@@ -424,6 +428,21 @@ export function createPluginApp(
); );
}, },
registerHostSection(section) {
return track(
registerHostSection({
id: section.id,
pluginId,
titleKey: key(section.titleKey),
order: section.order,
when: hostCallback(section.when ?? (() => true)) as never,
component: scoped(
section.component as unknown as ComponentType<HostSectionRenderProps>,
),
}),
);
},
registerHostContextMenuItem(item) { registerHostContextMenuItem(item) {
return track( return track(
registerHostContextMenuItem({ registerHostContextMenuItem({
+10
View File
@@ -823,3 +823,13 @@ export {
rightDockableIds, rightDockableIds,
useRailItems, useRailItems,
} from "@/sidebar/rail-items"; } from "@/sidebar/rail-items";
export {
hostSectionsFor,
type HostSectionDef,
listHostSections,
useHostSections,
} from "@/shell/host-sections";
export { HostTabEmbed } from "@/shell/HostTabEmbed";
import { createOperationsApi } from "@/lib/plugin-transport";
/** Core's /operations routes, with the user's own session. */
export const operationsApi = createOperationsApi();
+1
View File
@@ -471,6 +471,7 @@ export {
resetHostContributions, resetHostContributions,
} from "@/sidebar/host-contributions"; } from "@/sidebar/host-contributions";
export { registerHostProtocol } from "@/sidebar/host-protocols"; export { registerHostProtocol } from "@/sidebar/host-protocols";
export { registerHostSection, resetHostSections } from "@/shell/host-sections";
export { export {
RAIL_ITEMS, RAIL_ITEMS,
registerRailItem, registerRailItem,
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from "react";
import type { Host, Tab } from "@/types/ui-types";
import type { TabShellCallbacks } from "@/shell/tab-registry";
import { renderTabContent } from "@/shell/tabUtils";
/**
* Draws a registered tab type inside another view, such as a host page, with
* the same props the tab stage gives it. The embed has its own instance id, so
* it never collides with the same tool opened as a normal tab.
*/
export function HostTabEmbed({
host,
type,
label,
data,
isVisible = true,
shell,
}: {
host: Host;
type: string;
label?: string;
data?: Record<string, unknown>;
isVisible?: boolean;
shell: TabShellCallbacks;
}) {
const tab = useMemo<Tab>(
() => ({
id: `embed-${type}-${host.id}`,
instanceId: `embed-${type}-${host.id}`,
type,
label: label ?? host.name ?? host.ip,
host,
openedAt: Date.now(),
data,
}),
// A new host or tool is a new tab; label and data changes keep it.
// eslint-disable-next-line react-hooks/exhaustive-deps
[type, host.id],
);
return (
<div className="h-full min-h-0 flex flex-col">
{renderTabContent(tab, { shell, isVisible, isFocusedPane: isVisible })}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ComponentType } from "react";
import type { Host } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "@/shell/tab-registry";
export interface HostSectionRenderProps {
host: Host;
isVisible: boolean;
shell: TabShellCallbacks;
}
/**
* A card a plugin adds to a host page. The page itself belongs to a plugin;
* core only keeps the list so any plugin can fill it.
*/
export interface HostSectionDef {
id: string;
pluginId?: string;
titleKey: string;
order?: number;
when: (host: Host) => boolean;
component: ComponentType<HostSectionRenderProps>;
}
const sections = createRegistry<HostSectionDef>(byOrderThenId);
export const registerHostSection = sections.register;
export const useHostSections = sections.useList;
export const listHostSections = sections.list;
export const resetHostSections = sections.reset;
export function hostSectionsFor(
all: HostSectionDef[],
host: Host,
): HostSectionDef[] {
return all.filter((section) => {
try {
return section.when(host);
} catch {
return false;
}
});
}