Compare commits

...
41 Commits
Author SHA1 Message Date
LukeGus 789d27014f fix: ship the connect policy in sdk 1.1.1 and add privileged access to the readme 2026-11-05 22:43:47 -06:00
LukeGus 67b70f8a97 feat: connect policy hook so plugins can require approved access 2026-11-05 22:28:38 -06:00
LukeGus 1f5a19f54d chore: add security center to the plugin list 2026-11-05 18:25:44 -06:00
LukeGus fd3eeea3b9 docs: add log search to the plugin table 2026-11-05 18:20:15 -06:00
LukeGus 87d57770ca docs: add IP-KVM to plugin list 2026-11-05 18:19:15 -06:00
LukeGus d451628122 chore: add object storage plugin to readme 2026-11-05 18:15:39 -06:00
LukeGus b2d15281dd chore: add storage health to the plugin list 2026-11-05 17:49:17 -06:00
LukeGus 081c8b6021 docs: add out-of-band plugin to readme 2026-11-05 17:38:02 -06:00
LukeGus c28cda7822 fix: keep bottom line on tile grids that have content below 2026-11-05 16:47:37 -06:00
LukeGus 528092846c docs: add Notes to the plugin table 2026-11-05 16:36:34 -06:00
LukeGus 13ca4369c5 docs: add the Git plugin to the plugin table 2026-11-05 16:36:20 -06:00
LukeGus 741c211f7b fix: remove duplicate Network Devices row 2026-11-05 15:03:53 -06:00
LukeGus 7e308fe57c docs: add Ansible to the plugin table 2026-11-05 15:02:27 -06:00
LukeGus 358664648b chore: add network devices to plugin table 2026-11-05 15:02:12 -06:00
LukeGus 3824203639 docs: add UPS Manager to the plugin table 2026-11-05 15:01:49 -06:00
LukeGus 49b6dd6aa2 docs: add virtual machines to plugin table 2026-11-05 14:24:51 -06:00
LukeGus db5d4f4157 docs: add process manager to plugin table 2026-11-05 13:57:39 -06:00
LukeGus d0ef505951 chore: add web servers plugin to readme 2026-11-05 13:51:20 -06:00
LukeGus c09d09f2ce fix: test 2026-11-05 12:52:04 -06:00
LukeGus 041e10eb5c chore: add uptime to the plugin table 2026-11-05 00:41:49 -06:00
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
106 changed files with 4837 additions and 253 deletions

No files matched your search

