feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 11:24:16 -05:00
1 parent d2ff5fc78f
commit 3b2c849514
59 files changed
+31807 -2977

No files matched your search

+14
View File
@@ -1,4 +1,18 @@
{
"permissions": {
"view": {
"title": "View fleets",
"description": "See fleets, their members and inventory."
},
"manage": {
"title": "Manage fleets",
"description": "Create, edit and delete fleets, change membership, and share fleets with other users or roles."
},
"execute": {
"title": "Run fleet actions",
"description": "Run commands and package actions across a fleet, and transfer files to and from its member hosts."
}
},
"newUi": {
"sidebar": {
"fleets": {
+28
View File
@@ -16,12 +16,20 @@
},
"capabilities": [
"hosts:read",
"hosts:write",
"credentials:use",
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
],
"provides": [
{
"service": "fleets.access",
"version": "1.0.0",
"permission": "fleets.view"
}
],
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
"contributes": {
@@ -39,6 +47,26 @@
"titleKey": "nav.fleets",
"icon": "Boxes"
}
],
"permissions": [
{
"name": "view",
"titleKey": "permissions.view.title",
"descriptionKey": "permissions.view.description",
"defaultRoles": ["user"]
},
{
"name": "manage",
"titleKey": "permissions.manage.title",
"descriptionKey": "permissions.manage.description",
"defaultRoles": ["user"]
},
{
"name": "execute",
"titleKey": "permissions.execute.title",
"descriptionKey": "permissions.execute.description",
"defaultRoles": ["user"]
}
]
}
}
@@ -0,0 +1,42 @@
-- fleets 0001: adopt_fleets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `fleets` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`description` text,
`color` text,
`icon` text,
`tag_rules` text,
`sync_id` varchar(255) UNIQUE,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `fleets` TO `p_fleets_fleets`;
CREATE TABLE IF NOT EXISTS `fleet_members` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`fleet_id` int NOT NULL,
`host_id` int NOT NULL,
`added_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`fleet_id`) REFERENCES `p_fleets_fleets` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `fleet_members` TO `p_fleets_members`;
CREATE TABLE IF NOT EXISTS `fleet_inventory` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`host_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`os_pretty_name` text,
`kernel` text,
`architecture` text,
`hostname` text,
`uptime_seconds` int,
`ip` text,
`package_manager` text,
`collected_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `fleet_inventory` TO `p_fleets_inventory`;
@@ -0,0 +1,46 @@
-- fleets 0001: adopt_fleets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "fleets" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"description" text,
"color" text,
"icon" text,
"tag_rules" text,
"sync_id" varchar(255) UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";
CREATE TABLE IF NOT EXISTS "fleet_members" (
"id" serial PRIMARY KEY,
"fleet_id" integer NOT NULL,
"host_id" integer NOT NULL,
"added_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("fleet_id") REFERENCES "p_fleets_fleets" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_members_fleet_host" ON "fleet_members" ("fleet_id", "host_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_members_host" ON "fleet_members" ("host_id");
ALTER TABLE "fleet_members" RENAME TO "p_fleets_members";
CREATE TABLE IF NOT EXISTS "fleet_inventory" (
"id" serial PRIMARY KEY,
"host_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"os_pretty_name" text,
"kernel" text,
"architecture" text,
"hostname" text,
"uptime_seconds" integer,
"ip" text,
"package_manager" text,
"collected_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_inventory_host" ON "fleet_inventory" ("host_id", "user_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_inventory_user" ON "fleet_inventory" ("user_id");
ALTER TABLE "fleet_inventory" RENAME TO "p_fleets_inventory";
+141
View File
@@ -0,0 +1,141 @@
{
"version": 1,
"tables": {
"fleets": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"description": {
"type": "text"
},
"color": {
"type": "text"
},
"icon": {
"type": "text"
},
"tagRules": {
"type": "text"
},
"syncId": {
"type": "text",
"unique": true
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "fleets"
},
"members": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"fleetId": {
"type": "integer",
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"addedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_fleet_members_fleet_host",
"columns": ["fleetId", "hostId"],
"unique": true
},
{
"name": "idx_fleet_members_host",
"columns": ["hostId"]
}
],
"adopts": "fleet_members"
},
"inventory": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"osPrettyName": {
"type": "text"
},
"kernel": {
"type": "text"
},
"architecture": {
"type": "text"
},
"hostname": {
"type": "text"
},
"uptimeSeconds": {
"type": "integer"
},
"ip": {
"type": "text"
},
"packageManager": {
"type": "text"
},
"collectedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_fleet_inventory_host",
"columns": ["hostId", "userId"],
"unique": true
},
{
"name": "idx_fleet_inventory_user",
"columns": ["userId"]
}
],
"adopts": "fleet_inventory"
}
}
}
@@ -0,0 +1,46 @@
-- fleets 0001: adopt_fleets
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "fleets" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"color" text,
"icon" text,
"tag_rules" text,
"sync_id" text UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";
CREATE TABLE IF NOT EXISTS "fleet_members" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"fleet_id" integer NOT NULL,
"host_id" integer NOT NULL,
"added_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("fleet_id") REFERENCES "p_fleets_fleets" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_members_fleet_host" ON "fleet_members" ("fleet_id", "host_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_members_host" ON "fleet_members" ("host_id");
ALTER TABLE "fleet_members" RENAME TO "p_fleets_members";
CREATE TABLE IF NOT EXISTS "fleet_inventory" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"host_id" integer NOT NULL,
"user_id" text NOT NULL,
"os_pretty_name" text,
"kernel" text,
"architecture" text,
"hostname" text,
"uptime_seconds" integer,
"ip" text,
"package_manager" text,
"collected_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_fleet_inventory_host" ON "fleet_inventory" ("host_id", "user_id");
CREATE INDEX IF NOT EXISTS "idx_fleet_inventory_user" ON "fleet_inventory" ("user_id");
ALTER TABLE "fleet_inventory" RENAME TO "p_fleets_inventory";
+67 -6
View File
@@ -1,14 +1,75 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { startFleetsService, stopFleetsService } from "./routes.js";
import { fleets, fleetMembers, fleetInventory } from "./tables.js";
import { createFleetRepository } from "./repository.js";
import { registerFleetRoutes } from "./routes.js";
/** What other plugins get from ctx.services.get("fleets.access", {userId}). */
export interface FleetsService {
/** The calling user's fleets, without resolving membership. */
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
/** A fleet's effective member hosts (static membership union tag matches). */
members: (
fleetId: number,
) => Promise<Array<{ id: number; name: string | null; ip: string }>>;
/** Creates a fleet owned by the calling user. */
create: (input: {
name: string;
description?: string | null;
}) => Promise<{ id: number; name: string }>;
/** Adds a host the calling user owns to a fleet's static membership. */
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
startFleetsService(ctx.http.router());
const fleetsTable = await ctx.db.define(fleets);
const membersTable = await ctx.db.define(fleetMembers);
const inventoryTable = await ctx.db.define(fleetInventory);
const repo = createFleetRepository(
ctx.db,
ctx.hosts,
fleetsTable,
membersTable,
inventoryTable,
);
registerFleetRoutes(ctx.http.router<Router>(), repo, ctx);
const service: FleetsService = {
list: async () => {
const userId = ctx.currentActor();
if (!userId) return [];
const rows = await repo.listByUser(userId);
return rows.map((f) => ({ id: f.id, name: f.name, color: f.color }));
},
members: async (fleetId) => {
const userId = ctx.currentActor();
if (!userId) return [];
const members = await repo.listEffectiveMembers(userId, fleetId);
return members.map((m) => ({ id: m.id, name: m.name, ip: m.ip }));
},
create: async (input) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("fleets.access.create needs an actor");
const created = await repo.create(userId, input);
return { id: created.id, name: created.name };
},
addMember: async (fleetId, hostId) => {
const userId = ctx.currentActor();
if (!userId) throw new Error("fleets.access.addMember needs an actor");
const fleet = await repo.findById(userId, fleetId);
if (!fleet) throw new Error("Fleet not found");
await repo.addMember(fleetId, hostId);
},
};
ctx.services.provide("fleets.access", service);
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
}
export async function deactivate() {
stopFleetsService();
// Everything above was registered through ctx and is disposed by core.
}
+370
View File
@@ -0,0 +1,370 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type { PluginDatabase, PluginHosts } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The tables come from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface FleetRecord {
id: number;
userId: string;
name: string;
description: string | null;
color: string | null;
icon: string | null;
tagRules: string | null;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export interface FleetCreateInput {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetUpdateInput {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}
export interface FleetInventoryRecord {
id: number;
hostId: number;
userId: string;
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
collectedAt: string;
}
export interface FleetInventoryInput {
osPrettyName: string | null;
kernel: string | null;
architecture: string | null;
hostname: string | null;
uptimeSeconds: number | null;
ip: string | null;
packageManager: string | null;
}
function parseTagRules(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((t): t is string => typeof t === "string")
: [];
} catch {
return [];
}
}
export type FleetRepository = ReturnType<typeof createFleetRepository>;
/**
* Fleets, membership and inventory, written without RETURNING so it runs the
* same on all three engines: an insert is read back by its sync id, an
* update by its own id.
*/
export function createFleetRepository(
db: PluginDatabase,
hosts: PluginHosts,
fleetsTable: Table,
membersTable: Table,
inventoryTable: Table,
) {
const client = () => db.client<Drizzle>();
async function findById(
userId: string,
fleetId: number,
): Promise<FleetRecord | null> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(fleetsTable)
.where(and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)))
.limit(1);
return (rows[0] as FleetRecord) ?? null;
}
async function findBySyncId(syncId: string): Promise<FleetRecord> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(fleetsTable)
.where(eq(fleetsTable.syncId, syncId))
.limit(1);
return rows[0] as FleetRecord;
}
async function listStaticMemberIds(fleetId: number): Promise<number[]> {
const drizzle = await client();
const rows = await drizzle
.select({ hostId: membersTable.hostId })
.from(membersTable)
.where(eq(membersTable.fleetId, fleetId));
return rows.map((r: { hostId: number }) => r.hostId);
}
return {
async listByUser(userId: string): Promise<FleetRecord[]> {
const drizzle = await client();
return drizzle
.select()
.from(fleetsTable)
.where(eq(fleetsTable.userId, userId));
},
findById,
listStaticMemberIds,
async create(
userId: string,
input: FleetCreateInput,
now = new Date().toISOString(),
): Promise<FleetRecord> {
const syncId = randomUUID();
const drizzle = await client();
await drizzle.insert(fleetsTable).values({
userId,
name: input.name,
description: input.description ?? null,
color: input.color ?? null,
icon: input.icon ?? null,
tagRules: input.tagRules ? JSON.stringify(input.tagRules) : null,
syncId,
createdAt: now,
updatedAt: now,
});
await db.persist();
return findBySyncId(syncId);
},
async update(
userId: string,
fleetId: number,
input: FleetUpdateInput,
now = new Date().toISOString(),
): Promise<FleetRecord | null> {
const existing = await findById(userId, fleetId);
if (!existing) return null;
const drizzle = await client();
await drizzle
.update(fleetsTable)
.set({
name: input.name ?? existing.name,
description:
input.description === undefined
? existing.description
: input.description,
color: input.color === undefined ? existing.color : input.color,
icon: input.icon === undefined ? existing.icon : input.icon,
tagRules:
input.tagRules === undefined
? existing.tagRules
: JSON.stringify(input.tagRules),
updatedAt: now,
})
.where(
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
);
await db.persist();
return findById(userId, fleetId);
},
async delete(userId: string, fleetId: number): Promise<boolean> {
const existing = await findById(userId, fleetId);
if (!existing) return false;
const drizzle = await client();
await drizzle
.delete(fleetsTable)
.where(
and(eq(fleetsTable.id, fleetId), eq(fleetsTable.userId, userId)),
);
await db.persist();
return true;
},
async addMember(
fleetId: number,
hostId: number,
now = new Date().toISOString(),
): Promise<void> {
const drizzle = await client();
const existing = await drizzle
.select({ id: membersTable.id })
.from(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
)
.limit(1);
if (existing.length > 0) return;
await drizzle
.insert(membersTable)
.values({ fleetId, hostId, addedAt: now });
await db.persist();
},
async removeMember(fleetId: number, hostId: number): Promise<boolean> {
const drizzle = await client();
const existing = await drizzle
.select({ id: membersTable.id })
.from(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
)
.limit(1);
if (existing.length === 0) return false;
await drizzle
.delete(membersTable)
.where(
and(
eq(membersTable.fleetId, fleetId),
eq(membersTable.hostId, hostId),
),
);
await db.persist();
return true;
},
/**
* Effective membership = static members rows union hosts whose
* comma-separated tags string intersects any tag in the fleet's
* tagRules. Both sets are scoped to hosts the fleet owner can see
* (ctx.hosts.list already resolves owner-only, since a fleet only holds
* one user's hosts) and deduplicated by id.
*/
async listEffectiveMembers(userId: string, fleetId: number) {
const fleet = await findById(userId, fleetId);
if (!fleet) return [];
const staticIds = await listStaticMemberIds(fleetId);
const tagRules = parseTagRules(fleet.tagRules);
const ownedHosts = (await hosts.list()).filter(
(host) => host.userId === userId,
);
const byId = new Map<number, (typeof ownedHosts)[number]>();
for (const host of ownedHosts) {
if (staticIds.includes(host.id)) {
byId.set(host.id, host);
continue;
}
if (tagRules.length === 0) continue;
const hostTags = (host.tags ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
if (hostTags.some((tag) => tagRules.includes(tag))) {
byId.set(host.id, host);
}
}
return Array.from(byId.values());
},
async deleteByUserId(userId: string): Promise<number> {
const drizzle = await client();
const userFleets = await drizzle
.select({ id: fleetsTable.id })
.from(fleetsTable)
.where(eq(fleetsTable.userId, userId));
if (userFleets.length === 0) return 0;
await drizzle.delete(fleetsTable).where(eq(fleetsTable.userId, userId));
await db.persist();
return userFleets.length;
},
async upsertInventory(
userId: string,
hostId: number,
input: FleetInventoryInput,
now = new Date().toISOString(),
): Promise<FleetInventoryRecord> {
const drizzle = await client();
const existing = await drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.hostId, hostId),
eq(inventoryTable.userId, userId),
),
)
.limit(1);
if (existing[0]) {
await drizzle
.update(inventoryTable)
.set({ ...input, collectedAt: now })
.where(eq(inventoryTable.id, existing[0].id));
await db.persist();
const [updated] = await drizzle
.select()
.from(inventoryTable)
.where(eq(inventoryTable.id, existing[0].id))
.limit(1);
return updated as FleetInventoryRecord;
}
await drizzle
.insert(inventoryTable)
.values({ userId, hostId, ...input, collectedAt: now });
await db.persist();
const [created] = await drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.hostId, hostId),
eq(inventoryTable.userId, userId),
),
)
.limit(1);
return created as FleetInventoryRecord;
},
async listInventoryForHosts(
userId: string,
hostIds: number[],
): Promise<FleetInventoryRecord[]> {
if (hostIds.length === 0) return [];
const drizzle = await client();
return drizzle
.select()
.from(inventoryTable)
.where(
and(
eq(inventoryTable.userId, userId),
inArray(inventoryTable.hostId, hostIds),
),
);
},
};
}
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+98
View File
@@ -0,0 +1,98 @@
import {
adoptLegacyTable,
defineTable,
id,
integer,
refHost,
refUser,
text,
} from "@termix/plugin-sdk/db";
/**
* A fleet: a named group of hosts, by static membership or tag rules.
*
* Adopted from core's "fleets", so column and index names are the legacy
* ones: the rename carries the existing rows across.
*/
export const fleets = adoptLegacyTable(
"fleets",
defineTable("fleets", {
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
color: text(),
icon: text(),
// JSON array of tag strings, unioned with static members at resolution
// time. Kept to tag-equality matching for v1.
tagRules: text(),
syncId: text().unique(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* Static fleet membership. A host can also be an effective member through a
* tag rule match, resolved in the repository rather than stored here.
*
* fleetId has no declared refUser/refHost FK: the SDK's referenceable targets
* are core's users and ssh_data tables only, not another table this same
* plugin owns. The legacy foreign key to fleets(id) is preserved by the
* adoption migration's CREATE TABLE, which is why the migration file keeps
* the FOREIGN KEY clause the generator does not emit for this column.
*/
export const fleetMembers = adoptLegacyTable(
"fleet_members",
defineTable(
"members",
{
id: id(),
fleetId: integer().notNull(),
hostId: refHost(),
addedAt: text().notNull().defaultNow(),
},
{
// fleetId leads the unique pair, so listing a fleet's hosts is already
// served. Finding the fleets a host belongs to starts from hostId.
uniques: [
{
name: "idx_fleet_members_fleet_host",
columns: ["fleetId", "hostId"],
},
],
indexes: [{ name: "idx_fleet_members_host", columns: ["hostId"] }],
},
),
);
// Latest-only inventory snapshot per host, overwritten on each refresh - no
// historical log, matching the "latest snapshot only" scope decision.
export const fleetInventory = adoptLegacyTable(
"fleet_inventory",
defineTable(
"inventory",
{
id: id(),
hostId: refHost(),
userId: refUser(),
osPrettyName: text(),
kernel: text(),
architecture: text(),
hostname: text(),
uptimeSeconds: integer(),
ip: text(),
packageManager: text(),
collectedAt: text().notNull().defaultNow(),
},
{
// hostId leads the unique pair; a user's whole inventory reads by userId.
uniques: [
{ name: "idx_fleet_inventory_host", columns: ["hostId", "userId"] },
],
indexes: [{ name: "idx_fleet_inventory_user", columns: ["userId"] }],
},
),
);
export const tables = [fleets, fleetMembers, fleetInventory];
@@ -1,6 +1,7 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage, cn } from "./helpers.js";
import { useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input, Button } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import {
ArrowDown,
@@ -11,16 +12,7 @@ import {
Search,
Server,
} from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
import {
listFleets,
getFleetInventory,
refreshFleetInventory,
type FleetRow,
type FleetInventoryEntry,
} from "./fleets-api";
import type { FleetsApi, FleetRow, FleetInventoryEntry } from "./fleets-api.js";
type SortKey =
| "hostName"
@@ -90,9 +82,11 @@ function SortHeader({
}
export function FleetInventoryTab({
api,
fleetId,
isVisible = true,
}: {
api: FleetsApi;
fleetId?: number;
isVisible?: boolean;
}) {
@@ -113,10 +107,11 @@ export function FleetInventoryTab({
}, [fleetId]);
useEffect(() => {
listFleets()
api
.list()
.then(setFleets)
.catch(() => {});
}, []);
}, [api]);
useEffect(() => {
if (!fleets.length) return;
@@ -129,7 +124,7 @@ export function FleetInventoryTab({
if (selectedFleetId === undefined) return;
setLoading(true);
try {
const data = await getFleetInventory(selectedFleetId);
const data = await api.inventory(selectedFleetId);
setEntries(data);
} catch (error) {
const message = getErrorMessage(error, "");
@@ -137,7 +132,7 @@ export function FleetInventoryTab({
} finally {
setLoading(false);
}
}, [selectedFleetId, t]);
}, [api, selectedFleetId, t]);
useEffect(() => {
if (!isVisible) return;
@@ -148,7 +143,7 @@ export function FleetInventoryTab({
if (selectedFleetId === undefined) return;
setRefreshing(true);
try {
const { results } = await refreshFleetInventory(selectedFleetId);
const { results } = await api.refreshInventory(selectedFleetId);
const failed = results.filter((r) => !r.success).length;
if (failed > 0) {
toast.warning(
+108 -119
View File
@@ -1,6 +1,33 @@
import { getErrorMessage } from "@/lib/error-message.js";
import {
getErrorMessage,
extractSnippetInputs,
type SnippetInput,
} from "./helpers.js";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
useTranslation,
useHosts,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import {
Button,
Input,
Textarea,
Checkbox,
Badge,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
FOLDER_COLORS,
Select2,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import {
Boxes,
@@ -23,61 +50,19 @@ import {
User,
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { Badge } from "@/components/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { FOLDER_COLORS } from "@/lib/theme";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getUserList } from "@/api/user-management-api";
import {
getRoles,
type SharePermissionLevel,
type ShareTarget,
} from "@/api/rbac-api";
import { Select2 } from "@/components/select2";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import {
extractSnippetInputs,
type SnippetInput,
} from "@/lib/snippet-variables";
import {
listFleets,
createFleet,
updateFleet,
deleteFleet,
getFleetMembers,
addFleetMember,
removeFleetMember,
runFleetCommand,
pushFleetFile,
pullFleetFile,
getFleetInventory,
refreshFleetInventory,
runFleetPackageAction,
shareFleet,
type FleetRow,
type FleetMemberRow,
type FleetHostResult,
type FleetInventoryEntry,
type FleetPackageAction,
type FleetShareHostResult,
} from "./fleets-api";
import type { SSHHost } from "@/types/index";
import type {
FleetsApi,
FleetRow,
FleetMemberRow,
FleetHostResult,
FleetInventoryEntry,
FleetPackageAction,
FleetShareHostResult,
ShareableUser,
ShareableRole,
SharePermissionLevel,
ShareTarget,
} from "./fleets-api.js";
const SHARE_PERMISSION_LEVELS: SharePermissionLevel[] = [
"connect",
@@ -144,11 +129,13 @@ function ResultsList({ results }: { results: FleetHostResult[] }) {
}
function FleetFormDialog({
api,
open,
onClose,
fleet,
onSaved,
}: {
api: FleetsApi;
open: boolean;
onClose: () => void;
fleet: FleetRow | null;
@@ -182,7 +169,7 @@ function FleetFormDialog({
setSaving(true);
try {
if (fleet) {
await updateFleet(fleet.id, {
await api.update(fleet.id, {
name: name.trim(),
description: description.trim() || null,
color,
@@ -190,7 +177,7 @@ function FleetFormDialog({
});
toast.success(t("newUi.sidebar.fleets.fleetUpdated"));
} else {
await createFleet({
await api.create({
name: name.trim(),
description: description.trim() || null,
color,
@@ -304,6 +291,7 @@ function FleetFormDialog({
}
function MemberPickerDialog({
api,
open,
onClose,
fleetId,
@@ -311,10 +299,11 @@ function MemberPickerDialog({
memberIds,
onChanged,
}: {
api: FleetsApi;
open: boolean;
onClose: () => void;
fleetId: number;
allHosts: SSHHost[];
allHosts: PluginHostRecord[];
memberIds: Set<number>;
onChanged: () => void;
}) {
@@ -330,13 +319,14 @@ function MemberPickerDialog({
[allHosts, search],
);
async function toggleHost(host: SSHHost, isMember: boolean) {
setBusyId(host.id);
async function toggleHost(host: PluginHostRecord, isMember: boolean) {
const hostId = Number(host.id);
setBusyId(hostId);
try {
if (isMember) {
await removeFleetMember(fleetId, host.id);
await api.removeMember(fleetId, hostId);
} else {
await addFleetMember(fleetId, host.id);
await api.addMember(fleetId, hostId);
}
onChanged();
} catch (error) {
@@ -371,7 +361,8 @@ function MemberPickerDialog({
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{visibleHosts.map((host) => {
const isMember = memberIds.has(host.id);
const hostId = Number(host.id);
const isMember = memberIds.has(hostId);
return (
<label
key={host.id}
@@ -379,7 +370,7 @@ function MemberPickerDialog({
>
<Checkbox
checked={isMember}
disabled={busyId === host.id}
disabled={busyId === hostId}
onCheckedChange={() => toggleHost(host, isMember)}
/>
<span className="truncate flex-1">{host.name}</span>
@@ -411,10 +402,12 @@ function MemberPickerDialog({
}
function FleetShareDialog({
api,
open,
onClose,
fleet,
}: {
api: FleetsApi;
open: boolean;
onClose: () => void;
fleet: FleetRow | null;
@@ -422,12 +415,8 @@ function FleetShareDialog({
const { t } = useTranslation();
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [shareUsers, setShareUsers] = useState<
{ id: string; username: string }[]
>([]);
const [shareRoles, setShareRoles] = useState<
{ id: number; name: string; displayName?: string }[]
>([]);
const [shareUsers, setShareUsers] = useState<ShareableUser[]>([]);
const [shareRoles, setShareRoles] = useState<ShareableRole[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
new Set(),
);
@@ -457,26 +446,13 @@ function FleetShareDialog({
setTargetTab("user");
setSummary(null);
Promise.all([
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
api.shareTargetUsers().catch(() => ({ users: [] })),
api.shareTargetRoles().catch(() => ({ roles: [] })),
]).then(([usersRes, rolesRes]) => {
setShareUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.userId),
username: u.username,
})),
);
setShareRoles(
(rolesRes.roles ?? [])
.filter((r) => !r.isSystem)
.map((r) => ({
id: Number(r.id),
name: r.name,
displayName: r.displayName,
})),
);
setShareUsers(usersRes.users ?? []);
setShareRoles(rolesRes.roles ?? []);
});
}, [open]);
}, [open, api]);
const filteredUsers = useMemo(() => {
const q = search.trim().toLowerCase();
@@ -523,7 +499,7 @@ function FleetShareDialog({
setSubmitting(true);
try {
const result = await shareFleet(fleet.id, {
const result = await api.share(fleet.id, {
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
@@ -808,7 +784,7 @@ function FleetShareDialog({
);
}
function RunCommandTab({ fleetId }: { fleetId: number }) {
function RunCommandTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [command, setCommand] = useState("");
const [inputs, setInputs] = useState<SnippetInput[]>([]);
@@ -828,7 +804,7 @@ function RunCommandTab({ fleetId }: { fleetId: number }) {
setRunning(true);
setResults(null);
try {
const { results: r } = await runFleetCommand(
const { results: r } = await api.runCommand(
fleetId,
command,
inputs.length > 0 ? inputValues : undefined,
@@ -901,7 +877,7 @@ function RunCommandTab({ fleetId }: { fleetId: number }) {
);
}
function TransferTab({ fleetId }: { fleetId: number }) {
function TransferTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [direction, setDirection] = useState<"push" | "pull">("push");
const [remotePath, setRemotePath] = useState("");
@@ -923,14 +899,14 @@ function TransferTab({ fleetId }: { fleetId: number }) {
setResults(null);
try {
if (direction === "push" && file) {
const { results: r } = await pushFleetFile(fleetId, file, remotePath);
const { results: r } = await api.pushFile(fleetId, file, remotePath);
setResults(r);
} else {
const {
results: r,
blob,
fileName,
} = await pullFleetFile(fleetId, remotePath);
} = await api.pullFile(fleetId, remotePath);
setResults(r);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -1018,7 +994,7 @@ function TransferTab({ fleetId }: { fleetId: number }) {
);
}
function InventoryTab({ fleetId }: { fleetId: number }) {
function InventoryTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [entries, setEntries] = useState<FleetInventoryEntry[]>([]);
const [loading, setLoading] = useState(true);
@@ -1027,7 +1003,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
const load = useCallback(async () => {
setLoading(true);
try {
const data = await getFleetInventory(fleetId);
const data = await api.inventory(fleetId);
setEntries(data);
} catch (error) {
const message = getErrorMessage(error, "");
@@ -1035,7 +1011,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
} finally {
setLoading(false);
}
}, [fleetId, t]);
}, [api, fleetId, t]);
useEffect(() => {
load();
@@ -1044,7 +1020,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
async function handleRefresh() {
setRefreshing(true);
try {
const { results } = await refreshFleetInventory(fleetId);
const { results } = await api.refreshInventory(fleetId);
const failed = results.filter((r) => !r.success).length;
if (failed > 0) {
toast.warning(
@@ -1137,7 +1113,7 @@ function InventoryTab({ fleetId }: { fleetId: number }) {
);
}
function PackagesTab({ fleetId }: { fleetId: number }) {
function PackagesTab({ api, fleetId }: { api: FleetsApi; fleetId: number }) {
const { t } = useTranslation();
const [action, setAction] = useState<FleetPackageAction>("install");
const [packageName, setPackageName] = useState("");
@@ -1152,7 +1128,7 @@ function PackagesTab({ fleetId }: { fleetId: number }) {
setRunning(true);
setResults(null);
try {
const { results: r } = await runFleetPackageAction(
const { results: r } = await api.runPackageAction(
fleetId,
action,
action === "upgrade-all" ? undefined : packageName.trim(),
@@ -1240,14 +1216,16 @@ function PackagesTab({ fleetId }: { fleetId: number }) {
type FleetActionView = "run" | "transfer" | "inventory" | "packages";
function FleetDetail({
api,
fleet,
allHosts,
onBack,
onFleetChanged,
onOpenFleetInventory,
}: {
api: FleetsApi;
fleet: FleetRow;
allHosts: SSHHost[];
allHosts: PluginHostRecord[];
onBack: () => void;
onFleetChanged: () => void;
onOpenFleetInventory?: (fleetId: number) => void;
@@ -1262,7 +1240,7 @@ function FleetDetail({
const loadMembers = useCallback(async () => {
setLoadingMembers(true);
try {
const data = await getFleetMembers(fleet.id);
const data = await api.members(fleet.id);
setMembers(data);
} catch (error) {
const message = getErrorMessage(error, "");
@@ -1270,7 +1248,7 @@ function FleetDetail({
} finally {
setLoadingMembers(false);
}
}, [fleet.id, t]);
}, [api, fleet.id, t]);
useEffect(() => {
loadMembers();
@@ -1354,15 +1332,24 @@ function FleetDetail({
)}
</div>
{actionView === "run" && <RunCommandTab fleetId={fleet.id} />}
{actionView === "transfer" && <TransferTab fleetId={fleet.id} />}
{actionView === "inventory" && <InventoryTab fleetId={fleet.id} />}
{actionView === "packages" && <PackagesTab fleetId={fleet.id} />}
{actionView === "run" && (
<RunCommandTab api={api} fleetId={fleet.id} />
)}
{actionView === "transfer" && (
<TransferTab api={api} fleetId={fleet.id} />
)}
{actionView === "inventory" && (
<InventoryTab api={api} fleetId={fleet.id} />
)}
{actionView === "packages" && (
<PackagesTab api={api} fleetId={fleet.id} />
)}
</div>
)}
</div>
<MemberPickerDialog
api={api}
open={pickerOpen}
onClose={() => setPickerOpen(false)}
fleetId={fleet.id}
@@ -1372,6 +1359,7 @@ function FleetDetail({
/>
<FleetShareDialog
api={api}
open={shareOpen}
onClose={() => setShareOpen(false)}
fleet={fleet}
@@ -1381,15 +1369,17 @@ function FleetDetail({
}
export function FleetsPanel({
api,
active,
onOpenFleetInventory,
}: {
api: FleetsApi;
active?: boolean;
onOpenFleetInventory?: (fleetId: number) => void;
}) {
const { t } = useTranslation();
const { hosts: allHosts } = useHosts();
const [fleets, setFleets] = useState<FleetRow[]>([]);
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selectedFleet, setSelectedFleet] = useState<FleetRow | null>(null);
@@ -1401,7 +1391,7 @@ export function FleetsPanel({
const loadFleets = useCallback(async () => {
try {
const data = await listFleets();
const data = await api.list();
setFleets(data);
setSelectedFleet((prev) =>
prev ? (data.find((f) => f.id === prev.id) ?? null) : null,
@@ -1410,28 +1400,24 @@ export function FleetsPanel({
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
}
}, [t]);
}, [api, t]);
useEffect(() => {
if (!active || hasLoadedRef.current) return;
hasLoadedRef.current = true;
setLoading(true);
Promise.all([listFleets(), getSSHHosts({ includeStatus: false })])
.then(([fleetData, hosts]) => {
setFleets(fleetData);
setAllHosts(hosts as unknown as SSHHost[]);
})
loadFleets()
.catch((error: unknown) => {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.loadFailed"));
})
.finally(() => setLoading(false));
}, [active, hasLoadedRef, t]);
}, [active, hasLoadedRef, loadFleets, t]);
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteFleet(deleteTarget.id);
await api.remove(deleteTarget.id);
toast.success(t("newUi.sidebar.fleets.fleetDeleted"));
if (selectedFleet?.id === deleteTarget.id) setSelectedFleet(null);
setDeleteTarget(null);
@@ -1449,6 +1435,7 @@ export function FleetsPanel({
if (selectedFleet) {
return (
<FleetDetail
api={api}
fleet={selectedFleet}
allHosts={allHosts}
onBack={() => setSelectedFleet(null)}
@@ -1578,6 +1565,7 @@ export function FleetsPanel({
</div>
<FleetFormDialog
api={api}
open={formOpen}
onClose={() => setFormOpen(false)}
fleet={editingFleet}
@@ -1585,6 +1573,7 @@ export function FleetsPanel({
/>
<FleetShareDialog
api={api}
open={!!shareTarget}
onClose={() => setShareTarget(null)}
fleet={shareTarget}
+149 -234
View File
@@ -1,9 +1,22 @@
import {
authApi,
handleApiError,
type SharePermissionLevel,
type ShareTarget,
} from "@/main-axios";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export type SharePermissionLevel = "connect" | "view" | "edit" | "manage";
export interface ShareTarget {
type: "user" | "role";
id: string | number;
}
export interface ShareableUser {
id: string;
username: string;
}
export interface ShareableRole {
id: number;
name: string;
displayName: string | null;
}
export interface FleetRow {
id: number;
@@ -25,7 +38,7 @@ export interface FleetMemberRow {
ip: string;
tags: string[];
static: boolean;
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
permissionLevel: SharePermissionLevel | null;
}
export interface FleetHostResult {
@@ -58,243 +71,145 @@ export interface FleetInventoryEntry {
export type FleetPackageAction = "install" | "remove" | "upgrade-all";
export async function listFleets(): Promise<FleetRow[]> {
try {
const response = await authApi.get("/plugin-api/fleets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleets");
}
}
export async function createFleet(fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}): Promise<FleetRow> {
try {
const response = await authApi.post("/plugin-api/fleets", fleetData);
return response.data;
} catch (error) {
throw handleApiError(error, "create fleet");
}
}
export async function updateFleet(
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
): Promise<FleetRow> {
try {
const response = await authApi.patch(
`/plugin-api/fleets/${fleetId}`,
fleetData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "update fleet");
}
}
export async function deleteFleet(
fleetId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(`/plugin-api/fleets/${fleetId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "delete fleet");
}
}
export async function getFleetMembers(
fleetId: number,
): Promise<FleetMemberRow[]> {
try {
const response = await authApi.get(`/plugin-api/fleets/${fleetId}/members`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet members");
}
}
export async function addFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/members`,
{
hostId,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "add fleet member");
}
}
export async function removeFleetMember(
fleetId: number,
hostId: number,
): Promise<{ success: boolean }> {
try {
const response = await authApi.delete(
`/plugin-api/fleets/${fleetId}/members/${hostId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove fleet member");
}
}
export async function runFleetCommand(
fleetId: number,
command: string,
inputValues?: Record<string, string>,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/execute`,
{
command,
...(inputValues ? { inputValues } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet command");
}
}
export async function pushFleetFile(
fleetId: number,
file: File,
remotePath: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return response.data;
} catch (error) {
throw handleApiError(error, "push file to fleet");
}
}
export async function pullFleetFile(
fleetId: number,
remotePath: string,
): Promise<{ results: FleetHostResult[]; blob: Blob; fileName: string }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const resultsHeader = response.headers["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = response.headers["content-disposition"] as
string | undefined;
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
} catch (error) {
throw handleApiError(error, "pull file from fleet");
}
}
export async function getFleetInventory(
fleetId: number,
): Promise<FleetInventoryEntry[]> {
try {
const response = await authApi.get(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch fleet inventory");
}
}
export async function refreshFleetInventory(
fleetId: number,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/inventory`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "refresh fleet inventory");
}
}
export async function runFleetPackageAction(
fleetId: number,
action: FleetPackageAction,
packageName?: string,
): Promise<{ results: FleetHostResult[] }> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/packages`,
{
action,
...(packageName ? { package: packageName } : {}),
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "run fleet package action");
}
}
export interface FleetShareHostResult {
hostId: number;
shared: boolean;
reason?: string;
}
export async function shareFleet(
fleetId: number,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
): Promise<{
export interface FleetShareResult {
success: boolean;
permissionLevel: SharePermissionLevel;
expiresAt: string | null;
hostsShared: number;
hostsTotal: number;
hostResults: FleetShareHostResult[];
}> {
try {
const response = await authApi.post(
`/plugin-api/fleets/${fleetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share fleet");
}
}
/**
* The fleets routes, through the plugin's own client. Paths are relative to
* /plugin-api/fleets/, which the client already points at.
*/
export function createFleetsApi(api: PluginApiClient) {
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
return {
list: () => data(api.get<FleetRow[]>("/")),
create: (fleetData: {
name: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
}) => data(api.post<FleetRow>("/", fleetData)),
update: (
fleetId: number,
fleetData: {
name?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
tagRules?: string[];
},
) => data(api.patch<FleetRow>(`/${fleetId}`, fleetData)),
remove: (fleetId: number) =>
data(api.delete<{ success: boolean }>(`/${fleetId}`)),
members: (fleetId: number) =>
data(api.get<FleetMemberRow[]>(`/${fleetId}/members`)),
addMember: (fleetId: number, hostId: number) =>
data(api.post<{ success: boolean }>(`/${fleetId}/members`, { hostId })),
removeMember: (fleetId: number, hostId: number) =>
data(api.delete<{ success: boolean }>(`/${fleetId}/members/${hostId}`)),
runCommand: (
fleetId: number,
command: string,
inputValues?: Record<string, string>,
) =>
data(
api.post<{ results: FleetHostResult[] }>(`/${fleetId}/execute`, {
command,
...(inputValues ? { inputValues } : {}),
}),
),
pushFile: async (fleetId: number, file: File, remotePath: string) => {
const form = new FormData();
form.append("file", file);
form.append("remotePath", remotePath);
return data(
api.post<{ results: FleetHostResult[] }>(
`/${fleetId}/transfer/push`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
),
);
},
pullFile: async (
fleetId: number,
remotePath: string,
): Promise<{
results: FleetHostResult[];
blob: Blob;
fileName: string;
}> => {
const response = await api.post<Blob>(
`/${fleetId}/transfer/pull`,
{ remotePath },
{ responseType: "blob" },
);
const headers = (response as { headers?: Record<string, string> })
.headers;
const resultsHeader = headers?.["x-fleet-transfer-results"];
const results: FleetHostResult[] = resultsHeader
? JSON.parse(atob(resultsHeader))
: [];
const disposition = headers?.["content-disposition"];
const match = disposition?.match(/filename="([^"]+)"/);
const fileName = match?.[1] ?? "fleet-transfer.zip";
return { results, blob: response.data, fileName };
},
inventory: (fleetId: number) =>
data(api.get<FleetInventoryEntry[]>(`/${fleetId}/inventory`)),
refreshInventory: (fleetId: number) =>
data(api.post<{ results: FleetHostResult[] }>(`/${fleetId}/inventory`)),
runPackageAction: (
fleetId: number,
action: FleetPackageAction,
packageName?: string,
) =>
data(
api.post<{ results: FleetHostResult[] }>(`/${fleetId}/packages`, {
action,
...(packageName ? { package: packageName } : {}),
}),
),
share: (
fleetId: number,
shareData: {
targets: ShareTarget[];
permissionLevel: SharePermissionLevel;
durationHours?: number;
},
) => data(api.post<FleetShareResult>(`/${fleetId}/share`, shareData)),
shareTargetUsers: () =>
data(api.get<{ users: ShareableUser[] }>("/share-targets/users")),
shareTargetRoles: () =>
data(api.get<{ roles: ShareableRole[] }>("/share-targets/roles")),
};
}
export type FleetsApi = ReturnType<typeof createFleetsApi>;
+44
View File
@@ -0,0 +1,44 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Extract a stable, human-readable message from an unknown thrown value.
* Mirrors src/ui/lib/error-message.ts - kept as a small standalone copy
* rather than a cross-plugin dependency for one pure function.
*/
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
export interface SnippetInput {
key: string;
label: string;
}
const INPUT_PATTERN =
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
/**
* Finds the $INPUT_n placeholders in a command, for the fleet run box's
* input fields. Mirrors src/ui/lib/snippet-variables.ts extractSnippetInputs.
*/
export function extractSnippetInputs(content: string): SnippetInput[] {
const seen = new Map<string, SnippetInput>();
let match: RegExpExecArray | null;
INPUT_PATTERN.lastIndex = 0;
while ((match = INPUT_PATTERN.exec(content)) !== null) {
const digits = match[1] ?? match[3];
const key = `INPUT_${digits}`;
if (!seen.has(key)) {
seen.set(key, { key, label: match[2]?.trim() || `Input ${digits}` });
}
}
return Array.from(seen.values());
}
+29 -25
View File
@@ -4,34 +4,38 @@ import type {
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { FleetsPanel } from "./FleetsPanel";
import { FleetInventoryTab } from "./FleetInventoryTab";
import { listFleets } from "./fleets-api";
import { FleetsPanel } from "./FleetsPanel.js";
import { FleetInventoryTab } from "./FleetInventoryTab.js";
import { createFleetsApi } from "./fleets-api.js";
const INVENTORY_TAB = "fleet-inventory";
function Panel({ active, shell }: PanelProps) {
return (
<FleetsPanel
active={active}
onOpenFleetInventory={(fleetId) =>
shell.openSingletonTab(INVENTORY_TAB, { data: { fleetId } })
}
/>
);
}
function InventoryTab({ tab, isVisible }: TabProps) {
const fleetId = tab.data?.fleetId;
return (
<FleetInventoryTab
fleetId={typeof fleetId === "number" ? fleetId : undefined}
isVisible={isVisible}
/>
);
}
export function activate(app: TermixApp): void {
const api = createFleetsApi(app.api);
function Panel({ active, shell }: PanelProps) {
return (
<FleetsPanel
api={api}
active={active}
onOpenFleetInventory={(fleetId) =>
shell.openSingletonTab(INVENTORY_TAB, { data: { fleetId } })
}
/>
);
}
function InventoryTab({ tab, isVisible }: TabProps) {
const fleetId = tab.data?.fleetId;
return (
<FleetInventoryTab
api={api}
fleetId={typeof fleetId === "number" ? fleetId : undefined}
isVisible={isVisible}
/>
);
}
app.registerRailItem({
id: "fleets",
icon: Boxes,
@@ -51,5 +55,5 @@ export function activate(app: TermixApp): void {
});
// Other plugins (automations' host picker) list fleets through this.
app.registerAction("fleets.list", () => listFleets());
app.registerAction("fleets.list", () => api.list());
}
+159
View File
@@ -0,0 +1,159 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: {
permissions?: string[];
users?: string[];
hosts?: Array<{
id: number;
userId: string;
name?: string;
ip?: string;
tags?: string | null;
}>;
} = {},
): Promise<TestServer> {
const hosts = options.hosts ?? [
{ id: 10, userId: "user-1", name: "host-a", ip: "10.0.0.1" },
{ id: 11, userId: "user-1", name: "host-b", ip: "10.0.0.2" },
];
const db = await createTestDb(pluginDir, {
before: (sqlite) => {
for (const user of options.users ?? ["user-1", "user-2"]) {
sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
// The plugin's fleet_members/fleet_inventory tables FK to ssh_data,
// which ctx.hosts (faked below) never touches - seed it directly so
// the constraint is satisfied the way it is against the real table.
for (const host of hosts) {
sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(host.id);
}
},
});
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? [
"fleets.view",
"fleets.manage",
"fleets.execute",
],
});
// ctx.hosts is capability-checked separately from ctx.db; seed it with the
// same fixture hosts a real deployment would resolve through core.
mock.ctx.hosts.list = async () =>
hosts.map((h) => ({
id: h.id,
userId: h.userId,
name: h.name ?? null,
ip: h.ip ?? "",
port: 22,
username: "root",
tags: h.tags ?? null,
folder: null,
authType: "password",
}));
mock.ctx.hosts.get = async (hostId) => {
const found = hosts.find((h) => h.id === hostId);
if (!found) return null;
return {
id: found.id,
userId: found.userId,
name: found.name ?? null,
ip: found.ip ?? "",
port: 22,
username: "root",
tags: found.tags ?? null,
folder: null,
authType: "password",
};
};
mock.ctx.hosts.checkAccess = async (hostId) => {
const found = hosts.find((h) => h.id === hostId);
if (!found) return { hasAccess: false, isOwner: false, isShared: false };
return {
hasAccess: true,
isOwner: true,
isShared: false,
permissionLevel: "manage",
};
};
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,191 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// fleets/fleet_members/fleet_inventory as core's SQLite bootstrap created
// them before 2.9.0, plus the indexes performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE fleets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
color TEXT,
icon TEXT,
tag_rules TEXT,
sync_id TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE fleet_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fleet_id INTEGER NOT NULL REFERENCES fleets(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_fleet_members_fleet_host ON fleet_members(fleet_id, host_id);
CREATE INDEX idx_fleet_members_host ON fleet_members(host_id);
CREATE TABLE fleet_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
os_pretty_name TEXT,
kernel TEXT,
architecture TEXT,
hostname TEXT,
uptime_seconds INTEGER,
ip TEXT,
package_manager TEXT,
collected_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_fleet_inventory_host ON fleet_inventory(host_id, user_id);
CREATE INDEX idx_fleet_inventory_user ON fleet_inventory(user_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
)
.map((row) => row.name)
.sort();
describe("adopting fleets, fleet_members, fleet_inventory", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO fleets (user_id, name, tag_rules, sync_id) VALUES (?, ?, ?, ?)",
)
.run("user-1", "Web fleet", '["prod-web"]', "sync-a");
sqlite
.prepare(
"INSERT INTO fleet_members (fleet_id, host_id) VALUES (1, 1)",
)
.run();
sqlite
.prepare(
"INSERT INTO fleet_inventory (host_id, user_id, os_pretty_name) VALUES (1, 'user-1', 'Debian')",
)
.run();
},
});
expect(db.applied).toEqual(["0001_adopt_fleets"]);
expect(tableExists("fleets")).toBe(false);
expect(tableExists("fleet_members")).toBe(false);
expect(tableExists("fleet_inventory")).toBe(false);
expect(tableExists("p_fleets_fleets")).toBe(true);
expect(tableExists("p_fleets_members")).toBe(true);
expect(tableExists("p_fleets_inventory")).toBe(true);
const fleetRows = db.sqlite
.prepare("SELECT user_id, name, tag_rules, sync_id FROM p_fleets_fleets")
.all();
expect(fleetRows).toEqual([
{
user_id: "user-1",
name: "Web fleet",
tag_rules: '["prod-web"]',
sync_id: "sync-a",
},
]);
const memberRows = db.sqlite
.prepare("SELECT fleet_id, host_id FROM p_fleets_members")
.all();
expect(memberRows).toEqual([{ fleet_id: 1, host_id: 1 }]);
const inventoryRows = db.sqlite
.prepare(
"SELECT host_id, user_id, os_pretty_name FROM p_fleets_inventory",
)
.all();
expect(inventoryRows).toEqual([
{ host_id: 1, user_id: "user-1", os_pretty_name: "Debian" },
]);
expect(indexOn("p_fleets_members")).toEqual(
["idx_fleet_members_fleet_host", "idx_fleet_members_host"].sort(),
);
expect(indexOn("p_fleets_inventory")).toEqual(
["idx_fleet_inventory_host", "idx_fleet_inventory_user"].sort(),
);
});
it("creates the tables fresh when there are no legacy ones", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("fleets")).toBe(false);
expect(tableExists("p_fleets_fleets")).toBe(true);
expect(tableExists("p_fleets_members")).toBe(true);
expect(tableExists("p_fleets_inventory")).toBe(true);
expect(db.sqlite.prepare("SELECT * FROM p_fleets_fleets").all()).toEqual(
[],
);
});
it("cascades a fleet delete onto its member rows via the fleet_id FK", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
},
});
db.sqlite
.prepare(
"INSERT INTO p_fleets_fleets (id, user_id, name) VALUES (1, 'user-1', 'Fleet')",
)
.run();
db.sqlite
.prepare("INSERT INTO p_fleets_members (fleet_id, host_id) VALUES (1, 1)")
.run();
db.sqlite.exec("DELETE FROM p_fleets_fleets WHERE id = 1");
expect(db.sqlite.prepare("SELECT * FROM p_fleets_members").all()).toEqual(
[],
);
});
it("removes a user's fleets when the user is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
},
});
const insert = db.sqlite.prepare(
"INSERT INTO p_fleets_fleets (user_id, name) VALUES (?, ?)",
);
insert.run("user-1", "Mine");
insert.run("user-2", "Theirs");
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
const names = (
db.sqlite.prepare("SELECT name FROM p_fleets_fleets").all() as {
name: string;
}[]
).map((row) => row.name);
expect(names).toEqual(["Theirs"]);
});
});
@@ -1,103 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { FleetRepository } from "../../../../../src/backend/database/repositories/fleet-repository.js";
describe("FleetRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<{ repository: FleetRepository }> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, tags)
VALUES
(1, 'user-1', 'web-1', '10.0.0.1', 22, 'root', 'password', 'prod-web,edge'),
(2, 'user-1', 'web-2', '10.0.0.2', 22, 'root', 'password', 'prod-web'),
(3, 'user-1', 'db-1', '10.0.0.3', 22, 'root', 'password', 'prod-db'),
(4, 'user-1', 'static-only', '10.0.0.4', 22, 'root', 'password', NULL),
(5, 'user-2', 'other-user-host', '10.0.0.5', 22, 'root', 'password', 'prod-web');
`);
return { repository: new FleetRepository(context, onWrite) };
}
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
let writes = 0;
const { repository } = await createRepository(() => {
writes += 1;
});
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
// host 1 matches by tag AND is added statically - must appear once.
await repository.addMember(fleet.id, 1);
// host 4 has no tags - only reachable via static membership.
await repository.addMember(fleet.id, 4);
const members = await repository.listEffectiveMembers("user-1", fleet.id);
const ids = members.map((m) => m.id).sort((a, b) => a - b);
expect(ids).toEqual([1, 2, 4]);
expect(writes).toBeGreaterThan(0);
});
it("never returns another user's hosts even if tags match", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).not.toContain(5);
});
it("returns only static members when a fleet has no tag rules", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", { name: "static fleet" });
await repository.addMember(fleet.id, 3);
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toEqual([3]);
});
it("removeMember only drops the static row, not tag-based membership", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
await repository.addMember(fleet.id, 1);
await expect(repository.removeMember(fleet.id, 1)).resolves.toBe(true);
// host 1 still matches the tag rule, so it remains an effective member.
const members = await repository.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toContain(1);
});
it("returns an empty list for a fleet the caller does not own", async () => {
const { repository } = await createRepository();
const fleet = await repository.create("user-1", { name: "private" });
await expect(
repository.listEffectiveMembers("user-2", fleet.id),
).resolves.toEqual([]);
});
});
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import type {
PluginHostSummary,
PluginHosts,
} from "@termix/plugin-sdk/backend";
import { pluginDir } from "./helpers";
import {
fleets,
fleetMembers,
fleetInventory,
} from "../../src/backend/tables.js";
import { createFleetRepository } from "../../src/backend/repository.js";
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
return {
list: async () => hosts,
get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null,
checkAccess: async (hostId) => {
const found = hosts.find((h) => h.id === hostId);
return found
? {
hasAccess: true,
isOwner: true,
isShared: false,
permissionLevel: "manage",
}
: { hasAccess: false, isOwner: false, isShared: false };
},
share: async (hostId) => ({ hostId, shared: true }),
listUsers: async () => [],
listRoles: async () => [],
};
}
async function setup(hosts: PluginHostSummary[]) {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(
`INSERT INTO ssh_data (id) VALUES ${hosts.map((h) => `(${h.id})`).join(", ")}`,
);
},
});
const fleetsTable = await db.database.define(fleets);
const membersTable = await db.database.define(fleetMembers);
const inventoryTable = await db.database.define(fleetInventory);
const repo = createFleetRepository(
db.database,
fakeHosts(hosts),
fleetsTable,
membersTable,
inventoryTable,
);
return repo;
}
function host(
id: number,
overrides: Partial<PluginHostSummary> = {},
): PluginHostSummary {
return {
id,
userId: "user-1",
name: `host-${id}`,
ip: `10.0.0.${id}`,
port: 22,
username: "root",
tags: null,
folder: null,
authType: "password",
...overrides,
};
}
describe("FleetRepository.listEffectiveMembers", () => {
it("unions static membership and tag-matched hosts, deduplicated by id", async () => {
const repo = await setup([
host(1, { tags: "prod-web,edge" }),
host(2, { tags: "prod-web" }),
host(3, { tags: "prod-db" }),
host(4, { tags: null }),
host(5, { userId: "user-2", tags: "prod-web" }),
]);
const fleet = await repo.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
// host 1 matches by tag AND is added statically - must appear once.
await repo.addMember(fleet.id, 1);
// host 4 has no tags - only reachable via static membership.
await repo.addMember(fleet.id, 4);
const members = await repo.listEffectiveMembers("user-1", fleet.id);
const ids = members.map((m) => m.id).sort((a, b) => a - b);
expect(ids).toEqual([1, 2, 4]);
});
it("never returns another user's hosts even if tags match", async () => {
const repo = await setup([
host(1, { tags: "prod-web" }),
host(5, { userId: "user-2", tags: "prod-web" }),
]);
const fleet = await repo.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
const members = await repo.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).not.toContain(5);
});
it("returns only static members when a fleet has no tag rules", async () => {
const repo = await setup([host(3, { tags: "prod-db" })]);
const fleet = await repo.create("user-1", { name: "static fleet" });
await repo.addMember(fleet.id, 3);
const members = await repo.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toEqual([3]);
});
it("removeMember only drops the static row, not tag-based membership", async () => {
const repo = await setup([host(1, { tags: "prod-web" })]);
const fleet = await repo.create("user-1", {
name: "web fleet",
tagRules: ["prod-web"],
});
await repo.addMember(fleet.id, 1);
await expect(repo.removeMember(fleet.id, 1)).resolves.toBe(true);
// host 1 still matches the tag rule, so it remains an effective member.
const members = await repo.listEffectiveMembers("user-1", fleet.id);
expect(members.map((m) => m.id)).toContain(1);
});
it("returns an empty list for a fleet the caller does not own", async () => {
const repo = await setup([host(1)]);
const fleet = await repo.create("user-1", { name: "private" });
await expect(
repo.listEffectiveMembers("user-2", fleet.id),
).resolves.toEqual([]);
});
});
describe("FleetRepository CRUD", () => {
it("creates, updates and deletes a fleet", async () => {
const repo = await setup([host(1)]);
const created = await repo.create("user-1", {
name: "Fleet",
tagRules: ["a"],
});
expect(created.name).toBe("Fleet");
expect(created.tagRules).toBe('["a"]');
const updated = await repo.update("user-1", created.id, {
name: "Renamed",
});
expect(updated?.name).toBe("Renamed");
await expect(repo.delete("user-1", created.id)).resolves.toBe(true);
await expect(repo.findById("user-1", created.id)).resolves.toBeNull();
});
it("upserts and lists inventory per host", async () => {
const repo = await setup([host(1)]);
await repo.upsertInventory("user-1", 1, {
osPrettyName: "Debian",
kernel: "6.1.0",
architecture: "x86_64",
hostname: "web-1",
uptimeSeconds: 100,
ip: "10.0.0.1",
packageManager: "apt",
});
const rows = await repo.listInventoryForHosts("user-1", [1]);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ hostId: 1, osPrettyName: "Debian" });
// Refreshing overwrites the latest-only snapshot rather than adding a row.
await repo.upsertInventory("user-1", 1, {
osPrettyName: "Ubuntu",
kernel: "6.2.0",
architecture: "x86_64",
hostname: "web-1",
uptimeSeconds: 200,
ip: "10.0.0.1",
packageManager: "apt",
});
const refreshed = await repo.listInventoryForHosts("user-1", [1]);
expect(refreshed).toHaveLength(1);
expect(refreshed[0].osPrettyName).toBe("Ubuntu");
});
});
+106 -371
View File
@@ -1,388 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, Response, Router } from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
import { parseInventoryProbe } from "../../src/backend/routes.js";
const state = vi.hoisted(() => ({
currentUserId: "user-1",
fleets: new Map<number, { id: number; userId: string; name: string }>(),
members: new Map<number, { id: number; name: string }[]>(),
hostAccess: new Map<string, { hasAccess: boolean; isOwner: boolean }>(),
hosts: new Map<number, Record<string, unknown>>(),
}));
let server: TestServer;
vi.mock("../../../../src/backend/database/db/index.js", () => ({ db: {} }));
vi.mock("../../../../src/backend/utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../../../src/backend/utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
req.userId = state.currentUserId;
next();
},
createDataAccessMiddleware:
() => (_req: unknown, _res: unknown, next: () => void) =>
next(),
}),
},
}));
vi.mock("../../../../src/backend/utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string, hostId: number, level: string) => {
const key = `${userId}:${hostId}:${level}`;
const found = state.hostAccess.get(key);
if (found) return found;
// default: full access unless a test explicitly denies it
return { hasAccess: true, isOwner: true, permissionLevel: "manage" };
},
}),
},
}));
vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
}));
vi.mock("../../src/backend/ssh.js", () => ({
withSshConnection: async (
_host: unknown,
_options: unknown,
fn: (client: unknown) => unknown,
) => fn({}),
}));
vi.mock("../../../../src/backend/hosts/metrics-shared/common-utils.js", () => ({
execCommand: vi.fn(async () => ({ stdout: "ok", stderr: "", code: 0 })),
}));
vi.mock("../../../../src/backend/hosts/metrics-shared/platform.js", () => ({
detectPlatform: vi.fn(async () => ({ pkg: "apt", osPrettyName: "Debian" })),
}));
vi.mock(
"../../../../src/backend/hosts/metrics-shared/exec-elevated.js",
async () => {
class ElevationError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
}
}
return {
execElevated: vi.fn(),
ElevationError,
};
},
);
vi.mock(
"../../../../src/backend/hosts/metrics-shared/package-commands.js",
() => ({
buildPackageActionCommand: vi.fn(() => "apt-get install -y foo"),
}),
);
vi.mock("../../../../src/backend/hosts/metrics-shared/validation.js", () => ({
isValidPackageName: (v: unknown) => typeof v === "string" && v.length > 0,
}));
vi.mock(
"../../../../src/backend/database/routes/snippets-execution.js",
() => ({
resolveSnippetCommand: (command: string) => command,
}),
);
vi.mock("../../../../src/backend/database/routes/rbac.js", () => ({
isSharePermissionLevel: (v: unknown) =>
["connect", "view", "edit", "manage"].includes(v as string),
expiryFromDuration: () => null,
parseShareTargets: () => null,
}));
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
createCurrentFleetRepository: () => ({
findById: async (userId: string, fleetId: number) => {
const fleet = state.fleets.get(fleetId);
return fleet && fleet.userId === userId ? fleet : null;
},
listEffectiveMembers: async (_userId: string, fleetId: number) =>
state.members.get(fleetId) ?? [],
listStaticMemberIds: async () => [],
listByUser: async (userId: string) =>
[...state.fleets.values()].filter((f) => f.userId === userId),
}),
createCurrentFleetInventoryRepository: () => ({
listForHosts: async () => [],
upsert: async () => ({}),
}),
createCurrentRbacAccessRepository: () => ({}),
createCurrentRoleRepository: () => ({}),
createCurrentUserRepository: () => ({}),
}));
vi.mock("../../../../src/backend/database/routes/fleet-dispatch.js", () => ({
registerFleetsRouter: vi.fn(),
unregisterFleetsRouter: vi.fn(),
}));
const { router, parseInventoryProbe, buildRemoveCommand } =
await import("../../src/backend/routes.js");
function findLayer(method: string, path: string) {
const stack = (router as unknown as Router).stack as Array<{
route?: {
path: string;
methods: Record<string, boolean>;
stack: Array<{ handle: (req: Request, res: Response) => unknown }>;
};
}>;
const layer = stack.find(
(l) => l.route?.path === path && l.route?.methods[method],
);
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function makeReqRes(overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
}) {
const req = {
userId: state.currentUserId,
body: overrides.body ?? {},
params: overrides.params ?? {},
headers: {},
} as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as unknown,
status(code: number) {
(this as unknown as { statusCode: number }).statusCode = code;
return this;
},
json(payload: unknown) {
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
return this;
},
} as unknown as Response & { statusCode: number; jsonBody: unknown };
return { req, res };
}
async function invoke(
method: string,
path: string,
overrides: {
body?: Record<string, unknown>;
params?: Record<string, unknown>;
} = {},
) {
const handler = findLayer(method, path);
const { req, res } = makeReqRes(overrides);
await handler(req, res);
return res as unknown as {
statusCode: number;
jsonBody: Record<string, unknown> | null;
};
}
beforeEach(() => {
state.currentUserId = "user-1";
state.fleets = new Map([[1, { id: 1, userId: "user-1", name: "web fleet" }]]);
state.members = new Map([
[
1,
[
{ id: 10, name: "host-a" },
{ id: 11, name: "host-b" },
],
],
]);
state.hostAccess = new Map();
state.hosts = new Map([
[
10,
{
id: 10,
userId: "user-1",
name: "host-a",
ip: "10.0.0.1",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
[
11,
{
id: 11,
userId: "user-1",
name: "host-b",
ip: "10.0.0.2",
port: 22,
username: "root",
sudoPassword: "secret",
},
],
]);
afterEach(async () => {
await server?.close();
});
describe("GET /:id/members", () => {
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "999" },
async function createFleet(
server: TestServer,
body: Record<string, unknown> = { name: "Web fleet" },
) {
const res = await server.request("POST", "/", { body });
expect(res.status).toBe(200);
return res.body;
}
describe("fleet CRUD routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("creates and lists fleets with member counts", async () => {
const created = await createFleet(server);
expect(created).toMatchObject({ name: "Web fleet", tagRules: [] });
const list = await server.request("GET", "/");
expect(list.status).toBe(200);
expect(list.body).toHaveLength(1);
expect(list.body[0]).toMatchObject({ id: created.id, memberCount: 0 });
});
it("400s on a missing name", async () => {
const res = await server.request("POST", "/", { body: {} });
expect(res.status).toBe(400);
});
it("updates and deletes a fleet", async () => {
const created = await createFleet(server);
const updated = await server.request("PATCH", `/${created.id}`, {
body: { name: "Renamed" },
});
expect(res.statusCode).toBe(404);
expect(updated.status).toBe(200);
expect(updated.body.name).toBe("Renamed");
const deleted = await server.request("DELETE", `/${created.id}`);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/")).body).toHaveLength(0);
});
it("404s for a fleet the caller does not own", async () => {
const res = await server.request("GET", "/999/members");
expect(res.status).toBe(404);
});
it("400s on a non-numeric fleet id", async () => {
const res = await invoke("get", "/:id/members", {
params: { id: "not-a-number" },
});
expect(res.statusCode).toBe(400);
const res = await server.request("GET", "/not-a-number/members");
expect(res.status).toBe(400);
});
});
describe("POST /:id/execute", () => {
it("400s when command is missing", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: {},
});
expect(res.statusCode).toBe(400);
describe("fleet membership routes", () => {
beforeEach(async () => {
server = await startServer();
});
it("reports per-host success with the {hostId, hostName, success} shape", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
hostId: 10,
hostName: "host-a",
success: true,
it("adds and lists members, then removes one", async () => {
const fleet = await createFleet(server);
const added = await server.request("POST", `/${fleet.id}/members`, {
body: { hostId: 10 },
});
expect(added.status).toBe(200);
const members = await server.request("GET", `/${fleet.id}/members`);
expect(members.status).toBe(200);
expect(members.body).toHaveLength(1);
expect(members.body[0]).toMatchObject({ id: 10, static: true });
const removed = await server.request("DELETE", `/${fleet.id}/members/10`);
expect(removed.status).toBe(200);
expect(
(await server.request("GET", `/${fleet.id}/members`)).body,
).toHaveLength(0);
});
it("isolates one host's access denial - the other host still succeeds", async () => {
state.hostAccess.set("user-1:10:edit", {
hasAccess: false,
isOwner: false,
it("404s when adding a host outside the fixture host list", async () => {
const fleet = await createFleet(server);
const res = await server.request("POST", `/${fleet.id}/members`, {
body: { hostId: 999 },
});
const res = await invoke("post", "/:id/execute", {
params: { id: "1" },
body: { command: "uptime" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
const denied = results.find((r) => r.hostId === 10);
const allowed = results.find((r) => r.hostId === 11);
expect(denied).toMatchObject({ success: false });
expect(String(denied?.error)).toMatch(/edit/);
expect(allowed).toMatchObject({ success: true });
});
it("404s for a fleet the caller does not own", async () => {
const res = await invoke("post", "/:id/execute", {
params: { id: "999" },
body: { command: "uptime" },
});
expect(res.statusCode).toBe(404);
expect(res.status).toBe(404);
});
});
describe("POST /:id/packages", () => {
it("400s on an invalid action", async () => {
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "reformat-disk" },
describe("permission gates", () => {
it("view routes 403 without fleets.view", async () => {
server = await startServer({
permissions: ["fleets.manage", "fleets.execute"],
});
expect(res.statusCode).toBe(400);
const res = await server.request("GET", "/");
expect(res.status).toBe(403);
});
it("surfaces an ElevationError as a per-host error, not a request failure", async () => {
const { execElevated, ElevationError } =
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockRejectedValue(
new ElevationError("SUDO_REQUIRED", "sudo password required"),
);
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "install", package: "curl" },
it("manage routes 403 without fleets.manage", async () => {
server = await startServer({
permissions: ["fleets.view", "fleets.execute"],
});
expect(res.statusCode).toBe(200);
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(results[0].error).toMatch(/sudo password required/);
const res = await server.request("POST", "/", { body: { name: "x" } });
expect(res.status).toBe(403);
});
it("requires manage-level access, not just edit", async () => {
state.hostAccess.set("user-1:10:manage", {
hasAccess: false,
isOwner: false,
it("execute routes 403 without fleets.execute", async () => {
server = await startServer({
permissions: ["fleets.view", "fleets.manage"],
});
state.hostAccess.set("user-1:11:manage", {
hasAccess: false,
isOwner: false,
const fleet = await createFleet(server);
const res = await server.request("POST", `/${fleet.id}/execute`, {
body: { command: "uptime" },
});
const { execElevated } =
await import("../../../../src/backend/hosts/metrics-shared/exec-elevated.js");
(execElevated as ReturnType<typeof vi.fn>).mockResolvedValue({
code: 0,
stdout: "done",
stderr: "",
});
const res = await invoke("post", "/:id/packages", {
params: { id: "1" },
body: { action: "upgrade-all" },
});
const results = res.jsonBody?.results as Array<Record<string, unknown>>;
expect(results.every((r) => r.success === false)).toBe(true);
expect(String(results[0].error)).toMatch(/manage/);
});
});
describe("POST /:id/members", () => {
it("404s when the caller cannot access the target host", async () => {
state.hostAccess.set("user-1:99:connect", {
hasAccess: false,
isOwner: false,
});
const res = await invoke("post", "/:id/members", {
params: { id: "1" },
body: { hostId: 99 },
});
expect(res.statusCode).toBe(404);
expect(res.status).toBe(403);
});
});
@@ -413,8 +150,7 @@ describe("parseInventoryProbe", () => {
});
it("nulls uptimeSeconds when the value is not a bare integer", () => {
const out = "uptime_seconds=";
expect(parseInventoryProbe(out).uptimeSeconds).toBeNull();
expect(parseInventoryProbe("uptime_seconds=").uptimeSeconds).toBeNull();
});
it("ignores lines with no '=' separator", () => {
@@ -423,19 +159,18 @@ describe("parseInventoryProbe", () => {
});
});
describe("buildRemoveCommand", () => {
it("builds the correct remove command per package manager", () => {
expect(buildRemoveCommand("apt", "curl")).toContain(
"apt-get -y remove curl",
);
expect(buildRemoveCommand("dnf", "curl")).toBe("dnf -y remove curl");
expect(buildRemoveCommand("yum", "curl")).toBe("yum -y remove curl");
expect(buildRemoveCommand("pacman", "curl")).toBe(
"pacman -R --noconfirm curl",
);
describe("share target pickers", () => {
beforeEach(async () => {
server = await startServer();
});
it("returns null when no package manager was detected", () => {
expect(buildRemoveCommand(null, "curl")).toBeNull();
it("lists users and roles through ctx.hosts", async () => {
const users = await server.request("GET", "/share-targets/users");
expect(users.status).toBe(200);
expect(users.body).toEqual({ users: [] });
const roles = await server.request("GET", "/share-targets/roles");
expect(roles.status).toBe(200);
expect(roles.body).toEqual({ roles: [] });
});
});