Compare commits

..
Author SHA1 Message Date
LukeGus 6c265c4bc6 chore: add ping to the docker image 2026-11-05 00:29:26 -06:00
LukeGus 57eb7fab5c chore: add cron, users, packages and certificates to the plugin table 2026-11-05 00:26:50 -06:00
LukeGus a73782e2bc chore: add backups plugin to readme 2026-11-05 00:12:46 -06:00
LukeGus 18e7f5689b fix: match select and segmented heights to inputs 2026-11-04 20:58:33 -06:00
LukeGus a91339f09f fix: widen chart axis labels to fit long units 2026-11-04 20:29:44 -06:00
LukeGus a2308c9d71 fix: plugin page clipping and badges, match select and textarea sizes 2026-11-04 20:03:15 -06:00
LukeGus 2ee31ac9cc fix: honor signal refreshSec when caching signal reads 2026-11-04 19:30:12 -06:00
LukeGus 4aead47049 fix: save every ui pref key changed in one burst so dashboard reset sticks 2026-11-04 19:07:02 -06:00
LukeGus 19bdd589f3 feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0 2026-11-04 18:57:18 -06:00
LukeGus 1867be3f58 fix: keep line chart time labels from overlapping 2026-11-04 15:52:42 -06:00
LukeGus 09e04d076a fix: stop the jump host pipeline test failing under load 2026-11-04 15:30:21 -06:00
LukeGus ea4e2d2124 fix: remove the partial file when a desktop download is aborted early 2026-11-04 15:30:17 -06:00
LukeGus c7aa1c64b5 feat: new bundled plugins start on only if recommended, fetch missing shipped ones 2026-11-04 15:30:14 -06:00
LukeGus 9613b787a7 feat: shared tile grid for plugin pages and many-tile dashboard cards (SDK 1.0.26) 2026-11-04 15:29:46 -06:00
LukeGus 1c4fa52de3 fix: remove plugin updates the bundled copy caught up with 2026-11-04 08:11:34 -06:00
LukeGus 2e79bf9d0d feat: limit api keys to actions when they are made 2026-11-04 06:29:49 -06:00
LukeGus 03029cd372 feat: let a request approved in a plugin run by permissions 2026-11-03 23:03:20 -06:00
LukeGus 446596989e chore: add operations center to the plugin table 2026-11-03 22:55:45 -06:00
LukeGus b7f749cc9d feat: let a host click open the host page 2026-11-03 22:52:30 -06:00
LukeGus 3ae56a96d0 chore: bump the plugin sdk to 1.0.24 2026-11-03 22:34:51 -06:00
LukeGus 631f6ac9f7 feat: add host sections, tab embeds and the operations client to the plugin ui 2026-11-03 22:34:29 -06:00
93 changed files with 4243 additions and 247 deletions

No files matched your search