+23 -1
View File
@@ -5,13 +5,24 @@
### 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
- 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
- Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens
### 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
- 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
- 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
@@ -19,6 +30,17 @@
- 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
- 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
+23
View File
@@ -62,33 +62,50 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
| [Ansible](https://github.com/Termix-SSH/Plugin-Ansible) | Run Ansible playbooks and ad-hoc commands from a control host, with an inventory made from your Termix hosts. |
| [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. |
| [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 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. |
| [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. |
| [DNS](https://github.com/Termix-SSH/Plugin-DNS) | Manage DNS records and domains across Cloudflare, Route 53, Namecheap and other providers. |
| [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. |
| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [Git](https://github.com/Termix-SSH/Plugin-Git) | See, pull, commit and push git repos on your hosts, and keep them up to date with auto pull. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
| [IP-KVM](https://github.com/Termix-SSH/Plugin-IP-KVM) | See and control machines through PiKVM, NanoKVM and JetKVM devices. |
| [Kubernetes](https://github.com/Termix-SSH/Plugin-Kubernetes) | Manage Kubernetes clusters from your hosts over SSH, with logs, metrics, YAML and a shell. |
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Log Search](https://github.com/Termix-SSH/Plugin-Log-Search) | Search logs from the journal, log files and Docker containers across many hosts at once. |
| [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 Devices](https://github.com/Termix-SSH/Plugin-Network-Devices) | See and manage routers, switches and access points over SNMP and router APIs. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [Notes](https://github.com/Termix-SSH/Plugin-Notes) | Write markdown notes for each host, folder or yourself, with commands you can copy or send to the terminal. |
| [Object Storage](https://github.com/Termix-SSH/Plugin-Object-Storage) | Browse and manage S3-compatible object storage buckets and files. |
| [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. |
| [Out-of-band](https://github.com/Termix-SSH/Plugin-Out-Of-Band) | Power, sensors, event logs and consoles for server BMCs over Redfish, IPMI and Intel AMT. |
| [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. |
| [Privileged Access](https://github.com/Termix-SSH/Plugin-Privileged-Access) | Ask for approval before anyone connects to a privileged host, with access that ends on its own and a full audit trail. |
| [Process Manager](https://github.com/Termix-SSH/Plugin-Process-Manager) | See and control running processes, pm2 and supervisor apps on your hosts, read their logs and get alerts when they fail. |
| [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. |
| [Reverse Proxy](https://github.com/Termix-SSH/Plugin-Reverse-Proxy) | Reverse proxy for TCP, UDP, HTTP and HTTPS with free certificates and Termix login, managed per host. |
| [Samba Shares](https://github.com/Termix-SSH/Plugin-Samba) | Create and manage Samba (SMB) file shares, users and permissions on your hosts from one tab. |
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
| [Security Center](https://github.com/Termix-SSH/Plugin-Security-Center) | Audit SSH keys, rotate them and scan hosts for known vulnerabilities over SSH. |
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
| [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. |
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
@@ -99,16 +116,22 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [SSH Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Storage Health](https://github.com/Termix-SSH/Plugin-Storage-Health) | Watch drive and array health over SSH: SMART, NVMe, ZFS, mdadm, btrfs and LVM, with history, alerts and self tests. |
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
| [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. |
| [UPS Manager](https://github.com/Termix-SSH/Plugin-UPS-Manager) | Watch your UPS devices over NUT, apcupsd or SNMP, with history, alerts and self tests. |
| [Uptime](https://github.com/Termix-SSH/Plugin-Uptime) | Watch websites, ports, hosts and services, get alerts when they go down, and publish status pages. |
| [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. |
| [Virtual Machines](https://github.com/Termix-SSH/Plugin-Virtual-Machines) | Create and manage KVM virtual machines on your Linux hosts over SSH with libvirt. |
| [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. |
| [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 Servers](https://github.com/Termix-SSH/Plugin-Web-Servers) | Manage nginx, Apache and Caddy on your hosts: sites, config, logs and HTTPS, with every change tested first. |
| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
+1 -1
View File
@@ -114,7 +114,7 @@ ENV DATA_DIR=/app/data \
PORT=8080 \
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 && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.11.0",
"version": "26.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.11.0",
"version": "26.11.1",
"hasInstallScript": true,
"workspaces": [
"packages/*"
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.11.0",
"version": "26.11.1",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## 1.1.1
Plugin API 1.15.0.
### Added
- `ctx.hosts.connectPolicy.provide` and the `hosts:connect-policy` capability, so a plugin can allow, deny or ask for an access request before an SSH or remote desktop session opens
- SSH prepare outcomes can carry the error codes `access-denied` and `access-required` with a `requestTab`
## 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
Plugin API 1.14.0.
+31 -4
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.23 | Plugin API 1.14.0
SDK 1.1.1 | Plugin API 1.15.0
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.
@@ -354,6 +354,13 @@ router.post("/notes", async (req, res) => {
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
`ctx.credentials.list()` finds the acting user's credentials by name.
- Connect policy: with `hosts:connect-policy`, `ctx.hosts.connectPolicy.provide({ check(input) })` is asked
before every SSH config is built and every remote desktop login is handed out (`input` has `userId`,
`hostId`, `protocol`, `purpose`, `profile`, `interactive`, `origin`). Return `{ decision: "allow" }`,
`{ decision: "deny", reason }` or `{ decision: "request", reason, requestTab }`. The strictest answer of
all providers wins and a provider that throws denies. SSH prepare then gives an error outcome with code
`access-denied` or `access-required`, and `ctx.credentials.resolveHostProtocol` throws an error with that
`code` and a `toJSON()` body for a 403. Keep `check` fast, it also runs for background polling.
### Errors
@@ -766,9 +773,28 @@ exports with props is `ui.api.txt` in the Termix repo
- Small type: most text 11 to 13px.
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
`PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
forms end with `FormFooter`, empty lists show `EmptyState`.
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
- Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and
`ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows
`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
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
@@ -1066,6 +1092,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `hosts:connect-policy` | high | Allow, deny or ask for approval before sessions open |
| `network:probe` | medium | Check that a host answers on a TCP port |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.23",
"version": "1.1.1",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -171,6 +171,7 @@
"hosts:status-provide",
"hosts:activity-provide",
"hosts:lock",
"hosts:connect-policy",
"desktop:window",
"desktop:local",
"users:impersonate",
+58 -2
View File
@@ -1295,6 +1295,54 @@ export interface PluginHostLocksApi {
provide: (provider: PluginHostLockProvider) => () => void;
}
/** What core asks a connect policy before a session to a host opens. */
export interface PluginConnectPolicyInput {
/** The person connecting. */
userId: string;
hostId: number;
/** "ssh", or a remote desktop protocol id such as "rdp", "vnc" or "telnet". */
protocol: string;
/** Free form purpose the caller gave, such as "terminal" or "sftp". */
purpose: string;
/**
* SSH profile: terminal, session, stream, forward, background or jump.
* "desktop" for remote desktop logins.
*/
profile: string;
/** True when a person waits on this connect (a prompt channel is open). */
interactive: boolean;
/** Plugin id that asked for the connection, or "core". */
origin: string;
}
export type PluginConnectPolicyDecision =
| { decision: "allow" }
| { decision: "deny"; reason?: string }
| {
decision: "request";
reason?: string;
/** A tab id of the providing plugin where the user can ask for access. */
requestTab?: string;
};
/**
* Decides whether a user may open a session to a host. Core asks every
* provider before it builds an SSH config (terminal, file manager, tunnels,
* background exec) and before it hands out remote desktop logins. The
* strictest answer wins and a provider that throws denies. Keep check fast:
* it runs on every connect, including background polling.
*/
export interface PluginConnectPolicyProvider {
check: (
input: PluginConnectPolicyInput,
) => PluginConnectPolicyDecision | Promise<PluginConnectPolicyDecision>;
}
export interface PluginConnectPolicyApi {
/** Registers this plugin's policy. Needs hosts:connect-policy. Removed on deactivate. */
provide: (provider: PluginConnectPolicyProvider) => () => void;
}
/**
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
* needs to build a feature (like fleets) on top of hosts it does not own.
@@ -1441,6 +1489,8 @@ export interface PluginHosts {
activity: PluginHostActivityApi;
/** Read only hosts this plugin manages. */
locks: PluginHostLocksApi;
/** Who may open sessions to which hosts. */
connectPolicy: PluginConnectPolicyApi;
/** Folder rows for the acting user. Needs hosts:write. */
folders: PluginHostFoldersApi;
}
@@ -1668,8 +1718,12 @@ export type PluginSshAuthOutcome =
| "invalid-key"
| "passphrase-required"
| "provider-missing"
| "failed";
| "failed"
| "access-denied"
| "access-required";
message: string;
/** For access-required: a tab of the policy plugin where access is asked for. */
requestTab?: string;
}
| {
/** Try once more with these config changes. */
@@ -2695,7 +2749,9 @@ export interface PluginCredentials {
/**
* The host's login for one of this plugin's own contributes.protocols.
* Null when the host does not exist or the acting user cannot connect to
* it. Needs credentials:read. Audited.
* it. Throws an error with `code` "access-denied" or "access-required"
* (and `requestTab`, `toJSON()` for a 403 body) when a connect policy
* refuses the session. Needs credentials:read. Audited.
*/
resolveHostProtocol: (
hostId: number,
+1
View File
@@ -67,6 +67,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("hosts:status-provide", "medium"),
entry("hosts:activity-provide", "medium"),
entry("hosts:lock", "medium"),
entry("hosts:connect-policy", "high"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
+48
View File
@@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
/** A control in the host editor's toolbar. */
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 {
id: string;
titleKey: string;
@@ -783,6 +799,26 @@ export interface KeybindingDefaultContribution {
export interface DashboardCardProps {
isVisible: boolean;
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 {
@@ -797,6 +833,13 @@ export interface DashboardCardContribution {
* edge, for a strip of numbers. Defaults to "framed".
*/
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>;
}
@@ -1172,6 +1215,11 @@ export interface TermixApp extends TermixAppInfo {
registerHostContextMenuItem: (
item: HostContextMenuItemContribution,
) => 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;
registerPaletteGroup: (group: PaletteGroupContribution) => 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
* 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. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+20
View File
@@ -81,6 +81,7 @@ import type {
PluginHostStatusEntry,
PluginActivityProvider,
PluginHostLockProvider,
PluginConnectPolicyProvider,
PluginAuthPolicyProvider,
PluginCredentialSummary,
PluginHostShareGrant,
@@ -303,6 +304,8 @@ export interface FakePluginContext {
activityProviders: PluginActivityProvider[];
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
hostLockProviders: PluginHostLockProvider[];
/** Every provider passed to ctx.hosts.connectPolicy.provide that is still registered. */
connectPolicyProviders: PluginConnectPolicyProvider[];
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
@@ -549,6 +552,7 @@ export function createFakeContext(
const hostStatusProviders: PluginHostStatusProvider[] = [];
const activityProviders: PluginActivityProvider[] = [];
const hostLockProviders: PluginHostLockProvider[] = [];
const connectPolicyProviders: PluginConnectPolicyProvider[] = [];
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
@@ -1093,6 +1097,15 @@ export function createFakeContext(
};
},
},
connectPolicy: {
provide: (provider) => {
connectPolicyProviders.push(provider);
return () => {
const at = connectPolicyProviders.indexOf(provider);
if (at >= 0) connectPolicyProviders.splice(at, 1);
};
},
},
status: {
provide: (provider) => {
hostStatusProviders.push(provider);
@@ -1586,6 +1599,7 @@ export function createFakeContext(
hostStatusProviders,
activityProviders,
hostLockProviders,
connectPolicyProviders,
emitted,
kv,
tables,
@@ -2080,6 +2094,12 @@ export function createMockCtx(
return ctx.hosts.locks.provide(provider);
},
},
connectPolicy: {
provide: (provider) => {
require("hosts:connect-policy");
return ctx.hosts.connectPolicy.provide(provider);
},
},
status: {
provide: (provider) => {
require("hosts:read");
+47 -4
View File
@@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId
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
DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
@@ -133,7 +135,7 @@ GridColumns (type): GridColumns
GridLayout (type): GridLayout
GridSize (type): GridSize
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
Host (type): Host
HostActionDef (type): HostActionDef
@@ -159,12 +161,16 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields
HostSectionDef (type): HostSectionDef
HostSidebarFilterState (type): HostSidebarFilterState
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
HttpsSettings (type): HttpsSettings
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
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
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
LineChartSeries (type): LineChartSeries
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
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
ListRowTone (type): ListRowTone
LoadingState (value): ({ label, compact, className, }: { label?: string; compact?: boolean; className?: string; }) => Element
LogContext (type): LogContext
LogEntry (type): LogEntry
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
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
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
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
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_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"
Pager (value): ({ page, pages, hasNext, onPage, className, }: { page: number; pages?: number; hasNext?: boolean; onPage: (next: number) => void; className?: string; }) => Element
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
PanelDensity (type): PanelDensity
PanelFrame (value): ({ className, children, }: { 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
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
RailItemDef (type): RailItemDef
RecentActivityItem (type): RecentActivityItem
RefreshButton (value): ({ onClick, loading, disabled, label, className, }: { onClick: () => void; loading?: boolean; disabled?: boolean; label?: string; className?: string; }) => Element
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
ResolvedHostDefault (type): ResolvedHostDefault
@@ -246,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus
SSH_AUTH_PROTOCOL (value): "ssh"
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & 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
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
@@ -275,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
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
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
SortKey (type): SortKey
@@ -284,11 +299,16 @@ SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { a
SplitOpenTarget (type): SplitOpenTarget
SplitSummary (type): SplitSummary
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
SurfaceKind (type): SurfaceKind
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
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
Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks
@@ -303,6 +323,17 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps
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
ToolsTab (type): ToolsTab
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; }>
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
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
applyFontSize (value): (id: FontSizeId) => void
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
@@ -354,7 +386,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string
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>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
@@ -382,6 +414,7 @@ endSplitDrag (value): (commit: boolean) => void
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
findCoreHostDefault (value): (key: string) => CoreHostDefault
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[]
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
@@ -457,6 +490,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
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[]
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
invalidateHostsAndStatusCaches (value): () => void
@@ -475,6 +509,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
linkedServerUrl (value): () => Promise<string>
listHostActions (value): () => HostActionDef[]
listHostProtocols (value): () => HostProtocolDef[]
listHostSections (value): () => HostSectionDef[]
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
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
notifySyncChanged (value): () => 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[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date
@@ -509,6 +546,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string
rem (value): (px: number) => string
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
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>>
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
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
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[]
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
@@ -556,6 +595,7 @@ shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permiss
shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host
statusTone (value): (state: string) => StatusTone
tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest
@@ -598,8 +638,10 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[]
useHostEditorToolbarItems (value): () => HostBadgeDef[]
useHostProtocols (value): () => HostProtocolDef[]
useHostSections (value): () => HostSectionDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useHostStatusIndicators (value): () => HostBadgeDef[]
useInSeamlessTile (value): () => boolean
useInstalledPlugins (value): () => PluginSummary[]
useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean
@@ -607,6 +649,7 @@ useKeybindingActions (value): () => KeybindingActionDef[]
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
useOptionalConnectionLog (value): () => ConnectionLogContextType
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[]
usePaletteGroups (value): () => PaletteGroupDef[]
usePanels (value): () => PanelDef[]
+1 -1
View File
@@ -74,7 +74,7 @@ async function main() {
const staged = [...pinned, ...extra];
fs.writeFileSync(
path.join(destination, "bundled-index.json"),
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
`${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)}
`,
);
+50 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
@@ -221,7 +221,34 @@ describe("buildBundledIndex", () => {
];
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
version: 1,
profile: "standard",
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" },
});
});
});
@@ -444,6 +471,27 @@ describe("rebuilding local plugins", () => {
expect(isLocalBuildStale(root)).toBe(false);
});
it("survives a build replacing dist during the scan", () => {
const root = repo(tempDir(), "Plugin-A", "a");
const gone = path.join(root, "dist", "migrations", "postgres");
fs.mkdirSync(gone, { recursive: true });
fs.writeFileSync(path.join(root, "manifest.json"), "{}");
const realStat = fs.statSync;
const spy = vi.spyOn(fs, "statSync").mockImplementation(((
target: string,
) => {
if (target === gone) {
throw Object.assign(new Error("gone"), { code: "ENOENT" });
}
return realStat(target);
}) as typeof fs.statSync);
try {
expect(() => isLocalBuildStale(root)).not.toThrow();
} finally {
spy.mockRestore();
}
});
it("builds with this checkout's SDK CLI, not the plugin's own", () => {
const [command, args] = localSdkBuildCommand();
expect(command).toBe(process.execPath);
@@ -520,7 +568,7 @@ describe("default look", () => {
],
["look", "plain"],
),
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } });
const index = {
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 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",
"APP_VERSION",
"AccessRecord",
+19 -4
View File
@@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) {
/**
* 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 out = {};
const catalog = {};
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 (!plugin.onboarding && !plugin.defaultLook) continue;
out[plugin.id] = {
@@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) {
...(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. */
@@ -360,8 +370,9 @@ const BUILD_INPUTS = [
"package.json",
];
// A build can replace dist while this walks it, so a path can vanish mid scan.
function newestMtime(target) {
if (!fs.existsSync(target)) return 0;
try {
const stat = fs.statSync(target);
if (!stat.isDirectory()) return stat.mtimeMs;
let newest = 0;
@@ -369,6 +380,10 @@ function newestMtime(target) {
newest = Math.max(newest, newestMtime(path.join(target, name)));
}
return newest;
} catch (error) {
if (error.code === "ENOENT") return 0;
throw error;
}
}
/** True when a plugin repo has never been built or its sources changed since. */
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
/** JSON list, or null for full access. */
scopes: string | null;
}
export class ApiKeyRepository {
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive,
scopes: apiKeys.scopes,
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id))
@@ -622,6 +622,7 @@ router.get("/signals", authenticateJWT, async (req, res) => {
userId: authReq.userId,
actionId: signal.read,
hostIds,
freshMs: signal.refreshSec ? signal.refreshSec * 1000 : undefined,
origin: originOf(authReq),
}),
});
@@ -1,5 +1,6 @@
import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
* type: string
* format: date-time
* 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:
* 201:
* 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) => {
try {
const { name, userId: targetUserId, expiresAt } = req.body;
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository();
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
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 tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10);
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
expiresAt: expiresAtValue,
lastUsedAt: null,
isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
});
const actorId = (req as AuthenticatedRequest).userId;
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
details: JSON.stringify({
targetUserId,
targetUsername: targetUser.username,
scopes: scopesValue,
}),
ipAddress,
userAgent,
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken,
});
} catch (err) {
+165
View File
@@ -0,0 +1,165 @@
/**
* Connect policy from plugins through ctx.hosts.connectPolicy.provide. Core
* asks every provider before it builds an SSH config or hands out a remote
* desktop login, and the strictest answer wins: deny, then request, then
* allow. A provider that throws denies, since letting a session through when
* the access rule is broken is worse than a failed connect. No provider means
* allow.
*/
import type {
PluginConnectPolicyDecision,
PluginConnectPolicyInput,
PluginConnectPolicyProvider,
} from "@termix-ssh/plugin-sdk/backend";
import { pluginLogger } from "../utils/logger.js";
interface Entry {
pluginId: string;
provider: PluginConnectPolicyProvider;
}
const providers = new Map<symbol, Entry>();
export type ConnectPolicyResult =
| { decision: "allow" }
| {
decision: "deny" | "request";
reason: string;
pluginId: string;
requestTab: string | null;
};
export class ConnectPolicyError extends Error {
readonly status = 403;
readonly code: "access-denied" | "access-required";
constructor(
readonly result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
) {
super(result.reason);
this.name = "ConnectPolicyError";
this.code =
result.decision === "request" ? "access-required" : "access-denied";
}
/** The body routes send back, so a client can offer the request tab. */
toJSON(): {
error: string;
code: string;
requestTab: string | null;
pluginId: string;
} {
return {
error: this.message,
code: this.code,
requestTab: this.result.requestTab,
pluginId: this.result.pluginId,
};
}
}
export function provideConnectPolicy(
pluginId: string,
provider: PluginConnectPolicyProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearConnectPolicies(): void {
providers.clear();
}
export function hasConnectPolicies(): boolean {
return providers.size > 0;
}
const RANK = { allow: 0, request: 1, deny: 2 } as const;
function plainTab(value: unknown): string | null {
return typeof value === "string" &&
/^[a-z0-9][a-z0-9:_.-]{0,80}$/i.test(value)
? value
: null;
}
export async function evaluateConnectPolicy(
input: PluginConnectPolicyInput,
): Promise<ConnectPolicyResult> {
let result: ConnectPolicyResult = { decision: "allow" };
for (const entry of providers.values()) {
let decision: PluginConnectPolicyDecision;
try {
decision = (await entry.provider.check(input)) ?? { decision: "allow" };
} catch (error) {
pluginLogger.warn("Connect policy failed, refusing the connection", {
operation: "connect_policy",
pluginId: entry.pluginId,
hostId: input.hostId,
error: error instanceof Error ? error.message : String(error),
});
decision = {
decision: "deny",
reason: "Access to this host can't be checked right now",
};
}
const kind =
decision.decision === "deny" || decision.decision === "request"
? decision.decision
: "allow";
if (RANK[kind] <= RANK[result.decision]) continue;
if (kind === "allow") continue;
const reason =
"reason" in decision && typeof decision.reason === "string"
? decision.reason.slice(0, 300)
: "";
result = {
decision: kind,
reason:
reason ||
(kind === "request"
? "This host needs approved access"
: "Access to this host is not allowed"),
pluginId: entry.pluginId,
requestTab:
kind === "request" && "requestTab" in decision
? plainTab(decision.requestTab)
: null,
};
}
return result;
}
async function auditRefusal(
input: PluginConnectPolicyInput,
result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
): Promise<void> {
const audit = await import("../utils/audit-logger.js");
const username = await audit
.getAuditUsername(input.userId)
.catch(() => input.userId);
void audit.logAudit({
userId: input.userId,
username,
action: "connect_policy_denied",
resourceType: "host",
resourceId: String(input.hostId),
details: `${input.protocol} ${result.decision} by ${result.pluginId}: ${result.reason}`,
success: false,
});
}
/** Throws ConnectPolicyError when a provider denies or wants a request. */
export async function assertConnectAllowed(
input: PluginConnectPolicyInput,
): Promise<void> {
if (providers.size === 0) return;
const result = await evaluateConnectPolicy(input);
if (result.decision === "allow") return;
await auditRefusal(input, result).catch(() => {});
throw new ConnectPolicyError(result);
}
@@ -8,6 +8,7 @@ import {
requireSshAuthProvider,
} from "./auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "./core-providers.js";
import { ConnectPolicyError, assertConnectAllowed } from "../connect-policy.js";
import type {
MutableConnectConfig,
SshAuthEnv,
@@ -113,6 +114,8 @@ export interface BuildConnectConfigOptions {
/** Terminal socket for the changed-host-key prompt. */
hostKeySocket?: WebSocket | null;
interactive?: boolean;
/** Plugin id that asked for the connection, or "core". For connect policies. */
origin?: string;
log?: SshAuthLog;
/** Applied after the purpose defaults and before auth is prepared. */
overrides?: Partial<MutableConnectConfig>;
@@ -197,6 +200,35 @@ export async function buildConnectConfig(
log: options.log ?? noopLog,
};
try {
await assertConnectAllowed({
userId: options.userId,
hostId: serverHostId,
protocol: "ssh",
purpose: String(options.purpose),
profile,
interactive: options.interactive ?? false,
origin: options.origin ?? "core",
});
} catch (error) {
if (error instanceof ConnectPolicyError) {
return {
config,
provider: null,
env,
outcome: {
status: "error",
code: error.code,
message: error.message,
...(error.result.requestTab
? { requestTab: error.result.requestTab }
: {}),
},
};
}
throw error;
}
let provider: SshAuthProvider;
try {
provider = requireSshAuthProvider(authType);
@@ -72,6 +72,8 @@ export interface ConnectHostOptions {
*/
sock?: MutableConnectConfig["sock"];
log?: SshAuthLog;
/** Plugin id that asked for the connection. For connect policies. */
origin?: string;
}
export interface SshConnection {
@@ -107,6 +109,7 @@ export async function connectHost(
client,
hostKeySocket: options.prompt?.hostKeySocket ?? null,
interactive: !!options.prompt,
origin: options.origin,
log: options.log,
overrides: options.overrides,
});
+5 -1
View File
@@ -98,8 +98,12 @@ export type SshAuthOutcome =
| "invalid-key"
| "passphrase-required"
| "provider-missing"
| "failed";
| "failed"
| "access-denied"
| "access-required";
message: string;
/** For access-required: a tab of the policy plugin where access is asked for. */
requestTab?: string;
}
| {
/** Try once more with these config changes. Used by Tailscale. */
+2 -1
View File
@@ -69,7 +69,8 @@ export function clearApprovalPolicies(): void {
}
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"]);
/** A person is the check when the request came from something else. */
+5 -1
View File
@@ -78,6 +78,8 @@ interface ReadOptions {
actionId: string;
hostIds?: number[];
refresh?: boolean;
/** How long a cached answer stays fresh. Defaults to RESOURCE_FRESH_MS. */
freshMs?: number;
input?: Record<string, unknown>;
origin: ActorOrigin;
}
@@ -155,7 +157,9 @@ export async function readAcrossHosts<T>(
const key = cacheKey(options.userId, options.actionId, hostId);
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)) {
reads.push({
+17
View File
@@ -35,3 +35,20 @@ export function missingShippedPluginIds(
.map((record) => record.id)
.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";
export interface BundledCatalogEntry {
tier: string;
version?: string;
}
interface BundledIndex {
profile?: string;
catalog?: Record<string, BundledCatalogEntry>;
plugins: Record<
string,
{
@@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null {
);
cached =
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;
} catch {
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
* enabled, except on a fresh install, where only recommended ones do and
* consent plugins wait for the admin. A plugin dropped into the data
* directory always starts disabled.
* Plugins the image's profile ships (its tier and every smaller one), with
* the version it pins. Read from the bundled index, so core never names them.
*/
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(
pluginId: string,
source: "bundled" | "user",
freshInstall: boolean,
_freshInstall: boolean,
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
// 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";
return startsEnabled(pluginId) ? "enabled" : "disabled";
}
+21
View File
@@ -354,6 +354,27 @@ export function createPluginCredentials({
);
}
// Same connect policy as SSH: a remote desktop login is a session.
const { assertConnectAllowed } =
await import("../hosts/connect-policy.js");
try {
await assertConnectAllowed({
userId,
hostId,
protocol: String(protocol),
purpose: "desktop-login",
profile: "desktop",
interactive: true,
origin: pluginId,
});
} catch (error) {
await audit("credentials_read", details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentHostResolutionRepository();
+20
View File
@@ -41,6 +41,7 @@ import {
assertManualCreateAllowed,
provideHostLocks,
} from "../hosts/locks.js";
import { provideConnectPolicy } from "../hosts/connect-policy.js";
type AuditFn = (
action: string,
@@ -1041,6 +1042,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
},
},
connectPolicy: {
provide: (provider) => {
if (!declared.includes("hosts:connect-policy")) {
throw capabilityRefused(pluginId, "hosts:connect-policy");
}
const remove = provideConnectPolicy(pluginId, provider);
void audit(
"host_connect_policy_provide",
`${pluginId} decides who can connect`,
{ success: true },
);
const drop = bag?.add(remove, "connect policy provider");
return () => {
drop?.();
remove();
};
},
},
folders: {
ensure: async (name, style) => {
await requireWrite();
+2
View File
@@ -263,6 +263,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
userId,
purpose: purposeOf(options),
profile: profileOf(options),
origin: pluginId,
timeoutMs: options?.timeoutMs,
prompt: options?.prompt,
overrides: options?.overrides as Partial<MutableConnectConfig>,
@@ -478,6 +479,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
serverHostId: options.serverHostId,
log: options.log,
interactive: options.interactive,
origin: pluginId,
hostKeySocket: (options.hostKeySocket ?? null) as never,
});
await audit("ssh_prepare", describeHost(host), {
@@ -560,7 +560,13 @@ export async function openDownload(
}
},
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();
});
await fsp.rm(partialPath, { force: true }).catch(() => {});
release();
},
+3 -1
View File
@@ -243,8 +243,10 @@ export async function initializePlugins(
const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js");
const { installNewShippedPlugins, restoreMissingPlugins } =
await import("./manage.js");
loaded.push(...(await restoreMissingPlugins()));
loaded.push(...(await installNewShippedPlugins()));
}
await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) {
+20 -3
View File
@@ -409,6 +409,15 @@ export class PluginLoader {
}
loaded.push(plugin);
} 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(
describeSkipped(dir, entry.name, isArtifact, error),
);
@@ -948,6 +957,16 @@ function withTimeout<T>(
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:
* 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.
if (pinnedVersion && pinnedVersion === version) return;
if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
);
throw new SupersededUpdateError(id, version, bundledVersion);
}
}
+67 -2
View File
@@ -17,7 +17,11 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
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 {
activatePlugin,
@@ -26,7 +30,7 @@ import {
registerLoadedPlugins,
unloadPlugin,
} from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js";
import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js";
import {
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(
pluginId: string,
options: InstallOptions,
@@ -275,3 +275,27 @@ describe("GET /operations/catalog", () => {
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 }),
);
});
});
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const logAudit = vi.fn();
vi.mock("../../utils/logger.js", () => ({
pluginLogger: { warn: vi.fn() },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: (params: unknown) => logAudit(params),
getAuditUsername: async (userId: string) => `name-${userId}`,
}));
const {
assertConnectAllowed,
clearConnectPolicies,
ConnectPolicyError,
evaluateConnectPolicy,
hasConnectPolicies,
provideConnectPolicy,
} = await import("../../hosts/connect-policy.js");
const input = {
userId: "u1",
hostId: 4,
protocol: "ssh",
purpose: "terminal",
profile: "terminal",
interactive: true,
origin: "ssh-terminal",
};
afterEach(() => {
clearConnectPolicies();
logAudit.mockReset();
});
describe("connect policy", () => {
it("allows with no provider", async () => {
expect(hasConnectPolicies()).toBe(false);
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
expect(await evaluateConnectPolicy(input)).toEqual({ decision: "allow" });
});
it("passes the input to the provider", async () => {
const check = vi.fn(() => ({ decision: "allow" as const }));
provideConnectPolicy("p", { check });
await assertConnectAllowed(input);
expect(check).toHaveBeenCalledWith(input);
});
it("picks the strictest answer", async () => {
provideConnectPolicy("a", {
check: () => ({ decision: "request", reason: "ask", requestTab: "pa" }),
});
provideConnectPolicy("b", { check: () => ({ decision: "allow" }) });
expect(await evaluateConnectPolicy(input)).toEqual({
decision: "request",
reason: "ask",
pluginId: "a",
requestTab: "pa",
});
provideConnectPolicy("c", {
check: async () => ({ decision: "deny", reason: "no" }),
});
const result = await evaluateConnectPolicy(input);
expect(result).toMatchObject({ decision: "deny", pluginId: "c" });
});
it("denies when a provider throws", async () => {
provideConnectPolicy("broken", {
check: () => {
throw new Error("boom");
},
});
expect(await evaluateConnectPolicy(input)).toMatchObject({
decision: "deny",
pluginId: "broken",
});
});
it("throws a typed error and audits the refusal", async () => {
provideConnectPolicy("pa", {
check: () => ({ decision: "request", requestTab: "access" }),
});
const error = await assertConnectAllowed(input).catch((e) => e);
expect(error).toBeInstanceOf(ConnectPolicyError);
expect(error.code).toBe("access-required");
expect(error.status).toBe(403);
expect(error.toJSON()).toEqual({
error: "This host needs approved access",
code: "access-required",
requestTab: "access",
pluginId: "pa",
});
expect(logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "connect_policy_denied",
userId: "u1",
username: "name-u1",
resourceId: "4",
success: false,
}),
);
});
it("drops a request tab that is not a plain id", async () => {
provideConnectPolicy("pa", {
check: () => ({ decision: "request", requestTab: "../x y" }),
});
expect(await evaluateConnectPolicy(input)).toMatchObject({
requestTab: null,
});
});
it("stops asking once removed", async () => {
const remove = provideConnectPolicy("pa", {
check: () => ({ decision: "deny" }),
});
remove();
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ssh2 from "ssh2";
import { FakeSshClient } from "./pipeline-test-helpers.js";
@@ -83,6 +83,10 @@ import {
} from "../../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
import type { SshConnectHost } from "../../../hosts/connect/types.js";
import {
clearConnectPolicies,
provideConnectPolicy,
} from "../../../hosts/connect-policy.js";
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
@@ -148,8 +152,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(() => {
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.instances = [];
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
@@ -390,6 +409,46 @@ describe("profile defaults", () => {
});
});
describe("connect policy", () => {
afterEach(() => clearConnectPolicies());
it("refuses before auth is prepared", async () => {
const check = vi.fn(() => ({
decision: "request" as const,
reason: "Ask first",
requestTab: "access",
}));
provideConnectPolicy("pa", { check });
const built = await build(host());
expect(built.provider).toBeNull();
expect(built.outcome).toEqual({
status: "error",
code: "access-required",
message: "Ask first",
requestTab: "access",
});
expect(check).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
hostId: 7,
protocol: "ssh",
profile: "terminal",
origin: "core",
}),
);
});
it("connectHost throws with the policy code", async () => {
provideConnectPolicy("pa", { check: () => ({ decision: "deny" }) });
const error = await connectHost(host(), {
userId: "user-1",
purpose: "test",
}).catch((e) => e);
expect(error).toBeInstanceOf(SshConnectError);
expect(error.code).toBe("access-denied");
});
});
describe("provider registry", () => {
it("registers and removes a plugin provider", async () => {
ensureCoreSshAuthProviders();
@@ -532,7 +591,7 @@ describe("connectHost", () => {
"user-1",
prompt,
);
await vi.waitFor(() =>
await settle(() =>
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
);
const first = FakeSshClient.instances[0];
@@ -546,11 +605,9 @@ describe("connectHost", () => {
[{ prompt: "Verification code:", echo: false }],
firstAnswer,
);
await vi.waitFor(() =>
expect(firstAnswer).toHaveBeenCalledWith(["111111"]),
);
await settle(() => expect(firstAnswer).toHaveBeenCalledWith(["111111"]));
first.emit("ready");
await vi.waitFor(() =>
await settle(() =>
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
);
const second = FakeSshClient.instances[1];
@@ -566,7 +623,7 @@ describe("connectHost", () => {
],
secondAnswer,
);
await vi.waitFor(() =>
await settle(() =>
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
);
second.emit("ready");
@@ -674,7 +731,7 @@ describe("connectHost", () => {
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
FakeSshClient.nextBehaviour = ["hang"];
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
await vi.waitFor(() => {
await settle(() => {
const last = FakeSshClient.instances.at(-1)!;
expect(last.connectConfig).not.toBeNull();
});
@@ -704,7 +761,7 @@ describe("connectHost", () => {
purpose: "file-manager",
prompt: { ask },
});
await vi.waitFor(() =>
await settle(() =>
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
);
const client = FakeSshClient.instances.at(-1)!;
@@ -720,7 +777,7 @@ describe("connectHost", () => {
],
finish,
);
await vi.waitFor(() => expect(finish).toHaveBeenCalled());
await settle(() => expect(finish).toHaveBeenCalled());
expect(ask).toHaveBeenCalledWith({
kind: "totp",
prompt: "Verification code: ",
@@ -751,7 +808,7 @@ describe("connectHost", () => {
purpose: "fleet",
}),
).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() =>
await settle(() =>
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
);
dispose();
@@ -451,6 +451,15 @@ describe("policy", () => {
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 () => {
state.plugin.source = "user";
state.plugin.signedTier = "community";
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
missingShippedPluginIds,
newShippedPluginIds,
startablePluginIds,
} from "../../plugins/boot-state.js";
@@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => {
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,
onboardingDefaults,
resetBundledIndexCache,
shippedCatalog,
} from "../../plugins/bundled-index.js";
let dir: string;
function writeIndex(plugins: Record<string, unknown>) {
function writeIndex(
plugins: Record<string, unknown>,
extra: Record<string, unknown> = {},
) {
fs.writeFileSync(
path.join(dir, "bundled-index.json"),
JSON.stringify({ version: 1, plugins }),
JSON.stringify({ version: 1, plugins, ...extra }),
);
resetBundledIndexCache();
}
@@ -89,9 +93,10 @@ describe("initialPluginState", () => {
});
});
it("enables every bundled plugin on an existing install", () => {
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
it("enables only recommended plugins an existing install sees for the first time", () => {
expect(initialPluginState("kept", "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", () => {
@@ -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", () => {
it("lists the plugins flagged as the default look", () => {
writeIndex({
@@ -232,6 +232,36 @@ describe("ctx.credentials.resolveHostProtocol", () => {
expect(state.ownerDecrypts).toBe(0);
});
it("asks the connect policy and refuses before decrypting", async () => {
grants.set("demo", ["credentials:read"]);
const { provideConnectPolicy, clearConnectPolicies } =
await import("../../hosts/connect-policy.js");
const check = vi.fn(() => ({
decision: "request" as const,
requestTab: "access",
}));
provideConnectPolicy("pa", { check });
try {
const ctx = spiceContext(["credentials:read"]);
const error = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "spice"),
).catch((e) => e);
expect(error.code).toBe("access-required");
expect(check).toHaveBeenCalledWith(
expect.objectContaining({
userId: "owner",
hostId: 7,
protocol: "spice",
profile: "desktop",
origin: "demo",
}),
);
expect(state.ownerDecrypts).toBe(0);
} finally {
clearConnectPolicies();
}
});
it("returns the owner's stored login and audits it", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
@@ -186,6 +186,21 @@ describe("ctx.desktop.local", () => {
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 () => {
grants.set("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");
});
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 () => {
const { userDir } = setup();
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. */
export type HostClickBehavior =
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
"newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[];
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
"newTab",
"focusExisting",
"focusExistingDoubleClickNew",
"hostPage",
];
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
const FILTER_STATUS: HostSidebarFilterState["status"] = [
+4
View File
@@ -87,6 +87,8 @@ export interface ApiKey {
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
/** Null for full access. */
scopes?: string[] | null;
}
export interface CreatedApiKey extends ApiKey {
@@ -97,12 +99,14 @@ export async function createApiKey(
name: string,
userId: string,
expiresAt?: string,
scopes?: string[] | null,
): Promise<CreatedApiKey> {
try {
const response = await authApi.post("/users/api-keys", {
name,
userId,
expiresAt: expiresAt ?? null,
scopes: scopes?.length ? scopes : null,
});
return response.data;
} catch (error) {
+6 -1
View File
@@ -1,18 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { useInSeamlessTile } from "@/components/tile-grid/context";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return (
<div
data-slot="card"
data-tile-card=""
data-size={size}
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,
)}
{...props}
+26 -47
View File
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { parseServerTime } from "@/lib/server-time";
import { formatTimestamp, pickXTicks } from "./x-ticks";
export interface LineChartSeries {
key: string;
@@ -19,23 +20,11 @@ interface LineChartProps {
className?: string;
}
const Y_LABEL_W = 44;
const Y_LABEL_MIN = 44;
const X_LABEL_H = 20;
const TOP_PAD = 6;
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 {
const [lo, hi] = domain;
const range = hi === lo ? 1 : hi - lo;
@@ -95,7 +84,18 @@ export function LineChart({
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 svgH = height + X_LABEL_H;
@@ -105,36 +105,19 @@ export function LineChart({
parseServerTime(timestamps[0]).getTime()
: 0;
const yTicks = (() => {
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 xTickIndices = pickXTicks(timestamps, rangeMs, chartW);
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGElement>) => {
if (timestamps.length < 2 || chartW <= 0) return;
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(
Math.max(0, Math.min(1, relX / chartW)) * (timestamps.length - 1),
);
setTooltip({ x: e.clientX - rect.left, index });
},
[timestamps.length, chartW],
[timestamps.length, chartW, yLabelW],
);
return (
@@ -156,7 +139,7 @@ export function LineChart({
return (
<g key={ti}>
<line
x1={Y_LABEL_W}
x1={yLabelW}
y1={y}
x2={width}
y2={y}
@@ -165,7 +148,7 @@ export function LineChart({
strokeOpacity={0.6}
/>
<text
x={Y_LABEL_W - 6}
x={yLabelW - 6}
y={y + 4}
fontSize={10}
textAnchor="end"
@@ -179,15 +162,15 @@ export function LineChart({
{/* Left + bottom border */}
<line
x1={Y_LABEL_W}
x1={yLabelW}
y1={TOP_PAD}
x2={Y_LABEL_W}
x2={yLabelW}
y2={TOP_PAD + drawH}
stroke="var(--border)"
strokeWidth={1}
/>
<line
x1={Y_LABEL_W}
x1={yLabelW}
y1={TOP_PAD + drawH}
x2={width}
y2={TOP_PAD + drawH}
@@ -209,7 +192,7 @@ export function LineChart({
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
transform={`translate(${Y_LABEL_W},0)`}
transform={`translate(${yLabelW},0)`}
/>
);
})}
@@ -217,13 +200,9 @@ export function LineChart({
{/* Crosshair */}
{tooltip !== null && timestamps.length > 1 && chartW > 0 && (
<line
x1={
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
}
x1={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
y1={TOP_PAD}
x2={
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
}
x2={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
y2={TOP_PAD + drawH}
stroke="var(--muted-foreground)"
strokeOpacity={0.4}
@@ -237,7 +216,7 @@ export function LineChart({
const isFirst = li === 0;
const isLast = li === xTickIndices.length - 1;
const x =
Y_LABEL_W + (i / Math.max(1, timestamps.length - 1)) * chartW;
yLabelW + (i / Math.max(1, timestamps.length - 1)) * chartW;
return (
<text
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 { cn } from "@/lib/utils";
import { TileWall } from "@/components/tile-grid/TileWall";
import { BottomEdge } from "@/components/tile-grid/edge";
import {
PANEL,
gridSizeStyle,
type GridSize,
type PanelDensity,
type PanelViewMode,
@@ -91,7 +91,10 @@ function columnVisibility(hideBelow?: "md" | "lg"): string {
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>({
items,
view,
@@ -132,21 +135,18 @@ export function DataView<T>({
if (view === "grid") {
return (
<div
className={cn(
"grid",
PANEL.gap,
!size && gridClass(columns),
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
<TileWall
size={size}
minWidth={minCardWidth ?? 320}
gridClassName={size ? undefined : gridClass(columns)}
className={className}
>
{items.map((item) => (
<div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)}
</div>
))}
</div>
</TileWall>
);
}
@@ -154,8 +154,9 @@ export function DataView<T>({
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
return (
<BottomEdge>
<div
className={cn("@container border border-border bg-card", className)}
className={cn("@container", className)}
style={
{
"--data-view-cols": templates.base,
@@ -168,7 +169,7 @@ export function DataView<T>({
<div
className={cn(
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) => (
@@ -194,7 +195,7 @@ export function DataView<T>({
onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn(
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,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
@@ -217,5 +218,6 @@ export function DataView<T>({
})}
</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 { useInSeamlessTile } from "@/components/tile-grid/context";
import { cn } from "@/lib/utils";
/** 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. */
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 (
<div className="flex min-h-0 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<span className="text-muted-foreground">{icon}</span>
<span className="flex-1 truncate text-xs font-semibold uppercase tracking-widest text-muted-foreground">
<div
data-tile-card=""
className={cn(
"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}
</span>
{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,
Grid3X3,
List,
RefreshCw,
Rows3,
Search,
X,
@@ -14,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import {
Select,
SelectContent,
@@ -116,6 +118,9 @@ export function PanelShell({
const bands = (
<>
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
{toolbar && (
<div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
@@ -123,9 +128,6 @@ export function PanelShell({
{toolbar}
</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. */
/** 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({
value,
onChange,
@@ -333,7 +388,7 @@ export function Segmented<T extends string>({
title={option.title ?? option.label}
onClick={() => onChange(option.value)}
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",
active
? "bg-accent-brand/10 text-accent-brand"
@@ -458,7 +513,7 @@ function ToggleButton({
aria-label={title}
aria-pressed={active}
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",
active
? "bg-accent-brand/10 text-accent-brand"
@@ -513,13 +568,18 @@ export function GroupHeading({
title,
count,
action,
plain,
className,
}: {
title: string;
count?: number;
action?: React.ReactNode;
/** No rule after the title. The default inside a tile layout. */
plain?: boolean;
className?: string;
}) {
const tiled = useInSeamlessTile();
const noRule = plain ?? tiled;
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
@@ -530,7 +590,11 @@ export function GroupHeading({
{count}
</span>
)}
{noRule ? (
<div className="flex-1" />
) : (
<div className="h-px flex-1 bg-border" />
)}
{action}
</div>
);
+15 -4
View File
@@ -1,5 +1,7 @@
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
import { useState } from "react";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import { cn } from "@/lib/utils";
import type React from "react";
export function SectionCard({
@@ -18,19 +20,28 @@ export function SectionCard({
/** Lets settings search jump to this card. */
rowId?: string;
}) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return (
<div
data-tile-card=""
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">
<span className="text-muted-foreground">{icon}</span>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
<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">
{title}
</span>
{action && <div className="ml-auto">{action}</div>}
</div>
<div className="px-3 md:px-4 py-1">{children}</div>
<div className="px-3 py-1">{children}</div>
</div>
);
}
+2 -2
View File
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
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,
)}
{...props}
@@ -105,7 +105,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
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,
)}
{...props}
+4 -4
View File
@@ -154,7 +154,7 @@ function Select2({
aria-expanded={open}
aria-required={required}
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,
)}
onKeyDown={(event) => {
@@ -189,7 +189,7 @@ function Select2({
value={query}
onChange={(event) => setQuery(event.target.value)}
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
/>
</div>
@@ -198,7 +198,7 @@ function Select2({
className="max-h-64 overflow-y-auto p-1 thin-scrollbar"
>
{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")}
</div>
) : (
@@ -222,7 +222,7 @@ function Select2({
aria-selected={isSelected}
disabled={option.disabled}
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",
)}
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 { cn } from "@/lib/utils";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { TileColumn } from "@/components/tile-grid/TileColumn";
import {
PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON,
@@ -397,6 +398,7 @@ export function InlineView({
onBeforeClose,
scroll = true,
bare = false,
stacked = false,
className,
children,
}: {
@@ -416,6 +418,11 @@ export function InlineView({
scroll?: boolean;
/** Draw children edge to edge, for views with their own bands. */
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;
children: React.ReactNode;
}) {
@@ -462,6 +469,10 @@ export function InlineView({
>
{bare ? (
children
) : stacked ? (
<TileColumn className={cn("w-full", className)}>
{children}
</TileColumn>
) : (
<div
className={cn(
+1 -1
View File
@@ -96,7 +96,7 @@ export function TabStrip({
<div
className={cn(
"flex min-w-0 items-center",
variant === "secondary" && "border-t border-border bg-card",
variant === "secondary" && "border-t border-border",
className,
)}
>
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
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,
)}
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);
}
+89
View File
@@ -0,0 +1,89 @@
/* 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 line = reachesRef.current ? 0 : 1;
const end =
element.getBoundingClientRect().bottom -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
line;
// Compared with the whole scrollable height, so content below keeps the line.
setReaches(end >= scroller.scrollHeight - line - 0.5);
};
update();
const observer = new ResizeObserver(update);
observer.observe(scroller);
observer.observe(element);
for (const child of Array.from(scroller.children)) observer.observe(child);
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 };
for (const [key, value] of Object.entries(patch)) {
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>) ?? {}),
...(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") {
const prev = (merged.onboarding ?? {}) as Record<string, unknown>;
const next = value as Record<string, unknown>;
@@ -6,6 +6,24 @@ export interface DashboardCardRenderProps {
isVisible: boolean;
/** The shell, e.g. to open the full-page version of the card. */
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";
/** "framed" gets a titled header from the dashboard; "bare" draws its own. */
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>;
}
+28
View File
@@ -79,6 +79,34 @@ export function createPluginApi(pluginId: string): 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
* 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;
if (!label) return null;
return (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
{icon && <span className="text-accent-brand shrink-0">{icon}</span>}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
<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>
)}
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label}
</span>
</div>
+35
View File
@@ -609,6 +609,7 @@
"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.",
"hostClickNewTab": "Always open a new tab",
"hostClickHostPage": "Open the host page, when a plugin offers one",
"hostClickFocusExisting": "Switch to the open tab",
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
"openInNewTab": "Open in new tab",
@@ -1250,6 +1251,10 @@
"title": "Manage hosts as read only",
"consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else."
},
"hosts:connect-policy": {
"title": "Decide who can connect",
"consequence": "Can block or allow every SSH and remote desktop connection to any host."
},
"desktop:window": {
"title": "Open desktop windows",
"consequence": "Only in the Termix desktop app."
@@ -1675,6 +1680,12 @@
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
},
"pager": {
"previous": "Previous",
"next": "Next",
"page": "Page {{page}}",
"pageOf": "Page {{page}} of {{pages}}"
}
},
"fields": {
@@ -1693,5 +1704,29 @@
"admin": "Admin",
"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>
{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">
{t("looks.defaultsLabel")}
</span>
+21
View File
@@ -41,6 +41,10 @@ import {
type KeybindingEditorProps,
} from "@/shell/keybinding-registry";
import { registerHostProtocol } from "@/sidebar/host-protocols";
import {
registerHostSection,
type HostSectionRenderProps,
} from "@/shell/host-sections";
import {
registerDashboardCard,
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) {
return track(
registerHostContextMenuItem({
@@ -515,6 +534,8 @@ export function createPluginApp(
defaultHeight: card.defaultHeight,
defaultPanel: card.defaultPanel,
frame: card.frame,
defaultSize: card.defaultSize,
entries: card.entries,
component: scoped(
card.component as unknown as ComponentType<DashboardCardRenderProps>,
),
+45
View File
@@ -127,6 +127,26 @@ export {
type GridLayout,
type GridSlot,
} 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 { type ConnectionStatus } from "@/components/connection/connection-status";
export {
@@ -203,6 +223,19 @@ export { WidgetTitle } from "@/lib/widget-title";
export { runVisibleInterval } from "@/lib/visible-interval";
export { useIsMobile } from "@/hooks/use-mobile";
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 { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog";
@@ -285,8 +318,10 @@ export {
Facts,
GroupHeading,
PANEL,
PanelFrame,
PanelSearch,
PanelShell,
RefreshButton,
Segmented,
ViewToggle,
type GridSize,
@@ -823,3 +858,13 @@ export {
rightDockableIds,
useRailItems,
} 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,
} from "@/sidebar/host-contributions";
export { registerHostProtocol } from "@/sidebar/host-protocols";
export { registerHostSection, resetHostSections } from "@/shell/host-sections";
export {
RAIL_ITEMS,
registerRailItem,
+43 -41
View File
@@ -17,8 +17,9 @@ import {
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/button";
import { Card } from "@/components/card";
import { Facts, PANEL } from "@/components/panel-layout";
import { Facts } 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 {
Select,
@@ -41,6 +42,7 @@ import {
betaToTry,
describeContributions,
formatBytes,
hasSourceBadge,
orderByRisk,
reportIssueUrl,
requestFeatureUrl,
@@ -52,7 +54,7 @@ import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes";
import type { PluginsManager } from "./use-plugins-manager";
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({
plugin,
@@ -69,13 +71,16 @@ export function PluginDetail({
const running = plugin.status === "running";
const failed = plugin.status === "failed" || plugin.status === "blocked";
const compatible = plugin.versions.filter((v) => v.compatible);
const [picked, setPicked] = useState<string>(
plugin.version ?? plugin.latestVersion ?? "",
);
// A local build can be a version the registry never listed.
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(() => {
setPicked(plugin.version ?? plugin.latestVersion ?? "");
}, [plugin.version, plugin.latestVersion]);
setPicked(startVersion);
}, [startVersion]);
const contributions = describeContributions(plugin.contributes);
// Only people who can manage plugins get here, and they see admin settings
@@ -93,10 +98,8 @@ export function PluginDetail({
const tryableBeta = betaToTry(plugin);
return (
<div
className={`mx-auto flex w-full max-w-5xl flex-col ${PANEL.gap} ${PANEL.body}`}
>
<Card className="flex-row flex-wrap items-start gap-4 px-4 py-4">
<TileStack>
<div className="flex flex-row flex-wrap items-start gap-3 p-3">
<PluginIconBox
name={plugin.icon}
size="lg"
@@ -109,10 +112,12 @@ export function PluginDetail({
</span>
<Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />}
{hasSourceBadge(plugin.source) && (
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
)}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
{plugin.version ? (
@@ -223,10 +228,10 @@ export function PluginDetail({
</Button>
)}
</div>
</Card>
</div>
{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" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium">
@@ -242,7 +247,7 @@ export function PluginDetail({
)}
{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" />
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.runsOnServerHint")}
@@ -250,13 +255,13 @@ export function PluginDetail({
</div>
)}
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div className="flex flex-col gap-2 lg:col-span-2">
<div className="grid grid-cols-1 lg:grid-cols-3">
<TileStack className="-mb-px border-border lg:col-span-2 lg:border-r">
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
{plugin.features.length > 0 && (
<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) => (
<li
key={feature}
@@ -277,11 +282,11 @@ export function PluginDetail({
key={capability}
capability={capability}
showId
className="px-4 py-2.5"
className="py-2.5"
/>
))}
{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")}
</div>
)}
@@ -292,10 +297,7 @@ export function PluginDetail({
<SectionCard title={t("plugins.manager.envVars")} icon={null}>
<div className="divide-y divide-border">
{plugin.env.map((v) => (
<div
key={v.name}
className="flex flex-col gap-0.5 px-4 py-2.5"
>
<div key={v.name} className="flex flex-col gap-0.5 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<code className="font-mono text-xs font-semibold">
{v.name}
@@ -331,12 +333,12 @@ export function PluginDetail({
onDelete={() => manager.removeData(plugin)}
/>
)}
</div>
</TileStack>
<div className="flex flex-col gap-2">
<TileStack className="-mb-px">
{contributions.length > 0 && (
<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">
{contributions.map((c) => (
<span
@@ -358,7 +360,7 @@ export function PluginDetail({
{plugin.inRegistry && compatible.length > 0 && (
<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">
{plugin.installed
? t("plugins.manager.versionPickHintInstalled")
@@ -414,7 +416,7 @@ export function PluginDetail({
{plugin.installed && (
<SectionCard title={t("plugins.manager.updates")} icon={null}>
<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">
<span className="text-xs font-medium">
{t("plugins.manager.autoUpdate")}
@@ -431,7 +433,7 @@ export function PluginDetail({
}
/>
</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">
<span className="flex items-center gap-1.5 text-xs font-medium">
<FlaskConical className="size-3 text-muted-foreground" />
@@ -455,7 +457,7 @@ export function PluginDetail({
}
/>
</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">
<span className="flex items-center gap-1.5 text-xs font-medium">
<Pin className="size-3 text-muted-foreground" />
@@ -565,9 +567,9 @@ export function PluginDetail({
</span>
</a>
)}
</TileStack>
</div>
</div>
</div>
</TileStack>
);
}
@@ -625,16 +627,16 @@ function DataCard({
}
>
{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")}
</div>
) : !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")}
</div>
) : (
<div className="-mx-3 -my-1 flex flex-col md:-mx-4">
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
<div className="-mx-3 -my-1 flex flex-col">
<TileWall gridClassName="grid-cols-2 sm:grid-cols-4">
<Stat
label={t("plugins.manager.data.tables")}
value={`${data.tables.length}`}
@@ -655,13 +657,13 @@ function DataCard({
label={t("plugins.manager.data.files")}
value={formatBytes(data.filesBytes)}
/>
</div>
</TileWall>
{data.tables.length > 0 && (
<div className="divide-y divide-border border-t border-border">
{data.tables.map((table) => (
<div
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]">
{table.name}
@@ -675,7 +677,7 @@ function DataCard({
))}
</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>
{t("plugins.manager.data.migrations", {
count: data.migrations.length,
@@ -701,7 +703,7 @@ function Stat({
hint?: string;
}) {
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">
{label}
</span>
+3 -3
View File
@@ -62,7 +62,7 @@ function Release({
.filter((group) => group.items.length > 0);
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">
<span className="text-xs font-semibold">{row.version}</span>
{row.version === installedVersion && (
@@ -180,7 +180,7 @@ export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
) : undefined
}
>
<div className="-mx-3 -my-1 md:-mx-4">
<div className="-mx-3 -my-1">
<Release row={latest} installedVersion={plugin.version} />
{showOlder && older.length > 0 && (
<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;
return (
<div className="aspect-video w-full border border-border bg-black">
<div className="aspect-video w-full bg-black">
<iframe
src={src}
title={t("plugins.manager.videoTitle", { name })}
+42 -45
View File
@@ -42,6 +42,8 @@ import {
type PanelViewMode,
} from "@/components/panel-layout";
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 { cn } from "@/lib/utils";
import {
@@ -57,6 +59,7 @@ import { PluginDetail } from "./PluginDetail";
import {
categoriesOf,
describeContributions,
hasSourceBadge,
isSevere,
kindsOf,
matchesQuery,
@@ -321,7 +324,7 @@ function Toolbar({
children?: React.ReactNode;
}) {
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">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -346,8 +349,13 @@ function Group({
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-2.5">
<GroupHeading title={title} count={count} />
<section className="flex flex-col">
<GroupHeading
plain
title={title}
count={count}
className="border-b border-border px-3 py-1.5"
/>
{children}
</section>
);
@@ -653,7 +661,7 @@ function ActiveFilters({
});
}
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">
{t("plugins.manager.filters.results", { count: shown })}
</span>
@@ -703,7 +711,7 @@ function Pager({
const per = Number(size);
if (total <= Number(PAGE_SIZES[0])) return null;
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">
{t("plugins.manager.pager.range", {
from: (page - 1) * per + 1,
@@ -895,7 +903,7 @@ function InstalledSection({
rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />}
>
{(filtersButton) => (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<div className="flex flex-col">
<Toolbar
query={query}
onQuery={onQuery}
@@ -1071,10 +1079,12 @@ function InstalledCard({
facts={
<>
{plugin.unverified && <UnverifiedBadge />}
{hasSourceBadge(plugin.source) && (
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
)}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</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 ? (
<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
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}
>
<TriangleAlert className="size-3 shrink-0" />
<span className="truncate">
{plugin.status === "blocked"
? t("plugins.manager.status.blocked")
: t("plugins.manager.status.failed")}
</span>
</div>
) : !running ? (
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
<span className="text-[11px] text-muted-foreground">
{plugin.status === "server"
</span>
) : (
<span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<StatusDot tone={running ? "ok" : "idle"} className="size-1.5" />
<span className="truncate">
{!running
? plugin.status === "server"
? t("plugins.manager.status.server")
: plugin.status === "safeMode"
? t("plugins.manager.status.safeMode")
: t("plugins.manager.status.disabled")}
</span>
</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.status.disabled")
: plugin.pinnedVersion
? t("plugins.manager.pinnedTo", {
version: plugin.pinnedVersion,
})
: t("plugins.manager.status.running")}
</span>
</div>
</span>
)}
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
<div className="flex-1" />
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
<Button
variant="outline"
@@ -1159,11 +1164,7 @@ function InstalledCard({
: t("plugins.manager.enable")}
</Button>
)}
<DocsLink
href={plugin.docs}
variant="icon"
className="ml-auto size-6"
/>
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
{!plugin.defaultLook && (
<Button
variant="ghost"
@@ -1171,10 +1172,7 @@ function InstalledCard({
disabled={busy || locked}
title={t("plugins.manager.uninstall")}
aria-label={t("plugins.manager.uninstall")}
className={cn(
"text-muted-foreground hover:text-destructive",
!plugin.docs && "ml-auto",
)}
className="text-muted-foreground hover:text-destructive"
onClick={() => void manager.uninstall(plugin)}
>
<Trash2 className="size-3" />
@@ -1442,7 +1440,7 @@ function BrowseSection({
}
>
{(filtersButton) => (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<div className="flex flex-col">
<Toolbar
query={query}
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")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")}
@@ -1540,10 +1538,12 @@ function BrowseCard({
onOpen={onOpen}
facts={
<>
{hasSourceBadge(plugin.source) && (
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
)}
{plugin.author && <span>{plugin.author}</span>}
{plugin.latestVersion && <span>v{plugin.latestVersion}</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">
{plugin.unsupported && !plugin.installed
? ""
@@ -1646,9 +1646,9 @@ function UpdatesSection({
}
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<TileStack>
{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">
{t("plugins.manager.updatesReady", { count: plain.length })}
{gated.length > 0 &&
@@ -1664,10 +1664,8 @@ function UpdatesSection({
<Download className="size-3.5" />
{t("plugins.manager.updateAll")}
</Button>
</Card>
</div>
)}
<div className="flex flex-col gap-3">
{[...gated, ...plain, ...pinned].map((p) => (
<UpdateRow
key={p.id}
@@ -1676,8 +1674,7 @@ function UpdatesSection({
onOpen={() => onOpen(p.id)}
/>
))}
</div>
</div>
</TileStack>
);
}
+10 -5
View File
@@ -1,7 +1,12 @@
import { useTranslation } from "react-i18next";
import { PluginIcon } from "@/lib/plugin-icon";
import { cn } from "@/lib/utils";
import { capabilityRisk, formatCount, isSevere } from "./plugin-model";
import {
capabilityRisk,
formatCount,
hasSourceBadge,
isSevere,
} from "./plugin-model";
export function PluginIconBox({
name,
@@ -80,7 +85,7 @@ export function UnverifiedBadge() {
const { t } = useTranslation();
return (
<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")}
>
{t("plugins.manager.unverified")}
@@ -97,14 +102,14 @@ export function SourceBadge({
registryName?: string | null;
}) {
const { t } = useTranslation();
if (source !== "community" && source !== "custom") return null;
if (!hasSourceBadge(source)) return null;
const label =
source === "community"
? t("plugins.manager.registries.communityBadge")
: (registryName ?? t("plugins.manager.registries.customBadge"));
return (
<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`, {
name: label,
})}
@@ -118,7 +123,7 @@ export function BetaBadge() {
const { t } = useTranslation();
return (
<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")}
>
{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`
: 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;
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col">
{plugins.map((plugin) => (
<HostPluginSection
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 }) {
return (
<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">
{children}
</div>
<div className="flex flex-col flex-1 min-h-0 w-full">{children}</div>
</div>
);
}
+2 -2
View File
@@ -19,13 +19,13 @@ export function AuthEnrollmentSections({
);
if (sections.length === 0) return null;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col divide-y divide-border">
{sections.map((entry) => {
const Enrollment = entry.enrollment!;
return (
<div
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">
{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}
/>,
);
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("gap-");
expect(grid.style.gridTemplateColumns).toContain("/ 3");
});
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more