mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
1 parent
d2ff5fc78f
commit
3b2c849514
59 files changed
+31807
-2977
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user