+22 -1
View File
@@ -5,13 +5,23 @@
### 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
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
### Changed ### Changed
- Plugin cards and pages use the dashboard look everywhere, with shared lines between sections instead of separate boxes with gaps
- The Plugins tab and plugin pages use the same look
- Line charts that cover a few minutes show seconds in their times
- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on
- Host status, import and export, and credential tools now start on for new installs - Host status, import and export, and credential tools now start on for new installs
- Sync now starts on in a new desktop app, so it can link to a server - Sync now starts on in a new desktop app, so it can link to a server
- The first run plugin picker leaves plugins you did not choose off instead of removing them - The first run plugin picker leaves plugins you did not choose off instead of removing them
- Dropdowns and text areas are the same size as the text fields next to them
- A page's own tabs sit above its search bar
### Fixed ### Fixed
@@ -19,6 +29,17 @@
- Alerts with no icon no longer squeeze their text to one word per line - Alerts with no icon no longer squeeze their text to one word per line
- Password fields with the show button are the same height as the other fields next to them - Password fields with the show button are the same height as the other fields next to them
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on - The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot
- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind
- Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one
- Release notes on a plugin's page are no longer cut off on the left
- The Community badge in Browse is no longer missing its top border
- Official plugin pages no longer show a stray divider before the author
- The version picker on a plugin's page is no longer empty for a plugin installed from a local build
- The plugin video no longer has a second border around it
- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page
- Sign in methods on the Security page have a line between them
- Chart axis labels with long units, like KiB/s, are no longer cut off
## 26.11.0 ## 26.11.0
+6
View File
@@ -64,10 +64,13 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. | | [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. | | [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. | | [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Backups](https://github.com/Termix-SSH/Plugin-Backups) | Back up folders, databases and Docker data on your hosts with restic, then browse, download and restore. |
| [Certificates](https://github.com/Termix-SSH/Plugin-Certificates) | See, issue and renew TLS certificates on your hosts with certbot and acme.sh, and get an alert before they expire. |
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. | | [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. | | [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. | | [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. | | [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Cron](https://github.com/Termix-SSH/Plugin-Cron) | See, add and run cron jobs on your hosts, with a schedule preview and the system cron files. |
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. | | [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
@@ -82,7 +85,9 @@ 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. |
| [Packages](https://github.com/Termix-SSH/Plugin-Package-Manager) | See and install updates on your hosts, search, install and remove packages, and read the package history. |
| [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. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. | | [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
@@ -106,6 +111,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. | | [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. | | [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. | | [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Users](https://github.com/Termix-SSH/Plugin-User-Manager) | Manage the user accounts, groups, passwords and SSH keys on your hosts. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. | | [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. | | [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | | [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
+1 -1
View File
@@ -114,7 +114,7 @@ ENV DATA_DIR=/app/data \
PORT=8080 \ PORT=8080 \
NODE_ENV=production NODE_ENV=production
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \ RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget iputils-ping && \
update-ca-certificates && \ update-ca-certificates && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \ mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \
+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",
+36
View File
@@ -1,5 +1,41 @@
# Changelog # Changelog
## 1.1.0
Plugin API 1.15.0.
### Added
- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type
- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid
- Dashboard cards get `config`, `setConfig` and `editMode`
- `/ui` exports `TileWall` (a fixed grid of tiles with shared lines), `TileStack` and `TileColumn` (sections stacked with a line between), `TileEditBar`, and `SeamlessTileContext` with `useInSeamlessTile`
- `/ui` exports `StatTile`, `InfoGrid`, `StatusDot`, `StatusBadge` and `statusTone`, `LoadingState`, `NoticePanel`, `Pager` with `usePaged` and `pageOf`, `DataTable` with `DataTableRow`, `SideNav` with `SideNavItem`, `PanelFrame` and `RefreshButton`
- `InlineView` takes `stacked`, which lays SectionCards out in one column with lines between them
- `GroupHeading` takes `plain`, which drops the rule after the title. Inside a tile layout that is the default
### Changed
- `Card`, `SectionCard`, `MetricCard` and `DataView` use the tile look: no fill, and inside a tile layout no box either. `DataView` grids sit edge to edge with shared lines
## 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.
+23 -4
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.23 | Plugin API 1.14.0 SDK 1.1.0 | Plugin API 1.15.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.
@@ -766,9 +766,28 @@ exports with props is `ui.api.txt` in the Termix repo
- Small type: most text 11 to 13px. - Small type: most text 11 to 13px.
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use - No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
`PanePrompt` for small prompts. `PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, - Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and
forms end with `FormFooter`, empty lists show `EmptyState`. `ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same `LoadingState`, and "not installed" or "no access" shows `NoticePanel`.
- The tile look: page content sits edge to edge on the page background, with one line between sections and
no gaps, padding or card fill around them. The body of a `PanelShell` has no padding. Stack sections with
`TileStack` (or `TileColumn` when cards sit deeper), put numbers and small cards in a `TileWall` of
`StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside
these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s
takes `stacked`.
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes.
A page with many sections uses `SideNav` on the left. Put the refresh button in the header with
`RefreshButton`, and the docs link with the `docs` prop.
- States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows),
`InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists.
- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are
`{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps
preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. While editing, show
`TileEditBar` over the grid. Needs Termix 26.11.1.
- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the
card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for
status and action tiles.
- Card grids use `DataView` with `ViewToggle` (the cards share lines like a `TileWall`). Pass `columns` and `onColumns` to `ViewToggle` and the same
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props. row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends. - Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.23", "version": "1.1.0",
"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": {
+48
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;
@@ -783,6 +799,26 @@ export interface KeybindingDefaultContribution {
export interface DashboardCardProps { export interface DashboardCardProps {
isVisible: boolean; isVisible: boolean;
shell: ShellApi; shell: ShellApi;
/** This tile's settings, for cards with `entries`. Empty otherwise. Core 26.11.1+. */
config?: Record<string, unknown>;
/** Saves this tile's settings. Keep it small, it is stored in one line. */
setConfig?: (config: Record<string, unknown>) => void;
/** True while the user is arranging the dashboard. */
editMode?: boolean;
}
/** One tile a card with `entries` offers in the dashboard's add list. */
export interface DashboardCardEntry {
/** Stable within the card, used to mark entries already placed. */
key: string;
title: string;
description?: string;
/** Group heading in the add list. Defaults to the card title. */
group?: string;
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
/** Passed back as `config`. */
config: Record<string, unknown>;
defaultSize?: { w: number; h?: number | null };
} }
export interface DashboardCardContribution { export interface DashboardCardContribution {
@@ -797,6 +833,13 @@ export interface DashboardCardContribution {
* edge, for a strip of numbers. Defaults to "framed". * edge, for a strip of numbers. Defaults to "framed".
*/ */
frame?: "framed" | "bare"; frame?: "framed" | "bare";
/** Size on the 12 column dashboard grid when added. Core 26.11.1+. */
defaultSize?: { w: number; h?: number | null };
/**
* Lets the card be placed many times, once per entry, each with its own
* `config`. Titles come back already translated. Core 26.11.1+.
*/
entries?: (language: string) => Promise<DashboardCardEntry[]>;
component: ComponentType<DashboardCardProps>; component: ComponentType<DashboardCardProps>;
} }
@@ -1172,6 +1215,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;
+1 -1
View File
@@ -42,7 +42,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * version by the server.
*/ */
export const PLUGIN_API_VERSION = "1.14.0"; export const PLUGIN_API_VERSION = "1.15.0";
/** The API major, the default engine.api for a new plugin. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
+47 -4
View File
@@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder" DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T> DataColumn (type): DataColumn<T>
DataTable (value): ({ className, containerClassName, children, ...props }: ClassAttributes<HTMLTableElement> & TableHTMLAttributes<HTMLTableElement> & { containerClassName?: string; }) => Element
DataTableRow (value): ({ active, className, onClick, ...props }: ClassAttributes<HTMLTableRowElement> & HTMLAttributes<HTMLTableRowElement> & { active?: boolean; }) => Element
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
@@ -133,7 +135,7 @@ GridColumns (type): GridColumns
GridLayout (type): GridLayout GridLayout (type): GridLayout
GridSize (type): GridSize GridSize (type): GridSize
GridSlot (type): GridSlot GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element GroupHeading (value): ({ title, count, action, plain, className, }: { title: string; count?: number; action?: ReactNode; plain?: boolean; className?: string; }) => Element
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
Host (type): Host Host (type): Host
HostActionDef (type): HostActionDef HostActionDef (type): HostActionDef
@@ -159,12 +161,16 @@ 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
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal InfoGrid (value): ({ items, className, }: { items: InfoItem[]; className?: string; }) => Element
InfoItem (type): InfoItem
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, stacked, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; stacked?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>> Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; } KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
@@ -178,11 +184,12 @@ Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelEle
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries LineChartSeries (type): LineChartSeries
LinkedAccountInfo (type): LinkedAccountInfo LinkedAccountInfo (type): LinkedAccountInfo
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "warning" | "muted" | "brand" | "success"; className?: string; children: ReactNode; }) => Element
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element
ListRowTone (type): ListRowTone ListRowTone (type): ListRowTone
LoadingState (value): ({ label, compact, className, }: { label?: string; compact?: boolean; className?: string; }) => Element
LogContext (type): LogContext LogContext (type): LogContext
LogEntry (type): LogEntry LogEntry (type): LogEntry
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
@@ -199,6 +206,7 @@ MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scr
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
NativeViewProps (type): NativeViewProps NativeViewProps (type): NativeViewProps
NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }: { icon?: LucideIcon; title: ReactNode; children?: ReactNode; action?: ReactNode; tone?: "default" | "destructive" | "warning"; className?: string; }) => Element
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
OPEN_PLUGINS_EVENT (value): "termix:open-plugins" OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
OpenTabRecord (type): OpenTabRecord OpenTabRecord (type): OpenTabRecord
@@ -207,9 +215,11 @@ PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
Pager (value): ({ page, pages, hasNext, onPage, className, }: { page: number; pages?: number; hasNext?: boolean; onPage: (next: number) => void; className?: string; }) => Element
PaletteItemDef (type): PaletteItemDef PaletteItemDef (type): PaletteItemDef
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
PanelDensity (type): PanelDensity PanelDensity (type): PanelDensity
PanelFrame (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
@@ -234,6 +244,7 @@ RadialGaugeProps (type): RadialGaugeProps
RailGroup (type): RailGroup RailGroup (type): RailGroup
RailItemDef (type): RailItemDef RailItemDef (type): RailItemDef
RecentActivityItem (type): RecentActivityItem RecentActivityItem (type): RecentActivityItem
RefreshButton (value): ({ onClick, loading, disabled, label, className, }: { onClick: () => void; loading?: boolean; disabled?: boolean; label?: string; className?: string; }) => Element
ReleaseInfo (type): ReleaseInfo ReleaseInfo (type): ReleaseInfo
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
ResolvedHostDefault (type): ResolvedHostDefault ResolvedHostDefault (type): ResolvedHostDefault
@@ -246,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus
SSH_AUTH_PROTOCOL (value): "ssh" SSH_AUTH_PROTOCOL (value): "ssh"
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SeamlessTileContext (value): Context<boolean>
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element
Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element
@@ -275,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ShellConfirmCapture (value): () => null ShellConfirmCapture (value): () => null
SideNav (value): ({ entries, active, onSelect, footer, label, className, }: { entries: SideNavEntry[]; label?: string; active: string; onSelect: (id: string) => void; footer?: ReactNode; className?: string; }) => Element
SideNavEntry (type): SideNavEntry
SideNavItem (value): ({ entry, active, onClick, }: { entry: SideNavEntry; active: boolean; onClick: () => void; }) => Element
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
SortKey (type): SortKey SortKey (type): SortKey
@@ -284,11 +299,16 @@ SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { a
SplitOpenTarget (type): SplitOpenTarget SplitOpenTarget (type): SplitOpenTarget
SplitSummary (type): SplitSummary SplitSummary (type): SplitSummary
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
StatTile (value): ({ label, icon, value, unit, percent, hint, tone, actions, onClick, children, className, }: { label: ReactNode; icon?: ReactNode; value: ReactNode; unit?: ReactNode; percent?: number; hint?: ReactNode; tone?: "default" | "ok" | "warn" | "bad"; actions?: ReactNode; onClick?: () => void; children?: ReactNode; className?: string; }) => Element
StatusBadge (value): ({ tone, state, className, children, }: { tone?: StatusTone; state?: string; className?: string; children?: ReactNode; }) => Element
StatusDot (value): ({ tone, state, pulse, className, title, }: { tone?: StatusTone; state?: string; pulse?: boolean; className?: string; title?: string; }) => Element
StatusTone (type): StatusTone
StatusValue (type): StatusValue StatusValue (type): StatusValue
SurfaceKind (type): SurfaceKind SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
TILE_COLUMNS (value): 12
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Tab (type): Tab Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks TabShellCallbacks (type): TabShellCallbacks
@@ -303,6 +323,17 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>> Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps TextareaProps (type): TextareaProps
ThemeId (type): string ThemeId (type): string
TileColumn (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
TileEditBar (value): ({ children, onReset, onAdd, onDone, className, }: { children?: ReactNode; onReset?: () => void; onAdd?: () => void; onDone: () => void; className?: string; }) => Element
TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element
TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element
TileGridProps (type): TileGridProps
TileKind (type): TileKind
TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element
TilePickerEntry (type): TilePickerEntry
TileSlot (type): TileSlot
TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element
TlsStatus (type): TlsStatus TlsStatus (type): TlsStatus
ToolsTab (type): ToolsTab ToolsTab (type): ToolsTab
Tooltip (value): ({ ...props }: TooltipProps) => Element Tooltip (value): ({ ...props }: TooltipProps) => Element
@@ -339,6 +370,7 @@ adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }> adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>> adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host> adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
appendTile (value): (slots: TileSlot[], tile: { id: string; w?: number; h?: number; }) => TileSlot[]
applyAccentColor (value): (colorValue: string) => void applyAccentColor (value): (colorValue: string) => void
applyFontSize (value): (id: FontSizeId) => void applyFontSize (value): (id: FontSizeId) => void
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void> applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
@@ -354,7 +386,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
@@ -382,6 +414,7 @@ endSplitDrag (value): (commit: boolean) => void
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[] filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
findCoreHostDefault (value): (key: string) => CoreHostDefault findCoreHostDefault (value): (key: string) => CoreHostDefault
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[]
getActiveSessions (value): () => Promise<ActiveSessionInfo[]> getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }> getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
@@ -457,6 +490,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 +509,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 +523,8 @@ 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
pageOf (value): <T>(items: T[], page: number, size: number) => { rows: T[]; page: number; pages: number; }
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
@@ -509,6 +546,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string
rem (value): (px: number) => string rem (value): (px: number) => string
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>> removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }> removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
removeTile (value): (slots: TileSlot[], id: string) => TileSlot[]
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>> renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>> renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }> reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
@@ -538,6 +576,7 @@ runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallba
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[]
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void> saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }> saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void> saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
@@ -556,6 +595,7 @@ shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permiss
shell (value): TabShellCallbacks shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string] splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host sshHostToHost (value): (h: SSHHostWithStatus) => Host
statusTone (value): (state: string) => StatusTone
tabIcon (value): (type: TabType) => Element tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[] tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest takePendingManageRequest (value): () => ManageRequest
@@ -598,8 +638,10 @@ 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[]
useInSeamlessTile (value): () => boolean
useInstalledPlugins (value): () => PluginSummary[] useInstalledPlugins (value): () => PluginSummary[]
useIsDefaultsEditor (value): () => boolean useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean useIsMobile (value): () => boolean
@@ -607,6 +649,7 @@ useKeybindingActions (value): () => KeybindingActionDef[]
useKeybindingDefaults (value): () => KeybindingDefaultDef[] useKeybindingDefaults (value): () => KeybindingDefaultDef[]
useOptionalConnectionLog (value): () => ConnectionLogContextType useOptionalConnectionLog (value): () => ConnectionLogContextType
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
usePaged (value): <T>(items: T[], size: number, reset?: unknown) => { setPage: Dispatch<SetStateAction<number>>; rows: T[]; page: number; pages: number; }
usePaletteEntries (value): () => PaletteEntryDef[] usePaletteEntries (value): () => PaletteEntryDef[]
usePaletteGroups (value): () => PaletteGroupDef[] usePaletteGroups (value): () => PaletteGroupDef[]
usePanels (value): () => PanelDef[] usePanels (value): () => PanelDef[]
+1 -1
View File
@@ -74,7 +74,7 @@ async function main() {
const staged = [...pinned, ...extra]; const staged = [...pinned, ...extra];
fs.writeFileSync( fs.writeFileSync(
path.join(destination, "bundled-index.json"), path.join(destination, "bundled-index.json"),
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)} `${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)}
`, `,
); );
+28 -1
View File
@@ -221,7 +221,34 @@ describe("buildBundledIndex", () => {
]; ];
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({ expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
version: 1, version: 1,
profile: "standard",
plugins: { a: { onboarding: { recommended: true, consent: false } } }, plugins: { a: { onboarding: { recommended: true, consent: false } } },
catalog: {
a: { tier: "full" },
b: { tier: "full" },
c: { tier: "full" },
},
});
});
it("lists every pin with its tier and version", () => {
const plugins = [
{
id: "a",
tier: "standard",
url: "https://example.com/v1.2.0/a-1.2.0.tmxplug",
onboarding: null,
},
{
id: "b",
tier: "minimal",
path: "x/b-2.0.0-beta.1.tmxplug",
onboarding: null,
},
];
expect(buildBundledIndex(plugins, [], "minimal").catalog).toEqual({
a: { tier: "standard", version: "1.2.0" },
b: { tier: "minimal", version: "2.0.0-beta.1" },
}); });
}); });
}); });
@@ -520,7 +547,7 @@ describe("default look", () => {
], ],
["look", "plain"], ["look", "plain"],
), ),
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } }); ).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } });
const index = { const index = {
plugins: [ plugins: [
+43
View File
@@ -19,6 +19,49 @@ 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 = [
"SeamlessTileContext",
"TileEditBar",
"TileStack",
"TileColumn",
"TileWall",
"useInSeamlessTile",
"NoticePanel",
"LoadingState",
"StatusBadge",
"StatusDot",
"statusTone",
"StatusTone",
"StatTile",
"InfoGrid",
"InfoItem",
"Pager",
"pageOf",
"usePaged",
"DataTable",
"DataTableRow",
"SideNav",
"SideNavItem",
"SideNavEntry",
"PanelFrame",
"RefreshButton",
"TileFrame",
"TileGrid",
"TilePicker",
"appendTile",
"flowTiles",
"removeTile",
"sanitizeTiles",
"TILE_COLUMNS",
"TileGridProps",
"TileKind",
"TilePickerEntry",
"TileSlot",
"useHostSections",
"operationsApi",
"listHostSections",
"hostSectionsFor",
"HostTabEmbed",
"HostSectionDef",
"ACCENT_PRESET_COLORS", "ACCENT_PRESET_COLORS",
"APP_VERSION", "APP_VERSION",
"AccessRecord", "AccessRecord",
+13 -3
View File
@@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) {
/** /**
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each * What dist/plugins/bundled-index.json holds: the onboarding defaults of each
* staged plugin, read by the backend when it seeds a fresh install. * staged plugin, read by the backend when it seeds a fresh install, plus the
* profile and every pin it ships, so an existing install can fetch a shipped
* plugin it finds missing.
*/ */
function buildBundledIndex(plugins, stagedIds) { function buildBundledIndex(plugins, stagedIds, profile = DEFAULT_PROFILE) {
const staged = new Set(stagedIds); const staged = new Set(stagedIds);
const out = {}; const out = {};
const catalog = {};
for (const plugin of plugins) { for (const plugin of plugins) {
const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tmxplug$/.exec(
plugin.url ?? plugin.path ?? "",
)?.[1];
catalog[plugin.id] = {
tier: plugin.tier ?? "full",
...(version ? { version } : {}),
};
if (!staged.has(plugin.id)) continue; if (!staged.has(plugin.id)) continue;
if (!plugin.onboarding && !plugin.defaultLook) continue; if (!plugin.onboarding && !plugin.defaultLook) continue;
out[plugin.id] = { out[plugin.id] = {
@@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) {
...(plugin.defaultLook ? { defaultLook: true } : {}), ...(plugin.defaultLook ? { defaultLook: true } : {}),
}; };
} }
return { version: 1, plugins: out }; return { version: 1, profile, plugins: out, catalog };
} }
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */ /** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
@@ -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))
@@ -622,6 +622,7 @@ router.get("/signals", authenticateJWT, async (req, res) => {
userId: authReq.userId, userId: authReq.userId,
actionId: signal.read, actionId: signal.read,
hostIds, hostIds,
freshMs: signal.refreshSec ? signal.refreshSec * 1000 : undefined,
origin: originOf(authReq), origin: originOf(authReq),
}), }),
}); });
@@ -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. */
+5 -1
View File
@@ -78,6 +78,8 @@ interface ReadOptions {
actionId: string; actionId: string;
hostIds?: number[]; hostIds?: number[];
refresh?: boolean; refresh?: boolean;
/** How long a cached answer stays fresh. Defaults to RESOURCE_FRESH_MS. */
freshMs?: number;
input?: Record<string, unknown>; input?: Record<string, unknown>;
origin: ActorOrigin; origin: ActorOrigin;
} }
@@ -155,7 +157,9 @@ export async function readAcrossHosts<T>(
const key = cacheKey(options.userId, options.actionId, hostId); const key = cacheKey(options.userId, options.actionId, hostId);
const cached = cache.get(key); const cached = cache.get(key);
const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS; const fresh =
cached &&
Date.now() - cached.fetchedAt < (options.freshMs ?? RESOURCE_FRESH_MS);
if (isOffline(hostId)) { if (isOffline(hostId)) {
reads.push({ reads.push({
+17
View File
@@ -35,3 +35,20 @@ export function missingShippedPluginIds(
.map((record) => record.id) .map((record) => record.id)
.sort(); .sort();
} }
/**
* Plugins this image's profile ships that the install has never seen and
* that are not on disk. They get fetched from the registry once, the first
* boot that can reach it.
*/
export function newShippedPluginIds(
shipped: Iterable<string>,
records: { id: string }[],
presentIds: ReadonlySet<string>,
ignored: ReadonlySet<string>,
): string[] {
const known = new Set(records.map((record) => record.id));
return [...shipped]
.filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id))
.sort();
}
+49 -11
View File
@@ -18,7 +18,14 @@ export interface OnboardingDefaults {
type Recommended = boolean | "desktop"; type Recommended = boolean | "desktop";
export interface BundledCatalogEntry {
tier: string;
version?: string;
}
interface BundledIndex { interface BundledIndex {
profile?: string;
catalog?: Record<string, BundledCatalogEntry>;
plugins: Record< plugins: Record<
string, string,
{ {
@@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null {
); );
cached = cached =
raw && typeof raw.plugins === "object" && raw.plugins raw && typeof raw.plugins === "object" && raw.plugins
? { plugins: raw.plugins } ? {
plugins: raw.plugins,
profile: typeof raw.profile === "string" ? raw.profile : undefined,
catalog:
raw.catalog && typeof raw.catalog === "object"
? raw.catalog
: undefined,
}
: null; : null;
} catch { } catch {
cached = null; cached = null;
@@ -101,21 +115,45 @@ export function onboardingDefaults(pluginId: string): OnboardingDefaults {
}; };
} }
const PROFILES = ["minimal", "standard", "full"];
/** /**
* The state a newly seen plugin's row starts in. Bundled plugins start * Plugins the image's profile ships (its tier and every smaller one), with
* enabled, except on a fresh install, where only recommended ones do and * the version it pins. Read from the bundled index, so core never names them.
* consent plugins wait for the admin. A plugin dropped into the data */
* directory always starts disabled. export function shippedCatalog(): Map<string, BundledCatalogEntry> {
const index = readBundledIndex();
const out = new Map<string, BundledCatalogEntry>();
if (!index?.catalog) return out;
const rank = PROFILES.indexOf(index.profile ?? "standard");
for (const [id, entry] of Object.entries(index.catalog)) {
if (!entry || typeof entry !== "object") continue;
const tier = PROFILES.indexOf(entry.tier);
if (tier === -1 || rank === -1 || tier > rank) continue;
out.set(id, entry);
}
return out;
}
/** Whether a shipped plugin should be on when an install first sees it. */
export function startsEnabled(pluginId: string): boolean {
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return true;
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent;
}
/**
* The state a newly seen plugin's row starts in. A bundled plugin starts on
* only when it is recommended, so a plugin a later release adds to the image
* does not switch itself on everywhere. Consent plugins wait for the admin.
* A plugin dropped into the data directory always starts disabled.
*/ */
export function initialPluginState( export function initialPluginState(
pluginId: string, pluginId: string,
source: "bundled" | "user", source: "bundled" | "user",
freshInstall: boolean, _freshInstall: boolean,
): "enabled" | "disabled" { ): "enabled" | "disabled" {
if (source !== "bundled") return "disabled"; if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled"; return startsEnabled(pluginId) ? "enabled" : "disabled";
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
} }
@@ -560,7 +560,13 @@ export async function openDownload(
} }
}, },
abort: async () => { abort: async () => {
// The file opens in the background, so wait for the stream to close
// or a late open would leave the partial file behind.
await new Promise<void>((resolve) => {
if (stream.closed) return resolve();
stream.once("close", () => resolve());
stream.destroy(); stream.destroy();
});
await fsp.rm(partialPath, { force: true }).catch(() => {}); await fsp.rm(partialPath, { force: true }).catch(() => {});
release(); release();
}, },
+3 -1
View File
@@ -243,8 +243,10 @@ export async function initializePlugins(
const fresh = await seedPlugins(loaded, options.freshInstall === true); const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) { if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js"); const { installNewShippedPlugins, restoreMissingPlugins } =
await import("./manage.js");
loaded.push(...(await restoreMissingPlugins())); loaded.push(...(await restoreMissingPlugins()));
loaded.push(...(await installNewShippedPlugins()));
} }
await syncCapabilityGrants(loaded); await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) { if (loaded.some((plugin) => plugin.source === "bundled")) {
+20 -3
View File
@@ -409,6 +409,15 @@ export class PluginLoader {
} }
loaded.push(plugin); loaded.push(plugin);
} catch (error) { } catch (error) {
// A newer image ships a newer copy, so the old update can go.
if (isArtifact && error instanceof SupersededUpdateError) {
await fs.promises.rm(dir, { force: true }).catch(() => {});
await fs.promises.rm(`${dir}.sig`, { force: true }).catch(() => {});
pluginLogger.info(`Removed ${entry.name}: ${error.message}`, {
operation: "plugin_load",
});
continue;
}
this.skippedPlugins.push( this.skippedPlugins.push(
describeSkipped(dir, entry.name, isArtifact, error), describeSkipped(dir, entry.name, isArtifact, error),
); );
@@ -948,6 +957,16 @@ function withTimeout<T>(
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/** An installed update that the bundled copy has caught up with. */
export class SupersededUpdateError extends Error {
constructor(id: string, version: string, bundledVersion: string) {
super(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
);
this.name = "SupersededUpdateError";
}
}
/** /**
* Throws unless an artifact may replace the bundled plugin with the same id: * Throws unless an artifact may replace the bundled plugin with the same id:
* a trusted key signed it and its version is strictly higher, so an old * a trusted key signed it and its version is strictly higher, so an old
@@ -971,9 +990,7 @@ export function assertBundledUpdate(
// An admin pinning an exact older release is the one allowed downgrade. // An admin pinning an exact older release is the one allowed downgrade.
if (pinnedVersion && pinnedVersion === version) return; if (pinnedVersion && pinnedVersion === version) return;
if (bundledVersion && !semver.gt(version, bundledVersion)) { if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error( throw new SupersededUpdateError(id, version, bundledVersion);
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
);
} }
} }
+67 -2
View File
@@ -17,7 +17,11 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import semver from "semver"; import semver from "semver";
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js"; import {
isDefaultLookPlugin,
shippedCatalog,
startsEnabled,
} from "./bundled-index.js";
import { mayRunInSafeMode } from "./safe-mode.js"; import { mayRunInSafeMode } from "./safe-mode.js";
import { import {
activatePlugin, activatePlugin,
@@ -26,7 +30,7 @@ import {
registerLoadedPlugins, registerLoadedPlugins,
unloadPlugin, unloadPlugin,
} from "./index.js"; } from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js"; import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js"; import type { LoadedPlugin, PluginLoader } from "./loader.js";
import { import {
getBundledPluginsDir, getBundledPluginsDir,
@@ -1099,6 +1103,67 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
}); });
} }
/**
* Called at boot on an existing install. A plugin this image's profile ships
* that the install never had and that is not on disk is fetched from the
* official registry and starts on only if it is recommended. Offline, nothing
* is recorded, so the next start tries again.
*/
export function installNewShippedPlugins(): Promise<LoadedPlugin[]> {
return serialized(async () => {
if (await isLinkedDesktop()) return [];
const catalog = shippedCatalog();
if (catalog.size === 0) return [];
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const { loader } = getPluginRuntime();
const ids = newShippedPluginIds(
catalog.keys(),
await repository.listAll(),
new Set(loader.list().map((plugin) => plugin.id)),
await readUninstalled(),
);
const added: LoadedPlugin[] = [];
for (const id of ids) {
try {
const { source, release } = await requireRelease(
id,
catalog.get(id)?.version,
"stable",
OFFICIAL_REGISTRY_ID,
);
const plugin = await swapIn(id, { release, source });
const enabled = startsEnabled(id);
await repository.update(id, {
registryId: source.id,
tier: "bundled",
state: enabled ? "enabled" : "disabled",
lastError: null,
});
await grantDeclared(plugin, null);
added.push(plugin);
pluginLogger.info(
`Added ${id}@${release.version} from the registry (${enabled ? "on" : "off"})`,
{ operation: "plugin_install_shipped" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.warn(`Could not add ${id}: ${message}`, {
operation: "plugin_install_shipped",
});
// A half made row would hide the plugin from the next try.
if (!loader.get(id)) await repository.delete(id).catch(() => false);
if (
error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE"
)
break;
}
}
return added;
});
}
export function updatePlugin( export function updatePlugin(
pluginId: string, pluginId: string,
options: InstallOptions, options: InstallOptions,
@@ -275,3 +275,27 @@ describe("GET /operations/catalog", () => {
expect(response.body.actions).toEqual([{ id: "demo.restart" }]); expect(response.body.actions).toEqual([{ id: "demo.restart" }]);
}); });
}); });
describe("GET /operations/signals", () => {
it("keeps a signal's answer for its refreshSec", async () => {
const { buildCatalog } = await import("../../../operations/catalog.js");
const { readAcrossHosts } =
await import("../../../operations/resources.js");
vi.mocked(buildCatalog).mockResolvedValueOnce({
actions: [],
events: [],
resources: [],
signals: [
{ id: "a", read: "demo.a", scope: "host", refreshSec: 600 },
{ id: "b", read: "demo.b", scope: "host" },
],
} as never);
await request(app).get("/operations/signals").expect(200);
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
expect.objectContaining({ actionId: "demo.a", freshMs: 600_000 }),
);
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
expect.objectContaining({ actionId: "demo.b", freshMs: undefined }),
);
});
});
@@ -148,8 +148,23 @@ async function build(
}); });
} }
// Building a hop's config parses keys, which is slow while the whole suite
// runs, so give the multi hop steps more than vi.waitFor's 1 s.
const settle = (check: () => void) => vi.waitFor(check, { timeout: 10_000 });
vi.setConfig({ testTimeout: 30_000 });
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// clearAllMocks keeps queued Once values, so a test that stopped early
// would hand its leftovers to the next one.
for (const mock of [
mocks.resolveHostById,
mocks.createJumpHostChain,
mocks.createSocks5Connection,
mocks.applyCertificateAuth,
mocks.applyAgentAuth,
])
mock.mockReset();
FakeSshClient.nextBehaviour = []; FakeSshClient.nextBehaviour = [];
FakeSshClient.instances = []; FakeSshClient.instances = [];
mocks.createHostVerifier.mockResolvedValue(mocks.verifier); mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
@@ -532,7 +547,7 @@ describe("connectHost", () => {
"user-1", "user-1",
prompt, prompt,
); );
await vi.waitFor(() => await settle(() =>
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(), expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
); );
const first = FakeSshClient.instances[0]; const first = FakeSshClient.instances[0];
@@ -546,11 +561,9 @@ describe("connectHost", () => {
[{ prompt: "Verification code:", echo: false }], [{ prompt: "Verification code:", echo: false }],
firstAnswer, firstAnswer,
); );
await vi.waitFor(() => await settle(() => expect(firstAnswer).toHaveBeenCalledWith(["111111"]));
expect(firstAnswer).toHaveBeenCalledWith(["111111"]),
);
first.emit("ready"); first.emit("ready");
await vi.waitFor(() => await settle(() =>
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(), expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
); );
const second = FakeSshClient.instances[1]; const second = FakeSshClient.instances[1];
@@ -566,7 +579,7 @@ describe("connectHost", () => {
], ],
secondAnswer, secondAnswer,
); );
await vi.waitFor(() => await settle(() =>
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]), expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
); );
second.emit("ready"); second.emit("ready");
@@ -674,7 +687,7 @@ describe("connectHost", () => {
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => { it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
FakeSshClient.nextBehaviour = ["hang"]; FakeSshClient.nextBehaviour = ["hang"];
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" }); const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
await vi.waitFor(() => { await settle(() => {
const last = FakeSshClient.instances.at(-1)!; const last = FakeSshClient.instances.at(-1)!;
expect(last.connectConfig).not.toBeNull(); expect(last.connectConfig).not.toBeNull();
}); });
@@ -704,7 +717,7 @@ describe("connectHost", () => {
purpose: "file-manager", purpose: "file-manager",
prompt: { ask }, prompt: { ask },
}); });
await vi.waitFor(() => await settle(() =>
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(), expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
); );
const client = FakeSshClient.instances.at(-1)!; const client = FakeSshClient.instances.at(-1)!;
@@ -720,7 +733,7 @@ describe("connectHost", () => {
], ],
finish, finish,
); );
await vi.waitFor(() => expect(finish).toHaveBeenCalled()); await settle(() => expect(finish).toHaveBeenCalled());
expect(ask).toHaveBeenCalledWith({ expect(ask).toHaveBeenCalledWith({
kind: "totp", kind: "totp",
prompt: "Verification code: ", prompt: "Verification code: ",
@@ -751,7 +764,7 @@ describe("connectHost", () => {
purpose: "fleet", purpose: "fleet",
}), }),
).rejects.toThrow(/All configured authentication methods failed/); ).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() => await settle(() =>
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7), expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
); );
dispose(); dispose();
@@ -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";
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
missingShippedPluginIds, missingShippedPluginIds,
newShippedPluginIds,
startablePluginIds, startablePluginIds,
} from "../../plugins/boot-state.js"; } from "../../plugins/boot-state.js";
@@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => {
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]); expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
}); });
}); });
describe("newShippedPluginIds", () => {
it("lists shipped plugins the install never had and that are not on disk", () => {
expect(
newShippedPluginIds(
["new", "known", "here", "removed", "another"],
[{ id: "known" }],
new Set(["here"]),
new Set(["removed"]),
),
).toEqual(["another", "new"]);
});
it("finds nothing for an empty catalog", () => {
expect(newShippedPluginIds([], [], new Set(), new Set())).toEqual([]);
});
});
@@ -8,14 +8,18 @@ import {
isDefaultLookPlugin, isDefaultLookPlugin,
onboardingDefaults, onboardingDefaults,
resetBundledIndexCache, resetBundledIndexCache,
shippedCatalog,
} from "../../plugins/bundled-index.js"; } from "../../plugins/bundled-index.js";
let dir: string; let dir: string;
function writeIndex(plugins: Record<string, unknown>) { function writeIndex(
plugins: Record<string, unknown>,
extra: Record<string, unknown> = {},
) {
fs.writeFileSync( fs.writeFileSync(
path.join(dir, "bundled-index.json"), path.join(dir, "bundled-index.json"),
JSON.stringify({ version: 1, plugins }), JSON.stringify({ version: 1, plugins, ...extra }),
); );
resetBundledIndexCache(); resetBundledIndexCache();
} }
@@ -89,9 +93,10 @@ describe("initialPluginState", () => {
}); });
}); });
it("enables every bundled plugin on an existing install", () => { it("enables only recommended plugins an existing install sees for the first time", () => {
expect(initialPluginState("other", "bundled", false)).toBe("enabled"); expect(initialPluginState("kept", "bundled", false)).toBe("enabled");
expect(initialPluginState("ask", "bundled", false)).toBe("enabled"); expect(initialPluginState("other", "bundled", false)).toBe("disabled");
expect(initialPluginState("ask", "bundled", false)).toBe("disabled");
}); });
it("enables only recommended, non-consent plugins on a fresh install", () => { it("enables only recommended, non-consent plugins on a fresh install", () => {
@@ -106,6 +111,40 @@ describe("initialPluginState", () => {
}); });
}); });
describe("shippedCatalog", () => {
it("is empty for an index without a catalog", () => {
writeIndex({});
expect(shippedCatalog().size).toBe(0);
});
it("keeps the pins of the image's tier and smaller ones", () => {
writeIndex(
{},
{
profile: "standard",
catalog: {
look: { tier: "minimal", version: "1.0.0" },
ops: { tier: "standard", version: "1.0.0" },
big: { tier: "full", version: "2.0.0" },
odd: { tier: "huge" },
},
},
);
expect([...shippedCatalog().keys()].sort()).toEqual(["look", "ops"]);
});
it("follows a minimal image", () => {
writeIndex(
{},
{
profile: "minimal",
catalog: { look: { tier: "minimal" }, ops: { tier: "standard" } },
},
);
expect([...shippedCatalog().keys()]).toEqual(["look"]);
});
});
describe("default look", () => { describe("default look", () => {
it("lists the plugins flagged as the default look", () => { it("lists the plugins flagged as the default look", () => {
writeIndex({ writeIndex({
@@ -186,6 +186,21 @@ describe("ctx.desktop.local", () => {
expect(fs.readdirSync(dir)).toEqual([]); expect(fs.readdirSync(dir)).toEqual([]);
}); });
it("leaves no partial file when aborted before the file has opened", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
for (let i = 0; i < 20; i++) {
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, `early-${i}.txt`),
transferId: `early-${i}`,
});
await download.abort();
}
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fs.readdirSync(dir)).toEqual([]);
});
it("sends trash and open through Electron main and audits each call", async () => { it("sends trash and open through Electron main and audits each call", async () => {
grants.set("demo", ["desktop:local"]); grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]); const ctx = contextFor("demo", ["desktop:local"]);
@@ -231,6 +231,16 @@ describe("updating a bundled plugin", () => {
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0"); expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
}); });
it("removes an update the bundled copy caught up with", async () => {
const { userDir } = setup();
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
const loader = new PluginLoader();
await loader.loadAll();
expect(fs.existsSync(file)).toBe(false);
expect(fs.existsSync(`${file}.sig`)).toBe(false);
expect(loader.skipped()).toEqual([]);
});
it("refuses a downgrade after the bundled copy was unloaded", async () => { it("refuses a downgrade after the bundled copy was unloaded", async () => {
const { userDir } = setup(); const { userDir } = setup();
const loader = new PluginLoader(); const loader = new PluginLoader();
+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) {
+6 -1
View File
@@ -1,18 +1,23 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useInSeamlessTile } from "@/components/tile-grid/context";
function Card({ function Card({
className, className,
size = "default", size = "default",
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return ( return (
<div <div
data-slot="card" data-slot="card"
data-tile-card=""
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-none bg-card py-4 text-xs/relaxed text-card-foreground border border-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none", "group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
!tiled && "border border-border",
className, className,
)} )}
{...props} {...props}
+26 -47
View File
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect, useCallback } from "react"; import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseServerTime } from "@/lib/server-time"; import { parseServerTime } from "@/lib/server-time";
import { formatTimestamp, pickXTicks } from "./x-ticks";
export interface LineChartSeries { export interface LineChartSeries {
key: string; key: string;
@@ -19,23 +20,11 @@ interface LineChartProps {
className?: string; className?: string;
} }
const Y_LABEL_W = 44; const Y_LABEL_MIN = 44;
const X_LABEL_H = 20; const X_LABEL_H = 20;
const TOP_PAD = 6; const TOP_PAD = 6;
const TICK_COUNT = 5; const TICK_COUNT = 5;
function formatTimestamp(ts: string, rangeMs: number): string {
const d = parseServerTime(ts);
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return (
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
}
function valToY(v: number, drawH: number, domain: [number, number]): number { function valToY(v: number, drawH: number, domain: [number, number]): number {
const [lo, hi] = domain; const [lo, hi] = domain;
const range = hi === lo ? 1 : hi - lo; const range = hi === lo ? 1 : hi - lo;
@@ -95,7 +84,18 @@ export function LineChart({
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
const chartW = Math.max(0, width - Y_LABEL_W); const yTicks = (() => {
const [lo, hi] = domain;
const step = (hi - lo) / (TICK_COUNT - 1);
return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step);
})();
// Wide enough for the longest tick label, like "1.5 KiB/s".
const yLabelW = Math.max(
Y_LABEL_MIN,
Math.max(...yTicks.map((tick) => yFormatter(tick).length)) * 6 + 12,
);
const chartW = Math.max(0, width - yLabelW);
const drawH = height - TOP_PAD; const drawH = height - TOP_PAD;
const svgH = height + X_LABEL_H; const svgH = height + X_LABEL_H;
@@ -105,36 +105,19 @@ export function LineChart({
parseServerTime(timestamps[0]).getTime() parseServerTime(timestamps[0]).getTime()
: 0; : 0;
const yTicks = (() => { const xTickIndices = pickXTicks(timestamps, rangeMs, chartW);
const [lo, hi] = domain;
const step = (hi - lo) / (TICK_COUNT - 1);
return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step);
})();
const xTickIndices: number[] = [];
if (timestamps.length > 1 && chartW > 0) {
const n = timestamps.length;
// Long labels ("Jun 28 14:30") are ~80px; short ("14:30") are ~40px.
// Use at most 3 ticks for long labels, 5 for short, so they never crowd.
const longLabel = rangeMs > 25 * 60 * 60 * 1000;
const maxTicks = longLabel ? 3 : 5;
const count = Math.min(maxTicks, n);
for (let k = 0; k < count; k++) {
xTickIndices.push(Math.round((k / (count - 1)) * (n - 1)));
}
}
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGElement>) => { (e: React.MouseEvent<SVGElement>) => {
if (timestamps.length < 2 || chartW <= 0) return; if (timestamps.length < 2 || chartW <= 0) return;
const rect = (e.currentTarget as SVGElement).getBoundingClientRect(); const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
const relX = e.clientX - rect.left - Y_LABEL_W; const relX = e.clientX - rect.left - yLabelW;
const index = Math.round( const index = Math.round(
Math.max(0, Math.min(1, relX / chartW)) * (timestamps.length - 1), Math.max(0, Math.min(1, relX / chartW)) * (timestamps.length - 1),
); );
setTooltip({ x: e.clientX - rect.left, index }); setTooltip({ x: e.clientX - rect.left, index });
}, },
[timestamps.length, chartW], [timestamps.length, chartW, yLabelW],
); );
return ( return (
@@ -156,7 +139,7 @@ export function LineChart({
return ( return (
<g key={ti}> <g key={ti}>
<line <line
x1={Y_LABEL_W} x1={yLabelW}
y1={y} y1={y}
x2={width} x2={width}
y2={y} y2={y}
@@ -165,7 +148,7 @@ export function LineChart({
strokeOpacity={0.6} strokeOpacity={0.6}
/> />
<text <text
x={Y_LABEL_W - 6} x={yLabelW - 6}
y={y + 4} y={y + 4}
fontSize={10} fontSize={10}
textAnchor="end" textAnchor="end"
@@ -179,15 +162,15 @@ export function LineChart({
{/* Left + bottom border */} {/* Left + bottom border */}
<line <line
x1={Y_LABEL_W} x1={yLabelW}
y1={TOP_PAD} y1={TOP_PAD}
x2={Y_LABEL_W} x2={yLabelW}
y2={TOP_PAD + drawH} y2={TOP_PAD + drawH}
stroke="var(--border)" stroke="var(--border)"
strokeWidth={1} strokeWidth={1}
/> />
<line <line
x1={Y_LABEL_W} x1={yLabelW}
y1={TOP_PAD + drawH} y1={TOP_PAD + drawH}
x2={width} x2={width}
y2={TOP_PAD + drawH} y2={TOP_PAD + drawH}
@@ -209,7 +192,7 @@ export function LineChart({
strokeWidth={1.5} strokeWidth={1.5}
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round" strokeLinecap="round"
transform={`translate(${Y_LABEL_W},0)`} transform={`translate(${yLabelW},0)`}
/> />
); );
})} })}
@@ -217,13 +200,9 @@ export function LineChart({
{/* Crosshair */} {/* Crosshair */}
{tooltip !== null && timestamps.length > 1 && chartW > 0 && ( {tooltip !== null && timestamps.length > 1 && chartW > 0 && (
<line <line
x1={ x1={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
}
y1={TOP_PAD} y1={TOP_PAD}
x2={ x2={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
}
y2={TOP_PAD + drawH} y2={TOP_PAD + drawH}
stroke="var(--muted-foreground)" stroke="var(--muted-foreground)"
strokeOpacity={0.4} strokeOpacity={0.4}
@@ -237,7 +216,7 @@ export function LineChart({
const isFirst = li === 0; const isFirst = li === 0;
const isLast = li === xTickIndices.length - 1; const isLast = li === xTickIndices.length - 1;
const x = const x =
Y_LABEL_W + (i / Math.max(1, timestamps.length - 1)) * chartW; yLabelW + (i / Math.max(1, timestamps.length - 1)) * chartW;
return ( return (
<text <text
key={i} key={i}
+74
View File
@@ -0,0 +1,74 @@
import { parseServerTime } from "@/lib/server-time";
export function formatTimestamp(ts: string, rangeMs: number): string {
const d = parseServerTime(ts);
// A live chart covers a minute or two, so minutes alone would repeat.
if (rangeMs <= 10 * 60 * 1000) {
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return (
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
}
// Rough width of a 10px label, used to keep x labels apart.
const CHAR_W = 6.2;
const LABEL_GAP = 12;
/**
* Picks which timestamps get an x label. The count follows the chart width and
* label length, and a label that would touch its neighbour is dropped.
*/
export function pickXTicks(
timestamps: string[],
rangeMs: number,
chartW: number,
): number[] {
const n = timestamps.length;
if (n < 2 || chartW <= 0) return [];
const labelW = (i: number) =>
formatTimestamp(timestamps[i], rangeMs).length * CHAR_W;
const widest = Math.max(labelW(0), labelW(n - 1));
const fit = Math.floor((chartW + LABEL_GAP) / (widest + LABEL_GAP));
const count = Math.max(1, Math.min(5, n, fit));
const candidates =
count === 1
? [n - 1]
: Array.from({ length: count }, (_, k) =>
Math.round((k / (count - 1)) * (n - 1)),
);
// Box of a label as drawn: first is start anchored, last end, others middle.
const box = (i: number): [number, number] => {
const x = (i / (n - 1)) * chartW;
const w = labelW(i);
if (i === 0) return [x, x + w];
if (i === n - 1) return [x - w, x];
return [x - w / 2, x + w / 2];
};
const clashes = (a: number | undefined, b: number) =>
a !== undefined && box(a)[1] + LABEL_GAP > box(b)[0];
const placed: number[] = [];
for (const i of [...new Set(candidates)]) {
// The newest time stays visible on a live chart, older labels make room.
if (i === n - 1) {
while (placed.length && clashes(placed[placed.length - 1], i)) {
placed.pop();
}
} else if (clashes(placed[placed.length - 1], i)) {
continue;
}
placed.push(i);
}
return placed;
}
+57
View File
@@ -0,0 +1,57 @@
import type React from "react";
import { cn } from "@/lib/utils";
/**
* A plain table in the tile look: sticky header on the page background, a
* line under each row, no outer box. For wide data with real columns; use
* DataView when the same list also has a card view.
*/
export function DataTable({
className,
containerClassName,
children,
...props
}: React.ComponentProps<"table"> & {
/** Classes for the scroll box around the table, like "flex-1". */
containerClassName?: string;
}) {
return (
<div
className={cn(
"min-h-0 min-w-0 overflow-auto thin-scrollbar",
containerClassName,
)}
>
<table
className={cn(
"w-full border-collapse text-xs [&_td]:px-2 [&_td]:py-1.5 [&_th]:px-2 [&_th]:py-2 [&_th]:text-left [&_th]:text-[10px] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-widest [&_th]:text-muted-foreground [&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10 [&_thead]:bg-background [&_thead_tr]:border-b [&_tr]:border-b [&_tr]:border-border",
className,
)}
{...props}
>
{children}
</table>
</div>
);
}
/** A body row that highlights on hover, and when active. */
export function DataTableRow({
active = false,
className,
onClick,
...props
}: React.ComponentProps<"tr"> & { active?: boolean }) {
return (
<tr
onClick={onClick}
className={cn(
"transition-colors",
onClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
className,
)}
{...props}
/>
);
}
+17 -15
View File
@@ -1,8 +1,8 @@
import type React from "react"; import type React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TileWall } from "@/components/tile-grid/TileWall";
import { BottomEdge } from "@/components/tile-grid/edge";
import { import {
PANEL,
gridSizeStyle,
type GridSize, type GridSize,
type PanelDensity, type PanelDensity,
type PanelViewMode, type PanelViewMode,
@@ -91,7 +91,10 @@ function columnVisibility(hideBelow?: "md" | "lg"): string {
return ""; return "";
} }
/** One collection as a card grid or a dense row table. */ /**
* One collection as a card grid or a dense row table, edge to edge with
* shared lines like the rest of the tile look.
*/
export function DataView<T>({ export function DataView<T>({
items, items,
view, view,
@@ -132,21 +135,18 @@ export function DataView<T>({
if (view === "grid") { if (view === "grid") {
return ( return (
<div <TileWall
className={cn( size={size}
"grid", minWidth={minCardWidth ?? 320}
PANEL.gap, gridClassName={size ? undefined : gridClass(columns)}
!size && gridClass(columns), className={className}
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
> >
{items.map((item) => ( {items.map((item) => (
<div key={getKey(item)} className="flex flex-col *:flex-1"> <div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)} {renderCard(item)}
</div> </div>
))} ))}
</div> </TileWall>
); );
} }
@@ -154,8 +154,9 @@ export function DataView<T>({
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9"; const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
return ( return (
<BottomEdge>
<div <div
className={cn("@container border border-border bg-card", className)} className={cn("@container", className)}
style={ style={
{ {
"--data-view-cols": templates.base, "--data-view-cols": templates.base,
@@ -168,7 +169,7 @@ export function DataView<T>({
<div <div
className={cn( className={cn(
LIST_GRID_CLASS, LIST_GRID_CLASS,
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground", "sticky top-0 z-10 items-center border-b border-border bg-background text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)} )}
> >
{listColumns.map((column) => ( {listColumns.map((column) => (
@@ -194,7 +195,7 @@ export function DataView<T>({
onClick={onRowClick ? () => onRowClick(item) : undefined} onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn( className={cn(
LIST_GRID_CLASS, LIST_GRID_CLASS,
"items-center border-b border-border/50 text-xs transition-colors last:border-0", "items-center border-b border-border/50 text-xs transition-colors last:border-border",
rowHeight, rowHeight,
onRowClick && "cursor-pointer", onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40", active ? "bg-accent-brand/10" : "hover:bg-muted/40",
@@ -217,5 +218,6 @@ export function DataView<T>({
})} })}
</div> </div>
</div> </div>
</BottomEdge>
); );
} }
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface InfoItem {
label: ReactNode;
value: ReactNode;
/** Monospace value, for ids, paths and addresses. */
mono?: boolean;
}
/** Label and value pairs in two aligned columns. Empty values are skipped. */
export function InfoGrid({
items,
className,
}: {
items: InfoItem[];
className?: string;
}) {
const shown = items.filter(
(item) =>
item.value !== null && item.value !== undefined && item.value !== "",
);
if (shown.length === 0) return null;
return (
<dl
className={cn(
"grid grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs",
className,
)}
>
{shown.map((item, index) => (
<div key={index} className="contents">
<dt className="text-muted-foreground">{item.label}</dt>
<dd className={cn("min-w-0 break-words", item.mono && "font-mono")}>
{item.value}
</dd>
</div>
))}
</dl>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
/** A spinner with a short line, for a view waiting on its first load. */
export function LoadingState({
label,
compact = false,
className,
}: {
label?: string;
/** One inline line instead of the centered block. */
compact?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.loading");
if (compact) {
return (
<div
role="status"
className={cn(
"flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground",
className,
)}
>
<Loader2 className="size-3 animate-spin" />
{text}
</div>
);
}
return (
<div
role="status"
className={cn(
"flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground",
className,
)}
>
<Loader2 className="size-5 animate-spin opacity-60" />
{text}
</div>
);
}
+31 -4
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** Default cap (px) for a scrollable card body before it scrolls. */ /** Default cap (px) for a scrollable card body before it scrolls. */
@@ -29,11 +30,37 @@ export function MetricCard({
/** Max body height in px for scrollable cards. */ /** Max body height in px for scrollable cards. */
scrollMax?: number; scrollMax?: number;
}) { }) {
// In an edge to edge tile grid the grid draws the lines, so the card takes
// the dashboard tile look: no border or fill, the small title row.
const tiled = useInSeamlessTile();
return ( return (
<div className="flex min-h-0 flex-col overflow-hidden border border-border bg-card"> <div
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5"> data-tile-card=""
<span className="text-muted-foreground">{icon}</span> className={cn(
<span className="flex-1 truncate text-xs font-semibold uppercase tracking-widest text-muted-foreground"> "flex min-h-0 flex-col overflow-hidden",
!tiled && "border border-border",
)}
>
<div
className={cn(
"flex shrink-0 items-center gap-2 border-b border-border",
tiled ? "px-3 py-1.5" : "px-4 py-2.5",
)}
>
<span
className={cn(
"text-muted-foreground",
tiled && "flex [&_svg]:size-3",
)}
>
{icon}
</span>
<span
className={cn(
"flex-1 truncate font-semibold uppercase tracking-widest text-muted-foreground",
tiled ? "text-[10px]" : "text-xs",
)}
>
{title} {title}
</span> </span>
{action && <div className="ml-auto shrink-0">{action}</div>} {action && <div className="ml-auto shrink-0">{action}</div>}
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* A block that explains why a view has nothing to show yet and what to do,
* like "Samba is not installed" with an Install button. Unlike EmptyState the
* action always shows.
*/
export function NoticePanel({
icon: Icon,
title,
children,
action,
tone = "default",
className,
}: {
icon?: LucideIcon;
title: ReactNode;
children?: ReactNode;
action?: ReactNode;
tone?: "default" | "warning" | "destructive";
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-start gap-2 px-4 py-4",
tone === "warning" && "bg-warning/5",
tone === "destructive" && "bg-destructive/5",
className,
)}
>
{Icon && (
<Icon
className={cn(
"size-5 text-muted-foreground",
tone === "warning" && "text-warning",
tone === "destructive" && "text-destructive",
)}
/>
)}
<p className="text-sm font-medium">{title}</p>
{children && (
<div className="text-xs leading-relaxed text-muted-foreground">
{children}
</div>
)}
{action && <div className="mt-1 flex flex-wrap gap-2">{action}</div>}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
/** One page of a list, with the page clamped to what exists. */
export function pageOf<T>(
items: T[],
page: number,
size: number,
): { rows: T[]; page: number; pages: number } {
const pages = Math.max(1, Math.ceil(items.length / size));
const at = Math.min(Math.max(0, page), pages - 1);
return { rows: items.slice(at * size, at * size + size), page: at, pages };
}
/** Pages a list in memory. Goes back to the first page when `reset` changes. */
export function usePaged<T>(items: T[], size: number, reset?: unknown) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [reset]);
return { ...pageOf(items, page, size), setPage };
}
/** Previous and Next under a list. Hidden when there is only one page. */
export function Pager({
page,
pages,
hasNext,
onPage,
className,
}: {
page: number;
/** Total pages when known; leave out for a list read a page at a time. */
pages?: number;
hasNext?: boolean;
onPage: (next: number) => void;
className?: string;
}) {
const { t } = useTranslation();
const next = hasNext ?? (pages !== undefined && page < pages - 1);
if (page === 0 && !next) return null;
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2 border-t border-border px-2 py-1 text-xs text-muted-foreground",
className,
)}
>
<Button
size="sm"
variant="ghost"
disabled={page === 0}
onClick={() => onPage(page - 1)}
>
{t("panel.pager.previous")}
</Button>
<span>
{pages !== undefined
? t("panel.pager.pageOf", { page: page + 1, pages })
: t("panel.pager.page", { page: page + 1 })}
</span>
<Button
size="sm"
variant="ghost"
disabled={!next}
onClick={() => onPage(page + 1)}
>
{t("panel.pager.next")}
</Button>
</div>
);
}
+69 -5
View File
@@ -6,6 +6,7 @@ import {
Columns3, Columns3,
Grid3X3, Grid3X3,
List, List,
RefreshCw,
Rows3, Rows3,
Search, Search,
X, X,
@@ -14,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator"; import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -116,6 +118,9 @@ export function PanelShell({
const bands = ( const bands = (
<> <>
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
{toolbar && ( {toolbar && (
<div <div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`} className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
@@ -123,9 +128,6 @@ export function PanelShell({
{toolbar} {toolbar}
</div> </div>
)} )}
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
</> </>
); );
@@ -215,6 +217,59 @@ export function BackButton({
} }
/** Search box sized to match toolbar buttons. Esc clears it. */ /** Search box sized to match toolbar buttons. Esc clears it. */
/** The refresh button panel headers show, spinning while it loads. */
export function RefreshButton({
onClick,
loading = false,
disabled,
label,
className,
}: {
onClick: () => void;
loading?: boolean;
disabled?: boolean;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.refresh");
return (
<button
type="button"
onClick={onClick}
disabled={disabled ?? loading}
title={text}
aria-label={text}
className={cn(
"flex size-8 shrink-0 items-center justify-center text-accent-brand transition-colors hover:bg-muted/40 disabled:opacity-60",
className,
)}
>
<RefreshCw className={cn("size-4", loading && "animate-spin")} />
</button>
);
}
/**
* The outer box of a plugin tab: fills the tab and clips, so the PanelShell
* inside can scroll its own body.
*/
export function PanelFrame({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("h-full w-full overflow-hidden", className)}>
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
{children}
</div>
</div>
);
}
export function PanelSearch({ export function PanelSearch({
value, value,
onChange, onChange,
@@ -333,7 +388,7 @@ export function Segmented<T extends string>({
title={option.title ?? option.label} title={option.title ?? option.label}
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
className={cn( className={cn(
"flex h-8 items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring", "flex h-[30px] items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
i > 0 && "border-l border-border", i > 0 && "border-l border-border",
active active
? "bg-accent-brand/10 text-accent-brand" ? "bg-accent-brand/10 text-accent-brand"
@@ -458,7 +513,7 @@ function ToggleButton({
aria-label={title} aria-label={title}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
"flex size-8 items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring", "flex size-[30px] items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
bordered && "border-l border-border", bordered && "border-l border-border",
active active
? "bg-accent-brand/10 text-accent-brand" ? "bg-accent-brand/10 text-accent-brand"
@@ -513,13 +568,18 @@ export function GroupHeading({
title, title,
count, count,
action, action,
plain,
className, className,
}: { }: {
title: string; title: string;
count?: number; count?: number;
action?: React.ReactNode; action?: React.ReactNode;
/** No rule after the title. The default inside a tile layout. */
plain?: boolean;
className?: string; className?: string;
}) { }) {
const tiled = useInSeamlessTile();
const noRule = plain ?? tiled;
return ( return (
<div className={cn("flex items-center gap-2", className)}> <div className={cn("flex items-center gap-2", className)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
@@ -530,7 +590,11 @@ export function GroupHeading({
{count} {count}
</span> </span>
)} )}
{noRule ? (
<div className="flex-1" />
) : (
<div className="h-px flex-1 bg-border" /> <div className="h-px flex-1 bg-border" />
)}
{action} {action}
</div> </div>
); );
+15 -4
View File
@@ -1,5 +1,7 @@
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context"; import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
import { useState } from "react"; import { useState } from "react";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import { cn } from "@/lib/utils";
import type React from "react"; import type React from "react";
export function SectionCard({ export function SectionCard({
@@ -18,19 +20,28 @@ export function SectionCard({
/** Lets settings search jump to this card. */ /** Lets settings search jump to this card. */
rowId?: string; rowId?: string;
}) { }) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return ( return (
<div <div
data-tile-card=""
data-setting-row={rowId} data-setting-row={rowId}
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`} className={cn(
"flex flex-col overflow-hidden",
!tiled && "border border-border",
className,
)}
> >
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0"> <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
<span className="text-muted-foreground">{icon}</span> <span className="flex text-muted-foreground [&_svg]:size-3">
{icon}
</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1"> <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{title} {title}
</span> </span>
{action && <div className="ml-auto">{action}</div>} {action && <div className="ml-auto">{action}</div>}
</div> </div>
<div className="px-3 md:px-4 py-1">{children}</div> <div className="px-3 py-1">{children}</div>
</div> </div>
); );
} }
+2 -2
View File
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-2.5 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
@@ -105,7 +105,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className, className,
)} )}
{...props} {...props}
+4 -4
View File
@@ -154,7 +154,7 @@ function Select2({
aria-expanded={open} aria-expanded={open}
aria-required={required} aria-required={required}
className={cn( className={cn(
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-8 w-full items-center justify-between gap-2 rounded-none border px-2.5 py-1 text-xs shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -189,7 +189,7 @@ function Select2({
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={t("common.search")} placeholder={t("common.search")}
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
autoFocus autoFocus
/> />
</div> </div>
@@ -198,7 +198,7 @@ function Select2({
className="max-h-64 overflow-y-auto p-1 thin-scrollbar" className="max-h-64 overflow-y-auto p-1 thin-scrollbar"
> >
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground"> <div className="px-2 py-6 text-center text-xs text-muted-foreground">
{t("common.noResults")} {t("common.noResults")}
</div> </div>
) : ( ) : (
@@ -222,7 +222,7 @@ function Select2({
aria-selected={isSelected} aria-selected={isSelected}
disabled={option.disabled} disabled={option.disabled}
className={cn( className={cn(
"flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50", "flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-xs outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
isSelected && "bg-muted text-accent-foreground", isSelected && "bg-muted text-accent-foreground",
)} )}
onClick={() => selectValue(option.value)} onClick={() => selectValue(option.value)}
+127
View File
@@ -0,0 +1,127 @@
import type { ComponentType, ReactNode } from "react";
import { ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { Select2 } from "@/components/select2";
export interface SideNavEntry {
id: string;
label: string;
icon?: ComponentType<{ className?: string }>;
/** A count or short badge after the label. */
badge?: ReactNode;
/** Opens it in a normal tab instead, for split view. */
onPopOut?: () => void;
popOutLabel?: string;
/** Draws a line above this entry. */
divider?: boolean;
}
/**
* The left list of sections a page switches between, like the host page and
* the Operations tab. On a phone it turns into a select over the content.
*/
export function SideNav({
entries,
active,
onSelect,
footer,
label,
className,
}: {
entries: SideNavEntry[];
/** Names the list for screen readers and the phone select. */
label?: string;
active: string;
onSelect: (id: string) => void;
footer?: ReactNode;
className?: string;
}) {
return (
<>
<nav
aria-label={label}
className={cn(
"hidden w-44 shrink-0 flex-col gap-0.5 overflow-auto border-r border-border p-2 thin-scrollbar md:flex",
className,
)}
>
{entries.map((entry) => (
<div key={entry.id} className="contents">
{entry.divider && <div className="my-1 h-px bg-border" />}
<SideNavItem
entry={entry}
active={entry.id === active}
onClick={() => onSelect(entry.id)}
/>
</div>
))}
{footer && <div className="mt-auto pt-2">{footer}</div>}
</nav>
<div className="shrink-0 border-b border-border p-2 md:hidden">
<Select2
value={active}
aria-label={label}
onChange={(event) => onSelect(event.target.value)}
className="h-8 w-full px-2.5 text-xs"
>
{entries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</Select2>
</div>
</>
);
}
export function SideNavItem({
entry,
active,
onClick,
}: {
entry: SideNavEntry;
active: boolean;
onClick: () => void;
}) {
const Icon = entry.icon;
return (
<div
className={cn(
"group flex items-center",
active
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/60",
)}
>
<button
type="button"
onClick={onClick}
aria-current={active ? "page" : undefined}
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-xs"
>
{Icon && <Icon className="size-3.5 shrink-0" />}
<span className="truncate">{entry.label}</span>
{entry.badge !== undefined && entry.badge !== null && (
<span className="ml-auto shrink-0 px-1 text-[10px] tabular-nums">
{entry.badge}
</span>
)}
</button>
{entry.onPopOut && (
<button
type="button"
title={entry.popOutLabel}
aria-label={entry.popOutLabel}
onClick={entry.onPopOut}
className={cn(
"mr-1 shrink-0 p-1 text-muted-foreground hover:text-foreground",
!active && "opacity-0 focus:opacity-100 group-hover:opacity-100",
)}
>
<ExternalLink className="size-3" />
</button>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Meter } from "@/components/meter";
import { TileFrame } from "@/components/tile-grid/TileFrame";
const VALUE_TONE = {
default: "",
ok: "text-green-500",
warn: "text-warning",
bad: "text-destructive",
} as const;
/**
* One number in the tile look: the small title row, a big value, then an
* optional usage bar and a hint line. Put several in a TileWall for a strip.
*/
export function StatTile({
label,
icon,
value,
unit,
percent,
hint,
tone = "default",
actions,
onClick,
children,
className,
}: {
label: ReactNode;
icon?: ReactNode;
value: ReactNode;
unit?: ReactNode;
/** Draws a usage bar under the value. */
percent?: number | null;
hint?: ReactNode;
tone?: keyof typeof VALUE_TONE;
actions?: ReactNode;
onClick?: () => void;
/** Extra lines under the hint. */
children?: ReactNode;
className?: string;
}) {
const body = (
<div className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="flex items-baseline gap-1">
<span
className={cn(
"truncate text-2xl font-bold tabular-nums leading-none",
VALUE_TONE[tone],
)}
>
{value}
</span>
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
</div>
{percent !== undefined && <Meter percent={percent} />}
{hint && (
<span className="truncate text-[11px] text-muted-foreground">
{hint}
</span>
)}
{children}
</div>
);
return (
<TileFrame
title={label}
icon={icon}
actions={actions}
className={className}
bodyClassName="overflow-hidden"
>
{onClick ? (
<button
type="button"
onClick={onClick}
className="text-left transition-colors hover:bg-muted/40"
>
{body}
</button>
) : (
body
)}
</TileFrame>
);
}
+125
View File
@@ -0,0 +1,125 @@
/* eslint-disable react-refresh/only-export-components */
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { ListBadge } from "@/components/list-kit";
export type StatusTone = "ok" | "warn" | "bad" | "info" | "idle";
const DOT: Record<StatusTone, string> = {
ok: "bg-green-500",
warn: "bg-warning",
bad: "bg-destructive",
info: "bg-accent-brand",
idle: "bg-muted-foreground/50",
};
const BADGE = {
ok: "success",
warn: "warning",
bad: "destructive",
info: "brand",
idle: "muted",
} as const;
// Common state words, so most callers only pass the state.
const WORDS: Record<string, StatusTone> = {
running: "ok",
up: "ok",
online: "ok",
active: "ok",
healthy: "ok",
ready: "ok",
ok: "ok",
success: "ok",
succeeded: "ok",
done: "ok",
completed: "ok",
enabled: "ok",
connected: "ok",
bound: "ok",
compliant: "ok",
pending: "warn",
paused: "warn",
restarting: "warn",
starting: "warn",
stopping: "warn",
waiting: "warn",
degraded: "warn",
warn: "warn",
warning: "warn",
unhealthy: "warn",
drifted: "warn",
pending_approval: "warn",
needs_interaction: "warn",
interrupted: "warn",
update: "info",
created: "idle",
exited: "idle",
stopped: "idle",
inactive: "idle",
disabled: "idle",
offline: "bad",
down: "bad",
dead: "bad",
failed: "bad",
fail: "bad",
error: "bad",
crashloopbackoff: "bad",
};
/** The tone for a state word like "running" or "failed". Unknown words are idle. */
export function statusTone(state: string | null | undefined): StatusTone {
if (!state) return "idle";
const key = state.toLowerCase().replace(/[\s-]+/g, "_");
return WORDS[key] ?? WORDS[key.replace(/_/g, "")] ?? "idle";
}
/** A small round status light. */
export function StatusDot({
tone,
state,
pulse = false,
className,
title,
}: {
tone?: StatusTone;
/** A state word, used when no tone is given. */
state?: string | null;
pulse?: boolean;
className?: string;
title?: string;
}) {
const resolved = tone ?? statusTone(state);
return (
<span
title={title}
aria-hidden={title ? undefined : true}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
DOT[resolved],
pulse && "animate-pulse",
className,
)}
/>
);
}
/** A state as a small colored badge. The text is the state unless children are given. */
export function StatusBadge({
tone,
state,
className,
children,
}: {
tone?: StatusTone;
state?: string | null;
className?: string;
children?: ReactNode;
}) {
const resolved = tone ?? statusTone(state);
return (
<ListBadge tone={BADGE[resolved]} className={className}>
{children ?? state}
</ListBadge>
);
}
@@ -15,6 +15,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BackButton, PanelShell } from "@/components/panel-layout"; import { BackButton, PanelShell } from "@/components/panel-layout";
import { TileColumn } from "@/components/tile-grid/TileColumn";
import { import {
PROMPT_BUTTON, PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON, PROMPT_DESTRUCTIVE_BUTTON,
@@ -397,6 +398,7 @@ export function InlineView({
onBeforeClose, onBeforeClose,
scroll = true, scroll = true,
bare = false, bare = false,
stacked = false,
className, className,
children, children,
}: { }: {
@@ -416,6 +418,11 @@ export function InlineView({
scroll?: boolean; scroll?: boolean;
/** Draw children edge to edge, for views with their own bands. */ /** Draw children edge to edge, for views with their own bands. */
bare?: boolean; bare?: boolean;
/**
* Cards stacked in one column with a line between them and no padding,
* like a settings page. For forms built from SectionCards.
*/
stacked?: boolean;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
@@ -462,6 +469,10 @@ export function InlineView({
> >
{bare ? ( {bare ? (
children children
) : stacked ? (
<TileColumn className={cn("w-full", className)}>
{children}
</TileColumn>
) : ( ) : (
<div <div
className={cn( className={cn(
+1 -1
View File
@@ -96,7 +96,7 @@ export function TabStrip({
<div <div
className={cn( className={cn(
"flex min-w-0 items-center", "flex min-w-0 items-center",
variant === "secondary" && "border-t border-border bg-card", variant === "secondary" && "border-t border-border",
className, className,
)} )}
> >
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none", "flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-2.5 py-2 text-xs shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
className, className,
)} )}
ref={ref} ref={ref}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { SeamlessTileContext } from "./context";
import { BottomEdge, EdgeOwned } from "./edge";
/**
* A column of cards that can sit at any depth, like a settings page built
* from several panels. Cards drop their box and the outermost ones draw a
* line under themselves. Use TileStack when the sections are direct children.
*/
export function TileColumn({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<SeamlessTileContext.Provider value={true}>
<BottomEdge
className={className}
innerClassName="flex min-w-0 flex-col [&_[data-tile-card]]:border-x-0 [&_[data-tile-card]]:border-t-0 [&_[data-tile-card]]:border-b [&_[data-tile-card]]:border-border [&_[data-tile-card]_[data-tile-card]]:border-b-0"
>
<EdgeOwned>{children}</EdgeOwned>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
/**
* The bar over a tile grid while it is being edited: a hint or extra controls
* on the left, then Reset, Add tiles (while the picker is closed) and Done.
*/
export function TileEditBar({
children,
onReset,
onAdd,
onDone,
className,
}: {
children?: ReactNode;
onReset?: () => void;
/** Leave out while the tile picker is open. */
onAdd?: () => void;
onDone: () => void;
className?: string;
}) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 text-xs",
className,
)}
>
{children && (
<div className="flex min-w-0 flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
{children}
</div>
)}
<div className="ml-auto flex items-center gap-1">
{onReset && (
<Button size="xs" variant="ghost" className="gap-1" onClick={onReset}>
<RotateCcw className="size-3" />
{t("tileGrid.reset")}
</Button>
)}
{onAdd && (
<Button size="xs" variant="ghost" onClick={onAdd}>
{t("tileGrid.addTiles")}
</Button>
)}
<Button size="xs" onClick={onDone}>
{t("tileGrid.done")}
</Button>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* The look every tile shares with the dashboard: a small uppercase title row
* over a body that scrolls when the tile has a fixed height. The grid draws
* the lines between tiles. `bare` drops the title row.
*/
export function TileFrame({
title,
icon,
actions,
bare = false,
onTitleClick,
className,
bodyClassName,
children,
}: {
title?: ReactNode;
icon?: ReactNode;
actions?: ReactNode;
bare?: boolean;
onTitleClick?: () => void;
className?: string;
bodyClassName?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-col overflow-hidden",
className,
)}
>
{!bare && (title || icon || actions) && (
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
{icon && (
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
{icon}
</span>
)}
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
className="min-w-0 truncate text-left text-[10px] font-semibold uppercase tracking-widest text-muted-foreground hover:text-foreground"
>
{title}
</button>
) : (
<span className="min-w-0 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{title}
</span>
)}
{actions && (
<span className="ml-auto flex shrink-0 items-center gap-1">
{actions}
</span>
)}
</div>
)}
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-auto thin-scrollbar",
bodyClassName,
)}
>
{children}
</div>
</div>
);
}
+820
View File
@@ -0,0 +1,820 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { GripVertical, Settings2, X } from "lucide-react";
import { remScale } from "@/lib/rem";
import { cn } from "@/lib/utils";
import {
MAX_TILE_HEIGHT,
MIN_TILE_COLUMNS,
MIN_TILE_HEIGHT,
TILE_COLUMNS,
clampTileColumns,
settleTiles,
tileBottom,
tileBoxes,
tilesFromBoxes,
type TileBox,
type TileSlot,
} from "./layout";
import { SeamlessTileContext } from "./context";
import { gridLines } from "./layout";
import { scrollParent } from "./edge";
type Edge = "left" | "right" | "bottom" | "corner";
interface Proposal {
x?: number;
y?: number;
w?: number;
h?: number | null;
}
interface Interaction {
id: string;
kind: "move" | "resize";
base: TileSlot[];
proposal: Proposal;
frame: { left: number; top: number; width: number; height?: number };
}
const DRAG_THRESHOLD = 4;
const SNAP = 8;
const SCROLL_EDGE = 48;
const styles = `
.tg-grid { position: relative; }
.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; }
.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
.tg-tile[data-active] { z-index: 40; }
.tg-tile[data-active="move"] { opacity: 0.85; cursor: grabbing; }
.tg-grid[data-edit-mode="true"] .tg-tile { user-select: none; }
.tg-grid[data-edit-mode="true"] .tg-tile:not([data-active]) { cursor: grab; }
[data-tg-drop-preview] {
position: absolute; left: 0; top: 0; z-index: 5; pointer-events: none; box-sizing: border-box;
background: color-mix(in srgb, var(--accent-brand, #60a5fa) 12%, transparent);
border: 2px dashed color-mix(in srgb, var(--accent-brand, #60a5fa) 70%, transparent);
}
.tg-handle { position: absolute; z-index: 30; display: flex; touch-action: none; align-items: center; justify-content: center; }
.tg-handle > span { background: var(--border); }
.tg-handle:hover > span { background: var(--accent-brand, #60a5fa); }
.tg-edge-left, .tg-edge-right { top: 2rem; bottom: 1rem; width: 0.75rem; cursor: ew-resize; }
.tg-edge-left { left: 0; }
.tg-edge-right { right: 0; }
.tg-edge-bottom { bottom: 0; left: 1rem; right: 1rem; height: 0.75rem; cursor: ns-resize; }
.tg-edge-corner { bottom: 0; right: 0; width: 1rem; height: 1rem; cursor: nwse-resize; }
.tg-edge-corner::after { content: ""; width: 0.5rem; height: 0.5rem; border-right: 2px solid var(--border); border-bottom: 2px solid var(--border); }
.tg-handle:focus-visible { outline: 2px solid var(--accent-brand, #60a5fa); }
.tg-card[data-fixed-height="true"] { height: 100%; }
.tg-card[data-fixed-height="true"] > .tg-content { overflow: hidden; }
.tg-card[data-fixed-height="true"] > .tg-content > * { height: 100%; }
`;
const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value));
function propose(base: TileSlot[], id: string, proposal: Proposal) {
return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot));
}
const NO_DRAG =
"button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle";
export interface TileGridProps {
slots: TileSlot[];
editMode: boolean;
renderTile: (id: string) => ReactNode;
/** Name of a tile, for the edit controls. */
tileLabel: (id: string) => string;
onChange: (slots: TileSlot[]) => void;
/** Shows a settings button on each tile while editing. */
onSettings?: (id: string) => void;
/** Space between tiles in px. 0 draws tiles edge to edge with shared borders, like the dashboard. */
gap?: number;
/** Below this width tiles stack in one column and editing is off. */
stackBelow?: number;
minColumns?: number;
minHeight?: number;
/** Shown while editing an empty grid. */
empty?: ReactNode;
className?: string;
}
/**
* A 12 column grid of tiles the user can drag and resize. Tiles keep their
* column and settle upward, so a layout never has holes it did not ask for.
*/
export function TileGrid({
slots,
editMode,
renderTile,
tileLabel,
onChange,
onSettings,
gap = 0,
stackBelow = 640,
minColumns = MIN_TILE_COLUMNS,
minHeight = MIN_TILE_HEIGHT,
empty,
className,
}: TileGridProps) {
const { t } = useTranslation();
const root = useRef<HTMLDivElement>(null);
const gridEl = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
// Whether the grid reaches the bottom of the view it scrolls in. Then its
// last row meets the bottom of the screen and needs no line under it.
const [overflows, setOverflows] = useState(false);
const overflowsRef = useRef(overflows);
overflowsRef.current = overflows;
const [heights, setHeights] = useState<Record<string, number>>({});
const [active, setActive] = useState<Interaction | null>(null);
const cancel = useRef<(() => void) | null>(null);
const heightsRef = useRef(heights);
heightsRef.current = heights;
const scale = remScale();
const seamless = gap <= 0;
// Edge to edge tiles draw no lines of their own. The grid works out every
// line between them and draws each once, so a line is never doubled at any
// display scaling and a taller neighbour still gets its edge.
const inset = seamless ? 0 : gap * scale;
const settleGap = seamless ? 0 : gap;
const pitch = (width + inset) / TILE_COLUMNS;
const stacked = width > 0 && width < stackBelow;
const editing = editMode && !stacked;
const settled = useMemo(
() => settleTiles(tileBoxes(slots, heights), settleGap),
[slots, heights, settleGap],
);
const preview = useMemo(
() =>
active
? settleTiles(
tileBoxes(
propose(active.base, active.id, active.proposal),
heights,
),
settleGap,
)
: settled,
[active, settled, heights, settleGap],
);
const boxById = useMemo(
() => new Map(preview.map((box) => [box.id, box])),
[preview],
);
// Same elements across pointer moves, so tiles only re-render on new data.
const content = useMemo(
() =>
new Map<string, ReactNode>(
slots.map((slot) => [slot.id, renderTile(slot.id)]),
),
[slots, renderTile],
);
useLayoutEffect(() => {
const element = root.current;
if (!element) return;
const update = () =>
setWidth(element.getBoundingClientRect().width || element.clientWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
const element = root.current;
const scroller = seamless ? scrollParent(element) : null;
if (!element || !scroller) {
setOverflows(false);
return;
}
// Where the grid's last row ends inside the scrolled content, without the
// kept bottom line's px, so adding or dropping that line never flips it.
const update = () => {
const end =
element.getBoundingClientRect().bottom -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
(overflowsRef.current ? 0 : 1);
setOverflows(end >= scroller.clientHeight - 0.5);
};
update();
const observer = new ResizeObserver(update);
observer.observe(scroller);
observer.observe(element);
return () => observer.disconnect();
}, [seamless, settled, width]);
// An outside change (undo, reset, host switch) ends any drag in progress.
useEffect(() => () => cancel.current?.(), [slots, editing]);
const reportHeight = useCallback(
(id: string, height: number) =>
setHeights((previous) =>
Math.abs((previous[id] ?? -1) - height) < 0.5
? previous
: { ...previous, [id]: height },
),
[],
);
const commit = (base: TileSlot[], id: string | null, proposal: Proposal) => {
const next = id ? propose(base, id, proposal) : base;
const boxes = settleTiles(tileBoxes(next, heightsRef.current), settleGap);
onChange(tilesFromBoxes(next, boxes));
};
const settledBase = (): TileSlot[] =>
slots.map((slot) => ({
...slot,
y: settled.find((box) => box.id === slot.id)?.y ?? slot.y,
}));
const begin = (
event: ReactPointerEvent<HTMLElement>,
id: string,
edge?: Edge,
) => {
if (event.button !== 0 || !editing || cancel.current) return;
const element = gridEl.current;
const box = settled.find((b) => b.id === id);
const slot = slots.find((s) => s.id === id);
if (!element || !box || !slot) return;
if (!edge) {
const target = event.target as HTMLElement;
if (target.closest(NO_DRAG) && !target.closest("[data-tg-grip]")) return;
const native = event.nativeEvent;
const scrolls =
target.scrollHeight > target.clientHeight ||
target.scrollWidth > target.clientWidth;
if (
scrolls &&
(native.offsetX > target.clientWidth ||
native.offsetY > target.clientHeight)
)
return;
} else {
event.preventDefault();
event.stopPropagation();
}
const kind = edge ? "resize" : "move";
const base = settledBase();
const origin = element.getBoundingClientRect();
const startLeft = box.x * pitch;
const startTop = box.y * scale;
const startWidth = box.w * pitch - inset;
const startHeight = box.h * scale;
const fixed = slot.h != null;
const startX = event.clientX;
const startY = event.clientY;
const grabX = startX - origin.left - startLeft;
const grabY = startY - origin.top - startTop;
const scroller = scrollParent(element);
const others = settled.filter((b) => b.id !== id);
let pointer = { x: startX, y: startY };
let started = kind === "resize";
let current: Interaction | null = null;
let timer: ReturnType<typeof setInterval> | undefined;
const compute = (): Interaction => {
const rect = element.getBoundingClientRect();
if (kind === "move") {
const left = pointer.x - rect.left - grabX;
const top = pointer.y - rect.top - grabY;
return {
id,
kind,
base,
proposal: {
x: clamp(Math.round(left / pitch), 0, TILE_COLUMNS - box.w),
y: Math.max(0, top / scale),
},
frame: {
left,
top,
width: startWidth,
height: fixed ? startHeight : undefined,
},
};
}
const dx = pointer.x - startX;
const dy = pointer.y - rect.top - (startY - origin.top);
const proposal: Proposal = {};
const frame: Interaction["frame"] = {
left: startLeft,
top: startTop,
width: startWidth,
height: fixed ? startHeight : undefined,
};
const minWidth = minColumns * pitch - inset;
if (edge === "right" || edge === "corner") {
const raw = clamp(
startWidth + dx,
minWidth,
(TILE_COLUMNS - box.x) * pitch - inset,
);
proposal.w = Math.min(
clampTileColumns((raw + inset) / pitch, minColumns),
TILE_COLUMNS - box.x,
);
frame.width = raw;
}
if (edge === "left") {
const right = box.x + box.w;
const raw = clamp(startWidth - dx, minWidth, right * pitch - inset);
proposal.w = Math.min(
clampTileColumns((raw + inset) / pitch, minColumns),
right,
);
proposal.x = right - proposal.w;
frame.width = raw;
frame.left = startLeft + startWidth - raw;
}
if (edge === "bottom" || edge === "corner") {
let height =
clamp(startHeight + dy, minHeight * scale, MAX_TILE_HEIGHT * scale) /
scale;
// Line up with the bottom of a nearby tile.
for (const other of others) {
const bottom = other.y + other.h - box.y;
if (bottom >= minHeight && Math.abs(bottom - height) <= SNAP) {
height = bottom;
break;
}
}
proposal.h = Math.round(height);
frame.height = proposal.h * scale;
}
return { id, kind, base, proposal, frame };
};
const update = () => {
current = compute();
setActive(current);
};
const autoScroll = () => {
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const step =
pointer.y < rect.top + SCROLL_EDGE
? -12
: pointer.y > rect.bottom - SCROLL_EDGE
? 12
: 0;
if (!step) return;
const before = scroller.scrollTop;
scroller.scrollTop += step;
if (scroller.scrollTop !== before) update();
};
const move = (e: PointerEvent) => {
if (e.pointerId !== event.pointerId) return;
pointer = { x: e.clientX, y: e.clientY };
if (
!started &&
Math.hypot(pointer.x - startX, pointer.y - startY) < DRAG_THRESHOLD
)
return;
if (!started) {
started = true;
window.getSelection?.()?.removeAllRanges();
if (scroller) timer = setInterval(autoScroll, 16);
}
update();
};
const finish = (save: boolean) => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancelled);
window.removeEventListener("keydown", key);
window.removeEventListener("blur", blur);
clearInterval(timer);
cancel.current = null;
setActive(null);
if (save && started && current) commit(base, id, current.proposal);
};
const up = (e: PointerEvent) => {
if (e.pointerId === event.pointerId) finish(true);
};
const cancelled = (e: PointerEvent) => {
if (e.pointerId === event.pointerId) finish(false);
};
const key = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
finish(false);
};
const blur = () => finish(false);
cancel.current = () => finish(false);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cancelled);
window.addEventListener("keydown", key);
window.addEventListener("blur", blur);
if (started) update();
};
const moveByKey = (event: ReactKeyboardEvent, id: string) => {
const box = settled.find((b) => b.id === id);
if (!box || !event.key.startsWith("Arrow")) return;
const near = settled.filter(
(b) => b.id !== id && b.x < box.x + box.w && box.x < b.x + b.w,
);
let proposal: Proposal | null = null;
if (event.key === "ArrowLeft" && box.x > 0) proposal = { x: box.x - 1 };
if (event.key === "ArrowRight" && box.x + box.w < TILE_COLUMNS)
proposal = { x: box.x + 1 };
if (event.key === "ArrowUp") {
const above = near
.filter((b) => b.y < box.y)
.sort((a, b) => b.y - a.y)[0];
if (above) proposal = { y: Math.max(0, above.y - 1) };
}
if (event.key === "ArrowDown") {
const below = near
.filter((b) => b.y > box.y)
.sort((a, b) => a.y - b.y)[0];
if (below) proposal = { y: below.y + 1 };
}
event.preventDefault();
if (proposal) commit(settledBase(), id, proposal);
};
const resizeByKey = (
event: ReactKeyboardEvent,
id: string,
axis: "width" | "height",
) => {
const box = settled.find((b) => b.id === id);
if (!box) return;
const keys =
axis === "width" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];
if (![...keys, "Home", "End"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const sign = event.key === keys[1] ? 1 : -1;
if (axis === "width") {
const max = TILE_COLUMNS - box.x;
const w =
event.key === "Home"
? minColumns
: event.key === "End"
? max
: clamp(box.w + sign, minColumns, max);
if (w !== box.w) commit(settledBase(), id, { w });
} else {
const current = Math.round(box.h);
const h =
event.key === "Home"
? minHeight
: event.key === "End"
? MAX_TILE_HEIGHT
: clamp(
current + sign * (event.shiftKey ? 50 : 10),
minHeight,
MAX_TILE_HEIGHT,
);
if (h !== current) commit(settledBase(), id, { h });
}
};
const remove = (id: string) =>
commit(
settledBase().filter((slot) => slot.id !== id),
null,
{},
);
if (stacked) {
return (
<SeamlessTileContext.Provider value={seamless}>
<div
ref={root}
className={cn("flex flex-col", !seamless && "gap-3", className)}
>
{[...settled]
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((box) => (
<div
key={box.id}
className={cn(
"w-full min-w-0",
seamless && "border-b border-border",
)}
>
{content.get(box.id)}
</div>
))}
</div>
</SeamlessTileContext.Provider>
);
}
const activeBox = active ? boxById.get(active.id) : undefined;
// Edge to edge tiles sit on whole px so the lines between them are crisp.
const snap = (value: number) => (seamless ? Math.round(value) : value);
const leftOf = (box: TileBox) => snap(box.x * pitch);
const widthOf = (box: TileBox) =>
seamless
? snap((box.x + box.w) * pitch) - snap(box.x * pitch)
: box.w * pitch - inset;
const bottom = tileBottom(preview, settleGap) * scale;
// A kept bottom line sits one px under the last row.
const height = Math.max(
0,
seamless ? snap(bottom) + (overflows ? 0 : 1) : bottom - inset,
);
const lines = seamless
? gridLines(
preview.map((box) => ({
l: leftOf(box),
t: snap(box.y * scale),
r: leftOf(box) + widthOf(box),
b: snap((box.y + box.h) * scale),
})),
snap(width),
overflows,
)
: [];
return (
<SeamlessTileContext.Provider value={seamless}>
<div ref={root} className={className}>
<div
ref={gridEl}
className="tg-grid"
data-edit-mode={editing}
data-animate={editing}
style={{ height }}
>
<style>{styles}</style>
{lines.map((line) => (
<div
key={`${line.x},${line.y},${line.w},${line.h}`}
data-tg-line
aria-hidden="true"
// A real border, so it is as thin as every other line at any
// display scaling; a 1 px fill rounds up to two screen px.
className={cn(
"pointer-events-none absolute z-[25] border-border",
line.w === 1 ? "border-l" : "border-t",
)}
style={{
left: line.x,
top: line.y,
width: line.w === 1 ? 0 : line.w,
height: line.h === 1 ? 0 : line.h,
}}
/>
))}
{activeBox && (
<div
data-tg-drop-preview={activeBox.id}
aria-hidden="true"
style={{
transform: `translate(${leftOf(activeBox)}px, ${snap(activeBox.y * scale)}px)`,
width: widthOf(activeBox),
height: activeBox.h * scale,
}}
/>
)}
{slots.map((slot) => {
const box = boxById.get(slot.id)!;
const frame = active?.id === slot.id ? active.frame : null;
const tileHeight = frame
? frame.height
: seamless
? snap((box.y + box.h) * scale) - snap(box.y * scale)
: slot.h != null
? slot.h * scale
: undefined;
return (
<Tile
key={slot.id}
id={slot.id}
box={box}
label={tileLabel(slot.id)}
editMode={editing}
active={active?.id === slot.id ? active.kind : undefined}
left={frame ? frame.left : leftOf(box)}
top={frame ? frame.top : snap(box.y * scale)}
width={frame ? frame.width : widthOf(box)}
height={tileHeight}
measure={slot.h == null}
scale={scale}
seamless={seamless}
minColumns={minColumns}
minHeight={minHeight}
onHeight={reportHeight}
onPointerDown={(event, edge) => begin(event, slot.id, edge)}
onMoveKey={(event) => moveByKey(event, slot.id)}
onResizeKey={(event, axis) => resizeByKey(event, slot.id, axis)}
onSettings={onSettings ? () => onSettings(slot.id) : undefined}
onRemove={() => remove(slot.id)}
>
{content.get(slot.id)}
</Tile>
);
})}
</div>
{editing && slots.length === 0 && (
<div className="flex items-center justify-center border border-dashed border-border py-10 text-xs text-muted-foreground">
{empty ?? t("tileGrid.empty")}
</div>
)}
</div>
</SeamlessTileContext.Provider>
);
}
function Tile({
id,
box,
label,
editMode,
active,
left,
top,
width,
height,
measure,
scale,
seamless,
minColumns,
minHeight,
onHeight,
onPointerDown,
onMoveKey,
onResizeKey,
onSettings,
onRemove,
children,
}: {
id: string;
box: TileBox;
label: string;
editMode: boolean;
active?: Interaction["kind"];
left: number;
top: number;
width: number;
height?: number;
/** The tile has no height of its own and reports its content's. */
measure: boolean;
scale: number;
seamless: boolean;
minColumns: number;
minHeight: number;
onHeight: (id: string, height: number) => void;
onPointerDown: (event: ReactPointerEvent<HTMLElement>, edge?: Edge) => void;
onMoveKey: (event: ReactKeyboardEvent) => void;
onResizeKey: (event: ReactKeyboardEvent, axis: "width" | "height") => void;
onSettings?: () => void;
onRemove: () => void;
children: ReactNode;
}) {
const { t } = useTranslation();
const card = useRef<HTMLDivElement>(null);
const body = useRef<HTMLDivElement>(null);
const fixed = !measure;
// Tiles with no height of their own report their content's so the grid can
// place them. Edge to edge, that is rounded up to a whole px.
useLayoutEffect(() => {
const element = seamless ? body.current : card.current;
if (!element || fixed) return;
const report = () => {
const own = element.getBoundingClientRect().height;
onHeight(id, (seamless ? Math.ceil(own) : own) / scale);
};
report();
const observer = new ResizeObserver(report);
observer.observe(element);
return () => observer.disconnect();
}, [id, fixed, scale, seamless, onHeight]);
const button =
"flex size-6 items-center justify-center border border-border bg-card text-muted-foreground hover:text-foreground";
return (
<div
data-tg-tile={id}
data-active={active}
className="tg-tile"
style={{
transform: `translate(${left}px, ${top}px)`,
width,
height,
}}
onPointerDown={editMode ? (event) => onPointerDown(event) : undefined}
>
<div
ref={card}
className={cn(
"tg-card relative flex min-h-0 min-w-0 flex-col",
seamless && "min-h-full",
)}
data-tg-card={id}
data-fixed-height={fixed}
>
<div
ref={body}
className={cn(
"tg-content min-h-0",
seamless && measure ? "flex-none" : "flex-1",
)}
>
{children}
</div>
{editMode && (
<>
<div className="pointer-events-none absolute inset-0 z-10 border-2 border-dashed border-accent-brand/30" />
<div className="absolute top-1 right-1 z-20 flex items-center gap-1">
<button
type="button"
className={cn(button, "cursor-grab active:cursor-grabbing")}
title={t("tileGrid.move")}
aria-label={t("tileGrid.moveTile", { tile: label })}
onKeyDown={onMoveKey}
data-tg-grip
>
<GripVertical className="size-3" />
</button>
{onSettings && (
<button
type="button"
className={button}
title={t("tileGrid.settings", { tile: label })}
aria-label={t("tileGrid.settings", { tile: label })}
onClick={onSettings}
>
<Settings2 className="size-3" />
</button>
)}
<button
type="button"
onClick={onRemove}
className={cn(
button,
"hover:border-destructive/40 hover:text-destructive",
)}
title={t("tileGrid.remove", { tile: label })}
aria-label={t("tileGrid.remove", { tile: label })}
>
<X className="size-3" />
</button>
</div>
<div
className="tg-handle tg-edge-left"
aria-hidden="true"
onPointerDown={(event) => onPointerDown(event, "left")}
/>
{(["width", "height"] as const).map((axis) => (
<div
key={axis}
role="separator"
tabIndex={0}
aria-orientation={axis === "width" ? "vertical" : "horizontal"}
aria-label={`${label}: ${t(axis === "width" ? "tileGrid.width" : "tileGrid.height")}`}
aria-valuemin={axis === "width" ? minColumns : minHeight}
aria-valuemax={
axis === "width" ? TILE_COLUMNS : MAX_TILE_HEIGHT
}
aria-valuenow={axis === "width" ? box.w : Math.round(box.h)}
title={t(
axis === "width" ? "tileGrid.width" : "tileGrid.height",
)}
className={`tg-handle ${axis === "width" ? "tg-edge-right" : "tg-edge-bottom"}`}
onPointerDown={(event) =>
onPointerDown(event, axis === "width" ? "right" : "bottom")
}
onKeyDown={(event) => onResizeKey(event, axis)}
>
<span
className={
axis === "width"
? "h-12 w-0.5 rounded-full"
: "h-0.5 w-12 rounded-full"
}
/>
</div>
))}
<div
className="tg-handle tg-edge-corner"
aria-hidden="true"
onPointerDown={(event) => onPointerDown(event, "corner")}
/>
</>
)}
</div>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
ChevronDown,
ChevronRight,
Plus,
Search,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type TileKind =
"card" | "signal" | "action" | "session" | "resource" | "chart";
export interface TilePickerEntry {
/** Tile id the grid gets when this entry is added. */
id: string;
title: string;
description?: string;
/** Group heading, usually the plugin name. */
group: string;
kind: TileKind;
icon?: ReactNode;
/** Already on the grid; shown checked and not added twice. */
placed?: boolean;
}
const KINDS: TileKind[] = [
"card",
"chart",
"signal",
"action",
"resource",
"session",
];
const KIND_LABELS: Record<TileKind | "all", string> = {
all: "tileGrid.kind.all",
card: "tileGrid.kind.card",
chart: "tileGrid.kind.chart",
signal: "tileGrid.kind.signal",
action: "tileGrid.kind.action",
resource: "tileGrid.kind.resource",
session: "tileGrid.kind.session",
};
/**
* Everything that can go on a grid, in a narrow column beside it while
* editing. Search, filter by kind and fold groups, so long lists from many
* plugins stay usable.
*/
export function TilePicker({
entries,
onAdd,
onClose,
className,
}: {
entries: TilePickerEntry[];
onAdd: (entry: TilePickerEntry) => void;
onClose?: () => void;
className?: string;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [kind, setKind] = useState<TileKind | null>(null);
const [folded, setFolded] = useState<Set<string>>(() => new Set());
const kinds = useMemo(
() => KINDS.filter((k) => entries.some((entry) => entry.kind === k)),
[entries],
);
const groups = useMemo(() => {
const needle = query.trim().toLowerCase();
const shown = entries.filter(
(entry) =>
(!kind || entry.kind === kind) &&
(!needle ||
entry.title.toLowerCase().includes(needle) ||
entry.group.toLowerCase().includes(needle) ||
entry.description?.toLowerCase().includes(needle)),
);
const map = new Map<string, TilePickerEntry[]>();
for (const entry of shown) {
const list = map.get(entry.group) ?? [];
list.push(entry);
map.set(entry.group, list);
}
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [entries, query, kind]);
const searching = query.trim().length > 0;
const toggle = (group: string) =>
setFolded((current) => {
const next = new Set(current);
if (next.has(group)) next.delete(group);
else next.add(group);
return next;
});
return (
<div
className={cn(
"flex h-full w-72 shrink-0 flex-col border-l border-border bg-surface/40",
className,
)}
>
<div className="flex h-8 shrink-0 items-center gap-2 border-b border-border px-3">
<Plus className="size-3 text-accent-brand" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("tileGrid.addTiles")}
</span>
{onClose && (
<button
type="button"
onClick={onClose}
className="ml-auto text-muted-foreground hover:text-foreground"
title={t("tileGrid.closePicker")}
aria-label={t("tileGrid.closePicker")}
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex shrink-0 flex-col gap-2 border-b border-border p-2">
<label className="flex h-7 items-center gap-1.5 border border-border bg-background px-2">
<Search className="size-3 shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("tileGrid.search")}
aria-label={t("tileGrid.search")}
className="min-w-0 flex-1 bg-transparent text-xs outline-none"
/>
</label>
{kinds.length > 1 && (
<div className="flex flex-wrap gap-1">
{[null, ...kinds].map((k) => (
<button
key={k ?? "all"}
type="button"
onClick={() => setKind(k)}
className={cn(
"h-5 border px-1.5 text-[10px] transition-colors",
kind === k
? "border-accent-brand text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(KIND_LABELS[k ?? "all"])}
</button>
))}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{groups.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">
{t("tileGrid.noMatches")}
</p>
)}
{groups.map(([group, list]) => {
const open = searching || !folded.has(group);
return (
<div key={group} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => toggle(group)}
aria-expanded={open}
className="flex h-7 w-full items-center gap-1.5 px-2 text-left hover:bg-hover"
>
{open ? (
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<ChevronRight className="size-3 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{group}
</span>
<span className="text-[10px] tabular-nums text-muted-foreground/60">
{list.length}
</span>
</button>
{open && (
<ul className="pb-1">
{list.map((entry) => (
<li key={entry.id}>
<button
type="button"
disabled={entry.placed}
onClick={() => onAdd(entry)}
title={entry.description}
className="flex w-full items-center gap-2 px-3 py-1 text-left text-xs transition-colors hover:bg-hover disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent"
>
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-3">
{entry.icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{entry.title}</span>
{entry.description && (
<span className="block truncate text-[10px] text-muted-foreground">
{entry.description}
</span>
)}
</span>
{entry.placed ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<Plus className="size-3 shrink-0 text-muted-foreground" />
)}
</button>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { Children, isValidElement, type ReactNode } from "react";
import { SeamlessTileContext } from "./context";
import { BottomEdge, EdgeOwned } from "./edge";
/**
* Sections stacked edge to edge with a line under each, the column form of
* the tile look. Cards inside drop their own border. The last line goes away
* when the stack reaches the bottom of the screen.
*/
export function TileStack({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<SeamlessTileContext.Provider value={true}>
<BottomEdge className={className} innerClassName="flex min-w-0 flex-col">
<EdgeOwned>
{Children.toArray(children).map((section, index) => (
<div
key={keyOf(section, index)}
className="min-w-0 shrink-0 border-b border-border"
>
{section}
</div>
))}
</EdgeOwned>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
function keyOf(node: ReactNode, index: number): string | number {
return isValidElement(node) && node.key !== null ? node.key : index;
}
+53
View File
@@ -0,0 +1,53 @@
import { Children, isValidElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gridSizeStyle, type GridSize } from "@/components/panel-layout";
import { SeamlessTileContext } from "./context";
import { BottomEdge } from "./edge";
/**
* A fixed grid of tiles edge to edge with one line between neighbours, for
* stat strips and card lists. `size` is tiles per row ("auto" fits as many as
* `minWidth` allows). Use TileGrid when people should move and resize tiles.
*/
export function TileWall({
size = "auto",
minWidth = 280,
gridClassName,
className,
children,
}: {
size?: GridSize;
minWidth?: number;
/** Column classes (like "grid-cols-1 md:grid-cols-3") in place of size. */
gridClassName?: string;
className?: string;
children: ReactNode;
}) {
const tiles = Children.toArray(children);
return (
<SeamlessTileContext.Provider value={true}>
{/* The grid sits a pixel wider so the right edge line is cut off. */}
<BottomEdge className={className}>
<div
className={cn("-mr-px grid", gridClassName)}
style={
gridClassName ? undefined : gridSizeStyle(size, minWidth, "0px")
}
>
{tiles.map((tile, index) => (
<div
key={keyOf(tile, index)}
className="flex min-w-0 flex-col border-b border-r border-border *:flex-1"
>
{tile}
</div>
))}
</div>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
function keyOf(node: ReactNode, index: number): string | number {
return isValidElement(node) && node.key !== null ? node.key : index;
}
+8
View File
@@ -0,0 +1,8 @@
import { createContext, useContext } from "react";
/** True inside an edge to edge TileGrid, where the grid draws the lines. */
export const SeamlessTileContext = createContext(false);
export function useInSeamlessTile(): boolean {
return useContext(SeamlessTileContext);
}
+86
View File
@@ -0,0 +1,86 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export function scrollParent(element: HTMLElement | null) {
for (let node = element?.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return null;
}
/** True inside a TileStack or TileColumn section, which draws its own line. */
const EdgeOwnedContext = createContext(false);
export function EdgeOwned({ children }: { children: ReactNode }) {
return (
<EdgeOwnedContext.Provider value={true}>
{children}
</EdgeOwnedContext.Provider>
);
}
/**
* Whether the element's end reaches the bottom of the visible scroll area,
* where a closing line would sit on the screen edge and only add noise.
*/
function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
const [reaches, setReaches] = useState(false);
const reachesRef = useRef(reaches);
reachesRef.current = reaches;
useLayoutEffect(() => {
const element = ref.current;
const scroller = scrollParent(element);
if (!element || !scroller) return;
// Measured without the line itself, so showing it never flips the answer.
const update = () => {
const end =
element.getBoundingClientRect().bottom -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
(reachesRef.current ? 0 : 1);
setReaches(end >= scroller.clientHeight - 0.5);
};
update();
const observer = new ResizeObserver(update);
observer.observe(scroller);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return reaches;
}
/**
* Wraps tiles whose last row draws a line under itself. The line stays when
* there is room below and is clipped when the tiles reach the bottom of the
* screen, the same rule the tile grid uses. Inside a stack section the
* section owns the line, so it is always clipped.
*/
export function BottomEdge({
className,
innerClassName,
children,
}: {
className?: string;
innerClassName?: string;
children: ReactNode;
}) {
const owned = useContext(EdgeOwnedContext);
const ref = useRef<HTMLDivElement>(null);
const reaches = useReachesBottom(ref);
return (
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}>
{children}
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
export { TileGrid, type TileGridProps } from "./TileGrid";
export { TileFrame } from "./TileFrame";
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
export { TileWall } from "./TileWall";
export { TileStack } from "./TileStack";
export { TileColumn } from "./TileColumn";
export { TileEditBar } from "./TileEditBar";
export { SeamlessTileContext, useInSeamlessTile } from "./context";
export * from "./layout";
+234
View File
@@ -0,0 +1,234 @@
/**
* A tile on a 12 column grid. x and w are columns, y is unscaled px from the
* top (the grid settles it), h is a fixed height in unscaled px or null for
* the tile's natural height.
*/
export interface TileSlot {
id: string;
x: number;
y: number;
w: number;
h?: number | null;
}
/** A tile with a resolved height, as the grid draws it. */
export interface TileBox {
id: string;
x: number;
y: number;
w: number;
h: number;
}
export const TILE_COLUMNS = 12;
export const MIN_TILE_COLUMNS = 2;
export const MIN_TILE_HEIGHT = 48;
export const MAX_TILE_HEIGHT = 10000;
/** y given to a new tile so it settles under everything else. */
export const BOTTOM = 1_000_000;
export const clampTileColumns = (w: number, min = MIN_TILE_COLUMNS) =>
Math.max(min, Math.min(TILE_COLUMNS, Math.round(w) || min));
const overlapsX = (a: TileBox, b: TileBox) =>
a.x < b.x + b.w && b.x < a.x + a.w;
/**
* Settles tiles top to bottom in (y, x) order. Each tile sits right under the
* lowest tile placed before it in the columns it covers. Tiles never move
* sideways and never jump above a tile that came before them.
*/
export function settleTiles(boxes: TileBox[], gap = 0): TileBox[] {
const order = [...boxes].sort(
(a, b) => a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : 1),
);
const placed: TileBox[] = [];
for (const box of order) {
const y = placed.reduce(
(top, p) => (overlapsX(p, box) ? Math.max(top, p.y + p.h + gap) : top),
0,
);
placed.push({ ...box, y });
}
return placed;
}
export function tileBoxes(
slots: TileSlot[],
heights: Record<string, number>,
fallback = 160,
): TileBox[] {
return slots.map((slot) => ({
id: slot.id,
x: slot.x,
y: slot.y,
w: slot.w,
h: slot.h ?? heights[slot.id] ?? fallback,
}));
}
export function tileBottom(boxes: TileBox[], gap = 0) {
return boxes.reduce((max, box) => Math.max(max, box.y + box.h + gap), 0);
}
/** Drops broken entries and duplicates and keeps every tile inside the grid. */
export function sanitizeTiles(
value: unknown,
min = MIN_TILE_COLUMNS,
): TileSlot[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: TileSlot[] = [];
for (const raw of value) {
if (!raw || typeof raw !== "object") continue;
const slot = raw as Record<string, unknown>;
const id = typeof slot.id === "string" ? slot.id : "";
if (!id || id.length > 300 || seen.has(id)) continue;
seen.add(id);
const w = clampTileColumns(Number(slot.w), min);
const x = Math.max(
0,
Math.min(TILE_COLUMNS - w, Math.round(Number(slot.x)) || 0),
);
const y = Math.max(0, Number(slot.y) || 0);
const h =
slot.h == null || !Number.isFinite(Number(slot.h))
? null
: Math.max(
MIN_TILE_HEIGHT,
Math.min(MAX_TILE_HEIGHT, Math.round(Number(slot.h))),
);
out.push({ id, x, y, w, h });
}
return out.slice(0, 200);
}
/** Adds a tile at the bottom, at the left. */
export function appendTile(
slots: TileSlot[],
tile: { id: string; w?: number; h?: number | null },
): TileSlot[] {
if (slots.some((slot) => slot.id === tile.id)) return slots;
return [
...slots,
{
id: tile.id,
x: 0,
y: BOTTOM,
w: clampTileColumns(tile.w ?? 4),
h: tile.h ?? null,
},
];
}
export function removeTile(slots: TileSlot[], id: string): TileSlot[] {
return slots.filter((slot) => slot.id !== id);
}
/** Writes settled positions back, in reading order. */
export function tilesFromBoxes(
slots: TileSlot[],
boxes: TileBox[],
): TileSlot[] {
const byId = new Map(boxes.map((box) => [box.id, box]));
return slots
.map((slot) => {
const box = byId.get(slot.id);
return box ? { ...slot, x: box.x, y: Math.round(box.y), w: box.w } : slot;
})
.sort((a, b) => a.y - b.y || a.x - b.x);
}
/**
* Lays tiles out left to right in rows, for layouts that only know an order
* and a width (old dashboards, generated defaults).
*/
export function flowTiles(
tiles: { id: string; w: number; h?: number | null }[],
rowHeight = 160,
): TileSlot[] {
const out: TileSlot[] = [];
let x = 0;
let row = 0;
for (const tile of tiles) {
const w = clampTileColumns(tile.w);
if (x + w > TILE_COLUMNS) {
x = 0;
row++;
}
out.push({ id: tile.id, x, y: row * rowHeight, w, h: tile.h ?? null });
x += w;
}
return out;
}
/** A tile's box in whole screen px. */
export interface PixelRect {
l: number;
t: number;
r: number;
b: number;
}
/** A 1 px line the grid draws, as a box. */
export interface GridLine {
x: number;
y: number;
w: number;
h: number;
}
function mergeSpans(spans: [number, number][]): [number, number][] {
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
const out: [number, number][] = [];
for (const [start, end] of sorted) {
const last = out[out.length - 1];
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
else out.push([start, end]);
}
return out;
}
/**
* The lines between edge to edge tiles, each drawn once. A line sits on the
* first px column or row past a tile, so it covers the edge of whatever is
* next to it. The outer left, top and right edges are left out. With
* `openBottom` (the grid runs to the bottom of the screen) so is the line
* along the very bottom; a column that ends higher keeps its bottom line.
* Lines reach one px further so corners join.
*/
export function gridLines(
rects: PixelRect[],
width: number,
openBottom = false,
): GridLine[] {
const lowest = Math.max(0, ...rects.map((rect) => rect.b));
const end = openBottom ? lowest : Infinity;
const vertical = new Map<number, [number, number][]>();
const horizontal = new Map<number, [number, number][]>();
const add = (
map: Map<number, [number, number][]>,
at: number,
span: [number, number],
) => {
if (span[1] <= span[0]) return;
map.set(at, [...(map.get(at) ?? []), span]);
};
for (const rect of rects) {
if (rect.r <= rect.l || rect.b <= rect.t) continue;
const down: [number, number] = [rect.t, Math.min(end, rect.b + 1)];
if (rect.l > 0) add(vertical, rect.l, down);
if (rect.r < width) add(vertical, rect.r, down);
const across: [number, number] = [rect.l, Math.min(width, rect.r + 1)];
if (rect.t > 0) add(horizontal, rect.t, across);
if (rect.b < end) add(horizontal, rect.b, across);
}
const lines: GridLine[] = [];
for (const [x, spans] of vertical)
for (const [start, end] of mergeSpans(spans))
lines.push({ x, y: start, w: 1, h: end - start });
for (const [y, spans] of horizontal)
for (const [start, end] of mergeSpans(spans))
lines.push({ x: start, y, w: end - start, h: 1 });
return lines;
}
+16 -2
View File
@@ -179,10 +179,24 @@ export function UiPreferencesProvider({
const merged = { ...pendingPatch.current }; const merged = { ...pendingPatch.current };
for (const [key, value] of Object.entries(patch)) { for (const [key, value] of Object.entries(patch)) {
if (key === "overrides" && value && typeof value === "object") { if (key === "overrides" && value && typeof value === "object") {
merged.overrides = { // Two levels deep like the server, so two keys set in one area
// in the same burst both get sent.
const areas = {
...((merged.overrides as Record<string, unknown>) ?? {}), ...((merged.overrides as Record<string, unknown>) ?? {}),
...(value as Record<string, unknown>),
}; };
for (const [area, keys] of Object.entries(
value as Record<string, unknown>,
)) {
const prev = areas[area];
areas[area] =
keys &&
typeof keys === "object" &&
prev &&
typeof prev === "object"
? { ...prev, ...keys }
: keys;
}
merged.overrides = areas;
} else if (key === "onboarding" && value && typeof value === "object") { } else if (key === "onboarding" && value && typeof value === "object") {
const prev = (merged.onboarding ?? {}) as Record<string, unknown>; const prev = (merged.onboarding ?? {}) as Record<string, unknown>;
const next = value as Record<string, unknown>; const next = value as Record<string, unknown>;
@@ -6,6 +6,24 @@ export interface DashboardCardRenderProps {
isVisible: boolean; isVisible: boolean;
/** The shell, e.g. to open the full-page version of the card. */ /** The shell, e.g. to open the full-page version of the card. */
shell: TabShellCallbacks; shell: TabShellCallbacks;
/** This copy's settings, for cards with entries. Empty otherwise. */
config?: Record<string, unknown>;
setConfig?: (config: Record<string, unknown>) => void;
/** True while the user is arranging the dashboard. */
editMode?: boolean;
}
/** One thing the add list offers from a card that can be placed many times. */
export interface DashboardCardEntry {
/** Stable within the card, used to mark entries already placed. */
key: string;
title: string;
description?: string;
/** Group heading in the add list. Defaults to the card title. */
group?: string;
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
config: Record<string, unknown>;
defaultSize?: { w: number; h?: number | null };
} }
/** /**
@@ -23,6 +41,10 @@ export interface RegisteredDashboardCard {
defaultPanel?: "main" | "side"; defaultPanel?: "main" | "side";
/** "framed" gets a titled header from the dashboard; "bare" draws its own. */ /** "framed" gets a titled header from the dashboard; "bare" draws its own. */
frame?: "framed" | "bare"; frame?: "framed" | "bare";
/** Size on the 12 column dashboard grid when added. */
defaultSize?: { w: number; h?: number | null };
/** Lists what this card can show; each entry becomes its own tile. */
entries?: (language: string) => Promise<DashboardCardEntry[]>;
component: ComponentType<DashboardCardRenderProps>; component: ComponentType<DashboardCardRenderProps>;
} }
+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
+7 -3
View File
@@ -10,9 +10,13 @@ export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) {
const label = title || fallback; const label = title || fallback;
if (!label) return null; if (!label) return null;
return ( return (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0"> <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
{icon && <span className="text-accent-brand shrink-0">{icon}</span>} {icon && (
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate"> <span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
{icon}
</span>
)}
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label} {label}
</span> </span>
</div> </div>
+31
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",
@@ -1675,6 +1676,12 @@
"title": "Columns", "title": "Columns",
"auto": "Auto", "auto": "Auto",
"count": "{{count}} wide" "count": "{{count}} wide"
},
"pager": {
"previous": "Previous",
"next": "Next",
"page": "Page {{page}}",
"pageOf": "Page {{page}} of {{pages}}"
} }
}, },
"fields": { "fields": {
@@ -1693,5 +1700,29 @@
"admin": "Admin", "admin": "Admin",
"user": "User" "user": "User"
} }
},
"tileGrid": {
"empty": "No tiles yet. Add some from the list.",
"move": "Drag to move",
"moveTile": "Move {{tile}}",
"settings": "{{tile}} settings",
"remove": "Remove {{tile}}",
"width": "Width",
"height": "Height",
"addTiles": "Add tiles",
"closePicker": "Close",
"search": "Search tiles",
"noMatches": "Nothing matches.",
"kind": {
"all": "All",
"card": "Cards",
"chart": "Charts",
"signal": "Status",
"action": "Actions",
"resource": "Lists",
"session": "Sessions"
},
"reset": "Reset",
"done": "Done"
} }
} }
+1 -1
View File
@@ -48,7 +48,7 @@ export function LookSettings({ isAdmin }: { isAdmin: boolean }) {
</p> </p>
{isAdmin && response && ( {isAdmin && response && (
<div className="flex flex-col gap-1.5 border border-border p-2"> <div className="mt-1 flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-[11px] font-medium text-foreground"> <span className="text-[11px] font-medium text-foreground">
{t("looks.defaultsLabel")} {t("looks.defaultsLabel")}
</span> </span>
+21
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({
@@ -515,6 +534,8 @@ export function createPluginApp(
defaultHeight: card.defaultHeight, defaultHeight: card.defaultHeight,
defaultPanel: card.defaultPanel, defaultPanel: card.defaultPanel,
frame: card.frame, frame: card.frame,
defaultSize: card.defaultSize,
entries: card.entries,
component: scoped( component: scoped(
card.component as unknown as ComponentType<DashboardCardRenderProps>, card.component as unknown as ComponentType<DashboardCardRenderProps>,
), ),
+45
View File
@@ -127,6 +127,26 @@ export {
type GridLayout, type GridLayout,
type GridSlot, type GridSlot,
} from "@/components/card-grid/types"; } from "@/components/card-grid/types";
export {
SeamlessTileContext,
TileEditBar,
TileFrame,
TileGrid,
TilePicker,
TileColumn,
TileStack,
TileWall,
useInSeamlessTile,
appendTile,
flowTiles,
removeTile,
sanitizeTiles,
TILE_COLUMNS,
type TileGridProps,
type TileKind,
type TilePickerEntry,
type TileSlot,
} from "@/components/tile-grid";
export { ConnectionScreen } from "@/components/connection/ConnectionScreen"; export { ConnectionScreen } from "@/components/connection/ConnectionScreen";
export { type ConnectionStatus } from "@/components/connection/connection-status"; export { type ConnectionStatus } from "@/components/connection/connection-status";
export { export {
@@ -203,6 +223,19 @@ export { WidgetTitle } from "@/lib/widget-title";
export { runVisibleInterval } from "@/lib/visible-interval"; export { runVisibleInterval } from "@/lib/visible-interval";
export { useIsMobile } from "@/hooks/use-mobile"; export { useIsMobile } from "@/hooks/use-mobile";
export { EmptyState } from "@/components/empty-state"; export { EmptyState } from "@/components/empty-state";
export { NoticePanel } from "@/components/notice-panel";
export { LoadingState } from "@/components/loading-state";
export {
StatusBadge,
StatusDot,
statusTone,
type StatusTone,
} from "@/components/status";
export { StatTile } from "@/components/stat-tile";
export { InfoGrid, type InfoItem } from "@/components/info-grid";
export { Pager, pageOf, usePaged } from "@/components/pager";
export { DataTable, DataTableRow } from "@/components/data-table";
export { SideNav, SideNavItem, type SideNavEntry } from "@/components/side-nav";
export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog"; export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog";
export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog"; export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog";
@@ -285,8 +318,10 @@ export {
Facts, Facts,
GroupHeading, GroupHeading,
PANEL, PANEL,
PanelFrame,
PanelSearch, PanelSearch,
PanelShell, PanelShell,
RefreshButton,
Segmented, Segmented,
ViewToggle, ViewToggle,
type GridSize, type GridSize,
@@ -823,3 +858,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,
+43 -41
View File
@@ -17,8 +17,9 @@ import {
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Card } from "@/components/card"; import { Facts } from "@/components/panel-layout";
import { Facts, PANEL } from "@/components/panel-layout"; import { TileStack } from "@/components/tile-grid/TileStack";
import { TileWall } from "@/components/tile-grid/TileWall";
import { FakeSwitch, SectionCard } from "@/components/section-card"; import { FakeSwitch, SectionCard } from "@/components/section-card";
import { import {
Select, Select,
@@ -41,6 +42,7 @@ import {
betaToTry, betaToTry,
describeContributions, describeContributions,
formatBytes, formatBytes,
hasSourceBadge,
orderByRisk, orderByRisk,
reportIssueUrl, reportIssueUrl,
requestFeatureUrl, requestFeatureUrl,
@@ -52,7 +54,7 @@ import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes";
import type { PluginsManager } from "./use-plugins-manager"; import type { PluginsManager } from "./use-plugins-manager";
const LINK_ROW = const LINK_ROW =
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"; "flex w-full items-center gap-2 px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground";
export function PluginDetail({ export function PluginDetail({
plugin, plugin,
@@ -69,13 +71,16 @@ export function PluginDetail({
const running = plugin.status === "running"; const running = plugin.status === "running";
const failed = plugin.status === "failed" || plugin.status === "blocked"; const failed = plugin.status === "failed" || plugin.status === "blocked";
const compatible = plugin.versions.filter((v) => v.compatible); const compatible = plugin.versions.filter((v) => v.compatible);
const [picked, setPicked] = useState<string>( // A local build can be a version the registry never listed.
plugin.version ?? plugin.latestVersion ?? "", const startVersion =
); plugin.version && compatible.some((v) => v.version === plugin.version)
? plugin.version
: (plugin.latestVersion ?? compatible[0]?.version ?? "");
const [picked, setPicked] = useState<string>(startVersion);
useEffect(() => { useEffect(() => {
setPicked(plugin.version ?? plugin.latestVersion ?? ""); setPicked(startVersion);
}, [plugin.version, plugin.latestVersion]); }, [startVersion]);
const contributions = describeContributions(plugin.contributes); const contributions = describeContributions(plugin.contributes);
// Only people who can manage plugins get here, and they see admin settings // Only people who can manage plugins get here, and they see admin settings
@@ -93,10 +98,8 @@ export function PluginDetail({
const tryableBeta = betaToTry(plugin); const tryableBeta = betaToTry(plugin);
return ( return (
<div <TileStack>
className={`mx-auto flex w-full max-w-5xl flex-col ${PANEL.gap} ${PANEL.body}`} <div className="flex flex-row flex-wrap items-start gap-3 p-3">
>
<Card className="flex-row flex-wrap items-start gap-4 px-4 py-4">
<PluginIconBox <PluginIconBox
name={plugin.icon} name={plugin.icon}
size="lg" size="lg"
@@ -109,10 +112,12 @@ export function PluginDetail({
</span> </span>
<Facts className="text-[11px] text-muted-foreground/70"> <Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />} {plugin.unverified && <UnverifiedBadge />}
{hasSourceBadge(plugin.source) && (
<SourceBadge <SourceBadge
source={plugin.source} source={plugin.source}
registryName={plugin.registryName} registryName={plugin.registryName}
/> />
)}
{plugin.isBeta && <BetaBadge />} {plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
{plugin.version ? ( {plugin.version ? (
@@ -223,10 +228,10 @@ export function PluginDetail({
</Button> </Button>
)} )}
</div> </div>
</Card> </div>
{plugin.installed && failed && plugin.lastError && ( {plugin.installed && failed && plugin.lastError && (
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 px-3 py-2.5"> <div className="flex items-start gap-2 bg-destructive/5 px-3 py-2.5">
<TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" /> <TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium"> <span className="text-xs font-medium">
@@ -242,7 +247,7 @@ export function PluginDetail({
)} )}
{plugin.status === "server" && ( {plugin.status === "server" && (
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5"> <div className="flex items-start gap-2 bg-muted/30 px-3 py-2.5">
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" /> <Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
<span className="text-[11px] leading-snug text-muted-foreground"> <span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.runsOnServerHint")} {t("plugins.manager.runsOnServerHint")}
@@ -250,13 +255,13 @@ export function PluginDetail({
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div className="grid grid-cols-1 lg:grid-cols-3">
<div className="flex flex-col gap-2 lg:col-span-2"> <TileStack className="-mb-px border-border lg:col-span-2 lg:border-r">
<PluginVideo videoId={plugin.videoId} name={plugin.name} /> <PluginVideo videoId={plugin.videoId} name={plugin.name} />
{plugin.features.length > 0 && ( {plugin.features.length > 0 && (
<SectionCard title={t("plugins.manager.features")} icon={null}> <SectionCard title={t("plugins.manager.features")} icon={null}>
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 px-4 py-3 sm:grid-cols-2"> <ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 py-3 sm:grid-cols-2">
{plugin.features.map((feature) => ( {plugin.features.map((feature) => (
<li <li
key={feature} key={feature}
@@ -277,11 +282,11 @@ export function PluginDetail({
key={capability} key={capability}
capability={capability} capability={capability}
showId showId
className="px-4 py-2.5" className="py-2.5"
/> />
))} ))}
{plugin.capabilities.length === 0 && ( {plugin.capabilities.length === 0 && (
<div className="px-4 py-2.5 text-[11px] text-muted-foreground"> <div className="py-2.5 text-[11px] text-muted-foreground">
{t("plugins.manager.consent.none")} {t("plugins.manager.consent.none")}
</div> </div>
)} )}
@@ -292,10 +297,7 @@ export function PluginDetail({
<SectionCard title={t("plugins.manager.envVars")} icon={null}> <SectionCard title={t("plugins.manager.envVars")} icon={null}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{plugin.env.map((v) => ( {plugin.env.map((v) => (
<div <div key={v.name} className="flex flex-col gap-0.5 py-2.5">
key={v.name}
className="flex flex-col gap-0.5 px-4 py-2.5"
>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<code className="font-mono text-xs font-semibold"> <code className="font-mono text-xs font-semibold">
{v.name} {v.name}
@@ -331,12 +333,12 @@ export function PluginDetail({
onDelete={() => manager.removeData(plugin)} onDelete={() => manager.removeData(plugin)}
/> />
)} )}
</div> </TileStack>
<div className="flex flex-col gap-2"> <TileStack className="-mb-px">
{contributions.length > 0 && ( {contributions.length > 0 && (
<SectionCard title={t("plugins.manager.addsToTermix")} icon={null}> <SectionCard title={t("plugins.manager.addsToTermix")} icon={null}>
<div className="flex flex-col gap-2 px-4 py-3"> <div className="flex flex-col gap-2 py-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{contributions.map((c) => ( {contributions.map((c) => (
<span <span
@@ -358,7 +360,7 @@ export function PluginDetail({
{plugin.inRegistry && compatible.length > 0 && ( {plugin.inRegistry && compatible.length > 0 && (
<SectionCard title={t("plugins.manager.version")} icon={null}> <SectionCard title={t("plugins.manager.version")} icon={null}>
<div className="flex flex-col gap-2 px-4 py-3"> <div className="flex flex-col gap-2 py-3">
<span className="text-[11px] leading-snug text-muted-foreground"> <span className="text-[11px] leading-snug text-muted-foreground">
{plugin.installed {plugin.installed
? t("plugins.manager.versionPickHintInstalled") ? t("plugins.manager.versionPickHintInstalled")
@@ -414,7 +416,7 @@ export function PluginDetail({
{plugin.installed && ( {plugin.installed && (
<SectionCard title={t("plugins.manager.updates")} icon={null}> <SectionCard title={t("plugins.manager.updates")} icon={null}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<div className="flex items-center justify-between gap-3 px-4 py-3"> <div className="flex items-center justify-between gap-3 py-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{t("plugins.manager.autoUpdate")} {t("plugins.manager.autoUpdate")}
@@ -431,7 +433,7 @@ export function PluginDetail({
} }
/> />
</div> </div>
<div className="flex items-center justify-between gap-3 px-4 py-3"> <div className="flex items-center justify-between gap-3 py-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium"> <span className="flex items-center gap-1.5 text-xs font-medium">
<FlaskConical className="size-3 text-muted-foreground" /> <FlaskConical className="size-3 text-muted-foreground" />
@@ -455,7 +457,7 @@ export function PluginDetail({
} }
/> />
</div> </div>
<div className="flex items-center justify-between gap-3 px-4 py-3"> <div className="flex items-center justify-between gap-3 py-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium"> <span className="flex items-center gap-1.5 text-xs font-medium">
<Pin className="size-3 text-muted-foreground" /> <Pin className="size-3 text-muted-foreground" />
@@ -565,9 +567,9 @@ export function PluginDetail({
</span> </span>
</a> </a>
)} )}
</TileStack>
</div> </div>
</div> </TileStack>
</div>
); );
} }
@@ -625,16 +627,16 @@ function DataCard({
} }
> >
{error ? ( {error ? (
<div className="px-4 py-3 text-[11px] text-muted-foreground"> <div className="py-3 text-[11px] text-muted-foreground">
{t("plugins.manager.data.loadFailed")} {t("plugins.manager.data.loadFailed")}
</div> </div>
) : !data ? ( ) : !data ? (
<div className="px-4 py-3 text-[11px] text-muted-foreground"> <div className="py-3 text-[11px] text-muted-foreground">
{t("plugins.manager.loading")} {t("plugins.manager.loading")}
</div> </div>
) : ( ) : (
<div className="-mx-3 -my-1 flex flex-col md:-mx-4"> <div className="-mx-3 -my-1 flex flex-col">
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4"> <TileWall gridClassName="grid-cols-2 sm:grid-cols-4">
<Stat <Stat
label={t("plugins.manager.data.tables")} label={t("plugins.manager.data.tables")}
value={`${data.tables.length}`} value={`${data.tables.length}`}
@@ -655,13 +657,13 @@ function DataCard({
label={t("plugins.manager.data.files")} label={t("plugins.manager.data.files")}
value={formatBytes(data.filesBytes)} value={formatBytes(data.filesBytes)}
/> />
</div> </TileWall>
{data.tables.length > 0 && ( {data.tables.length > 0 && (
<div className="divide-y divide-border border-t border-border"> <div className="divide-y divide-border border-t border-border">
{data.tables.map((table) => ( {data.tables.map((table) => (
<div <div
key={table.name} key={table.name}
className="flex items-center gap-2 px-4 py-1.5" className="flex items-center gap-2 px-3 py-1.5"
> >
<span className="min-w-0 flex-1 truncate font-mono text-[11px]"> <span className="min-w-0 flex-1 truncate font-mono text-[11px]">
{table.name} {table.name}
@@ -675,7 +677,7 @@ function DataCard({
))} ))}
</div> </div>
)} )}
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[11px] text-muted-foreground"> <div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
<span> <span>
{t("plugins.manager.data.migrations", { {t("plugins.manager.data.migrations", {
count: data.migrations.length, count: data.migrations.length,
@@ -701,7 +703,7 @@ function Stat({
hint?: string; hint?: string;
}) { }) {
return ( return (
<div className="flex flex-col gap-0.5 bg-card px-4 py-2.5"> <div className="flex flex-col gap-0.5 px-3 py-2.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label} {label}
</span> </span>
+3 -3
View File
@@ -62,7 +62,7 @@ function Release({
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
return ( return (
<div className="flex flex-col gap-2 px-4 py-3"> <div className="flex flex-col gap-2 px-3 py-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold">{row.version}</span> <span className="text-xs font-semibold">{row.version}</span>
{row.version === installedVersion && ( {row.version === installedVersion && (
@@ -180,7 +180,7 @@ export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
) : undefined ) : undefined
} }
> >
<div className="-mx-3 -my-1 md:-mx-4"> <div className="-mx-3 -my-1">
<Release row={latest} installedVersion={plugin.version} /> <Release row={latest} installedVersion={plugin.version} />
{showOlder && older.length > 0 && ( {showOlder && older.length > 0 && (
<div className="max-h-96 divide-y divide-border overflow-y-auto border-t border-border"> <div className="max-h-96 divide-y divide-border overflow-y-auto border-t border-border">
@@ -211,7 +211,7 @@ export function PluginVideo({
if (!src) return null; if (!src) return null;
return ( return (
<div className="aspect-video w-full border border-border bg-black"> <div className="aspect-video w-full bg-black">
<iframe <iframe
src={src} src={src}
title={t("plugins.manager.videoTitle", { name })} title={t("plugins.manager.videoTitle", { name })}
+42 -45
View File
@@ -42,6 +42,8 @@ import {
type PanelViewMode, type PanelViewMode,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
import { DataView, type DataColumn } from "@/components/data-view"; import { DataView, type DataColumn } from "@/components/data-view";
import { StatusDot } from "@/components/status";
import { TileStack } from "@/components/tile-grid/TileStack";
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope"; import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -57,6 +59,7 @@ import { PluginDetail } from "./PluginDetail";
import { import {
categoriesOf, categoriesOf,
describeContributions, describeContributions,
hasSourceBadge,
isSevere, isSevere,
kindsOf, kindsOf,
matchesQuery, matchesQuery,
@@ -321,7 +324,7 @@ function Toolbar({
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
<div className="relative min-w-40 max-w-md flex-1"> <div className="relative min-w-40 max-w-md flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@@ -346,8 +349,13 @@ function Group({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="flex flex-col gap-2.5"> <section className="flex flex-col">
<GroupHeading title={title} count={count} /> <GroupHeading
plain
title={title}
count={count}
className="border-b border-border px-3 py-1.5"
/>
{children} {children}
</section> </section>
); );
@@ -653,7 +661,7 @@ function ActiveFilters({
}); });
} }
return ( return (
<div className="flex min-h-6 flex-wrap items-center gap-1.5"> <div className="flex min-h-6 flex-wrap items-center gap-1.5 border-b border-border px-3 py-1.5">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{t("plugins.manager.filters.results", { count: shown })} {t("plugins.manager.filters.results", { count: shown })}
</span> </span>
@@ -703,7 +711,7 @@ function Pager({
const per = Number(size); const per = Number(size);
if (total <= Number(PAGE_SIZES[0])) return null; if (total <= Number(PAGE_SIZES[0])) return null;
return ( return (
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-2.5"> <div className="flex flex-wrap items-center gap-2 px-3 py-2">
<span className="text-[11px] tabular-nums text-muted-foreground"> <span className="text-[11px] tabular-nums text-muted-foreground">
{t("plugins.manager.pager.range", { {t("plugins.manager.pager.range", {
from: (page - 1) * per + 1, from: (page - 1) * per + 1,
@@ -895,7 +903,7 @@ function InstalledSection({
rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />} rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />}
> >
{(filtersButton) => ( {(filtersButton) => (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}> <div className="flex flex-col">
<Toolbar <Toolbar
query={query} query={query}
onQuery={onQuery} onQuery={onQuery}
@@ -1071,10 +1079,12 @@ function InstalledCard({
facts={ facts={
<> <>
{plugin.unverified && <UnverifiedBadge />} {plugin.unverified && <UnverifiedBadge />}
{hasSourceBadge(plugin.source) && (
<SourceBadge <SourceBadge
source={plugin.source} source={plugin.source}
registryName={plugin.registryName} registryName={plugin.registryName}
/> />
)}
{plugin.isBeta && <BetaBadge />} {plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span> <span>v{plugin.version}</span>
@@ -1083,43 +1093,38 @@ function InstalledCard({
} }
/> />
<div className="flex items-center gap-1.5 px-3 pb-2.5">
{failed ? ( {failed ? (
<div className="flex items-center gap-2 border-t border-border bg-destructive/5 px-3 py-2">
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
<span <span
className="flex-1 truncate text-[11px] text-muted-foreground" className="flex min-w-0 items-center gap-1.5 text-[11px] text-destructive"
title={plugin.lastError ?? undefined} title={plugin.lastError ?? undefined}
> >
<TriangleAlert className="size-3 shrink-0" />
<span className="truncate">
{plugin.status === "blocked" {plugin.status === "blocked"
? t("plugins.manager.status.blocked") ? t("plugins.manager.status.blocked")
: t("plugins.manager.status.failed")} : t("plugins.manager.status.failed")}
</span> </span>
</div> </span>
) : !running ? ( ) : (
<div className="flex items-center gap-2 border-t border-border px-3 py-2"> <span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="size-1.5 shrink-0 bg-muted-foreground/40" /> <StatusDot tone={running ? "ok" : "idle"} className="size-1.5" />
<span className="text-[11px] text-muted-foreground"> <span className="truncate">
{plugin.status === "server" {!running
? plugin.status === "server"
? t("plugins.manager.status.server") ? t("plugins.manager.status.server")
: plugin.status === "safeMode" : plugin.status === "safeMode"
? t("plugins.manager.status.safeMode") ? t("plugins.manager.status.safeMode")
: t("plugins.manager.status.disabled")} : t("plugins.manager.status.disabled")
</span> : plugin.pinnedVersion
</div>
) : (
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
<span className="size-1.5 shrink-0 bg-green-500" />
<span className="text-[11px] text-muted-foreground">
{plugin.pinnedVersion
? t("plugins.manager.pinnedTo", { ? t("plugins.manager.pinnedTo", {
version: plugin.pinnedVersion, version: plugin.pinnedVersion,
}) })
: t("plugins.manager.status.running")} : t("plugins.manager.status.running")}
</span> </span>
</div> </span>
)} )}
<div className="flex-1" />
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && ( {plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
<Button <Button
variant="outline" variant="outline"
@@ -1159,11 +1164,7 @@ function InstalledCard({
: t("plugins.manager.enable")} : t("plugins.manager.enable")}
</Button> </Button>
)} )}
<DocsLink <DocsLink href={plugin.docs} variant="icon" className="size-6" />
href={plugin.docs}
variant="icon"
className="ml-auto size-6"
/>
{!plugin.defaultLook && ( {!plugin.defaultLook && (
<Button <Button
variant="ghost" variant="ghost"
@@ -1171,10 +1172,7 @@ function InstalledCard({
disabled={busy || locked} disabled={busy || locked}
title={t("plugins.manager.uninstall")} title={t("plugins.manager.uninstall")}
aria-label={t("plugins.manager.uninstall")} aria-label={t("plugins.manager.uninstall")}
className={cn( className="text-muted-foreground hover:text-destructive"
"text-muted-foreground hover:text-destructive",
!plugin.docs && "ml-auto",
)}
onClick={() => void manager.uninstall(plugin)} onClick={() => void manager.uninstall(plugin)}
> >
<Trash2 className="size-3" /> <Trash2 className="size-3" />
@@ -1442,7 +1440,7 @@ function BrowseSection({
} }
> >
{(filtersButton) => ( {(filtersButton) => (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}> <div className="flex flex-col">
<Toolbar <Toolbar
query={query} query={query}
onQuery={onQuery} onQuery={onQuery}
@@ -1508,7 +1506,7 @@ function BrowseSection({
}} }}
/> />
<p className="text-[11px] text-muted-foreground"> <p className="px-3 py-2 text-[11px] text-muted-foreground">
{t("plugins.manager.registries.browseHint")}{" "} {t("plugins.manager.registries.browseHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]"> <DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")} {t("plugins.manager.communitySubmit")}
@@ -1540,10 +1538,12 @@ function BrowseCard({
onOpen={onOpen} onOpen={onOpen}
facts={ facts={
<> <>
{hasSourceBadge(plugin.source) && (
<SourceBadge <SourceBadge
source={plugin.source} source={plugin.source}
registryName={plugin.registryName} registryName={plugin.registryName}
/> />
)}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>} {plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
{plugin.category && <span>{plugin.category}</span>} {plugin.category && <span>{plugin.category}</span>}
@@ -1551,7 +1551,7 @@ function BrowseCard({
</> </>
} }
/> />
<div className="flex items-center gap-2 border-t border-border px-3 py-2"> <div className="flex items-center gap-2 px-3 pb-2.5">
<span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground"> <span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground">
{plugin.unsupported && !plugin.installed {plugin.unsupported && !plugin.installed
? "" ? ""
@@ -1646,9 +1646,9 @@ function UpdatesSection({
} }
return ( return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}> <TileStack>
{plain.length > 0 && ( {plain.length > 0 && (
<Card className="flex-row items-center gap-3 px-3 py-2.5"> <div className="flex items-center gap-3 px-3 py-2.5">
<span className="flex-1 text-xs text-muted-foreground"> <span className="flex-1 text-xs text-muted-foreground">
{t("plugins.manager.updatesReady", { count: plain.length })} {t("plugins.manager.updatesReady", { count: plain.length })}
{gated.length > 0 && {gated.length > 0 &&
@@ -1664,10 +1664,8 @@ function UpdatesSection({
<Download className="size-3.5" /> <Download className="size-3.5" />
{t("plugins.manager.updateAll")} {t("plugins.manager.updateAll")}
</Button> </Button>
</Card> </div>
)} )}
<div className="flex flex-col gap-3">
{[...gated, ...plain, ...pinned].map((p) => ( {[...gated, ...plain, ...pinned].map((p) => (
<UpdateRow <UpdateRow
key={p.id} key={p.id}
@@ -1676,8 +1674,7 @@ function UpdatesSection({
onOpen={() => onOpen(p.id)} onOpen={() => onOpen(p.id)}
/> />
))} ))}
</div> </TileStack>
</div>
); );
} }
+10 -5
View File
@@ -1,7 +1,12 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { capabilityRisk, formatCount, isSevere } from "./plugin-model"; import {
capabilityRisk,
formatCount,
hasSourceBadge,
isSevere,
} from "./plugin-model";
export function PluginIconBox({ export function PluginIconBox({
name, name,
@@ -80,7 +85,7 @@ export function UnverifiedBadge() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span <span
className="shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning" className="inline-block shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 leading-none text-[10px] font-bold uppercase tracking-wider text-warning"
title={t("plugins.manager.unverifiedHint")} title={t("plugins.manager.unverifiedHint")}
> >
{t("plugins.manager.unverified")} {t("plugins.manager.unverified")}
@@ -97,14 +102,14 @@ export function SourceBadge({
registryName?: string | null; registryName?: string | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (source !== "community" && source !== "custom") return null; if (!hasSourceBadge(source)) return null;
const label = const label =
source === "community" source === "community"
? t("plugins.manager.registries.communityBadge") ? t("plugins.manager.registries.communityBadge")
: (registryName ?? t("plugins.manager.registries.customBadge")); : (registryName ?? t("plugins.manager.registries.customBadge"));
return ( return (
<span <span
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground" className="inline-block max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 leading-none align-middle text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
title={t(`plugins.manager.registries.${source}BadgeHint`, { title={t(`plugins.manager.registries.${source}BadgeHint`, {
name: label, name: label,
})} })}
@@ -118,7 +123,7 @@ export function BetaBadge() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span <span
className="shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand" className="inline-block shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 leading-none text-[10px] font-bold uppercase tracking-wider text-accent-brand"
title={t("plugins.manager.beta.badgeHint")} title={t("plugins.manager.beta.badgeHint")}
> >
{t("plugins.manager.beta.badge")} {t("plugins.manager.beta.badge")}
+5
View File
@@ -591,3 +591,8 @@ export function youtubeEmbedUrl(videoId: string | undefined): string | null {
? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0` ? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`
: null; : null;
} }
/** True when SourceBadge draws something, so lists can skip the empty slot. */
export function hasSourceBadge(source: string): boolean {
return source === "community" || source === "custom";
}
+1 -1
View File
@@ -66,7 +66,7 @@ export function HostPluginSections({
if (plugins.length === 0) return null; if (plugins.length === 0) return null;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col">
{plugins.map((plugin) => ( {plugins.map((plugin) => (
<HostPluginSection <HostPluginSection
key={plugin.id} key={plugin.id}
+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;
}
});
}
+1 -3
View File
@@ -73,9 +73,7 @@ function withTabSuspense(node: React.ReactNode) {
function PanelTabFrame({ children }: { children: React.ReactNode }) { function PanelTabFrame({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex h-full w-full justify-center overflow-y-auto bg-background"> <div className="flex h-full w-full justify-center overflow-y-auto bg-background">
<div className="flex flex-col flex-1 min-h-0 w-full max-w-5xl"> <div className="flex flex-col flex-1 min-h-0 w-full">{children}</div>
{children}
</div>
</div> </div>
); );
} }
+2 -2
View File
@@ -19,13 +19,13 @@ export function AuthEnrollmentSections({
); );
if (sections.length === 0) return null; if (sections.length === 0) return null;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col divide-y divide-border">
{sections.map((entry) => { {sections.map((entry) => {
const Enrollment = entry.enrollment!; const Enrollment = entry.enrollment!;
return ( return (
<div <div
key={`${entry.pluginId}:${entry.id}`} key={`${entry.pluginId}:${entry.id}`}
className="flex flex-col gap-2" className="flex flex-col gap-2 py-3 first:pt-0"
> >
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t(entry.titleKey)} {t(entry.titleKey)}
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { pickXTicks } from "../../../components/charts/x-ticks";
const live = (n: number) =>
Array.from({ length: n }, (_, i) =>
new Date(Date.UTC(2026, 10, 4, 15, 37, i * 2)).toISOString(),
);
describe("pickXTicks", () => {
it("returns nothing without two points or width", () => {
expect(pickXTicks(live(1), 0, 300)).toEqual([]);
expect(pickXTicks(live(5), 8000, 0)).toEqual([]);
});
it("uses up to five labels on a wide chart", () => {
const ts = live(60);
expect(pickXTicks(ts, 118_000, 900)).toHaveLength(5);
});
it("fits fewer labels on a narrow chart and keeps the newest", () => {
const ts = live(60);
const ticks = pickXTicks(ts, 118_000, 220);
expect(ticks.length).toBeLessThan(5);
expect(ticks[ticks.length - 1]).toBe(59);
});
it("never lets labels touch", () => {
for (const w of [120, 180, 220, 260, 300, 400, 600]) {
for (const n of [2, 3, 4, 5, 8, 60]) {
const ts = live(n);
const range = (n - 1) * 2000;
const ticks = pickXTicks(ts, range, w);
const labelW = 11 * 6.2;
const boxes = ticks.map((i) => {
const x = (i / (n - 1)) * w;
if (i === 0) return [x, x + labelW];
if (i === n - 1) return [x - labelW, x];
return [x - labelW / 2, x + labelW / 2];
});
for (let k = 1; k < boxes.length; k++) {
expect(boxes[k][0]).toBeGreaterThanOrEqual(boxes[k - 1][1]);
}
}
}
});
});
+2 -1
View File
@@ -47,8 +47,9 @@ describe("DataView", () => {
empty={null} empty={null}
/>, />,
); );
const grid = container.firstElementChild as HTMLElement; const grid = container.querySelector(".grid") as HTMLElement;
expect(grid.className).not.toContain("md:grid-cols-2"); expect(grid.className).not.toContain("md:grid-cols-2");
expect(grid.className).not.toContain("gap-");
expect(grid.style.gridTemplateColumns).toContain("/ 3"); expect(grid.style.gridTemplateColumns).toContain("/ 3");
}); });
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
renderHook,
screen,
} from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { Pager, pageOf, usePaged } from "../../components/pager";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
afterEach(cleanup);
describe("pageOf", () => {
it("clamps the page to what exists", () => {
const items = [1, 2, 3, 4, 5];
expect(pageOf(items, 9, 2)).toEqual({ rows: [5], page: 2, pages: 3 });
expect(pageOf([], 0, 2)).toEqual({ rows: [], page: 0, pages: 1 });
});
});
describe("usePaged", () => {
it("goes back to the first page when reset changes", () => {
const { result, rerender } = renderHook(
({ reset }) => usePaged([1, 2, 3], 1, reset),
{ initialProps: { reset: "a" } },
);
act(() => result.current.setPage(2));
expect(result.current.rows).toEqual([3]);
rerender({ reset: "b" });
expect(result.current.page).toBe(0);
});
});
describe("Pager", () => {
it("hides with one page", () => {
const { container } = render(
<Pager page={0} pages={1} onPage={() => {}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("moves between pages", () => {
const onPage = vi.fn();
render(<Pager page={0} pages={3} onPage={onPage} />);
expect(screen.getByText("panel.pager.previous")).toBeDisabled();
fireEvent.click(screen.getByText("panel.pager.next"));
expect(onPage).toHaveBeenCalledWith(1);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { StatusBadge, StatusDot, statusTone } from "../../components/status";
afterEach(cleanup);
describe("statusTone", () => {
it("maps common state words", () => {
expect(statusTone("Running")).toBe("ok");
expect(statusTone("CrashLoopBackOff")).toBe("bad");
expect(statusTone("pending approval")).toBe("warn");
expect(statusTone("exited")).toBe("idle");
});
it("treats unknown and empty states as idle", () => {
expect(statusTone("banana")).toBe("idle");
expect(statusTone(null)).toBe("idle");
});
});
describe("StatusBadge", () => {
it("shows the state with its tone", () => {
render(<StatusBadge state="failed" />);
expect(screen.getByText("failed")).toHaveClass("text-destructive");
});
it("lets a tone win over the state", () => {
render(
<StatusBadge state="failed" tone="ok">
Fine
</StatusBadge>,
);
expect(screen.getByText("Fine")).toHaveClass("text-green-500");
});
});
describe("StatusDot", () => {
it("colors by state", () => {
render(<StatusDot state="running" title="up" />);
expect(screen.getByTitle("up")).toHaveClass("bg-green-500");
});
});
@@ -0,0 +1,299 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import "@testing-library/jest-dom/vitest";
import { TileGrid } from "../../../components/tile-grid/TileGrid";
import { MetricCard } from "../../../components/metric-card";
import type { TileSlot } from "../../../components/tile-grid/layout";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
// 1212px wide grid: one column is (1212 + 12) / 12 = 102px.
let gridWidth = 1212;
const PITCH = 102;
const original = HTMLElement.prototype.getBoundingClientRect;
beforeAll(() => {
document.documentElement.style.fontSize = "14px";
vi.stubGlobal(
"PointerEvent",
class extends MouseEvent {
pointerId: number;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
}
},
);
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
if (this.dataset.tgCard) return { height: 200, width: 0 } as DOMRect;
return {
left: 0,
top: 0,
width: gridWidth,
height: 0,
right: gridWidth,
bottom: 0,
} as DOMRect;
};
});
afterAll(() => {
HTMLElement.prototype.getBoundingClientRect = original;
document.documentElement.style.removeProperty("font-size");
vi.unstubAllGlobals();
});
beforeEach(() => {
gridWidth = 1212;
});
afterEach(cleanup);
const three: TileSlot[] = [
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
{ id: "memory", x: 4, y: 0, w: 4, h: null },
{ id: "disk", x: 8, y: 0, w: 4, h: null },
];
function mount(layout = three, editMode = true) {
const changed = vi.fn();
function Harness() {
const [value, setValue] = useState(layout);
return (
<TileGrid
slots={value}
gap={12}
minHeight={80}
editMode={editMode}
tileLabel={(id) => id}
onChange={(next) => {
changed(next);
setValue(next);
}}
renderTile={(id) => <div data-testid={`body-${id}`}>{id}</div>}
/>
);
}
return { ...render(<Harness />), changed };
}
const tile = (id: string) =>
document.querySelector<HTMLElement>(`[data-tg-tile="${id}"]`)!;
const position = (element: HTMLElement) => {
const match = /translate\((-?[\d.]+)px, (-?[\d.]+)px\)/.exec(
element.style.transform,
)!;
return { x: Number(match[1]), y: Number(match[2]) };
};
const pointer = (
target: Element | Window,
type: "pointerDown" | "pointerMove" | "pointerUp",
x: number,
y: number,
) =>
fireEvent[type](target, { clientX: x, clientY: y, button: 0, pointerId: 1 });
const slot = (slots: TileSlot[], id: string) => slots.find((s) => s.id === id)!;
describe("TileGrid", () => {
it("places tiles on the 12 column grid", () => {
mount();
expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 });
expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 });
expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 });
expect(tile("cpu").style.width).toBe(`${4 * PITCH - 12}px`);
});
it("drops a dragged card exactly where the preview showed it", () => {
const { changed } = mount();
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
pointer(window, "pointerMove", 10 + 4 * PITCH, 300);
const ghost = document.querySelector<HTMLElement>(
"[data-tg-drop-preview]",
)!;
const previewed = position(ghost);
expect(previewed).toEqual({ x: 4 * PITCH, y: 212 });
expect(position(tile("memory"))).toEqual({ x: 4 * PITCH, y: 0 });
expect(position(tile("disk"))).toEqual({ x: 8 * PITCH, y: 0 });
pointer(window, "pointerUp", 10 + 4 * PITCH, 300);
const next = changed.mock.calls[0][0] as TileSlot[];
expect(slot(next, "cpu")).toMatchObject({ x: 4, y: 212 });
expect(position(tile("cpu"))).toEqual(previewed);
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 });
expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0 });
});
it("moves a card into another column without touching the rest", () => {
const layout: TileSlot[] = [
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
{ id: "memory", x: 4, y: 0, w: 4, h: null },
];
const { changed } = mount(layout);
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
pointer(window, "pointerMove", 10 + 8 * PITCH, 10);
pointer(window, "pointerUp", 10 + 8 * PITCH, 10);
const next = changed.mock.calls[0][0] as TileSlot[];
expect(slot(next, "cpu")).toMatchObject({ x: 8, y: 0 });
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 0 });
});
it("does nothing on a click without movement", () => {
const { changed } = mount();
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
pointer(window, "pointerMove", 12, 11);
pointer(window, "pointerUp", 12, 11);
expect(changed).not.toHaveBeenCalled();
});
it("only pushes the overlapping card when a card gets wider", () => {
const { changed } = mount();
const edge = tile("cpu").querySelector(".tg-edge-right")!;
pointer(edge, "pointerDown", 400, 100);
pointer(window, "pointerMove", 400 + 2 * PITCH, 100);
expect(tile("cpu").dataset.active).toBe("resize");
pointer(window, "pointerUp", 400 + 2 * PITCH, 100);
const next = changed.mock.calls[0][0] as TileSlot[];
expect(slot(next, "cpu")).toMatchObject({ x: 0, y: 0, w: 6 });
expect(slot(next, "memory")).toMatchObject({ x: 4, y: 212 });
expect(slot(next, "disk")).toMatchObject({ x: 8, y: 0, w: 4 });
});
it("resizes from the left edge keeping the right side in place", () => {
const { changed } = mount();
const edge = tile("disk").querySelector(".tg-edge-left")!;
pointer(edge, "pointerDown", 8 * PITCH, 100);
pointer(window, "pointerMove", 8 * PITCH + 2 * PITCH, 100);
pointer(window, "pointerUp", 8 * PITCH + 2 * PITCH, 100);
const next = changed.mock.calls[0][0] as TileSlot[];
expect(slot(next, "disk")).toMatchObject({ x: 10, w: 2 });
});
it("pins the height and snaps to a neighbour's bottom", () => {
const layout: TileSlot[] = [
{ id: "cpu", x: 0, y: 0, w: 4, h: null },
{ id: "memory", x: 4, y: 0, w: 4, h: 300 },
];
const { changed } = mount(layout);
const edge = tile("memory").querySelector(".tg-edge-bottom")!;
pointer(edge, "pointerDown", 500, 300);
pointer(window, "pointerMove", 500, 196);
pointer(window, "pointerUp", 500, 196);
const next = changed.mock.calls[0][0] as TileSlot[];
expect(slot(next, "memory").h).toBe(200);
});
it("cancels on Escape without saving", () => {
const { changed } = mount();
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
pointer(window, "pointerMove", 10, 300);
fireEvent.keyDown(window, { key: "Escape" });
pointer(window, "pointerUp", 10, 300);
expect(changed).not.toHaveBeenCalled();
expect(document.querySelector("[data-tg-drop-preview]")).toBeNull();
expect(position(tile("cpu"))).toEqual({ x: 0, y: 0 });
});
it("resizes and moves with the keyboard", () => {
const { changed } = mount();
const width = tile("disk").querySelector('[aria-orientation="vertical"]')!;
fireEvent.keyDown(width, { key: "ArrowLeft" });
expect(slot(changed.mock.calls[0][0], "disk").w).toBe(3);
const grip = tile("cpu").querySelector("[data-tg-grip]")!;
fireEvent.keyDown(grip, { key: "ArrowRight" });
expect(slot(changed.mock.calls[1][0], "cpu").x).toBe(1);
expect(slot(changed.mock.calls[1][0], "memory")).toMatchObject({ y: 212 });
});
it("removes tiles", () => {
const { changed } = mount();
fireEvent.click(screen.getAllByLabelText("tileGrid.remove")[0]);
expect(changed.mock.calls[0][0]).toHaveLength(2);
});
it("draws each line between edge to edge tiles once", () => {
render(
<TileGrid
slots={[
{ id: "cpu", x: 0, y: 0, w: 4, h: 100 },
{ id: "memory", x: 4, y: 0, w: 4, h: 300 },
{ id: "disk", x: 0, y: 100, w: 4, h: 50 },
]}
editMode={false}
tileLabel={(id) => id}
onChange={() => {}}
renderTile={(id) => <div>{id}</div>}
/>,
);
// Whole px tiles that touch, with no lines of their own.
expect(position(tile("memory")).x).toBe(404);
expect(parseFloat(tile("cpu").style.width)).toBe(404);
expect(parseFloat(tile("cpu").style.height)).toBe(100);
expect(tile("cpu").querySelector(".tg-card")!.className).not.toMatch(
/(^| )border/,
);
const vertical = [
...document.querySelectorAll<HTMLElement>("[data-tg-line].border-l"),
].map((el) => [
parseFloat(el.style.left),
parseFloat(el.style.top),
parseFloat(el.style.height),
]);
// One line down the middle, as tall as the taller side, and one on the
// open right edge of the middle tile. Drawn as borders so they are as
// thin as other lines at any display scaling.
expect(vertical).toEqual([
[404, 0, 301],
[808, 0, 301],
]);
});
it("gives metric cards the tile look only in an edge to edge grid", () => {
const card = (gap?: number) =>
render(
<TileGrid
slots={[{ id: "cpu", x: 0, y: 0, w: 4, h: null }]}
gap={gap}
editMode={false}
tileLabel={(id) => id}
onChange={() => {}}
renderTile={() => (
<MetricCard title="CPU" icon={null}>
x
</MetricCard>
)}
/>,
).container.querySelector(".tg-content > div")!.className;
expect(card()).not.toContain("bg-card");
cleanup();
expect(card(12)).toContain("border border-border");
});
it("stacks cards with no edit controls on narrow screens", () => {
gridWidth = 500;
mount();
expect(document.querySelector("[data-tg-tile]")).toBeNull();
expect(screen.getByTestId("body-disk")).toBeInTheDocument();
expect(screen.queryAllByRole("separator")).toHaveLength(0);
});
it("shows no edit controls outside edit mode", () => {
mount(three, false);
expect(screen.queryAllByRole("separator")).toHaveLength(0);
pointer(screen.getByTestId("body-cpu"), "pointerDown", 10, 10);
expect(document.querySelector("[data-active]")).toBeNull();
});
});
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { TileWall } from "../../../components/tile-grid/TileWall";
import { TileStack } from "../../../components/tile-grid/TileStack";
import { TileColumn } from "../../../components/tile-grid/TileColumn";
import { MetricCard } from "../../../components/metric-card";
import { SectionCard } from "../../../components/section-card";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
afterEach(cleanup);
const cardOf = (text: string) =>
screen.getByText(text).closest("[data-tile-card]") as HTMLElement;
describe("TileWall", () => {
it("wraps each tile in a cell with the shared lines", () => {
render(
<TileWall size="2">
<span>one</span>
{null}
<span>two</span>
</TileWall>,
);
const cell = screen.getByText("one").parentElement!;
expect(cell).toHaveClass("border-b", "border-r");
expect(cell.parentElement?.children).toHaveLength(2);
});
it("drops the box of cards inside it", () => {
render(
<TileWall>
<MetricCard title="CPU" icon={null}>
x
</MetricCard>
</TileWall>,
);
expect(cardOf("CPU")).not.toHaveClass("border");
});
it("uses column classes in place of size", () => {
render(
<TileWall gridClassName="grid-cols-3">
<span>a</span>
</TileWall>,
);
const grid = screen.getByText("a").parentElement!.parentElement!;
expect(grid).toHaveClass("grid-cols-3");
expect(grid.style.gridTemplateColumns).toBe("");
});
});
describe("bottom line", () => {
const edgeOf = (text: string) =>
screen.getByText(text).closest(".overflow-clip")!.firstElementChild;
it("stays when the tiles do not reach the bottom of the screen", () => {
render(
<TileWall>
<span>alone</span>
</TileWall>,
);
expect(edgeOf("alone")).not.toHaveClass("-mb-px");
});
it("is left to the section inside a stack", () => {
render(
<TileStack>
<TileWall>
<span>nested</span>
</TileWall>
</TileStack>,
);
expect(edgeOf("nested")).toHaveClass("-mb-px");
});
it("is cut off once the tiles reach the bottom of the scroll area", () => {
const rect = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({ top: 0, bottom: 500 } as DOMRect);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
render(
<div style={{ overflowY: "auto" }}>
<TileWall>
<span>full</span>
</TileWall>
</div>,
);
expect(edgeOf("full")).toHaveClass("-mb-px");
rect.mockRestore();
vi.unstubAllGlobals();
});
});
describe("TileStack", () => {
it("draws a line under each section", () => {
render(
<TileStack>
<span>first</span>
<span>second</span>
</TileStack>,
);
expect(screen.getByText("first").parentElement).toHaveClass("border-b");
expect(screen.getByText("second").parentElement).toHaveClass("border-b");
});
});
describe("cards outside a tile layout", () => {
it("keep their border but no fill", () => {
render(
<SectionCard title="Shell" icon={null}>
body
</SectionCard>,
);
const card = cardOf("Shell");
expect(card).toHaveClass("border");
expect(card).not.toHaveClass("bg-card");
});
it("drop the border inside a TileColumn", () => {
render(
<TileColumn>
<div>
<SectionCard title="Nested" icon={null}>
body
</SectionCard>
</div>
</TileColumn>,
);
expect(cardOf("Nested")).not.toHaveClass("border");
});
});
@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import {
BOTTOM,
appendTile,
flowTiles,
gridLines,
removeTile,
sanitizeTiles,
settleTiles,
tileBottom,
tileBoxes,
tilesFromBoxes,
} from "../../../components/tile-grid/layout";
describe("tile grid layout", () => {
it("settles tiles under the ones above them in the same columns", () => {
const boxes = settleTiles([
{ id: "a", x: 0, y: 0, w: 6, h: 100 },
{ id: "b", x: 6, y: 0, w: 6, h: 50 },
{ id: "c", x: 6, y: 500, w: 6, h: 50 },
{ id: "d", x: 0, y: 900, w: 12, h: 20 },
]);
const y = Object.fromEntries(boxes.map((box) => [box.id, box.y]));
expect(y).toEqual({ a: 0, b: 0, c: 50, d: 100 });
expect(tileBottom(boxes)).toBe(120);
});
it("overlaps by the gap when it is negative, for shared borders", () => {
const boxes = settleTiles(
[
{ id: "a", x: 0, y: 0, w: 12, h: 100 },
{ id: "b", x: 0, y: 1, w: 12, h: 100 },
],
-1,
);
expect(boxes[1].y).toBe(99);
});
it("uses measured heights for tiles without a fixed one", () => {
const boxes = tileBoxes(
[
{ id: "a", x: 0, y: 0, w: 4, h: null },
{ id: "b", x: 0, y: 0, w: 4, h: 30 },
{ id: "c", x: 0, y: 0, w: 4 },
],
{ a: 77 },
10,
);
expect(boxes.map((box) => box.h)).toEqual([77, 30, 10]);
});
it("appends at the bottom once and removes by id", () => {
const start = [{ id: "a", x: 0, y: 0, w: 4, h: null }];
const added = appendTile(start, { id: "b", w: 30 });
expect(added[1]).toEqual({ id: "b", x: 0, y: BOTTOM, w: 12, h: null });
expect(appendTile(added, { id: "b" })).toBe(added);
expect(removeTile(added, "a").map((slot) => slot.id)).toEqual(["b"]);
});
it("writes settled positions back in reading order", () => {
const slots = [
{ id: "b", x: 6, y: 300, w: 6, h: 10 },
{ id: "a", x: 0, y: 0, w: 6, h: 10 },
];
const out = tilesFromBoxes(slots, settleTiles(tileBoxes(slots, {})));
expect(out.map((slot) => [slot.id, slot.y])).toEqual([
["a", 0],
["b", 0],
]);
});
it("cleans saved layouts", () => {
expect(sanitizeTiles("nope")).toEqual([]);
expect(
sanitizeTiles([
{ id: "a", x: 11, y: -5, w: 6, h: 3 },
{ id: "a", x: 0, y: 0, w: 4 },
{ x: 0 },
null,
{ id: "b", x: "x", y: 10, w: 1, h: "big" },
]),
).toEqual([
{ id: "a", x: 6, y: 0, w: 6, h: 48 },
{ id: "b", x: 0, y: 10, w: 2, h: null },
]);
});
it("flows tiles into rows by width", () => {
const out = flowTiles([
{ id: "a", w: 8 },
{ id: "b", w: 4 },
{ id: "c", w: 6 },
]);
expect(out.map((slot) => [slot.id, slot.x, slot.y])).toEqual([
["a", 0, 0],
["b", 8, 0],
["c", 0, 160],
]);
});
});
describe("gridLines", () => {
it("draws a shared edge once, as long as the longer side", () => {
const lines = gridLines(
[
{ l: 0, t: 0, r: 100, b: 50 },
{ l: 100, t: 0, r: 200, b: 120 },
],
200,
);
expect(lines.filter((line) => line.w === 1)).toEqual([
{ x: 100, y: 0, w: 1, h: 121 },
]);
expect(lines.filter((line) => line.h === 1)).toEqual([
{ x: 0, y: 50, w: 101, h: 1 },
{ x: 100, y: 120, w: 100, h: 1 },
]);
});
it("leaves out the outer left, top and right edges and keeps bottoms", () => {
expect(gridLines([{ l: 0, t: 0, r: 200, b: 80 }], 200)).toEqual([
{ x: 0, y: 80, w: 200, h: 1 },
]);
});
it("drops the very bottom line when the grid runs to the bottom of the screen", () => {
const lines = gridLines(
[
{ l: 0, t: 0, r: 100, b: 50 },
{ l: 100, t: 0, r: 200, b: 120 },
],
200,
true,
);
// The shorter column still gets its bottom line.
expect(lines.filter((line) => line.h === 1)).toEqual([
{ x: 0, y: 50, w: 101, h: 1 },
]);
expect(lines.filter((line) => line.w === 1)).toEqual([
{ x: 100, y: 0, w: 1, h: 120 },
]);
});
it("joins rows into one line across tiles", () => {
const lines = gridLines(
[
{ l: 0, t: 0, r: 100, b: 50 },
{ l: 100, t: 0, r: 200, b: 50 },
{ l: 0, t: 50, r: 200, b: 90 },
],
200,
);
expect(lines.filter((line) => line.h === 1 && line.y === 50)).toEqual([
{ x: 0, y: 50, w: 200, h: 1 },
]);
});
});
@@ -185,6 +185,30 @@ describe("UiPreferencesProvider", () => {
expect(result.current?.preferences.overrides).toEqual({}); expect(result.current?.preferences.overrides).toEqual({});
}); });
it("sends every key set in one area within a burst", async () => {
vi.useFakeTimers();
api.getUserPreferences.mockResolvedValue({ storageMode: "cloud" });
api.getUiPreferences.mockResolvedValue({
...defaultUiPreferences(),
overrides: { "plugin:x": { grid: ["a"], slots: ["b"] } },
});
const { result } = renderHook(() => useUiPreferencesContext(), { wrapper });
await vi.waitFor(() => expect(result.current?.loaded).toBe(true));
act(() => {
result.current?.setPluginOverride("x", "grid", null);
result.current?.setPluginOverride("x", "slots", null);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(api.saveUiPreferences).toHaveBeenCalledWith({
overrides: { "plugin:x": { grid: null, slots: null } },
});
});
it("prunes an area once its last override is cleared", async () => { it("prunes an area once its last override is cleared", async () => {
api.getUiPreferences.mockResolvedValue({ api.getUiPreferences.mockResolvedValue({
...defaultUiPreferences(), ...defaultUiPreferences(),