mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -30,6 +30,8 @@ import syncRoutes from "./routes/sync.js";
|
||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||
import fs from "fs";
|
||||
@@ -1779,6 +1781,10 @@ app.use("/secret-sources", secretSourceRoutes);
|
||||
app.use("/", notificationChannelsRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => getPluginRuntime().loader.get(id)),
|
||||
);
|
||||
app.use("/plugin-api", authenticateJWT, pluginApiRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createCurrentPluginRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import {
|
||||
findField,
|
||||
@@ -44,6 +45,65 @@ const requireManagePlugins = permissionManager.requirePermission(
|
||||
"admin.plugins.manage",
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/public:
|
||||
* get:
|
||||
* summary: List the plugin frontends anonymous guest pages need
|
||||
* description: >
|
||||
* No auth. A shared-session or collab guest link has no session, yet its
|
||||
* page still draws surfaces a plugin provides (a remote desktop stream).
|
||||
* Returns only enabled plugins whose manifest sets contributes.guest,
|
||||
* with the fields the browser loader needs and nothing operational.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Guest-capable plugins.
|
||||
*/
|
||||
router.get("/public", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const records = await createCurrentPluginRepository().listAll();
|
||||
const { loader } = getPluginRuntime();
|
||||
const plugins = records.flatMap((record) => {
|
||||
if (record.state !== "enabled") return [];
|
||||
const loaded = loader.get(record.id);
|
||||
let manifest: {
|
||||
contributes?: { guest?: boolean };
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
try {
|
||||
manifest = JSON.parse(record.manifestJson);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (manifest?.contributes?.guest !== true) return [];
|
||||
return [
|
||||
{
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: true,
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes: { guest: true },
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
optionalDependencies: manifest.optionalDependencies ?? {},
|
||||
...describePluginFrontend(loaded),
|
||||
},
|
||||
];
|
||||
});
|
||||
res.json(plugins);
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to list guest plugins",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_list_public" },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to list plugins" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins:
|
||||
@@ -61,6 +121,13 @@ const requireManagePlugins = permissionManager.requirePermission(
|
||||
* do, what has been granted to it and why it failed are operational
|
||||
* details, so capabilities, grants and lastError are included only for
|
||||
* holders of admin.plugins.manage.
|
||||
*
|
||||
* The frontend fields drive the browser's plugin loader: `frontend`
|
||||
* says there is a bundle at /plugin-assets/<id>/frontend.js,
|
||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||
* `dependencies` and `optionalDependencies` let the loader activate
|
||||
* frontends in the same order the server does.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -100,17 +167,23 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let contributes: unknown = null;
|
||||
let capabilities: string[] = [];
|
||||
let icon: string | undefined;
|
||||
let dependencies: Record<string, string> = {};
|
||||
let optionalDependencies: Record<string, string> = {};
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
capabilities?: unknown;
|
||||
icon?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
|
||||
dependencies = manifest?.dependencies ?? {};
|
||||
optionalDependencies = manifest?.optionalDependencies ?? {};
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
@@ -123,6 +196,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
state: loaded?.state ?? record.state,
|
||||
contributes,
|
||||
icon,
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
|
||||
if (!canManage) return summary;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
|
||||
//
|
||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||
// bearer header, and nothing here is secret. It only ever serves files from
|
||||
// the two directories a plugin ships for the browser, so a request can never
|
||||
// reach the plugin's backend bundle, migrations or anything outside it.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import {
|
||||
DEFAULT_FRONTEND_ENTRY,
|
||||
DEFAULT_LOCALES_DIR,
|
||||
type PluginManifest,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
import type { LoadedPlugin } from "./loader.js";
|
||||
|
||||
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest">;
|
||||
|
||||
export interface PluginFrontendInfo {
|
||||
frontend: boolean;
|
||||
css: boolean;
|
||||
/** Changes whenever the built bundle does, for cache busting. */
|
||||
assetVersion: string | null;
|
||||
/** Language codes with a locale file, "en" plus xx_YY names. */
|
||||
locales: string[];
|
||||
}
|
||||
|
||||
function frontendEntry(
|
||||
plugin: AssetPlugin,
|
||||
): { dir: string; file: string } | null {
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
const entry = path.resolve(
|
||||
plugin.dir,
|
||||
manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
|
||||
);
|
||||
if (!isInside(plugin.dir, entry)) return null;
|
||||
return { dir: path.dirname(entry), file: entry };
|
||||
}
|
||||
|
||||
function localesDir(plugin: AssetPlugin): string | null {
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
const dir = path.resolve(plugin.dir, manifest.locales ?? DEFAULT_LOCALES_DIR);
|
||||
return isInside(plugin.dir, dir) ? dir : null;
|
||||
}
|
||||
|
||||
function isInside(root: string, target: string): boolean {
|
||||
const relative = path.relative(path.resolve(root), path.resolve(target));
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function fileExists(file: string): boolean {
|
||||
try {
|
||||
return fs.statSync(file).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function describePluginFrontend(
|
||||
plugin: AssetPlugin | undefined,
|
||||
): PluginFrontendInfo {
|
||||
const none: PluginFrontendInfo = {
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
};
|
||||
if (!plugin) return none;
|
||||
|
||||
const locales: string[] = [];
|
||||
const dir = localesDir(plugin);
|
||||
if (dir && fileExists(path.join(dir, "en.json"))) locales.push("en");
|
||||
if (dir) {
|
||||
try {
|
||||
for (const name of fs.readdirSync(path.join(dir, "translated"))) {
|
||||
if (/^[a-z]{2}_[A-Z]{2}\.json$/.test(name)) {
|
||||
locales.push(name.slice(0, -".json".length));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No translations yet.
|
||||
}
|
||||
}
|
||||
|
||||
const entry = frontendEntry(plugin);
|
||||
if (!entry || !fileExists(entry.file)) return { ...none, locales };
|
||||
|
||||
const stat = fs.statSync(entry.file);
|
||||
const assetVersion = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
|
||||
return {
|
||||
frontend: true,
|
||||
css: fileExists(path.join(entry.dir, "frontend.css")),
|
||||
assetVersion,
|
||||
locales: locales.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
|
||||
*
|
||||
* `locales/...` reads the plugin's locales directory; anything else reads the
|
||||
* directory the frontend bundle sits in. Only .js, .css, .json and .map are
|
||||
* served, and never a path that escapes either directory.
|
||||
*/
|
||||
export function resolvePluginAsset(
|
||||
plugin: AssetPlugin,
|
||||
requestPath: string,
|
||||
): string | null {
|
||||
const clean = requestPath.replace(/^\/+/, "");
|
||||
if (!clean || clean.includes("\0") || clean.includes("\\")) return null;
|
||||
if (!/\.(js|css|json|map)$/.test(clean)) return null;
|
||||
|
||||
let root: string | null;
|
||||
let rest: string;
|
||||
if (clean.startsWith("locales/")) {
|
||||
root = localesDir(plugin);
|
||||
rest = clean.slice("locales/".length);
|
||||
} else {
|
||||
root = frontendEntry(plugin)?.dir ?? null;
|
||||
rest = clean;
|
||||
// The backend bundle sits beside the frontend one in dist/.
|
||||
if (/^backend\.js(\.map)?$/.test(rest)) return null;
|
||||
}
|
||||
if (!root) return null;
|
||||
|
||||
const file = path.resolve(root, rest);
|
||||
if (!isInside(root, file) || !fileExists(file)) return null;
|
||||
return file;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
export function createPluginAssetsRouter(
|
||||
getPlugin: (id: string) => AssetPlugin | undefined,
|
||||
): express.Router {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-assets/{pluginId}/{path}:
|
||||
* get:
|
||||
* summary: Serve a plugin's frontend bundle, stylesheet or locale file
|
||||
* description: >
|
||||
* Public, like the app's own /assets, because a module import cannot
|
||||
* send an auth header and plugin frontends are not secret. Serves only
|
||||
* the directory holding the plugin's frontend bundle and its locales
|
||||
* directory. A `v` query parameter (the assetVersion from GET
|
||||
* /plugins) makes the response cacheable for a year.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: pluginId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: v
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The file.
|
||||
* 404:
|
||||
* description: Unknown plugin or file.
|
||||
*/
|
||||
router.get(
|
||||
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
|
||||
(req: Request, res: Response) => {
|
||||
const params = req.params as unknown as Record<string, string>;
|
||||
const pluginId = params[0];
|
||||
const plugin = getPlugin(pluginId);
|
||||
const file = plugin ? resolvePluginAsset(plugin, params[1]) : null;
|
||||
|
||||
// Loaded as a module from Electron's file:// page and the Vite dev
|
||||
// server, so any origin may read it. No credentials are involved.
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.removeHeader("Access-Control-Allow-Credentials");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
|
||||
if (!file) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
typeof req.query.v === "string" && req.query.v
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
);
|
||||
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
|
||||
res.sendFile(file);
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -92,7 +92,9 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({ loader: { list: () => [] } }),
|
||||
getPluginRuntime: () => ({
|
||||
loader: { list: () => [], get: () => undefined },
|
||||
}),
|
||||
activatePlugin: vi.fn(),
|
||||
deactivatePlugin: vi.fn(),
|
||||
}));
|
||||
@@ -180,6 +182,50 @@ describe("plugins route", () => {
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("GET /plugins/public", () => {
|
||||
it("lists only enabled plugins that opt into guest pages", async () => {
|
||||
state.plugins.set(
|
||||
"guest-plugin",
|
||||
makePlugin({
|
||||
id: "guest-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
capabilities: ["hosts:read"],
|
||||
contributes: { guest: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"off-guest",
|
||||
makePlugin({
|
||||
id: "off-guest",
|
||||
state: "disabled",
|
||||
manifestJson: JSON.stringify({ contributes: { guest: true } }),
|
||||
}),
|
||||
);
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins/public`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
|
||||
"guest-plugin",
|
||||
]);
|
||||
expect(body[0].contributes).toEqual({ guest: true });
|
||||
expect(body[0]).not.toHaveProperty("capabilities");
|
||||
expect(body[0]).not.toHaveProperty("lastError");
|
||||
});
|
||||
|
||||
it("skips a plugin whose manifest does not parse", async () => {
|
||||
state.plugins.set(
|
||||
"broken",
|
||||
makePlugin({ id: "broken", manifestJson: "{" }),
|
||||
);
|
||||
const res = await fetch(`${baseUrl}/plugins/public`);
|
||||
expect(await res.json()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a plugin's declared and granted capabilities", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
@@ -335,6 +381,12 @@ describe("plugins route", () => {
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginAssetsRouter,
|
||||
describePluginFrontend,
|
||||
resolvePluginAsset,
|
||||
} from "../../plugins/assets.js";
|
||||
|
||||
let root = "";
|
||||
let plugin: { id: string; dir: string; manifest: never };
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
|
||||
function write(relative: string, content: string) {
|
||||
const file = path.join(root, "gizmo", relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-"));
|
||||
write("dist/frontend.js", "export function activate() {}");
|
||||
write("dist/frontend.css", ".gizmo{}");
|
||||
write("dist/backend.js", "secret()");
|
||||
write("dist/notes.txt", "text");
|
||||
write("locales/en.json", "{}");
|
||||
write("locales/translated/de_DE.json", "{}");
|
||||
write("manifest.json", "{}");
|
||||
fs.writeFileSync(path.join(root, "outside.js"), "nope");
|
||||
plugin = {
|
||||
id: "gizmo",
|
||||
dir: path.join(root, "gizmo"),
|
||||
manifest: { id: "gizmo", version: "1.0.0" } as never,
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
|
||||
);
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("describePluginFrontend", () => {
|
||||
it("reports the bundle, stylesheet, version and locales", () => {
|
||||
const info = describePluginFrontend(plugin);
|
||||
expect(info.frontend).toBe(true);
|
||||
expect(info.css).toBe(true);
|
||||
expect(info.assetVersion).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(info.locales).toEqual(["de_DE", "en"]);
|
||||
});
|
||||
|
||||
it("reports nothing for an unknown plugin", () => {
|
||||
expect(describePluginFrontend(undefined)).toEqual({
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePluginAsset", () => {
|
||||
it("serves the bundle and locale files", () => {
|
||||
expect(resolvePluginAsset(plugin, "frontend.js")).toBe(
|
||||
path.join(root, "gizmo", "dist", "frontend.js"),
|
||||
);
|
||||
expect(resolvePluginAsset(plugin, "locales/translated/de_DE.json")).toBe(
|
||||
path.join(root, "gizmo", "locales", "translated", "de_DE.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("never serves the backend bundle", () => {
|
||||
expect(resolvePluginAsset(plugin, "backend.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses paths that escape the plugin's directories", () => {
|
||||
expect(resolvePluginAsset(plugin, "../manifest.json")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "../../outside.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "locales/../dist/backend.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "..\\manifest.json")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses file types it does not serve", () => {
|
||||
expect(resolvePluginAsset(plugin, "notes.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /plugin-assets", () => {
|
||||
it("serves a bundle as a module any origin can load", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/javascript");
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(res.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(await res.text()).toContain("activate");
|
||||
});
|
||||
|
||||
it("caches a versioned request for good", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js?v=abc`);
|
||||
expect(res.headers.get("cache-control")).toContain("immutable");
|
||||
});
|
||||
|
||||
it("404s an unknown plugin", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/nobody/frontend.js`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s the backend bundle and traversal attempts", async () => {
|
||||
for (const target of [
|
||||
"gizmo/backend.js",
|
||||
"gizmo/%2e%2e/manifest.json",
|
||||
"gizmo/..%2f..%2foutside.js",
|
||||
]) {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/${target}`);
|
||||
expect(res.status, target).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -119,11 +119,9 @@ describe("unknown field rejection", () => {
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside contributes", () => {
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { dashboardCards: [] } }),
|
||||
);
|
||||
const errors = validateManifest(base({ contributes: { widgets: [] } }));
|
||||
|
||||
expect(errors.join()).toMatch(/Unknown field "dashboardCards"/);
|
||||
expect(errors.join()).toMatch(/Unknown field "widgets"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a tab", () => {
|
||||
@@ -557,3 +555,50 @@ describe("tabs and host capabilities", () => {
|
||||
).toMatch(/duplicates/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("panels, dashboard cards and guest pages", () => {
|
||||
it("accepts declared panels, cards and the guest flag", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "sample-panel", titleKey: "nav.panel" }],
|
||||
dashboardCards: [
|
||||
{ id: "sample-card", titleKey: "cards.sample", icon: "Box" },
|
||||
],
|
||||
guest: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate view ids and missing title keys", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [
|
||||
{ id: "p", titleKey: "a" },
|
||||
{ id: "p", titleKey: "b" },
|
||||
],
|
||||
dashboardCards: [{ id: "c" }],
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/panels\[1\]\.id duplicates "p"/);
|
||||
expect(errors).toMatch(/dashboardCards\[0\]\.titleKey/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a view and a non-boolean guest", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "p", titleKey: "a", width: 3 }],
|
||||
guest: "yes",
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/Unknown field "width"/);
|
||||
expect(errors).toMatch(/contributes\.guest" must be a boolean/);
|
||||
});
|
||||
});
|
||||
+42
-54
@@ -24,17 +24,16 @@ import { useTranslation } from "react-i18next";
|
||||
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
|
||||
import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext";
|
||||
import { BrandingProvider } from "@/contexts/BrandingContext";
|
||||
import { startPluginRuntime } from "@/plugin-host/loader";
|
||||
import { standaloneViewFor } from "@/shell/tab-registry";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
|
||||
const AppShell = lazy(() =>
|
||||
import("@/AppShell").then((m) => ({ default: m.AppShell })),
|
||||
);
|
||||
|
||||
// Full-screen apps opened via query params (e.g. from external links or Electron)
|
||||
const TerminalApp = lazy(() =>
|
||||
import("@/features/terminal/TerminalApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
// Full-screen apps opened via query params (e.g. from external links or
|
||||
// Electron). Plugin views register a standalone component with their tab.
|
||||
const FileManagerApp = lazy(() =>
|
||||
import("@/features/file-manager/FileManagerApp").then((m) => ({
|
||||
default: m.default,
|
||||
@@ -43,26 +42,6 @@ const FileManagerApp = lazy(() =>
|
||||
const TunnelApp = lazy(() =>
|
||||
import("@/features/tunnel/TunnelApp").then((m) => ({ default: m.default })),
|
||||
);
|
||||
const HostMetricsApp = lazy(() =>
|
||||
import("../plugins/host-metrics/src/frontend/HostMetricsApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
const ProxmoxStatsApp = lazy(() =>
|
||||
import("@/features/proxmox-stats/ProxmoxStatsApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
const DockerApp = lazy(() =>
|
||||
import("../plugins/docker/src/frontend/DockerApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
const GuacamoleApp = lazy(() =>
|
||||
import("../plugins/remote-desktop/src/frontend/GuacamoleApp").then((m) => ({
|
||||
default: m.default,
|
||||
})),
|
||||
);
|
||||
// --- tmux-monitor ---
|
||||
const TmuxMonitorApp = lazy(() =>
|
||||
import("@/features/tmux-monitor/TmuxMonitorApp").then((m) => ({
|
||||
@@ -101,17 +80,9 @@ function FullscreenApp() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const view = searchParams.get("view");
|
||||
const hostId = searchParams.get("hostId");
|
||||
const tmuxSession = searchParams.get("tmuxSession");
|
||||
const path = searchParams.get("path");
|
||||
|
||||
switch (view) {
|
||||
case "terminal":
|
||||
return (
|
||||
<TerminalApp
|
||||
hostId={hostId || undefined}
|
||||
tmuxSession={tmuxSession || undefined}
|
||||
/>
|
||||
);
|
||||
case "file-manager":
|
||||
return (
|
||||
<FileManagerApp
|
||||
@@ -121,29 +92,25 @@ function FullscreenApp() {
|
||||
);
|
||||
case "tunnel":
|
||||
return <TunnelApp hostId={hostId || undefined} />;
|
||||
case "host-metrics":
|
||||
case "server-stats":
|
||||
return <HostMetricsApp hostId={hostId || undefined} />;
|
||||
case "proxmox-stats":
|
||||
return <ProxmoxStatsApp hostId={hostId || undefined} />;
|
||||
case "docker":
|
||||
return <DockerApp hostId={hostId || undefined} />;
|
||||
case "rdp":
|
||||
case "vnc":
|
||||
case "telnet":
|
||||
return (
|
||||
<GuacamoleApp
|
||||
hostId={hostId || undefined}
|
||||
protocol={view as "rdp" | "vnc" | "telnet"}
|
||||
/>
|
||||
);
|
||||
case "tmux-monitor": // --- tmux-monitor ---
|
||||
case "tmux_monitor": // tab type spelling, so copied links also resolve
|
||||
return <TmuxMonitorApp hostId={hostId || undefined} />;
|
||||
case "homepage":
|
||||
return <HomepageApp />;
|
||||
default:
|
||||
return null;
|
||||
default: {
|
||||
const def = view ? standaloneViewFor(view) : undefined;
|
||||
if (!def?.standalone) {
|
||||
return view ? <PluginViewPlaceholder kind="tab" viewId={view} /> : null;
|
||||
}
|
||||
const Standalone = def.standalone;
|
||||
return (
|
||||
<Standalone
|
||||
hostId={hostId || undefined}
|
||||
view={view!}
|
||||
params={searchParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +133,9 @@ function FullscreenAppGate() {
|
||||
// WebSocket connections can still fall back to cookie auth.
|
||||
}
|
||||
}
|
||||
// Plugin views (metrics, Docker, remote desktop) register their
|
||||
// full-screen component when their plugin starts.
|
||||
await startPluginRuntime().catch(() => {});
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -432,6 +402,20 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest links draw surfaces plugins provide (a remote desktop stream), so the
|
||||
* guest-capable plugins load first, without a session.
|
||||
*/
|
||||
function GuestPlugins({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
startPluginRuntime({ guest: true })
|
||||
.catch(() => {})
|
||||
.finally(() => setReady(true));
|
||||
}, []);
|
||||
return ready ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
function RootApp() {
|
||||
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
||||
|
||||
@@ -445,14 +429,18 @@ function RootApp() {
|
||||
if (searchParams.get("view") === "shared") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SharedSessionView />
|
||||
<GuestPlugins>
|
||||
<SharedSessionView />
|
||||
</GuestPlugins>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (searchParams.get("view") === "collab-guest") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CollabGuestView />
|
||||
<GuestPlugins>
|
||||
<CollabGuestView />
|
||||
</GuestPlugins>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ export type WidgetTypeId =
|
||||
| "search_links"
|
||||
| "link_tree"
|
||||
| "docker_activity"
|
||||
| "ssh_quick_connect";
|
||||
| "ssh_quick_connect"
|
||||
| (string & {});
|
||||
|
||||
export interface HomepageItemRow {
|
||||
id: number;
|
||||
@@ -177,8 +178,8 @@ export interface PingStatusConfig {
|
||||
showLatency: boolean;
|
||||
}
|
||||
|
||||
export type ActivityType =
|
||||
"terminal" | "file_manager" | "docker" | "tunnel" | "rdp" | "vnc" | "telnet";
|
||||
/** Core activity types; plugin tabs record their own. */
|
||||
export type ActivityType = "file_manager" | "tunnel" | (string & {});
|
||||
|
||||
export interface RecentActivityConfig {
|
||||
maxItems: number;
|
||||
@@ -225,15 +226,8 @@ export interface SshTerminalConfig {
|
||||
autoConnect: boolean;
|
||||
}
|
||||
|
||||
export type QuickConnectType =
|
||||
| "terminal"
|
||||
| "files"
|
||||
| "docker"
|
||||
| "tunnel"
|
||||
| "host-metrics"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet";
|
||||
/** Tab types a quick connect widget opens: core tools or plugin actions. */
|
||||
export type QuickConnectType = "files" | "tunnel" | (string & {});
|
||||
|
||||
export interface QuickConnectConfig {
|
||||
hostIds: number[];
|
||||
|
||||
@@ -316,10 +316,8 @@ export type Tab = {
|
||||
initialFilePath?: string;
|
||||
/** Directory to open a Files tab into, distinct from initialFilePath (a specific file to open in an editor window). */
|
||||
initialPath?: string;
|
||||
/** Which fleet a fleet-inventory tab is currently showing (singleton tab, re-targeted on reopen). */
|
||||
fleetId?: number;
|
||||
/** Which web endpoint this tab shows. Only set when type is "web-endpoint". */
|
||||
endpointId?: string;
|
||||
/** Payload owned by the tab's plugin, e.g. which fleet or endpoint it shows. */
|
||||
data?: Record<string, unknown>;
|
||||
/** Which collab room a collab tab is showing. */
|
||||
collabRoomId?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
@@ -343,15 +341,16 @@ export type Tab = {
|
||||
} | null>;
|
||||
};
|
||||
|
||||
/** Core cards are named here; plugin cards add their own ids. */
|
||||
export type DashboardCardId =
|
||||
| "stats_bar"
|
||||
| "counters_bar"
|
||||
| "quick_actions"
|
||||
| "host_status"
|
||||
| "recent_activity"
|
||||
| "network_graph"
|
||||
| "service_links"
|
||||
| "homepage_preview";
|
||||
| "homepage_preview"
|
||||
| (string & {});
|
||||
|
||||
export type DashboardCardConfig = {
|
||||
id: DashboardCardId;
|
||||
@@ -425,7 +424,10 @@ export type WorkspaceTabSnapshot = {
|
||||
customLabel?: string;
|
||||
initialFilePath?: string;
|
||||
initialPath?: string;
|
||||
/** Read from payloads saved before tabs carried `data`. */
|
||||
fleetId?: number;
|
||||
/** The tab's plugin payload. */
|
||||
data?: Record<string, unknown>;
|
||||
/** Only present when type === "serial". Fully self-contained, no host resolution needed. */
|
||||
serialConfig?: SerialConfig;
|
||||
};
|
||||
|
||||
+221
-326
@@ -26,14 +26,15 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useAiAvailability } from "@/hooks/use-ai-availability";
|
||||
import { resetPermissionsCache } from "@/hooks/use-permissions";
|
||||
import { MobileBottomBar } from "@/shell/MobileBottomBar";
|
||||
import { AppRail, type RailView } from "@/sidebar/AppRail";
|
||||
import {
|
||||
isCoreRailView,
|
||||
railItemLabel,
|
||||
PROMOTABLE_IDS,
|
||||
RIGHT_DOCKABLE_IDS,
|
||||
promotableIds,
|
||||
rightDockableIds,
|
||||
useRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { MultiPanelHint } from "@/sidebar/MultiPanelHint";
|
||||
import { OnboardingDialog } from "@/onboarding/OnboardingDialog";
|
||||
@@ -91,33 +92,6 @@ const SnippetsPanel = lazy(() =>
|
||||
const MacrosPanel = lazy(() =>
|
||||
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
||||
);
|
||||
const FleetsPanel = lazy(() =>
|
||||
import("../../plugins/fleets/src/frontend/FleetsPanel").then((m) => ({
|
||||
default: m.FleetsPanel,
|
||||
})),
|
||||
);
|
||||
const TailscaleDevicesPanel = lazy(() =>
|
||||
import("../../plugins/tailscale/src/frontend/TailscaleDevicesPanel").then(
|
||||
(m) => ({ default: m.TailscaleDevicesPanel }),
|
||||
),
|
||||
);
|
||||
const WorkspacesPanel = lazy(() =>
|
||||
import("../../plugins/workspaces/src/frontend/WorkspacesPanel").then((m) => ({
|
||||
default: m.WorkspacesPanel,
|
||||
})),
|
||||
);
|
||||
const AutomationsPanel = lazy(() =>
|
||||
import("../../plugins/automations/src/frontend/AutomationsPanel").then(
|
||||
(m) => ({
|
||||
default: m.AutomationsPanel,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const AiPanel = lazy(() =>
|
||||
import("@/features/ai/AiPanel").then((m) => ({
|
||||
default: m.AiPanel,
|
||||
})),
|
||||
);
|
||||
const HistoryPanel = lazy(() =>
|
||||
import("@/sidebar/HistoryPanel").then((m) => ({ default: m.HistoryPanel })),
|
||||
);
|
||||
@@ -168,7 +142,6 @@ import type {
|
||||
ThemeId,
|
||||
FontSizeId,
|
||||
SerialConfig,
|
||||
Workspace,
|
||||
WorkspacePayload,
|
||||
} from "@/types/ui-types";
|
||||
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
|
||||
@@ -193,15 +166,11 @@ import {
|
||||
type OpenTabRecord,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
listWorkspaces,
|
||||
applyWorkspaceServer,
|
||||
saveLastSessionWorkspace,
|
||||
} from "../../plugins/workspaces/src/frontend/workspaces-api";
|
||||
import {
|
||||
buildWorkspacePayload as buildWorkspacePayloadUtil,
|
||||
buildLayoutPayload,
|
||||
remapSlotIds,
|
||||
resolveWorkspaceTabTarget,
|
||||
} from "../../plugins/workspaces/src/frontend/workspaceUtils";
|
||||
resolveLayoutTabTarget,
|
||||
snapshotData,
|
||||
} from "@/shell/shell-layout";
|
||||
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
||||
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
||||
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
|
||||
@@ -221,36 +190,31 @@ import {
|
||||
serializeSplitTabs,
|
||||
type PersistedSplitTab,
|
||||
} from "@/shell/splitTabUtils";
|
||||
import { isTabTypeAvailable, refreshPluginState } from "@/shell/pluginLoader";
|
||||
import {
|
||||
canRestoreTabType,
|
||||
getTabType,
|
||||
isPersistentTabType,
|
||||
isSessionTabType,
|
||||
type TabShellCallbacks,
|
||||
} from "@/shell/tab-registry";
|
||||
import { getPanel, usePanels } from "@/shell/panel-registry";
|
||||
import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
notifyShellReady,
|
||||
notifyTabsChanged,
|
||||
resetShellBridge,
|
||||
setShellCallbacks,
|
||||
setShellHosts,
|
||||
setShellLayoutProvider,
|
||||
} from "@/plugin-host/shell-bridge";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
|
||||
export { buildHostTree } from "@/sidebar/build-host-tree";
|
||||
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
|
||||
// ─── AppShell ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tab types whose open/close is mirrored to the backend's open-tabs record, so
|
||||
* they survive a reload or reopen on another device.
|
||||
*
|
||||
* "web-endpoint" is deliberately ABSENT. The backend closes an idle tunnel
|
||||
* after ten minutes and re-binds a fresh kernel-assigned port on the next
|
||||
* open, so a restored web-endpoint tab could never hold a valid URL -- and the
|
||||
* endpoint may have been edited or deleted meanwhile besides. Restoring one
|
||||
* would mean re-opening the tunnel on restore, which is a feature, not
|
||||
* symmetry. A web-endpoint tab is session-only and simply closes on reload,
|
||||
* the same as "local-terminal" already does.
|
||||
*/
|
||||
export const PERSISTENT_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
"files",
|
||||
"docker",
|
||||
"host-metrics",
|
||||
"tunnel",
|
||||
];
|
||||
|
||||
export function AppShell({
|
||||
username,
|
||||
onLogout,
|
||||
@@ -260,8 +224,10 @@ export function AppShell({
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
const { globallyEnabled: aiGloballyEnabled, loaded: aiStatusLoaded } =
|
||||
useAiAvailability();
|
||||
// Re-render when plugins add or remove rail items, panels or tabs.
|
||||
useRailItems();
|
||||
const registeredPanels = usePanels();
|
||||
const { settled: pluginsSettled } = usePluginStore();
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{
|
||||
id: "dashboard",
|
||||
@@ -309,6 +275,8 @@ export function AppShell({
|
||||
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
||||
const allHostsRef = useRef(allHosts);
|
||||
allHostsRef.current = allHosts;
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
// The standalone desktop backend still owns system settings such as the
|
||||
// Tailscale API key, even though it has only one implicit user.
|
||||
@@ -316,7 +284,6 @@ export function AppShell({
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [showDonationModal, setShowDonationModal] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [onboardingAiEnabled, setOnboardingAiEnabled] = useState(false);
|
||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||
OpenTabRecord[]
|
||||
>([]);
|
||||
@@ -329,18 +296,12 @@ export function AppShell({
|
||||
uiPrefs.preferences.onboarding.completedVersion < UI_ONBOARDING_VERSION;
|
||||
|
||||
/**
|
||||
* Both onboarding entry points resolve the same context first: whether an
|
||||
* admin has enabled the AI assistant. The AI step is skipped entirely when
|
||||
* it is off, so the answer has to be in before the dialog opens.
|
||||
* Both onboarding entry points wait for plugins first, since plugins add
|
||||
* steps of their own (the AI step, feature cards).
|
||||
*/
|
||||
const loadOnboardingContext = useCallback(async () => {
|
||||
try {
|
||||
const { getAiStatus } = await import("@/api/ai-api");
|
||||
const aiStatus = await getAiStatus();
|
||||
setOnboardingAiEnabled(aiStatus.globallyEnabled);
|
||||
} catch {
|
||||
setOnboardingAiEnabled(false);
|
||||
}
|
||||
const { settledPromise } = await import("@/plugin-host/plugin-store");
|
||||
await settledPromise();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -382,7 +343,7 @@ export function AppShell({
|
||||
// stay visible while the left sidebar is used for something else.
|
||||
const [rightRailView, setRightRailView] = useState<RailView | null>(() => {
|
||||
const saved = localStorage.getItem("termix_rightRailView");
|
||||
return saved && RIGHT_DOCKABLE_IDS.includes(saved)
|
||||
return saved && rightDockableIds().includes(saved)
|
||||
? (saved as RailView)
|
||||
: null;
|
||||
});
|
||||
@@ -463,10 +424,15 @@ export function AppShell({
|
||||
.catch(() => setIsAdmin(false));
|
||||
}, []);
|
||||
|
||||
// Plugin state gates which tab types can be opened, so it has to land before
|
||||
// a saved session is restored. A failure leaves everything enabled.
|
||||
// Plugin frontends register the tabs, panels and actions that a saved
|
||||
// session may need, so they load before tabs are restored. On logout every
|
||||
// plugin is deactivated, since the next user may see different ones.
|
||||
useEffect(() => {
|
||||
refreshPluginState().catch(() => {});
|
||||
startPluginRuntime().catch(() => {});
|
||||
return () => {
|
||||
void stopPluginRuntime();
|
||||
resetShellBridge();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDismissDonationModal = useCallback(() => {
|
||||
@@ -1266,7 +1232,7 @@ export function AppShell({
|
||||
}, [allHosts]);
|
||||
|
||||
function buildWorkspacePayload(): WorkspacePayload {
|
||||
return buildWorkspacePayloadUtil({
|
||||
return buildLayoutPayload({
|
||||
tabs,
|
||||
activeTabId,
|
||||
splitMode,
|
||||
@@ -1284,7 +1250,10 @@ export function AppShell({
|
||||
});
|
||||
}
|
||||
|
||||
async function applyWorkspace(workspace: Workspace) {
|
||||
async function applyLayout(
|
||||
payload: WorkspacePayload,
|
||||
name: string,
|
||||
): Promise<{ skipped: string[] }> {
|
||||
// Tear down the current arrangement the same way an individual tab close does.
|
||||
for (const tab of [...tabsRef.current]) {
|
||||
doCloseTab(tab.id);
|
||||
@@ -1293,8 +1262,8 @@ export function AppShell({
|
||||
const slotIdToNewTabId = new Map<string, string>();
|
||||
const skippedTabs: string[] = [];
|
||||
|
||||
for (const snapshot of workspace.payload.tabs) {
|
||||
const target = resolveWorkspaceTabTarget(snapshot, allHosts);
|
||||
for (const snapshot of payload.tabs) {
|
||||
const target = resolveLayoutTabTarget(snapshot, allHostsRef.current);
|
||||
|
||||
if (target.kind === "skip") {
|
||||
skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label);
|
||||
@@ -1312,7 +1281,7 @@ export function AppShell({
|
||||
snapshot.type,
|
||||
undefined,
|
||||
target.host,
|
||||
snapshot.fleetId,
|
||||
snapshotData(snapshot),
|
||||
);
|
||||
slotIdToNewTabId.set(snapshot.slotId, snapshot.type);
|
||||
continue;
|
||||
@@ -1330,27 +1299,21 @@ export function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
const restoredPaneIds = remapSlotIds(
|
||||
workspace.payload.paneTabIds,
|
||||
slotIdToNewTabId,
|
||||
);
|
||||
const restoredPaneIds = remapSlotIds(payload.paneTabIds, slotIdToNewTabId);
|
||||
let restoredSplitTabId: string | null = null;
|
||||
if (
|
||||
workspace.payload.splitMode !== "none" &&
|
||||
restoredPaneIds.some(Boolean)
|
||||
) {
|
||||
if (payload.splitMode !== "none" && restoredPaneIds.some(Boolean)) {
|
||||
const instanceId = crypto.randomUUID();
|
||||
restoredSplitTabId = `split-${instanceId}`;
|
||||
const splitTab: Tab = {
|
||||
id: restoredSplitTabId,
|
||||
instanceId,
|
||||
type: "split-screen",
|
||||
label: workspace.name,
|
||||
label: name,
|
||||
openedAt: Date.now(),
|
||||
splitConfig: createSplitConfig(
|
||||
workspace.payload.splitMode,
|
||||
payload.splitMode,
|
||||
restoredPaneIds,
|
||||
workspace.payload,
|
||||
payload,
|
||||
),
|
||||
};
|
||||
setTabs((prev) =>
|
||||
@@ -1358,13 +1321,13 @@ export function AppShell({
|
||||
);
|
||||
}
|
||||
|
||||
const activeId = workspace.payload.activeSlotId
|
||||
? (slotIdToNewTabId.get(workspace.payload.activeSlotId) ?? "dashboard")
|
||||
const activeId = payload.activeSlotId
|
||||
? (slotIdToNewTabId.get(payload.activeSlotId) ?? "dashboard")
|
||||
: "dashboard";
|
||||
setActiveTabId(restoredSplitTabId ?? activeId);
|
||||
|
||||
// Older payloads predate the sidebar field, so leave the docks alone then.
|
||||
const sidebar = workspace.payload.sidebar;
|
||||
const sidebar = payload.sidebar;
|
||||
if (sidebar) {
|
||||
if (sidebar.left.view) setRailView(sidebar.left.view as RailView);
|
||||
setSidebarOpen(sidebar.left.open);
|
||||
@@ -1372,30 +1335,22 @@ export function AppShell({
|
||||
|
||||
const right = sidebar.right.open ? sidebar.right.view : null;
|
||||
setRightRailView(
|
||||
right && RIGHT_DOCKABLE_IDS.includes(right)
|
||||
right && rightDockableIds().includes(right)
|
||||
? (right as RailView)
|
||||
: null,
|
||||
);
|
||||
if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width);
|
||||
}
|
||||
|
||||
if (skippedTabs.length > 0) {
|
||||
toast.warning(
|
||||
t("newUi.sidebar.workspaces.tabsSkipped", {
|
||||
count: skippedTabs.length,
|
||||
names: skippedTabs.join(", "),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
applyWorkspaceServer(workspace.id).catch(() => {});
|
||||
return { skipped: skippedTabs };
|
||||
}
|
||||
|
||||
// On load: always read saved tabs from DB so background sessions are preserved across refreshes.
|
||||
// If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar.
|
||||
const tabRestoreAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hostsLoaded || !userPrefsLoaded) return;
|
||||
// Waits for plugins too, so a plugin's saved tabs come back as themselves.
|
||||
if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return;
|
||||
if (tabRestoreAttemptedRef.current) return;
|
||||
tabRestoreAttemptedRef.current = true;
|
||||
|
||||
@@ -1416,7 +1371,7 @@ export function AppShell({
|
||||
|
||||
if (userPrefs.reopenTabsOnLogin) {
|
||||
const hasPersistentTabs = tabs.some((t) =>
|
||||
PERSISTENT_TAB_TYPES.includes(t.type),
|
||||
isPersistentTabType(t.type),
|
||||
);
|
||||
if (!hasPersistentTabs) {
|
||||
const restoredTabs: Tab[] = [];
|
||||
@@ -1424,16 +1379,7 @@ export function AppShell({
|
||||
const host = saved.hostId
|
||||
? allHosts.find((h) => h.id === String(saved.hostId))
|
||||
: undefined;
|
||||
const hostlessTypes: TabType[] = ["dashboard", "tunnel"];
|
||||
if (!host && !hostlessTypes.includes(saved.tabType as TabType))
|
||||
continue;
|
||||
|
||||
if (host) {
|
||||
if (saved.tabType === "terminal" && !host.enableSsh) continue;
|
||||
if (saved.tabType === "rdp" && !host.enableRdp) continue;
|
||||
if (saved.tabType === "vnc" && !host.enableVnc) continue;
|
||||
if (saved.tabType === "telnet" && !host.enableTelnet) continue;
|
||||
}
|
||||
if (!canRestoreTabType(saved.tabType, host)) continue;
|
||||
|
||||
// Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID
|
||||
const tabId = host
|
||||
@@ -1457,9 +1403,7 @@ export function AppShell({
|
||||
host,
|
||||
openedAt: new Date(saved.createdAt).getTime(),
|
||||
restoredSessionId,
|
||||
terminalRef: SESSION_TAB_TYPES.includes(
|
||||
saved.tabType as TabType,
|
||||
)
|
||||
terminalRef: isSessionTabType(saved.tabType)
|
||||
? createRef()
|
||||
: undefined,
|
||||
});
|
||||
@@ -1489,33 +1433,12 @@ export function AppShell({
|
||||
}
|
||||
|
||||
loadSavedTabs();
|
||||
}, [hostsLoaded, userPrefsLoaded]);
|
||||
}, [hostsLoaded, userPrefsLoaded, pluginsSettled]);
|
||||
|
||||
// If reopenTabsOnLogin didn't already restore anything (off, or on but
|
||||
// nothing to restore), auto-apply the user's default workspace if they set
|
||||
// one. Runs once, after the open-tabs restore above has had its chance —
|
||||
// that path wins when both would otherwise fire, since it's more granular
|
||||
// and live-session-aware than a workspace snapshot.
|
||||
const defaultWorkspaceAttemptedRef = useRef(false);
|
||||
// Plugins that act once the session is back (the workspaces plugin applies
|
||||
// a default workspace) wait for this.
|
||||
useEffect(() => {
|
||||
if (!tabsReady || defaultWorkspaceAttemptedRef.current) return;
|
||||
defaultWorkspaceAttemptedRef.current = true;
|
||||
|
||||
const hasPersistentTabs = tabs.some((t) =>
|
||||
PERSISTENT_TAB_TYPES.includes(t.type),
|
||||
);
|
||||
if (userPrefs.reopenTabsOnLogin && hasPersistentTabs) return;
|
||||
|
||||
listWorkspaces()
|
||||
.then((workspaces) => {
|
||||
const defaultWorkspace = workspaces.find(
|
||||
(w) => w.kind === "manual" && w.isDefault,
|
||||
);
|
||||
if (defaultWorkspace) {
|
||||
applyWorkspace(defaultWorkspace);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
if (tabsReady) notifyShellReady();
|
||||
}, [tabsReady]);
|
||||
|
||||
// Restore named split tabs once their child sessions have stable live ids. The old
|
||||
@@ -1599,9 +1522,7 @@ export function AppShell({
|
||||
const prevTabOrderRef = useRef<string>("");
|
||||
useEffect(() => {
|
||||
if (!tabsReady) return;
|
||||
const persistable = tabs.filter((t) =>
|
||||
PERSISTENT_TAB_TYPES.includes(t.type),
|
||||
);
|
||||
const persistable = tabs.filter((t) => isPersistentTabType(t.type));
|
||||
const orderKey = persistable.map((t) => t.instanceId).join(",");
|
||||
if (orderKey === prevTabOrderRef.current) return;
|
||||
prevTabOrderRef.current = orderKey;
|
||||
@@ -1619,25 +1540,10 @@ export function AppShell({
|
||||
};
|
||||
}, [tabs, tabsReady]);
|
||||
|
||||
// Debounced "Last Session" auto-save: keeps an implicit workspace snapshot
|
||||
// current so the arrangement can always be recovered, even if the user never
|
||||
// manually saves one. Never auto-applied on login — see the default-workspace
|
||||
// effect above, which only considers kind === "manual" rows.
|
||||
const lastSessionSaveTimeoutRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null);
|
||||
// Tells plugins the arrangement changed, e.g. so the workspaces plugin can
|
||||
// keep its last-session snapshot current.
|
||||
useEffect(() => {
|
||||
if (!tabsReady) return;
|
||||
if (lastSessionSaveTimeoutRef.current)
|
||||
clearTimeout(lastSessionSaveTimeoutRef.current);
|
||||
lastSessionSaveTimeoutRef.current = setTimeout(() => {
|
||||
saveLastSessionWorkspace(buildWorkspacePayload()).catch(() => {});
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
if (lastSessionSaveTimeoutRef.current)
|
||||
clearTimeout(lastSessionSaveTimeoutRef.current);
|
||||
};
|
||||
if (tabsReady) notifyTabsChanged();
|
||||
}, [
|
||||
tabs,
|
||||
paneTabIds,
|
||||
@@ -1668,19 +1574,19 @@ export function AppShell({
|
||||
joinShareId?: string | null;
|
||||
collabRoomId?: string;
|
||||
},
|
||||
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
|
||||
options?: {
|
||||
data?: Record<string, unknown>;
|
||||
label?: string;
|
||||
forceNewTab?: boolean;
|
||||
},
|
||||
) {
|
||||
// A tab type owned by a disabled plugin cannot be opened, including from a
|
||||
// restored session. Guarded here rather than at each call site because
|
||||
// every path into a tab goes through this one.
|
||||
if (!isTabTypeAvailable(type)) return null;
|
||||
|
||||
if (!restore && !options?.forceNewTab) {
|
||||
const dataKey = JSON.stringify(options?.data ?? null);
|
||||
const existing = tabsRef.current.find(
|
||||
(t) =>
|
||||
t.type === type &&
|
||||
t.host?.id === host.id &&
|
||||
t.endpointId === options?.endpointId,
|
||||
JSON.stringify(t.data ?? null) === dataKey,
|
||||
);
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id);
|
||||
@@ -1694,7 +1600,7 @@ export function AppShell({
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`);
|
||||
const openedAt = Date.now();
|
||||
const ref = SESSION_TAB_TYPES.includes(type) ? createRef() : undefined;
|
||||
const ref = isSessionTabType(type) ? createRef() : undefined;
|
||||
if (ref) terminalRefs.current.set(tabId, ref);
|
||||
|
||||
let finalLabel = host.name;
|
||||
@@ -1770,13 +1676,13 @@ export function AppShell({
|
||||
initialPath,
|
||||
serialConfig,
|
||||
collabRoomId: restore?.collabRoomId,
|
||||
endpointId: options?.endpointId,
|
||||
data: options?.data,
|
||||
},
|
||||
];
|
||||
});
|
||||
setActiveTabId(tabId);
|
||||
|
||||
if (PERSISTENT_TAB_TYPES.includes(type)) {
|
||||
if (isPersistentTabType(type)) {
|
||||
addOpenTab({
|
||||
id: instanceId,
|
||||
tabType: type,
|
||||
@@ -1792,7 +1698,11 @@ export function AppShell({
|
||||
function connectHost(
|
||||
host: Host,
|
||||
preferredType?: TabType,
|
||||
options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
|
||||
options?: {
|
||||
data?: Record<string, unknown>;
|
||||
label?: string;
|
||||
forceNewTab?: boolean;
|
||||
},
|
||||
) {
|
||||
const type = resolveHostTabType(host, preferredType);
|
||||
// --- tmux-monitor --- singleton tab, not a per-host tab
|
||||
@@ -1946,15 +1856,12 @@ export function AppShell({
|
||||
type: TabType,
|
||||
pendingEvent?: string,
|
||||
host?: Host,
|
||||
fleetId?: number,
|
||||
data?: Record<string, unknown>,
|
||||
) {
|
||||
// Local terminals are never singletons, each one is its own shell.
|
||||
if (type === "local-terminal") {
|
||||
return openLocalTerminalTab();
|
||||
}
|
||||
// The admin kill switch removes the assistant for everyone, so it can
|
||||
// never be promoted into the tab bar while it is off.
|
||||
if (type === "ai" && !aiGloballyEnabled) return;
|
||||
if (type === "host-manager") {
|
||||
if (pendingEvent === "host-manager:add-credential") {
|
||||
setSidebarOpen(true);
|
||||
@@ -1990,27 +1897,28 @@ export function AppShell({
|
||||
const id = type;
|
||||
const singletonLabels: Partial<Record<TabType, string>> = {
|
||||
"host-manager": t("nav.hostManager"),
|
||||
docker: t("nav.docker"),
|
||||
tunnel: t("nav.tunnels"),
|
||||
sftp: t("nav.sftp"),
|
||||
network_graph: t("nav.networkGraph"),
|
||||
tmux_monitor: t("nav.tmuxMonitor"), // --- tmux-monitor ---
|
||||
homepage: t("nav.homepage"),
|
||||
"fleet-inventory": t("nav.fleets"),
|
||||
};
|
||||
// Promoted rail panels reuse the rail's own label so the two stay in sync.
|
||||
const label = singletonLabels[type] ?? railItemLabel(type, t);
|
||||
// A plugin tab names itself; promoted rail panels reuse the rail's own
|
||||
// label so the two stay in sync.
|
||||
const titleKey = getTabType(type)?.titleKey;
|
||||
const label =
|
||||
singletonLabels[type] ??
|
||||
(titleKey ? t(titleKey) : railItemLabel(type, t));
|
||||
setTabs((prev) => {
|
||||
const existing = prev.find((t) => t.id === id);
|
||||
if (existing) {
|
||||
// --- tmux-monitor --- refocusing with a host preselects it
|
||||
if (!host && fleetId === undefined) return prev;
|
||||
if (!host && data === undefined) return prev;
|
||||
return prev.map((t) =>
|
||||
t.id === id
|
||||
? {
|
||||
...t,
|
||||
...(host ? { host } : {}),
|
||||
...(fleetId !== undefined ? { fleetId } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
}
|
||||
: t,
|
||||
);
|
||||
@@ -2024,12 +1932,12 @@ export function AppShell({
|
||||
label,
|
||||
openedAt: Date.now(),
|
||||
...(host ? { host } : {}), // --- tmux-monitor ---
|
||||
...(fleetId !== undefined ? { fleetId } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
setActiveTabId(id);
|
||||
if (PERSISTENT_TAB_TYPES.includes(type)) {
|
||||
if (isPersistentTabType(type)) {
|
||||
addOpenTab({
|
||||
id,
|
||||
tabType: type,
|
||||
@@ -2039,24 +1947,15 @@ export function AppShell({
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
[t, aiGloballyEnabled],
|
||||
[t],
|
||||
);
|
||||
|
||||
const SESSION_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
"serial",
|
||||
];
|
||||
const ACTIVE_CLOSE_CONFIRM_TYPES: TabType[] = SESSION_TAB_TYPES;
|
||||
|
||||
const getTabCloseLabel = useCallback((tab: Tab) => {
|
||||
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
|
||||
}, []);
|
||||
|
||||
const isActiveConnectionTab = useCallback((tab: Tab) => {
|
||||
if (!ACTIVE_CLOSE_CONFIRM_TYPES.includes(tab.type)) return false;
|
||||
if (!isSessionTabType(tab.type)) return false;
|
||||
return tab.terminalRef?.current?.isConnected?.() === true;
|
||||
}, []);
|
||||
|
||||
@@ -2084,10 +1983,7 @@ export function AppShell({
|
||||
if (tabToClose?.terminalRef?.current?.disconnect) {
|
||||
tabToClose.terminalRef.current.disconnect();
|
||||
}
|
||||
if (
|
||||
tabToClose?.instanceId &&
|
||||
PERSISTENT_TAB_TYPES.includes(tabToClose.type)
|
||||
) {
|
||||
if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) {
|
||||
deleteOpenTab(tabToClose.instanceId).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -2136,15 +2032,11 @@ export function AppShell({
|
||||
|
||||
function refreshTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
if (!tab) return;
|
||||
if (tab.type === "terminal") {
|
||||
const ref = tab.terminalRef?.current;
|
||||
ref?.reconnect?.();
|
||||
} else if (["rdp", "vnc", "telnet"].includes(tab.type)) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:refresh-guacamole", { detail: { tabId: id } }),
|
||||
);
|
||||
}
|
||||
const handle = tab?.terminalRef?.current;
|
||||
if (!handle) return;
|
||||
// Session tabs expose one or the other on their handle.
|
||||
if (handle.reconnect) handle.reconnect();
|
||||
else handle.refresh?.();
|
||||
}
|
||||
|
||||
function openShareForTab(id: string) {
|
||||
@@ -2188,23 +2080,13 @@ export function AppShell({
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab && SESSION_TAB_TYPES.includes(tab.type) && confirmEnabled) {
|
||||
if (tab && isSessionTabType(tab.type) && confirmEnabled) {
|
||||
toast.dismiss(`close-tab-${id}`);
|
||||
}
|
||||
|
||||
doCloseTab(id);
|
||||
}
|
||||
|
||||
// An admin can turn the assistant off while tabs are already open, and a
|
||||
// saved workspace or restored session can bring one back. Either way the
|
||||
// leftover tab and panel go away as soon as the status says it is off.
|
||||
useEffect(() => {
|
||||
if (!aiStatusLoaded || aiGloballyEnabled) return;
|
||||
if (tabs.some((tab) => tab.type === "ai")) doCloseTab("ai");
|
||||
setRailView((prev) => (prev === "ai" ? "hosts" : prev));
|
||||
setRightRailView((prev) => (prev === "ai" ? null : prev));
|
||||
}, [aiStatusLoaded, aiGloballyEnabled, tabs]);
|
||||
|
||||
closeActiveTabRef.current = () => {
|
||||
const id = activeTabIdRef.current;
|
||||
if (id !== "dashboard") closeTab(id);
|
||||
@@ -2332,7 +2214,7 @@ export function AppShell({
|
||||
setRightRailView(null);
|
||||
return;
|
||||
}
|
||||
const fallback = lastRightRailViewRef.current ?? RIGHT_DOCKABLE_IDS[0];
|
||||
const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0];
|
||||
if (fallback) setRightRailView(fallback as RailView);
|
||||
}
|
||||
|
||||
@@ -2544,6 +2426,79 @@ export function AppShell({
|
||||
*/
|
||||
// The param deliberately shadows the outer railView so the body reads the
|
||||
// same whichever dock is rendering.
|
||||
const newInstanceId = () =>
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
/**
|
||||
* What plugins may ask of the shell. Rebuilt every render so it always
|
||||
* closes over current state, and published to the plugin runtime.
|
||||
*/
|
||||
const shellCallbacks: TabShellCallbacks = {
|
||||
openTab: (host, type, options) => {
|
||||
if (!host || getTabType(type)?.singleton) {
|
||||
openSingletonTab(type, undefined, host ?? undefined, options?.data);
|
||||
return;
|
||||
}
|
||||
openTab(host, type, undefined, options);
|
||||
},
|
||||
openSingletonTab: (type, options) =>
|
||||
openSingletonTab(type, undefined, undefined, options?.data),
|
||||
closeTab: (tabId) => closeTab(tabId),
|
||||
renameTab: (tabId, label) => renameTab(tabId, label),
|
||||
openFileInEditor: (host, filePath) =>
|
||||
openTab(host, "files", {
|
||||
instanceId: newInstanceId(),
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
openFileManager: (host, path) =>
|
||||
openTab(host, "files", {
|
||||
instanceId: newInstanceId(),
|
||||
restoredSessionId: null,
|
||||
initialPath: path,
|
||||
}),
|
||||
openTerminalTab: (host, path) =>
|
||||
openTab(host, "terminal", {
|
||||
instanceId: newInstanceId(),
|
||||
restoredSessionId: null,
|
||||
initialFilePath: path,
|
||||
}),
|
||||
openRailView: (id) => {
|
||||
setRailView(id as RailView);
|
||||
setSidebarOpen(true);
|
||||
},
|
||||
closeRailView: (id) => {
|
||||
setRailView((prev) => (prev === id ? "hosts" : prev));
|
||||
setRightRailView((prev) => (prev === id ? null : prev));
|
||||
},
|
||||
saveQuickConnect: saveQuickConnectHost,
|
||||
};
|
||||
|
||||
// Panels close the sidebar on mobile after opening something, the same as
|
||||
// the hosts panel does.
|
||||
const panelShell: TabShellCallbacks = {
|
||||
...shellCallbacks,
|
||||
openTab: (...args) => {
|
||||
shellCallbacks.openTab(...args);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setShellCallbacks(shellCallbacks);
|
||||
setShellLayoutProvider({
|
||||
getLayout: () => (tabsReady ? buildWorkspacePayload() : null),
|
||||
applyLayout: (layout, options) =>
|
||||
applyLayout(layout as WorkspacePayload, options?.name ?? ""),
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setShellHosts(allHosts, hostsLoaded);
|
||||
}, [allHosts, hostsLoaded]);
|
||||
|
||||
const renderSidebarPanels = (railView: RailView, owned = true) => (
|
||||
<Suspense fallback={<SidebarPanelFallback />}>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
@@ -2606,15 +2561,6 @@ export function AppShell({
|
||||
/>
|
||||
)}
|
||||
|
||||
{railView === "tailscale" && (
|
||||
<TailscaleDevicesPanel
|
||||
onConnect={(host, type) => {
|
||||
openTab(host, type);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{railView === "ssh-tools" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SshToolsPanel
|
||||
@@ -2648,24 +2594,6 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{owned && (
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-h-0 ${railView === "fleets" ? "" : "hidden"}`}
|
||||
>
|
||||
<FleetsPanel
|
||||
active={railView === "fleets"}
|
||||
onOpenFleetInventory={(fleetId) =>
|
||||
openSingletonTab(
|
||||
"fleet-inventory",
|
||||
undefined,
|
||||
undefined,
|
||||
fleetId,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "history" && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<HistoryPanel
|
||||
@@ -2693,33 +2621,32 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "workspaces" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<WorkspacesPanel
|
||||
active={railView === "workspaces"}
|
||||
currentPayload={buildWorkspacePayload}
|
||||
onApplyWorkspace={applyWorkspace}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{registeredPanels.map((panel) => {
|
||||
const shown = railView === panel.id;
|
||||
// A kept-mounted panel lives in the owning dock only, so two live
|
||||
// copies never fight over the same state.
|
||||
if (panel.keepMounted ? !owned : !shown) return null;
|
||||
const Panel = panel.component;
|
||||
return (
|
||||
<div
|
||||
key={panel.id}
|
||||
className={`flex flex-col flex-1 min-h-0 overflow-y-auto ${shown ? "" : "hidden"}`}
|
||||
>
|
||||
<Panel
|
||||
active={shown}
|
||||
shell={panelShell}
|
||||
setEditing={setSidebarEditing}
|
||||
activeTabType={
|
||||
tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined
|
||||
}
|
||||
placement={owned ? "left" : "right"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{railView === "automations" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AutomationsPanel
|
||||
active={railView === "automations"}
|
||||
onEditingChange={setSidebarEditing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "ai" && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<AiPanel
|
||||
activeTab={
|
||||
tabs.find((tab) => tab.id === activeTabId)?.type ?? null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!isCoreRailView(railView) && !getPanel(railView) && (
|
||||
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
|
||||
)}
|
||||
|
||||
{railView === "connections" && (
|
||||
@@ -2918,7 +2845,7 @@ export function AppShell({
|
||||
<span className="flex-1 min-w-0 whitespace-nowrap text-base font-bold tracking-tight text-foreground px-3">
|
||||
{sidebarTitle(railView)}
|
||||
</span>
|
||||
{!isMobile && PROMOTABLE_IDS.includes(railView) && (
|
||||
{!isMobile && promotableIds().includes(railView) && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<Button
|
||||
@@ -2933,7 +2860,7 @@ export function AppShell({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isMobile && RIGHT_DOCKABLE_IDS.includes(railView) && (
|
||||
{!isMobile && rightDockableIds().includes(railView) && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<Button
|
||||
@@ -3007,8 +2934,8 @@ export function AppShell({
|
||||
|
||||
const sidebarHint = !isMobile && (
|
||||
<MultiPanelHint
|
||||
canPromote={PROMOTABLE_IDS.includes(railView)}
|
||||
canRightDock={RIGHT_DOCKABLE_IDS.includes(railView)}
|
||||
canPromote={promotableIds().includes(railView)}
|
||||
canRightDock={rightDockableIds().includes(railView)}
|
||||
onOpenAsTab={() => openSingletonTab(railView as TabType)}
|
||||
onOpenInRightDock={() => openInRightDock(railView)}
|
||||
/>
|
||||
@@ -3189,43 +3116,11 @@ export function AppShell({
|
||||
? paneIdx === (focusedPaneIndex ?? 0)
|
||||
: activeInline;
|
||||
return createPortal(
|
||||
renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
inPane || activeInline,
|
||||
(host, filePath) =>
|
||||
openTab(host, "files", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
(host, path) =>
|
||||
openTab(host, "files", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialPath: path,
|
||||
}),
|
||||
(host, path) =>
|
||||
openTab(host, "terminal", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: path,
|
||||
}),
|
||||
renameTab,
|
||||
saveQuickConnectHost,
|
||||
renderTabContent(tab, {
|
||||
shell: shellCallbacks,
|
||||
isVisible: inPane || activeInline,
|
||||
isFocusedPane,
|
||||
{
|
||||
panelProps: {
|
||||
terminalTabs,
|
||||
targetTerminalTabId,
|
||||
storageMode:
|
||||
@@ -3233,7 +3128,7 @@ export function AppShell({
|
||||
? "cloud"
|
||||
: "local",
|
||||
},
|
||||
),
|
||||
}),
|
||||
tabNode,
|
||||
tab.id,
|
||||
);
|
||||
@@ -3330,7 +3225,7 @@ export function AppShell({
|
||||
</Suspense>
|
||||
<OnboardingDialog
|
||||
open={showOnboarding}
|
||||
context={{ aiGloballyEnabled: onboardingAiEnabled }}
|
||||
context={{}}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
/>
|
||||
<DonationReminderModal
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AiProviderType =
|
||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||
|
||||
export interface AiProvider {
|
||||
id: number;
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
/** The first few characters only; the key itself never leaves the server. */
|
||||
apiKeyPrefix: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
id: number;
|
||||
title: string | null;
|
||||
providerId: number | null;
|
||||
model: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCalls: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiProposal {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
kind: string;
|
||||
summary: string | null;
|
||||
payload: string;
|
||||
status: "pending" | "applied" | "rejected" | "expired";
|
||||
resultSummary: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AiStatus {
|
||||
globallyEnabled: boolean;
|
||||
enabled: boolean;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export async function getAiStatus(): Promise<AiStatus> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/status")).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviders(): Promise<AiProvider[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI providers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAiProvider(input: {
|
||||
providerType: AiProviderType;
|
||||
label: string;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
defaultModel?: string | null;
|
||||
}): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.post("/plugin-api/ai/providers", input)).data
|
||||
.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAiProvider(
|
||||
id: number,
|
||||
input: Partial<{
|
||||
label: string;
|
||||
baseUrl: string | null;
|
||||
apiKey: string | null;
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
): Promise<AiProvider> {
|
||||
try {
|
||||
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
|
||||
.provider;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiProvider(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/plugin-api/ai/providers/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI provider");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model list for a provider that may not be saved yet, so the add form can
|
||||
* fill its picker before anything is persisted.
|
||||
*/
|
||||
export async function probeAiModels(input: {
|
||||
providerType: AiProviderType;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
providerId?: number | null;
|
||||
}): Promise<{
|
||||
models: string[];
|
||||
source: "live" | "fallback" | "none";
|
||||
warning?: string;
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "detect models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiProviderModels(id: number): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
|
||||
.models;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list provider models");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversations(): Promise<AiConversation[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/conversations")).data
|
||||
.conversations;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list AI conversations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiConversation(id: number): Promise<{
|
||||
conversation: AiConversation;
|
||||
messages: AiMessage[];
|
||||
proposals: AiProposal[];
|
||||
}> {
|
||||
try {
|
||||
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "load AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAiConversation(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete AI conversation");
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAiProposal(
|
||||
id: number,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "apply AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectAiProposal(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reject AI proposal");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For the terminal-docked assistant: records that a run_command proposal was
|
||||
* typed into the user's open terminal instead of run over a pooled connection.
|
||||
*/
|
||||
export async function markAiProposalRunInTerminal(
|
||||
id: number,
|
||||
hostId: number,
|
||||
summary?: string,
|
||||
): Promise<{ success: boolean; summary: string }> {
|
||||
try {
|
||||
return (
|
||||
await authApi.post(
|
||||
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
|
||||
{
|
||||
hostId,
|
||||
summary,
|
||||
},
|
||||
)
|
||||
).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "mark AI proposal run in terminal");
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin ---
|
||||
|
||||
export async function getAiGloballyEnabled(): Promise<boolean> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
|
||||
try {
|
||||
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
|
||||
.enabled;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAiPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get AI endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAiPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
|
||||
.data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update AI endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNotificationPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/notification-private-endpoints")).data
|
||||
.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get notification endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Step CA endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStepCaPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
|
||||
.data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update Step CA endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/secret-source-private-endpoints")).data
|
||||
.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSecretSourcePrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (
|
||||
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
|
||||
).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setNotificationPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (
|
||||
await authApi.patch("/users/notification-private-endpoints", { hosts })
|
||||
).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update notification endpoint allowlist");
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,8 @@ export interface UptimeInfo {
|
||||
export interface RecentActivityItem {
|
||||
id: number;
|
||||
userId: string;
|
||||
type:
|
||||
| "terminal"
|
||||
| "file_manager"
|
||||
| "server_stats"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "telnet"
|
||||
| "vnc"
|
||||
| "rdp";
|
||||
/** Core records file_manager and tunnel; plugin tabs record their own. */
|
||||
type: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
timestamp: string;
|
||||
@@ -50,15 +43,7 @@ export async function getRecentActivity(
|
||||
}
|
||||
|
||||
export async function logActivity(
|
||||
type:
|
||||
| "terminal"
|
||||
| "file_manager"
|
||||
| "server_stats"
|
||||
| "tunnel"
|
||||
| "docker"
|
||||
| "rdp"
|
||||
| "vnc"
|
||||
| "telnet",
|
||||
type: string,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
): Promise<{ message: string; id: number | string }> {
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { dockerApi, handleApiError } from "@/main-axios";
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerLogOptions,
|
||||
DockerStats,
|
||||
DockerValidation,
|
||||
} from "@/types/index";
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ConnectErrorResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function connectDockerSession(
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
config?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
},
|
||||
): Promise<{
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
requires_totp?: boolean;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_warpgate?: boolean;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
hostId,
|
||||
...config,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.status === "auth_required"
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_totp
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_warpgate
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
throw handleApiError(error, "connect to Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDockerTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker TOTP");
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDockerWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker Warpgate");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "disconnect from Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepaliveDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "keepalive Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; connected: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker session status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateDockerAvailability(
|
||||
sessionId: string,
|
||||
): Promise<DockerValidation> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/validate/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate Docker availability");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDockerContainers(
|
||||
sessionId: string,
|
||||
all: boolean = true,
|
||||
): Promise<DockerContainer[]> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/containers/${sessionId}`, {
|
||||
params: { all },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list Docker containers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerContainerDetails(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerContainer> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker container details");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "stop Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "restart Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/pause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "pause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/unpause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unpause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.delete(
|
||||
`/containers/${sessionId}/${containerId}/remove`,
|
||||
{
|
||||
params: { force },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<{ logs: string }> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: options,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: { ...options, download: true },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "download container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerStats(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerStats> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container stats");
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
type SharePermissionLevel,
|
||||
type ShareTarget,
|
||||
} from "@/main-axios";
|
||||
|
||||
export interface FleetRow {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
tagRules: string[];
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export interface FleetMemberRow {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
tags: string[];
|
||||
static: boolean;
|
||||
permissionLevel: "connect" | "view" | "edit" | "manage" | null;
|
||||
}
|
||||
|
||||
export interface FleetHostResult {
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: 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 FleetInventoryEntry {
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
inventory: FleetInventoryRecord | null;
|
||||
}
|
||||
|
||||
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<{
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||
|
||||
// Every function below is keyed by a host's numeric database id, and the
|
||||
// receiving backend must own that host in its own database -- a synced
|
||||
// host has a different numeric id on each side (only its syncId matches
|
||||
// across them). These calls always target the embedded local backend; see
|
||||
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
|
||||
// call that IS safely merged across local + remote (a process-local,
|
||||
// in-memory aggregate keyed by whichever host ids that process happens to
|
||||
// know about, not a per-host lookup).
|
||||
|
||||
export interface MetricsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
mem_percent: number | null;
|
||||
disk_percent: number | null;
|
||||
net_rx_bytes: number | null;
|
||||
net_tx_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface MetricsHistoryResponse {
|
||||
rows: MetricsHistoryRow[];
|
||||
fromTs: string;
|
||||
toTs: string;
|
||||
}
|
||||
|
||||
export async function getMetricsHistory(
|
||||
hostId: number,
|
||||
opts: { range?: string; from?: string; to?: string },
|
||||
): Promise<MetricsHistoryResponse> {
|
||||
const res = await statsApi.get(`/metrics/history/${hostId}`, {
|
||||
params: opts,
|
||||
});
|
||||
return res.data as MetricsHistoryResponse;
|
||||
}
|
||||
|
||||
export async function getMetricsHistoryRetention(): Promise<number> {
|
||||
const res = await statsApi.get("/global-settings/history");
|
||||
return (res.data as { metricsHistoryRetentionDays: number })
|
||||
.metricsHistoryRetentionDays;
|
||||
}
|
||||
|
||||
export async function saveMetricsHistoryRetention(days: number): Promise<void> {
|
||||
await statsApi.post("/global-settings/history", {
|
||||
metricsHistoryRetentionDays: days,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Host Metrics layout persistence (server-synced per user/host) + the manager
|
||||
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
|
||||
* stats app (port 30005).
|
||||
*/
|
||||
|
||||
export async function getHostMetricsLayout(
|
||||
hostId: number,
|
||||
): Promise<HostMetricsLayout | null> {
|
||||
try {
|
||||
const res = await statsApi.get(`/host-metrics/preferences/${hostId}`, {
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
return res.data?.layout ?? null;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host metrics layout");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHostMetricsLayout(
|
||||
hostId: number,
|
||||
layout: HostMetricsLayout,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post(`/host-metrics/preferences/${hostId}`, layout);
|
||||
} catch (error) {
|
||||
handleApiError(error, "save host metrics layout");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Managers ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PlatformInfo {
|
||||
hasSystemd: boolean;
|
||||
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
|
||||
hasCertbot: boolean;
|
||||
hasAcmeSh: boolean;
|
||||
hasDocker: boolean;
|
||||
osPrettyName: string | null;
|
||||
}
|
||||
|
||||
export async function getHostPlatform(hostId: number): Promise<PlatformInfo> {
|
||||
const res = await statsApi.get(`/host-metrics/platform/${hostId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** GET a manager resource (read). */
|
||||
export async function managerGet<T>(
|
||||
hostId: number,
|
||||
resource: string,
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<T> {
|
||||
const res = await statsApi.get(
|
||||
`/host-metrics/managers/${resource}/${hostId}`,
|
||||
{ params },
|
||||
);
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a manager sub-resource where the host id sits in the middle of the path,
|
||||
* e.g. /managers/logs/{id}/files.
|
||||
*/
|
||||
export async function managerGetSub<T>(
|
||||
hostId: number,
|
||||
resource: string,
|
||||
sub: string,
|
||||
params?: Record<string, string | number>,
|
||||
): Promise<T> {
|
||||
const res = await statsApi.get(
|
||||
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
|
||||
{ params },
|
||||
);
|
||||
return res.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a manager action. `resource` is the manager name; `action` is the
|
||||
* optional sub-path (e.g. "action", "signal", "renew"). The host id always
|
||||
* sits between them: /managers/{resource}/{id}[/{action}].
|
||||
*/
|
||||
export async function managerPost<T>(
|
||||
hostId: number,
|
||||
resource: string,
|
||||
body: unknown,
|
||||
action?: string,
|
||||
): Promise<T> {
|
||||
const suffix = action ? `/${action}` : "";
|
||||
const res = await statsApi.post(
|
||||
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
|
||||
body,
|
||||
);
|
||||
return res.data as T;
|
||||
}
|
||||
@@ -46,9 +46,21 @@ export interface PluginSettingsContribution {
|
||||
host?: PluginHostSettingsContribution;
|
||||
}
|
||||
|
||||
/** A panel or dashboard card, declared so its owner is known while it is off. */
|
||||
export interface PluginViewContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
dashboardCards?: PluginViewContribution[];
|
||||
/** The frontend also runs on anonymous guest pages. */
|
||||
guest?: boolean;
|
||||
settings?: PluginSettingsContribution;
|
||||
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
|
||||
}
|
||||
|
||||
/** A secret as it arrives from the server. The value never leaves the server. */
|
||||
@@ -173,6 +185,16 @@ export interface PluginSummary {
|
||||
contributes: PluginContributions | null;
|
||||
/** Lucide icon name from the manifest. */
|
||||
icon?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
/** A built frontend bundle is served at /plugin-assets/<id>/frontend.js. */
|
||||
frontend?: boolean;
|
||||
/** A frontend.css sits beside the bundle. */
|
||||
css?: boolean;
|
||||
/** Cache key for the bundle; changes when it is rebuilt. */
|
||||
assetVersion?: string | null;
|
||||
/** "en" plus the xx_YY names of shipped translations. */
|
||||
locales?: string[];
|
||||
|
||||
tier?: string;
|
||||
source?: string;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
/**
|
||||
* Admin allowlists of private addresses that outbound requests may reach, one
|
||||
* per core feature that makes them.
|
||||
*/
|
||||
|
||||
export async function getNotificationPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/notification-private-endpoints")).data
|
||||
.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get notification endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStepCaPrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/step-ca-private-endpoints")).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Step CA endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStepCaPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.patch("/users/step-ca-private-endpoints", { hosts }))
|
||||
.data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update Step CA endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
|
||||
try {
|
||||
return (await authApi.get("/users/secret-source-private-endpoints")).data
|
||||
.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSecretSourcePrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (
|
||||
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
|
||||
).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update secret source endpoint allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setNotificationPrivateEndpoints(
|
||||
hosts: string[],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
return (
|
||||
await authApi.patch("/users/notification-private-endpoints", { hosts })
|
||||
).data.hosts;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update notification endpoint allowlist");
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
|
||||
// Every function below is keyed by a host's numeric database id, and the
|
||||
// receiving backend must own that host in its own database -- same caveat as
|
||||
// host-metrics-api.ts / host-metrics-status-api.ts. All routes live under the
|
||||
// `/proxmox-stats/*` prefix on the stats app (port 30005).
|
||||
|
||||
export async function getProxmoxStats(
|
||||
hostId: number,
|
||||
): Promise<ProxmoxStatsSnapshot | null> {
|
||||
try {
|
||||
const response = await statsApi.get(`/proxmox-stats/${hostId}`, {
|
||||
// Treat 404 as an expected "no stats yet / disabled" signal rather than
|
||||
// an error so we don't spam warn logs on the client.
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
handleApiError(error, "fetch proxmox stats");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startProxmoxStatsPolling(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post(`/proxmox-stats/start/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "start proxmox stats polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopProxmoxStatsPolling(
|
||||
hostId: number,
|
||||
viewerSessionId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post(`/proxmox-stats/stop/${hostId}`, { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "stop proxmox stats polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendProxmoxStatsHeartbeat(
|
||||
viewerSessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/proxmox-stats/heartbeat", { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "send proxmox stats heartbeat");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProxmoxStatsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
mem_percent: number | null;
|
||||
disk_percent: number | null;
|
||||
net_rx_bytes: number | null;
|
||||
net_tx_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxStatsHistoryResponse {
|
||||
rows: ProxmoxStatsHistoryRow[];
|
||||
fromTs: string;
|
||||
toTs: string;
|
||||
}
|
||||
|
||||
export async function getProxmoxStatsHistory(
|
||||
hostId: number,
|
||||
opts: { range?: string; from?: string; to?: string },
|
||||
): Promise<ProxmoxStatsHistoryResponse> {
|
||||
const res = await statsApi.get(`/proxmox-stats/history/${hostId}`, {
|
||||
params: opts,
|
||||
});
|
||||
return res.data as ProxmoxStatsHistoryResponse;
|
||||
}
|
||||
@@ -13,12 +13,10 @@ import {
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
Terminal,
|
||||
Trash2,
|
||||
User,
|
||||
Zap,
|
||||
@@ -50,7 +48,18 @@ import {
|
||||
} from "@/main-axios";
|
||||
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NetworkGraphCard } from "../../../plugins/network-topology/src/frontend/NetworkGraphCard";
|
||||
import {
|
||||
getRegisteredDashboardCard,
|
||||
useRegisteredDashboardCards,
|
||||
} from "./dashboard-cards-registry";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { shell } from "@/plugin-host/shell-bridge";
|
||||
import {
|
||||
defaultConnectAction,
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import { HomepagePreviewCard } from "@/dashboard/cards/HomepagePreviewCard";
|
||||
import { HomepageCanvas } from "@/features/homepage/HomepageCanvas";
|
||||
|
||||
@@ -126,6 +135,51 @@ const DEFAULT_SLOTS: CardSlot[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Cards core draws itself; any other id belongs to a plugin. */
|
||||
const CORE_CARD_IDS = new Set<string>([
|
||||
"stats_bar",
|
||||
"counters_bar",
|
||||
"quick_actions",
|
||||
"host_status",
|
||||
"recent_activity",
|
||||
"service_links",
|
||||
"homepage_preview",
|
||||
]);
|
||||
|
||||
/**
|
||||
* A plugin's card, or a placeholder that keeps the slot while its plugin is
|
||||
* off, so the card comes back in the same place when the plugin does.
|
||||
*/
|
||||
export function PluginCardSlot({
|
||||
id,
|
||||
isVisible,
|
||||
onOpenSingletonTab,
|
||||
}: {
|
||||
id: string;
|
||||
isVisible: boolean;
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
}) {
|
||||
useRegisteredDashboardCards();
|
||||
const card = getRegisteredDashboardCard(id);
|
||||
if (!card) {
|
||||
return (
|
||||
<Card className="flex h-full w-full overflow-hidden py-0">
|
||||
<PluginViewPlaceholder kind="card" viewId={id} compact />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const Component = card.component;
|
||||
return (
|
||||
<Component
|
||||
isVisible={isVisible}
|
||||
shell={{
|
||||
...shell,
|
||||
openSingletonTab: (type) => onOpenSingletonTab(type),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Card components ──────────────────────────────────────────────────────────
|
||||
|
||||
function StatsBarCard({
|
||||
@@ -261,23 +315,20 @@ function QuickActionsCard({
|
||||
const { t } = useTranslation();
|
||||
const pinnedHosts = hosts.filter((h) => h.pin);
|
||||
const getConnectionEndpoint = (host: Host) => {
|
||||
const type = getDefaultConnectionTab(host);
|
||||
const port =
|
||||
type === "rdp"
|
||||
const port = host.enableSsh
|
||||
? host.sshPort
|
||||
: host.enableRdp
|
||||
? host.rdpPort
|
||||
: type === "vnc"
|
||||
: host.enableVnc
|
||||
? host.vncPort
|
||||
: type === "telnet"
|
||||
: host.enableTelnet
|
||||
? host.telnetPort
|
||||
: host.sshPort;
|
||||
return `${host.ip}:${port}`;
|
||||
: host.port;
|
||||
return `${host.ip}:${port ?? host.port}`;
|
||||
};
|
||||
const renderConnectionIcon = (host: Host) => {
|
||||
const type = getDefaultConnectionTab(host);
|
||||
if (type === "terminal" || type === "telnet") {
|
||||
return <Terminal className="size-3 text-accent-brand" />;
|
||||
}
|
||||
return <Server className="size-3 text-accent-brand" />;
|
||||
const Icon = defaultConnectAction(listHostActions(), host)?.icon ?? Server;
|
||||
return <Icon className="size-3 text-accent-brand" />;
|
||||
};
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
@@ -473,20 +524,14 @@ export function HostStatusCard({
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() =>
|
||||
onOpenTab(
|
||||
host,
|
||||
host.enableSsh
|
||||
? "host-metrics"
|
||||
: host.enableRdp
|
||||
? "rdp"
|
||||
: host.enableVnc
|
||||
? "vnc"
|
||||
: host.enableTelnet
|
||||
? "telnet"
|
||||
: "host-metrics",
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
// An overview action (a metrics view) wins over connecting.
|
||||
const actions = hostActionsFor(listHostActions(), host);
|
||||
const target =
|
||||
actions.find((action) => action.overview)?.tabType ??
|
||||
defaultConnectAction(actions, host)?.tabType;
|
||||
if (target) onOpenTab(host, target);
|
||||
}}
|
||||
className="flex min-w-0 items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
@@ -580,35 +625,13 @@ function RecentActivityCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = {
|
||||
terminal: <Terminal className="size-2.5" />,
|
||||
file_manager: <Server className="size-2.5" />,
|
||||
server_stats: <Activity className="size-2.5" />,
|
||||
tunnel: <Network className="size-2.5" />,
|
||||
docker: <Server className="size-2.5" />,
|
||||
rdp: <Server className="size-2.5" />,
|
||||
vnc: <Server className="size-2.5" />,
|
||||
telnet: <MessagesSquare className="size-2.5" />,
|
||||
const typeIcon = (type: string): React.ReactNode => {
|
||||
const Icon = activityTarget(type)?.icon ?? Server;
|
||||
return <Icon className="size-2.5" />;
|
||||
};
|
||||
const typeToTab: Record<RecentActivityItem["type"], TabType> = {
|
||||
terminal: "terminal",
|
||||
file_manager: "files",
|
||||
server_stats: "host-metrics",
|
||||
tunnel: "tunnel",
|
||||
docker: "docker",
|
||||
rdp: "rdp",
|
||||
vnc: "vnc",
|
||||
telnet: "telnet",
|
||||
};
|
||||
const typeLabel: Record<RecentActivityItem["type"], string> = {
|
||||
terminal: t("networkGraph.terminal"),
|
||||
file_manager: t("networkGraph.fileManager"),
|
||||
server_stats: t("networkGraph.serverStats"),
|
||||
tunnel: t("networkGraph.tunnel"),
|
||||
docker: t("networkGraph.docker"),
|
||||
rdp: "RDP",
|
||||
vnc: "VNC",
|
||||
telnet: "Telnet",
|
||||
const typeLabel = (type: string): string => {
|
||||
const labelKey = activityTarget(type)?.labelKey;
|
||||
return labelKey ? t(labelKey) : type.replace("_", " ");
|
||||
};
|
||||
function formatTime(ts: string) {
|
||||
const diffMs = Date.now() - new Date(ts).getTime();
|
||||
@@ -649,7 +672,8 @@ function RecentActivityCard({
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
if (host) onOpenTab(host, typeToTab[item.type]);
|
||||
const target = activityTarget(item.type);
|
||||
if (host && target) onOpenTab(host, target.tab);
|
||||
}}
|
||||
className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
@@ -662,8 +686,8 @@ function RecentActivityCard({
|
||||
{item.hostName}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
{typeIcon[item.type]}
|
||||
<span className="text-[10px]">{typeLabel[item.type]}</span>
|
||||
{typeIcon(item.type)}
|
||||
<span className="text-[10px]">{typeLabel(item.type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -958,11 +982,11 @@ function CardItem({
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "network_graph" && (
|
||||
<NetworkGraphCard
|
||||
embedded={true}
|
||||
{!CORE_CARD_IDS.has(slot.id) && (
|
||||
<PluginCardSlot
|
||||
id={slot.id}
|
||||
isVisible={isVisible}
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
@@ -1031,12 +1055,16 @@ function AddCardTray({
|
||||
onAdd,
|
||||
cardLabels,
|
||||
}: {
|
||||
activeIds: DashboardCardId[];
|
||||
onAdd: (id: DashboardCardId) => void;
|
||||
cardLabels: Record<DashboardCardId, string>;
|
||||
activeIds: string[];
|
||||
onAdd: (id: string) => void;
|
||||
cardLabels: Record<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id));
|
||||
const registeredCards = useRegisteredDashboardCards();
|
||||
const available = [
|
||||
...DASHBOARD_CARDS.map((card) => ({ id: card.id as string })),
|
||||
...registeredCards.map((card) => ({ id: card.id })),
|
||||
].filter((c) => !activeIds.includes(c.id));
|
||||
if (available.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-2 flex-wrap shrink-0">
|
||||
@@ -1241,6 +1269,7 @@ export function DashboardTab({
|
||||
/** When false, pause dashboard metrics refresh while the tab stays mounted. */
|
||||
isVisible?: boolean;
|
||||
}) {
|
||||
const registeredCards = useRegisteredDashboardCards();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { initialLoadComplete } = useServerStatus();
|
||||
const statusLoading = !initialLoadComplete;
|
||||
@@ -1561,15 +1590,17 @@ export function DashboardTab({
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const hasSide = sideSlots.length > 0;
|
||||
|
||||
const cardLabels: Record<DashboardCardId, string> = {
|
||||
const cardLabels: Record<string, string> = {
|
||||
stats_bar: t("dashboard.serverOverview"),
|
||||
counters_bar: t("dashboard.serverStats"),
|
||||
quick_actions: t("dashboard.quickActions"),
|
||||
host_status: t("dashboardTab.hostStatus"),
|
||||
recent_activity: t("dashboard.recentActivity"),
|
||||
network_graph: t("dashboard.networkGraph"),
|
||||
service_links: t("dashboard.serviceLinks"),
|
||||
homepage_preview: t("dashboard.homepagePreview"),
|
||||
...Object.fromEntries(
|
||||
registeredCards.map((card) => [card.id, t(card.titleKey)]),
|
||||
),
|
||||
};
|
||||
|
||||
const onColumnDividerMouseDown = useCallback(
|
||||
@@ -1640,13 +1671,12 @@ export function DashboardTab({
|
||||
? Math.max(...panelSlots.map((s) => s.order)) + 1
|
||||
: 0;
|
||||
const defaultHeight: number | null =
|
||||
id === "network_graph"
|
||||
? 350
|
||||
: id === "host_status" || id === "recent_activity"
|
||||
? null
|
||||
: id === "service_links"
|
||||
? 200
|
||||
: 150;
|
||||
getRegisteredDashboardCard(id)?.defaultHeight ??
|
||||
(id === "host_status" || id === "recent_activity"
|
||||
? null
|
||||
: id === "service_links"
|
||||
? 200
|
||||
: 150);
|
||||
const key = `${id}_${Date.now()}`;
|
||||
return [
|
||||
...prev,
|
||||
@@ -1828,11 +1858,11 @@ export function DashboardTab({
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "network_graph" && (
|
||||
<NetworkGraphCard
|
||||
embedded={true}
|
||||
{!CORE_CARD_IDS.has(slot.id) && (
|
||||
<PluginCardSlot
|
||||
id={slot.id}
|
||||
isVisible={isVisible}
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentType } from "react";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
import type { TabShellCallbacks } from "@/shell/tab-registry";
|
||||
|
||||
export interface DashboardCardRenderProps {
|
||||
isVisible: boolean;
|
||||
/** The shell, e.g. to open the full-page version of the card. */
|
||||
shell: TabShellCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime registry for plugin-contributed dashboard cards.
|
||||
*
|
||||
* Unlike registerRailItem/registerTabComponent (rail-items.ts, tabUtils.tsx),
|
||||
* nothing in DashboardTab.tsx consumes this yet: its card system
|
||||
* (DASHBOARD_CARDS in @/lib/theme, DashboardCardId in @/types/ui-types) is a
|
||||
* closed, typed set with its own drag/drop layout and per-user persisted
|
||||
* slot preferences, and no plugin today contributes an actual dashboard
|
||||
* card. This registry exists so a plugin CAN register one without another
|
||||
* round of seam-building, but adding it to the DashboardTab render/layout
|
||||
* system is future work, not part of this change.
|
||||
* A dashboard card a plugin contributes. DashboardTab offers registered cards
|
||||
* next to the core ones, and a saved slot whose card is not registered keeps
|
||||
* its place and shows a placeholder, so turning a plugin off and on again
|
||||
* puts the card back where it was.
|
||||
*/
|
||||
export interface RegisteredDashboardCard {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
titleKey: string;
|
||||
render: () => ReactNode;
|
||||
defaultHeight?: number;
|
||||
component: ComponentType<DashboardCardRenderProps>;
|
||||
}
|
||||
|
||||
const registeredDashboardCards = new Map<string, RegisteredDashboardCard>();
|
||||
const registry = createRegistry<RegisteredDashboardCard>();
|
||||
|
||||
export function registerDashboardCard(def: RegisteredDashboardCard): void {
|
||||
registeredDashboardCards.set(def.id, def);
|
||||
}
|
||||
|
||||
export function unregisterDashboardCard(id: string): void {
|
||||
registeredDashboardCards.delete(id);
|
||||
}
|
||||
|
||||
export function registeredDashboardCardList(): RegisteredDashboardCard[] {
|
||||
return [...registeredDashboardCards.values()];
|
||||
}
|
||||
export const registerDashboardCard = registry.register;
|
||||
export const unregisterDashboardCard = registry.unregister;
|
||||
export const getRegisteredDashboardCard = registry.get;
|
||||
export const registeredDashboardCardList = registry.list;
|
||||
export const useRegisteredDashboardCards = registry.useList;
|
||||
export const resetDashboardCards = registry.reset;
|
||||
@@ -1,23 +0,0 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface AiMessageProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function AiMessage({ role, content }: AiMessageProps) {
|
||||
if (role === "user") {
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-sm leading-relaxed [&_a]:underline [&_code]:font-mono [&_code]:text-xs [&_li]:my-0.5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
getAiConversation,
|
||||
getAiProviders,
|
||||
getAiStatus,
|
||||
type AiProposal,
|
||||
type AiProvider,
|
||||
} from "@/api/ai-api";
|
||||
import { AiMessage } from "./AiMessage";
|
||||
import { AiProviderSettings } from "./AiProviderSettings";
|
||||
import { AiToolCall } from "./AiToolCall";
|
||||
import { ProposalCard } from "./ProposalCard";
|
||||
import { useAiStream, type ToolActivity } from "./use-ai-stream";
|
||||
import {
|
||||
activeMentionQuery,
|
||||
useMentions,
|
||||
type MentionItem,
|
||||
} from "./useMentions";
|
||||
import { mentionLabel } from "./labels";
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** One entry in the rendered conversation, in the order it happened. */
|
||||
type TimelineItem =
|
||||
| {
|
||||
kind: "message";
|
||||
key: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
| { kind: "tool"; key: string; tool: ToolActivity }
|
||||
| { kind: "proposal"; key: string; proposal: AiProposal };
|
||||
|
||||
export function AiPanel({ activeTab }: { activeTab?: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, send, stop } = useAiStream();
|
||||
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
const [providerId, setProviderId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [resolvedProposals, setResolvedProposals] = useState<
|
||||
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
|
||||
>({});
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [mention, setMention] = useState<{
|
||||
query: string;
|
||||
start: number;
|
||||
} | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const { search: searchMentions } = useMentions(Boolean(providerId));
|
||||
|
||||
const mentionMatches = mention ? searchMentions(mention.query) : [];
|
||||
|
||||
const updateMentionQuery = (value: string, caret: number) => {
|
||||
const next = activeMentionQuery(value, caret);
|
||||
setMention(next);
|
||||
setMentionIndex(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the "@partial" under the caret with the chosen name. The mention
|
||||
* is plain text: the assistant still has to call a read tool to look the
|
||||
* item up, so nothing is attached to the prompt behind the user's back.
|
||||
*/
|
||||
const insertMention = (item: MentionItem) => {
|
||||
if (!mention) return;
|
||||
const caret = textareaRef.current?.selectionStart ?? input.length;
|
||||
const before = input.slice(0, mention.start);
|
||||
const after = input.slice(caret);
|
||||
const inserted = `@${item.label} `;
|
||||
|
||||
setInput(`${before}${inserted}${after}`);
|
||||
setMention(null);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const position = before.length + inserted.length;
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.setSelectionRange(position, position);
|
||||
});
|
||||
};
|
||||
|
||||
const loadProviders = useCallback(async (selectId?: number) => {
|
||||
try {
|
||||
const list = await getAiProviders();
|
||||
setProviders(list);
|
||||
setProviderId((current) => {
|
||||
// A provider that was just added becomes the active one, so there is
|
||||
// no second step between creating it and being able to use it.
|
||||
if (selectId && list.some((entry) => entry.id === selectId)) {
|
||||
return selectId;
|
||||
}
|
||||
// Fall back to the first if the current selection has gone away.
|
||||
if (current && list.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return list[0]?.id ?? null;
|
||||
});
|
||||
return list;
|
||||
} catch {
|
||||
setProviders([]);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const status = await getAiStatus();
|
||||
if (cancelled) return;
|
||||
|
||||
// Opening the panel is consent to use it. Without this every write
|
||||
// 403s through the gate, which surfaces as a bare permission error
|
||||
// rather than anything the user can act on.
|
||||
if (status.globallyEnabled && !status.enabled) {
|
||||
await saveUserPreferences({ aiAssistantEnabled: true });
|
||||
}
|
||||
if (!status.globallyEnabled) return;
|
||||
|
||||
const list = await loadProviders();
|
||||
// With nothing configured there is nothing to chat with, so go
|
||||
// straight to the form instead of showing a card that just vanishes.
|
||||
if (!cancelled && list.length === 0) setShowSettings(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [state.assistantText, state.tools.length, history.length]);
|
||||
|
||||
const reloadHistory = useCallback(
|
||||
async (conversationId: number | null, reply: string) => {
|
||||
// Reload from the server so the transcript is the stored one rather
|
||||
// than a client-side reconstruction.
|
||||
if (conversationId) {
|
||||
try {
|
||||
const loaded = await getAiConversation(conversationId);
|
||||
setHistory(
|
||||
loaded.messages
|
||||
.filter((entry) => entry.role !== "tool")
|
||||
.map((entry) => ({
|
||||
role: entry.role as "user" | "assistant",
|
||||
content: entry.content,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to keeping the streamed copy.
|
||||
}
|
||||
}
|
||||
|
||||
// No stored copy to fall back on, so the streamed reply is appended
|
||||
// locally. Without this it would vanish when the next turn resets the
|
||||
// stream state.
|
||||
if (reply) {
|
||||
setHistory((prev) => [...prev, { role: "assistant", content: reply }]);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
async function handleSend() {
|
||||
const message = input.trim();
|
||||
if (!message || !providerId || state.streaming) return;
|
||||
|
||||
setInput("");
|
||||
setHistory((prev) => [...prev, { role: "user", content: message }]);
|
||||
|
||||
await send({
|
||||
message,
|
||||
providerId,
|
||||
conversationId: state.conversationId,
|
||||
activeTab,
|
||||
onComplete: reloadHistory,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the outcome on the card and tells the assistant what happened.
|
||||
*
|
||||
* Without the follow-up the run just stops: the assistant proposed a
|
||||
* command, the user approved it, and the model never learns it produced
|
||||
* output, so it sits there as if the tool were still running.
|
||||
*/
|
||||
function handleProposalResolved(
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) {
|
||||
setResolvedProposals((prev) => ({
|
||||
...prev,
|
||||
[id]: { status, resultSummary },
|
||||
}));
|
||||
|
||||
if (!providerId || state.streaming) return;
|
||||
|
||||
const proposal = state.proposals.find((entry) => entry.id === id);
|
||||
const label = proposal?.summary ?? proposal?.kind ?? `proposal ${id}`;
|
||||
|
||||
const followUp =
|
||||
status === "applied"
|
||||
? resultSummary
|
||||
? `I approved "${label}". Result:\n\n${resultSummary}`
|
||||
: `I approved "${label}". It completed.`
|
||||
: `I rejected "${label}". Do not retry it unless I ask.`;
|
||||
|
||||
setHistory((prev) => [...prev, { role: "user", content: followUp }]);
|
||||
|
||||
void send({
|
||||
message: followUp,
|
||||
providerId,
|
||||
conversationId: state.conversationId,
|
||||
activeTab,
|
||||
onComplete: reloadHistory,
|
||||
});
|
||||
}
|
||||
|
||||
const proposals: AiProposal[] = state.proposals.map((proposal) => {
|
||||
const resolved = resolvedProposals[proposal.id];
|
||||
if (!resolved) return proposal;
|
||||
return {
|
||||
...proposal,
|
||||
status: resolved.status,
|
||||
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* The reply exists in two places once a turn finishes: the streamed text in
|
||||
* `state`, and the stored copy that `onComplete` reloads into `history`.
|
||||
* Whichever arrives is shown exactly once, and the stored copy wins because
|
||||
* it sits in the right place in the timeline.
|
||||
*/
|
||||
const last = history[history.length - 1];
|
||||
const historyEndsWithThisReply =
|
||||
state.assistantText.length > 0 &&
|
||||
last?.role === "assistant" &&
|
||||
last.content === state.assistantText;
|
||||
|
||||
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
|
||||
|
||||
/**
|
||||
* One ordered list rather than three stacked blocks.
|
||||
*
|
||||
* Tool calls happen before the reply that describes them, but once a turn
|
||||
* ends its reply moves into `history`, which used to render above a separate
|
||||
* tools block. The tool call appeared to jump below the answer it produced.
|
||||
* Splicing the current turn's activity in ahead of its own reply keeps the
|
||||
* order stable across that handover.
|
||||
*/
|
||||
const timeline: TimelineItem[] = [];
|
||||
|
||||
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
|
||||
|
||||
history.forEach((entry, index) => {
|
||||
if (index === trailingReply) return;
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: `history-${index}`,
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
});
|
||||
});
|
||||
|
||||
for (const tool of state.tools) {
|
||||
timeline.push({ kind: "tool", key: tool.id, tool });
|
||||
}
|
||||
|
||||
if (trailingReply >= 0) {
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: `history-${trailingReply}`,
|
||||
role: "assistant",
|
||||
content: history[trailingReply].content,
|
||||
});
|
||||
} else if (streamingReply) {
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: "streaming",
|
||||
role: "assistant",
|
||||
content: streamingReply,
|
||||
});
|
||||
}
|
||||
|
||||
for (const proposal of proposals) {
|
||||
timeline.push({
|
||||
kind: "proposal",
|
||||
key: `proposal-${proposal.id}`,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="animate-spin text-muted-foreground" size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/*
|
||||
The panel also lives in the narrow sidebar, so the title and the
|
||||
provider picker get their own rows rather than competing for one line.
|
||||
*/}
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Sparkles size={16} className="shrink-0" />
|
||||
<span className="truncate text-sm font-medium">{t("ai.title")}</span>
|
||||
<a
|
||||
href="https://docs.termix.site/features/ai/overview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={() => setShowSettings((value) => !value)}
|
||||
aria-label={t("ai.providerSettings")}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{providers.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 pb-2">
|
||||
<Select
|
||||
value={providerId ? String(providerId) : undefined}
|
||||
onValueChange={(value) => setProviderId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-7 min-w-0 flex-1 rounded-none text-xs">
|
||||
<SelectValue placeholder={t("ai.selectProvider")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={String(provider.id)}>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSettings && (
|
||||
<div className="border-b border-border p-3">
|
||||
<AiProviderSettings
|
||||
providers={providers}
|
||||
onChanged={loadProviders}
|
||||
// Adding the first provider is the reason the form opened, so it
|
||||
// closes once that is done and the chat becomes usable.
|
||||
onAdded={() => setShowSettings(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "message") {
|
||||
return (
|
||||
<AiMessage
|
||||
key={item.key}
|
||||
role={item.role}
|
||||
content={item.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === "tool") {
|
||||
return (
|
||||
<AiToolCall
|
||||
key={item.key}
|
||||
tool={item.tool}
|
||||
streaming={state.streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ProposalCard
|
||||
key={item.key}
|
||||
proposal={item.proposal}
|
||||
onResolved={handleProposalResolved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{state.error && (
|
||||
<div className="rounded-none border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative border-t border-border p-3">
|
||||
{mentionMatches.length > 0 && (
|
||||
<div className="absolute bottom-full left-3 right-3 z-10 max-h-56 overflow-y-auto border border-border bg-popover shadow-md">
|
||||
{mentionMatches.map((item, index) => (
|
||||
<button
|
||||
key={`${item.kind}-${item.id}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs ${
|
||||
index === mentionIndex ? "bg-muted" : ""
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
// mousedown, so the textarea does not lose the caret first.
|
||||
event.preventDefault();
|
||||
insertMention(item);
|
||||
}}
|
||||
>
|
||||
<span className="shrink-0 text-[10px] uppercase text-muted-foreground">
|
||||
{mentionLabel(item.kind)}
|
||||
</span>
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.detail && (
|
||||
<span className="ml-auto shrink-0 truncate text-[10px] text-muted-foreground">
|
||||
{item.detail}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className="min-h-[64px] resize-none rounded-none text-sm"
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
setInput(event.target.value);
|
||||
updateMentionQuery(
|
||||
event.target.value,
|
||||
event.target.selectionStart ?? 0,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (mentionMatches.length > 0) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setMentionIndex((i) => (i + 1) % mentionMatches.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setMentionIndex(
|
||||
(i) =>
|
||||
(i - 1 + mentionMatches.length) % mentionMatches.length,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
insertMention(mentionMatches[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setMention(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("ai.inputPlaceholder")}
|
||||
disabled={!providerId}
|
||||
/>
|
||||
{/*
|
||||
The hint gets its own line and wraps: sharing a row with the send
|
||||
button left it truncated to an ellipsis in the sidebar, where the
|
||||
panel is at its narrowest.
|
||||
*/}
|
||||
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("ai.attachHint")}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center justify-end gap-2">
|
||||
{state.streaming ? (
|
||||
<Button size="sm" variant="outline" onClick={stop}>
|
||||
<Square size={14} />
|
||||
{t("ai.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={!input.trim() || !providerId}
|
||||
onClick={() => void handleSend()}
|
||||
>
|
||||
<Send size={14} />
|
||||
{t("ai.send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Label } from "@/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
createAiProvider,
|
||||
deleteAiProvider,
|
||||
getAiProviderModels,
|
||||
probeAiModels,
|
||||
updateAiProvider,
|
||||
type AiProvider,
|
||||
type AiProviderType,
|
||||
} from "@/api/ai-api";
|
||||
|
||||
const PROVIDER_TYPES: Array<{
|
||||
value: AiProviderType;
|
||||
labelKey: string;
|
||||
needsBaseUrl: boolean;
|
||||
needsApiKey: boolean;
|
||||
defaultBaseUrl?: string;
|
||||
}> = [
|
||||
{
|
||||
value: "ollama",
|
||||
labelKey: "ai.providerOllama",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
defaultBaseUrl: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "anthropic",
|
||||
labelKey: "ai.providerAnthropic",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai",
|
||||
labelKey: "ai.providerOpenai",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "gemini",
|
||||
labelKey: "ai.providerGemini",
|
||||
needsBaseUrl: false,
|
||||
needsApiKey: true,
|
||||
},
|
||||
{
|
||||
value: "openai_compatible",
|
||||
labelKey: "ai.providerOpenaiCompatible",
|
||||
needsBaseUrl: true,
|
||||
needsApiKey: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface AiProviderSettingsProps {
|
||||
providers: AiProvider[];
|
||||
/** selectId names a provider that should become the active one. */
|
||||
onChanged: (selectId?: number) => void;
|
||||
onAdded?: () => void;
|
||||
}
|
||||
|
||||
function AiProviderEditForm({
|
||||
provider,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
provider: AiProvider;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState(provider.label);
|
||||
const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? "");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const detected = await getAiProviderModels(provider.id);
|
||||
setModels(detected);
|
||||
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
||||
} catch (error) {
|
||||
setModels([]);
|
||||
setCustomModel(true);
|
||||
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [provider.id, defaultModel, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void detectModels();
|
||||
// The initial model value belongs to this provider. Subsequent edits must
|
||||
// not trigger a provider model-list request on every keystroke.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider.id]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!label.trim()) {
|
||||
toast.error(t("ai.labelRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAiProvider(provider.id, {
|
||||
label: label.trim(),
|
||||
defaultModel: defaultModel.trim() || null,
|
||||
});
|
||||
toast.success(t("ai.providerUpdated"));
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-none border border-border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`ai-provider-label-${provider.id}`}>
|
||||
{t("ai.providerLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`ai-provider-label-${provider.id}`}
|
||||
className="rounded-none"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={`ai-provider-model-${provider.id}`}
|
||||
className="min-w-0 flex-1"
|
||||
>
|
||||
{t("ai.defaultModel")}
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
onClick={() => void detectModels()}
|
||||
disabled={detecting}
|
||||
>
|
||||
{detecting ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}
|
||||
{t("ai.modelRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && !customModel ? (
|
||||
<Select
|
||||
value={defaultModel || undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomModel(true);
|
||||
setDefaultModel("");
|
||||
return;
|
||||
}
|
||||
setDefaultModel(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`ai-provider-model-${provider.id}`}
|
||||
className="rounded-none"
|
||||
>
|
||||
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom__">{t("ai.modelCustom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={`ai-provider-model-${provider.id}`}
|
||||
className="rounded-none"
|
||||
value={defaultModel}
|
||||
onChange={(event) => setDefaultModel(event.target.value)}
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-destructive">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" disabled={saving} onClick={() => void handleSave()}>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("ai.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("ai.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiProviderSettings({
|
||||
providers,
|
||||
onChanged,
|
||||
onAdded,
|
||||
}: AiProviderSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
||||
const [label, setLabel] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("http://localhost:11434");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [defaultModel, setDefaultModel] = useState("");
|
||||
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
||||
const [customModel, setCustomModel] = useState(false);
|
||||
|
||||
const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!;
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(spec.defaultBaseUrl ?? "");
|
||||
setApiKey("");
|
||||
setModels([]);
|
||||
setDefaultModel("");
|
||||
setCustomModel(false);
|
||||
setDetectWarning(null);
|
||||
}, [providerType, spec.defaultBaseUrl]);
|
||||
|
||||
/**
|
||||
* Fetches the provider's own model list so nobody has to go and look model
|
||||
* names up. Falls back to a curated list when the endpoint is unreachable,
|
||||
* and a free-text field is always available for anything not listed.
|
||||
*/
|
||||
const detectModels = useCallback(async () => {
|
||||
setDetecting(true);
|
||||
setDetectWarning(null);
|
||||
try {
|
||||
const result = await probeAiModels({
|
||||
providerType,
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
});
|
||||
setModels(result.models);
|
||||
if (result.source !== "live") {
|
||||
setDetectWarning(result.warning || t("ai.modelDetectFailed"));
|
||||
}
|
||||
// Pick the first suggestion so the field is never left empty.
|
||||
setDefaultModel((current) => current || result.models[0] || "");
|
||||
} catch {
|
||||
setDetectWarning(t("ai.modelDetectFailed"));
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}, [providerType, baseUrl, apiKey, t]);
|
||||
|
||||
// Detect as soon as the provider has enough detail to be reachable.
|
||||
useEffect(() => {
|
||||
if (!adding) return;
|
||||
const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true;
|
||||
if (!ready) return;
|
||||
const timer = setTimeout(() => void detectModels(), 400);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adding, providerType, baseUrl, apiKey]);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!label.trim()) {
|
||||
toast.error(t("ai.labelRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await createAiProvider({
|
||||
providerType,
|
||||
label: label.trim(),
|
||||
baseUrl: baseUrl.trim() || null,
|
||||
apiKey: apiKey.trim() || null,
|
||||
defaultModel: defaultModel.trim() || null,
|
||||
});
|
||||
setAdding(false);
|
||||
setLabel("");
|
||||
setApiKey("");
|
||||
setDefaultModel("");
|
||||
onChanged(created.id);
|
||||
onAdded?.();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteAiProvider(id);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.providerDeleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) =>
|
||||
editingId === provider.id ? (
|
||||
<AiProviderEditForm
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
onSaved={() => {
|
||||
setEditingId(null);
|
||||
onChanged(provider.id);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{provider.label}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{provider.providerType}
|
||||
{provider.defaultModel ? ` · ${provider.defaultModel}` : ""}
|
||||
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
||||
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAdding(false);
|
||||
setEditingId(provider.id);
|
||||
}}
|
||||
aria-label={t("ai.editProvider")}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
aria-label={t("ai.removeProvider")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
{!adding && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("ai.addProvider")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<div className="space-y-3 rounded-none border border-border p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerType")}</Label>
|
||||
<Select
|
||||
value={providerType}
|
||||
onValueChange={(value) =>
|
||||
setProviderType(value as AiProviderType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDER_TYPES.map((entry) => (
|
||||
<SelectItem key={entry.value} value={entry.value}>
|
||||
{t(entry.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.providerLabel")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={t("ai.providerLabelPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{spec.needsBaseUrl && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.baseUrl")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={baseUrl}
|
||||
onChange={(event) => setBaseUrl(event.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("ai.privateEndpointHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(spec.needsApiKey || providerType === "openai_compatible") && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("ai.apiKey")}</Label>
|
||||
<Input
|
||||
className="rounded-none"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="min-w-0 flex-1">{t("ai.defaultModel")}</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void detectModels()}
|
||||
disabled={detecting}
|
||||
>
|
||||
{detecting ? (
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={11} />
|
||||
)}
|
||||
{t("ai.modelRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && !customModel ? (
|
||||
<Select
|
||||
value={defaultModel || undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomModel(true);
|
||||
setDefaultModel("");
|
||||
return;
|
||||
}
|
||||
setDefaultModel(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="rounded-none">
|
||||
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* Anything the provider did not list is still reachable. */}
|
||||
<SelectItem value="__custom__">
|
||||
{t("ai.modelCustom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="rounded-none"
|
||||
value={defaultModel}
|
||||
onChange={(event) => setDefaultModel(event.target.value)}
|
||||
placeholder={t("ai.defaultModelPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detectWarning && (
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{detectWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={saving}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("ai.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => setAdding(false)}
|
||||
>
|
||||
{t("ai.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, Loader2, Wrench } from "lucide-react";
|
||||
import type { ToolActivity } from "./use-ai-stream";
|
||||
import { toolLabel } from "./labels";
|
||||
|
||||
/**
|
||||
* Every tool call is shown, so the user can always see what the assistant
|
||||
* looked at rather than having to trust the summary.
|
||||
*/
|
||||
export function AiToolCall({
|
||||
tool,
|
||||
streaming = false,
|
||||
}: {
|
||||
tool: ToolActivity;
|
||||
/** False once the turn has ended, whatever ended it. */
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A tool can only still be running while the turn is. An aborted or failed
|
||||
// stream leaves calls without results, and treating those as pending left a
|
||||
// spinner running forever after the conversation had finished.
|
||||
const pending = !("result" in tool) && streaming;
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted/50 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{pending ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Wrench size={12} />
|
||||
)}
|
||||
<span className="truncate">{toolLabel(tool.name)}</span>
|
||||
{pending && (
|
||||
<span className="text-muted-foreground">{t("ai.running")}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-2 border-t border-border px-2 py-2">
|
||||
{/* The header reads as an action, so the actual tool id lives here. */}
|
||||
<div className="font-mono text-[10px] text-muted-foreground">
|
||||
{tool.name}
|
||||
</div>
|
||||
{Object.keys(tool.arguments).length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolArguments")}
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
Gated on the result existing, not on !pending: an interrupted turn
|
||||
leaves a call with no result at all, which would otherwise print
|
||||
"undefined" under a Result heading.
|
||||
*/}
|
||||
{"result" in tool && (
|
||||
<div>
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("ai.toolResult")}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono">
|
||||
{JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
applyAiProposal,
|
||||
rejectAiProposal,
|
||||
type AiProposal,
|
||||
} from "@/api/ai-api";
|
||||
import { fieldLabel, toolLabel } from "./labels";
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: AiProposal;
|
||||
onResolved: (
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function isDestructive(kind: string): boolean {
|
||||
return kind.includes("delete");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields shown in their own block rather than the generic row list, so the
|
||||
* card does not print the same command twice.
|
||||
*/
|
||||
const PROMOTED_FIELDS = new Set(["command", "explanation", "reason"]);
|
||||
|
||||
/** Renders the payload as readable field rows rather than raw JSON. */
|
||||
function fieldRows(payload: Record<string, unknown>): Array<[string, string]> {
|
||||
const rows: Array<[string, string]> = [];
|
||||
|
||||
const push = (key: string, value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return;
|
||||
if (PROMOTED_FIELDS.has(key)) return;
|
||||
const text =
|
||||
typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
rows.push([key, text]);
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "changes" && value && typeof value === "object") {
|
||||
for (const [childKey, childValue] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
push(childKey, childValue);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
push(key, value);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function asText(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function ProposalCard({ proposal, onResolved }: ProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<"apply" | "reject" | null>(null);
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(proposal.payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const rows = fieldRows(payload);
|
||||
const destructive = isDestructive(proposal.kind);
|
||||
const resolved = proposal.status !== "pending";
|
||||
|
||||
const command = asText(payload.command);
|
||||
const explanation = asText(payload.explanation) ?? asText(payload.reason);
|
||||
|
||||
// The stored summary repeats the command for run_command proposals, so a
|
||||
// short action label is used instead of printing it twice.
|
||||
const title =
|
||||
command !== null
|
||||
? t("ai.runCommandTitle")
|
||||
: (proposal.summary ?? toolLabel(proposal.kind));
|
||||
|
||||
async function handleApply() {
|
||||
setBusy("apply");
|
||||
try {
|
||||
const result = await applyAiProposal(proposal.id);
|
||||
// The result goes into the card, not a toast: command output can be
|
||||
// hundreds of lines, which covered the screen.
|
||||
onResolved(proposal.id, "applied", result.summary);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setBusy("reject");
|
||||
try {
|
||||
await rejectAiProposal(proposal.id);
|
||||
onResolved(proposal.id, "rejected");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{destructive && (
|
||||
<TriangleAlert size={13} className="shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{title}
|
||||
</span>
|
||||
{resolved && (
|
||||
<span className="shrink-0 border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{proposal.status === "applied"
|
||||
? t("ai.statusApplied")
|
||||
: t("ai.statusRejected")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
A command is the payload, not a field of it, so it gets its own block
|
||||
rather than a label column. In the sidebar a fixed label gutter left
|
||||
barely any room for the command itself.
|
||||
*/}
|
||||
{command && (
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
|
||||
{command}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{explanation && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{explanation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
|
||||
{rows.map(([key, value]) => (
|
||||
// Stacked, not a two-column row: the value is what matters and it
|
||||
// needs the full width to stay readable.
|
||||
<div key={key} className="min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{fieldLabel(key)}
|
||||
</span>
|
||||
<div className="break-all font-mono text-[11px] leading-snug">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.resultSummary && (
|
||||
// Command output comes back here, so it keeps the monospace treatment.
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{proposal.resultSummary}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!resolved && (
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className={`h-7 flex-1 text-xs ${
|
||||
destructive
|
||||
? ""
|
||||
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
}`}
|
||||
variant={destructive ? "destructive" : "outline"}
|
||||
disabled={busy !== null}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{busy === "apply" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)}
|
||||
{t("ai.approve")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{busy === "reject" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<X size={13} />
|
||||
)}
|
||||
{t("ai.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Turns the internal identifiers the assistant works in into text a user can
|
||||
* read. Tool names, proposal kinds and payload keys all surface in the panel,
|
||||
* and raw ids like "propose_create_automation" or "thresholddurationseconds"
|
||||
* are not something to show anyone.
|
||||
*/
|
||||
|
||||
/** What each tool is doing, phrased as an action rather than a function name. */
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
list_hosts: "Reading hosts",
|
||||
get_host: "Reading host",
|
||||
list_fleets: "Reading fleets",
|
||||
list_snippets: "Reading snippets",
|
||||
list_automations: "Reading automations",
|
||||
get_automation: "Reading automation",
|
||||
list_workspaces: "Reading workspaces",
|
||||
list_notification_channels: "Reading notification channels",
|
||||
list_homepage_items: "Reading homepage items",
|
||||
get_command_history: "Reading command history",
|
||||
get_network_topology: "Reading network topology",
|
||||
propose_create_host: "Add host",
|
||||
propose_update_host: "Update host",
|
||||
propose_delete_host: "Delete host",
|
||||
propose_create_snippet: "Create snippet",
|
||||
propose_update_snippet: "Update snippet",
|
||||
propose_delete_snippet: "Delete snippet",
|
||||
propose_create_automation: "Create automation",
|
||||
propose_create_fleet: "Create fleet",
|
||||
propose_run_command: "Run command",
|
||||
};
|
||||
|
||||
/** Readable names for the payload keys the propose tools emit. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
hostId: "Host",
|
||||
hostIds: "Hosts",
|
||||
snippetId: "Snippet",
|
||||
automationId: "Automation",
|
||||
name: "Name",
|
||||
ip: "Address",
|
||||
port: "Port",
|
||||
username: "Username",
|
||||
folder: "Folder",
|
||||
tags: "Tags",
|
||||
content: "Command",
|
||||
description: "Description",
|
||||
definition: "Definition",
|
||||
enabled: "Enabled",
|
||||
triggerType: "Trigger",
|
||||
thresholdValue: "Threshold",
|
||||
thresholdDurationSeconds: "Duration (seconds)",
|
||||
cooldownMinutes: "Cooldown (minutes)",
|
||||
};
|
||||
|
||||
const MENTION_LABELS: Record<string, string> = {
|
||||
host: "Host",
|
||||
snippet: "Snippet",
|
||||
automation: "Automation",
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits snake_case and camelCase into words and capitalises the first.
|
||||
* Used whenever an identifier has no explicit label, so something added later
|
||||
* still reads as words rather than one run-on token.
|
||||
*/
|
||||
export function humanize(value: string): string {
|
||||
const spaced = value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.trim();
|
||||
if (!spaced) return value;
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function toolLabel(name: string): string {
|
||||
return TOOL_LABELS[name] ?? humanize(name);
|
||||
}
|
||||
|
||||
export function fieldLabel(key: string): string {
|
||||
return FIELD_LABELS[key] ?? humanize(key);
|
||||
}
|
||||
|
||||
export function mentionLabel(kind: string): string {
|
||||
return MENTION_LABELS[kind] ?? humanize(kind);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { authApi } from "@/main-axios";
|
||||
import type { AiProposal } from "@/api/ai-api";
|
||||
|
||||
/**
|
||||
* Reuses whatever base authApi resolved to, so the stream follows the same
|
||||
* dev proxy, Electron localhost and reverse-proxy base path rules as every
|
||||
* other call instead of hardcoding an origin.
|
||||
*/
|
||||
function streamUrl(): string {
|
||||
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
return `${base}/ai/chat/stream`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the chat stream.
|
||||
*
|
||||
* EventSource cannot POST, and the request carries a message body, so this
|
||||
* reads the SSE frames off a fetch response by hand.
|
||||
*/
|
||||
|
||||
export interface ToolActivity {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export interface StreamState {
|
||||
streaming: boolean;
|
||||
assistantText: string;
|
||||
tools: ToolActivity[];
|
||||
proposals: AiProposal[];
|
||||
error: string | null;
|
||||
conversationId: number | null;
|
||||
}
|
||||
|
||||
const INITIAL: StreamState = {
|
||||
streaming: false,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: null,
|
||||
};
|
||||
|
||||
export function useAiStream() {
|
||||
const [state, setState] = useState<StreamState>(INITIAL);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState(INITIAL);
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (input: {
|
||||
message: string;
|
||||
providerId: number;
|
||||
model?: string;
|
||||
conversationId?: number | null;
|
||||
activeTab?: string | null;
|
||||
onComplete?: (conversationId: number | null, reply: string) => void;
|
||||
}) => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setState({
|
||||
streaming: true,
|
||||
assistantText: "",
|
||||
tools: [],
|
||||
proposals: [],
|
||||
error: null,
|
||||
conversationId: input.conversationId ?? null,
|
||||
});
|
||||
|
||||
let conversationId = input.conversationId ?? null;
|
||||
let replyText = "";
|
||||
let toolSequence = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch(streamUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
message: input.message,
|
||||
providerId: input.providerId,
|
||||
model: input.model,
|
||||
conversationId: input.conversationId ?? undefined,
|
||||
activeTab: input.activeTab ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = "The assistant could not be reached";
|
||||
try {
|
||||
message = (await response.json()).error ?? message;
|
||||
} catch {
|
||||
// Keep the generic message.
|
||||
}
|
||||
setState((prev) => ({ ...prev, streaming: false, error: message }));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
error: "The assistant returned an empty response",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line.slice(5).trim());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "conversation") {
|
||||
conversationId = event.conversationId;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
conversationId: event.conversationId,
|
||||
}));
|
||||
} else if (event.type === "token") {
|
||||
replyText += event.text;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
assistantText: prev.assistantText + event.text,
|
||||
}));
|
||||
} else if (event.type === "tool_call") {
|
||||
// The id is assigned here rather than inside the updater: React
|
||||
// may run an updater more than once, and deriving the id from
|
||||
// prev.tools.length appended a duplicate entry that no result
|
||||
// could ever match, leaving it stuck on "running".
|
||||
const activityId = `tool-${toolSequence++}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tools: [
|
||||
...prev.tools,
|
||||
{
|
||||
id: activityId,
|
||||
name: event.name,
|
||||
arguments: event.arguments ?? {},
|
||||
},
|
||||
],
|
||||
}));
|
||||
} else if (event.type === "tool_result") {
|
||||
setState((prev) => {
|
||||
const tools = [...prev.tools];
|
||||
// Attach to the most recent call of this tool awaiting a result.
|
||||
for (let i = tools.length - 1; i >= 0; i -= 1) {
|
||||
if (tools[i].name === event.name && !("result" in tools[i])) {
|
||||
tools[i] = { ...tools[i], result: event.result };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { ...prev, tools };
|
||||
});
|
||||
} else if (event.type === "proposal") {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
proposals: [...prev.proposals, event.proposal],
|
||||
}));
|
||||
} else if (event.type === "error") {
|
||||
setState((prev) => ({ ...prev, error: event.message }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
input.onComplete?.(conversationId, replyText);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
setState((prev) => ({ ...prev, streaming: false }));
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
streaming: false,
|
||||
error: getErrorMessage(error, "The assistant stopped unexpectedly"),
|
||||
}));
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { state, send, stop, reset, setState };
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { getSnippets } from "@/api/snippets-api";
|
||||
import { listAutomations } from "../../../../plugins/automations/src/frontend/automations-api";
|
||||
|
||||
/**
|
||||
* Backs the @-mention picker in the composer.
|
||||
*
|
||||
* A mention is only ever a name the assistant reads in the message text; it
|
||||
* does not grant access to anything. The assistant still has to call a read
|
||||
* tool to see the referenced item, so every access stays visible in the
|
||||
* transcript rather than being silently attached to the prompt.
|
||||
*/
|
||||
|
||||
export type MentionKind = "host" | "snippet" | "automation";
|
||||
|
||||
export interface MentionItem {
|
||||
kind: MentionKind;
|
||||
id: number;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Extracts the "@partial" the caret currently sits in, if any. */
|
||||
export function activeMentionQuery(
|
||||
value: string,
|
||||
caret: number,
|
||||
): { query: string; start: number } | null {
|
||||
const before = value.slice(0, caret);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at === -1) return null;
|
||||
|
||||
// Only trigger at a word boundary, so an email address does not open it.
|
||||
if (at > 0 && !/\s/.test(before[at - 1])) return null;
|
||||
|
||||
const query = before.slice(at + 1);
|
||||
if (/\s/.test(query)) return null;
|
||||
|
||||
return { query, start: at };
|
||||
}
|
||||
|
||||
export function useMentions(enabled: boolean) {
|
||||
const [items, setItems] = useState<MentionItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const [hosts, snippets, automations] = await Promise.allSettled([
|
||||
getSSHHosts(),
|
||||
getSnippets(),
|
||||
listAutomations(),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
const collected: MentionItem[] = [];
|
||||
|
||||
if (hosts.status === "fulfilled") {
|
||||
for (const host of (hosts.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "host",
|
||||
id: host.id,
|
||||
label: host.name || host.ip,
|
||||
detail: host.ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (snippets.status === "fulfilled") {
|
||||
for (const snippet of (snippets.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "snippet",
|
||||
id: snippet.id,
|
||||
label: snippet.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (automations.status === "fulfilled") {
|
||||
for (const automation of (automations.value ?? []) as any[]) {
|
||||
collected.push({
|
||||
kind: "automation",
|
||||
id: automation.id,
|
||||
label: automation.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setItems(collected);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): MentionItem[] => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const matches = needle
|
||||
? items.filter((item) => item.label.toLowerCase().includes(needle))
|
||||
: items;
|
||||
return matches.slice(0, 8);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ items, search }), [items, search]);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Eye, Presentation, Users } from "lucide-react";
|
||||
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
|
||||
import { RemoteDisplay } from "@/features/remote-display/RemoteDisplay";
|
||||
import {
|
||||
GuestTerminalView,
|
||||
type SessionParticipantInfo,
|
||||
@@ -100,13 +100,10 @@ export default function CollabGuestView() {
|
||||
}
|
||||
/>
|
||||
) : stage.connectParams?.token ? (
|
||||
<GuacamoleDisplay
|
||||
<RemoteDisplay
|
||||
key={stage.shareId}
|
||||
connectionConfig={{
|
||||
token: stage.connectParams.token,
|
||||
protocol: stage.protocol,
|
||||
type: stage.protocol,
|
||||
}}
|
||||
token={stage.connectParams.token}
|
||||
protocol={stage.protocol}
|
||||
isVisible
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -33,8 +33,10 @@ import { Input } from "@/components/input";
|
||||
import { CollabMembersSidebar } from "./CollabMembersSidebar";
|
||||
import { Terminal } from "@/features/terminal/Terminal";
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
||||
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
|
||||
import { getGuacamoleTokenFromHost } from "../../../../plugins/remote-desktop/src/frontend/guacamole-api";
|
||||
import {
|
||||
RemoteDisplay,
|
||||
createRemoteSessionToken,
|
||||
} from "@/features/remote-display/RemoteDisplay";
|
||||
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
|
||||
import { getRoles } from "@/api/rbac-api";
|
||||
import type { Role } from "@/main-axios";
|
||||
@@ -341,7 +343,7 @@ export function CollabRoomTab({
|
||||
setDraft({ protocol, host });
|
||||
return;
|
||||
}
|
||||
const response = await getGuacamoleTokenFromHost(
|
||||
const response = await createRemoteSessionToken(
|
||||
Number(host.id),
|
||||
await resolveConnectionOrigin({
|
||||
connectionType: protocol,
|
||||
@@ -349,7 +351,7 @@ export function CollabRoomTab({
|
||||
}),
|
||||
protocol,
|
||||
);
|
||||
if (!response.guacamoleConnectionId) {
|
||||
if (!response?.connectionId) {
|
||||
toast.error(t("collab.stageLoading"));
|
||||
return;
|
||||
}
|
||||
@@ -357,7 +359,7 @@ export function CollabRoomTab({
|
||||
protocol,
|
||||
host,
|
||||
token: response.token,
|
||||
guacamoleConnectionId: response.guacamoleConnectionId,
|
||||
guacamoleConnectionId: response.connectionId,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
@@ -689,12 +691,9 @@ export function CollabRoomTab({
|
||||
/>
|
||||
</CommandHistoryProvider>
|
||||
) : (
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{
|
||||
token: draft.token,
|
||||
protocol: draft.protocol,
|
||||
type: draft.protocol,
|
||||
}}
|
||||
<RemoteDisplay
|
||||
token={draft.token}
|
||||
protocol={draft.protocol}
|
||||
isVisible={isVisible}
|
||||
onConnect={() =>
|
||||
void registerStage(
|
||||
@@ -735,13 +734,10 @@ export function CollabRoomTab({
|
||||
/>
|
||||
</CommandHistoryProvider>
|
||||
) : stage.connectParams?.token ? (
|
||||
<GuacamoleDisplay
|
||||
<RemoteDisplay
|
||||
key={stage.connectParams.token}
|
||||
connectionConfig={{
|
||||
token: stage.connectParams.token,
|
||||
protocol: stage.protocol,
|
||||
type: stage.protocol,
|
||||
}}
|
||||
token={stage.connectParams.token}
|
||||
protocol={stage.protocol}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -37,7 +37,6 @@ import "./widgets/FolderWidget";
|
||||
import "./widgets/WeatherWidget";
|
||||
import "./widgets/IframeWidget";
|
||||
import "./widgets/RssFeedWidget";
|
||||
import "./widgets/MetricsChartWidget";
|
||||
import "./widgets/HostGridWidget";
|
||||
import "./widgets/PingStatusWidget";
|
||||
import "./widgets/RecentActivityWidget";
|
||||
@@ -46,7 +45,6 @@ import "./widgets/SystemOverviewWidget";
|
||||
import "./widgets/SshTerminalWidget";
|
||||
import "./widgets/QuickConnectWidget";
|
||||
import "./widgets/FileManagerWidget";
|
||||
import "./widgets/DockerWidget";
|
||||
import "./widgets/TunnelWidget";
|
||||
import "./widgets/CalendarWidget";
|
||||
import "./widgets/CountdownWidget";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAllWidgetTypes } from "../widgets/WidgetRegistry";
|
||||
import { useWidgetTypes } from "../widgets/WidgetRegistry";
|
||||
import type { ContextMenuState, WidgetTypeId } from "@/types/homepage-types";
|
||||
|
||||
interface AddWidgetMenuProps {
|
||||
@@ -16,7 +16,7 @@ const MARGIN = 8;
|
||||
export function AddWidgetMenu({ state, onAdd, onClose }: AddWidgetMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const allTypes = getAllWidgetTypes();
|
||||
const allTypes = useWidgetTypes();
|
||||
const [pos, setPos] = useState({ left: state.screenX, top: state.screenY });
|
||||
const [selectedCat, setSelectedCat] = useState<
|
||||
(typeof CATEGORY_ORDER)[number]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "./SingleHostEditForm";
|
||||
|
||||
export function DockerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerWidgetConfig>) {
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableDocker)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
MetricsChartConfig,
|
||||
MetricsChartMetric,
|
||||
MetricsChartRange,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
const METRICS: { id: MetricsChartMetric; label: string }[] = [
|
||||
{ id: "cpu", label: "CPU" },
|
||||
{ id: "memory", label: "Memory" },
|
||||
{ id: "disk", label: "Disk" },
|
||||
{ id: "net_rx", label: "RX MB/s" },
|
||||
{ id: "net_tx", label: "TX MB/s" },
|
||||
];
|
||||
|
||||
const RANGES: { id: MetricsChartRange; label: string }[] = [
|
||||
{ id: "15m", label: "15m" },
|
||||
{ id: "1h", label: "1h" },
|
||||
{ id: "6h", label: "6h" },
|
||||
{ id: "24h", label: "24h" },
|
||||
];
|
||||
|
||||
export function MetricsChartEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<MetricsChartConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={config.hostId}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs bg-background border border-border px-2 text-foreground"
|
||||
>
|
||||
<option value={0}>{t("homepage.selectHost")}</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.chartMetric")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{METRICS.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, metric: m.id })}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.metric === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.metricRange")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, range: r.id })}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.range === r.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.showCurrentValue}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, showCurrentValue: e.target.checked })
|
||||
}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("homepage.showCurrentValue")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,17 +7,8 @@ import type {
|
||||
} from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
|
||||
const ALL_TYPES: QuickConnectType[] = [
|
||||
"terminal",
|
||||
"files",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"host-metrics",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
];
|
||||
import { useHostActions } from "@/sidebar/host-contributions";
|
||||
import { quickConnectTargets } from "../quick-connect-targets";
|
||||
|
||||
export function QuickConnectEditForm({
|
||||
config,
|
||||
@@ -25,6 +16,7 @@ export function QuickConnectEditForm({
|
||||
}: WidgetEditFormProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
@@ -84,7 +76,7 @@ export function QuickConnectEditForm({
|
||||
{t("homepage.connectionTypes")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{ALL_TYPES.map((type) => (
|
||||
{targets.map(({ type, labelKey }) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-xs cursor-pointer"
|
||||
@@ -95,7 +87,7 @@ export function QuickConnectEditForm({
|
||||
onChange={() => toggleType(type)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t(`homepage.connType_${type}`)}
|
||||
{t(labelKey, type)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/input";
|
||||
import { activityFilterTypes } from "../quick-connect-targets";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
ActivityType,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
|
||||
const ALL_TYPES: ActivityType[] = [
|
||||
"terminal",
|
||||
"file_manager",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"telnet",
|
||||
];
|
||||
|
||||
export function RecentActivityEditForm({
|
||||
config,
|
||||
onChange,
|
||||
@@ -55,7 +46,7 @@ export function RecentActivityEditForm({
|
||||
{t("homepage.filterActivityTypes")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ALL_TYPES.map((type) => (
|
||||
{activityFilterTypes().map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { getWidgetType } from "../widgets/WidgetRegistry";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,7 +21,6 @@ import type {
|
||||
WeatherConfig,
|
||||
IframeConfig,
|
||||
RssFeedConfig,
|
||||
MetricsChartConfig,
|
||||
HostGridConfig,
|
||||
PingStatusConfig,
|
||||
RecentActivityConfig,
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
SshTerminalConfig,
|
||||
QuickConnectConfig,
|
||||
FileManagerWidgetConfig,
|
||||
DockerWidgetConfig,
|
||||
TunnelWidgetConfig,
|
||||
CalendarConfig,
|
||||
CountdownConfig,
|
||||
@@ -52,7 +51,6 @@ import { HostStatusEditForm } from "./HostStatusEditForm";
|
||||
import { WeatherEditForm } from "./WeatherEditForm";
|
||||
import { IframeEditForm } from "./IframeEditForm";
|
||||
import { RssFeedEditForm } from "./RssFeedEditForm";
|
||||
import { MetricsChartEditForm } from "./MetricsChartEditForm";
|
||||
import { HostGridEditForm } from "./HostGridEditForm";
|
||||
import { PingStatusEditForm } from "./PingStatusEditForm";
|
||||
import { RecentActivityEditForm } from "./RecentActivityEditForm";
|
||||
@@ -61,7 +59,6 @@ import { SystemOverviewEditForm } from "./SystemOverviewEditForm";
|
||||
import { SshTerminalEditForm } from "./SshTerminalEditForm";
|
||||
import { QuickConnectEditForm } from "./QuickConnectEditForm";
|
||||
import { FileManagerWidgetEditForm } from "./FileManagerWidgetEditForm";
|
||||
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
|
||||
import { TunnelWidgetEditForm } from "./TunnelWidgetEditForm";
|
||||
import { CalendarEditForm } from "./CalendarEditForm";
|
||||
import { CountdownEditForm } from "./CountdownEditForm";
|
||||
@@ -181,13 +178,6 @@ export function WidgetEditDialog({
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "metrics_chart":
|
||||
return (
|
||||
<MetricsChartEditForm
|
||||
config={config as unknown as MetricsChartConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "host_grid":
|
||||
return (
|
||||
<HostGridEditForm
|
||||
@@ -244,13 +234,6 @@ export function WidgetEditDialog({
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "docker_widget":
|
||||
return (
|
||||
<DockerWidgetEditForm
|
||||
config={config as unknown as DockerWidgetConfig}
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
case "tunnel_widget":
|
||||
return (
|
||||
<TunnelWidgetEditForm
|
||||
@@ -335,8 +318,13 @@ export function WidgetEditDialog({
|
||||
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
default: {
|
||||
// Plugin widgets bring their own form.
|
||||
const EditForm = getWidgetType(widget.typeId)?.editFormComponent;
|
||||
return EditForm ? (
|
||||
<EditForm config={config} onChange={setConfig} />
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { FolderSearch, Network } from "lucide-react";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import type { HostActionDef } from "@/sidebar/host-contributions";
|
||||
import { listTabTypes } from "@/shell/tab-registry";
|
||||
|
||||
export interface QuickConnectTarget {
|
||||
/** Tab type, and the value stored in a widget's config. */
|
||||
type: string;
|
||||
icon: ComponentType<{ size?: number | string; className?: string }>;
|
||||
/** i18n key, or a literal label from a plugin action. */
|
||||
labelKey: string;
|
||||
enabled: (host: SSHHostWithStatus) => boolean;
|
||||
}
|
||||
|
||||
/** What a quick connect widget can open: core tools plus plugin actions. */
|
||||
export function quickConnectTargets(
|
||||
actions: HostActionDef[],
|
||||
): QuickConnectTarget[] {
|
||||
const core: QuickConnectTarget[] = [
|
||||
{
|
||||
type: "files",
|
||||
icon: FolderSearch,
|
||||
labelKey: "homepage.connType_files",
|
||||
enabled: (h) => !!(h.enableSsh && h.enableFileManager),
|
||||
},
|
||||
{
|
||||
type: "tunnel",
|
||||
icon: Network,
|
||||
labelKey: "homepage.connType_tunnel",
|
||||
enabled: (h) => !!(h.enableSsh && h.enableTunnel),
|
||||
},
|
||||
];
|
||||
const seen = new Set(core.map((target) => target.type));
|
||||
const fromPlugins: QuickConnectTarget[] = [];
|
||||
for (const action of actions) {
|
||||
if (!action.tabType || seen.has(action.tabType)) continue;
|
||||
seen.add(action.tabType);
|
||||
fromPlugins.push({
|
||||
type: action.tabType,
|
||||
icon: action.icon,
|
||||
labelKey: action.titleKey,
|
||||
enabled: (h) => {
|
||||
try {
|
||||
return action.when(sshHostToHost(h) as Host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...fromPlugins, ...core];
|
||||
}
|
||||
|
||||
/** Recent-activity types a filter can pick: core ones plus plugin tabs'. */
|
||||
export function activityFilterTypes(): string[] {
|
||||
const types = new Set(["file_manager", "tunnel"]);
|
||||
for (const def of listTabTypes()) {
|
||||
for (const type of def.activityTypes ?? []) types.add(type);
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { DockerManager } from "../../../../../plugins/docker/src/frontend/DockerManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function DockerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find(
|
||||
(h) => h.id === config.hostId && h.enableDocker,
|
||||
);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Box size={20} />
|
||||
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DockerManager
|
||||
hostConfig={host as unknown as SSHHost}
|
||||
title={host.name || host.ip}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<DockerWidgetConfig>({
|
||||
id: "docker_widget",
|
||||
name: "Docker Manager",
|
||||
description: "Embedded Docker container manager for a configured host",
|
||||
category: "system",
|
||||
icon: <Box size={14} />,
|
||||
defaultConfig: { hostId: 0 },
|
||||
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
|
||||
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
|
||||
component: DockerWidget,
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
MetricsChartConfig,
|
||||
MetricsChartMetric,
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
getMetricsHistory,
|
||||
type MetricsHistoryRow,
|
||||
} from "@/api/host-metrics-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
import { runVisibleInterval } from "../use-visible-interval";
|
||||
|
||||
function getAccentColor(): string {
|
||||
return (
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--accent-brand")
|
||||
.trim() || "#f59145"
|
||||
);
|
||||
}
|
||||
|
||||
function getMetricValue(
|
||||
row: MetricsHistoryRow,
|
||||
metric: MetricsChartMetric,
|
||||
): number | null {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return row.cpu_percent;
|
||||
case "memory":
|
||||
return row.mem_percent;
|
||||
case "disk":
|
||||
return row.disk_percent;
|
||||
case "net_rx":
|
||||
return row.net_rx_bytes != null ? row.net_rx_bytes / 1_000_000 : null;
|
||||
case "net_tx":
|
||||
return row.net_tx_bytes != null ? row.net_tx_bytes / 1_000_000 : null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricLabel(
|
||||
metric: MetricsChartMetric,
|
||||
t: (k: string) => string,
|
||||
): string {
|
||||
switch (metric) {
|
||||
case "cpu":
|
||||
return t("homepage.metricCpu");
|
||||
case "memory":
|
||||
return t("homepage.metricMemory");
|
||||
case "disk":
|
||||
return t("homepage.metricDisk");
|
||||
case "net_rx":
|
||||
return "RX MB/s";
|
||||
case "net_tx":
|
||||
return "TX MB/s";
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricUnit(metric: MetricsChartMetric): string {
|
||||
if (metric === "net_rx" || metric === "net_tx") return " MB/s";
|
||||
return "%";
|
||||
}
|
||||
|
||||
interface SparklineProps {
|
||||
values: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function Sparkline({ values, width, height, color }: SparklineProps) {
|
||||
if (values.length < 2) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = 0;
|
||||
const range = max - min;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * width;
|
||||
const y = height - ((v - min) / range) * height;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const polyPts = [`0,${height}`, ...pts, `${width},${height}`].join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`mg-${color.replace("#", "")}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={polyPts} fill={`url(#mg-${color.replace("#", "")})`} />
|
||||
<polyline
|
||||
points={pts.join(" ")}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsChartWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<MetricsChartConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hostId, metric, range, showCurrentValue } = config;
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [values, setValues] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dims, setDims] = useState({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return;
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const h = hosts.find((x) => x.id === hostId);
|
||||
if (h) setHostName(h.name);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const obs = new ResizeObserver((entries) => {
|
||||
const e = entries[0]?.contentRect;
|
||||
if (e) setDims({ w: Math.floor(e.width), h: Math.floor(e.height) });
|
||||
});
|
||||
obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
const fetchData = () => {
|
||||
if (!hostId) return;
|
||||
getMetricsHistory(hostId, { range })
|
||||
.then((res) => {
|
||||
const vals = res.rows
|
||||
.map((r) => getMetricValue(r, metric))
|
||||
.filter((v): v is number => v != null);
|
||||
setValues(vals);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
return runVisibleInterval(() => {
|
||||
void fetchData();
|
||||
}, 60_000);
|
||||
}, [hostId, metric, range]);
|
||||
|
||||
const accent = getAccentColor();
|
||||
const current = values.length > 0 ? values[values.length - 1] : null;
|
||||
const unit = getMetricUnit(metric);
|
||||
const label = getMetricLabel(metric, t);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
{t("homepage.noHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<TrendingUp size={11} />} />
|
||||
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-1 shrink-0">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-semibold text-foreground truncate">
|
||||
{hostName ?? `Host #${hostId}`}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{label} · {range}
|
||||
</span>
|
||||
</div>
|
||||
{showCurrentValue && current != null && (
|
||||
<span
|
||||
className="text-sm font-bold tabular-nums shrink-0"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
{metric === "net_rx" || metric === "net_tx"
|
||||
? current.toFixed(2)
|
||||
: Math.round(current)}
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex-1 min-h-0 relative">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
) : values.length < 2 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
|
||||
{t("homepage.noData")}
|
||||
</div>
|
||||
) : (
|
||||
dims.w > 0 &&
|
||||
dims.h > 0 && (
|
||||
<Sparkline
|
||||
values={values}
|
||||
width={dims.w}
|
||||
height={dims.h}
|
||||
color={accent}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerWidget<MetricsChartConfig>({
|
||||
id: "metrics_chart",
|
||||
name: "Metrics Chart",
|
||||
description: "Historical CPU, memory, disk, or network chart for a host",
|
||||
category: "monitoring",
|
||||
icon: <TrendingUp size={14} />,
|
||||
defaultConfig: {
|
||||
hostId: 0,
|
||||
metric: "cpu",
|
||||
range: "1h",
|
||||
showCurrentValue: true,
|
||||
},
|
||||
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
|
||||
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
|
||||
component: MetricsChartWidget,
|
||||
});
|
||||
|
||||
export { MetricsChartWidget };
|
||||
@@ -1,15 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Terminal,
|
||||
FolderSearch,
|
||||
Box,
|
||||
Network,
|
||||
Server,
|
||||
Monitor,
|
||||
Tv,
|
||||
Phone,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
@@ -22,30 +12,8 @@ import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
const TYPE_ICONS: Record<QuickConnectType, React.ReactNode> = {
|
||||
terminal: <Terminal size={12} />,
|
||||
files: <FolderSearch size={12} />,
|
||||
docker: <Box size={12} />,
|
||||
tunnel: <Network size={12} />,
|
||||
"host-metrics": <Server size={12} />,
|
||||
rdp: <Monitor size={12} />,
|
||||
vnc: <Tv size={12} />,
|
||||
telnet: <Phone size={12} />,
|
||||
};
|
||||
|
||||
type EnableCheck = (host: SSHHostWithStatus) => boolean;
|
||||
|
||||
const TYPE_ENABLED: Record<QuickConnectType, EnableCheck> = {
|
||||
terminal: (h) => !!(h.enableSsh && h.enableTerminal),
|
||||
files: (h) => !!(h.enableSsh && h.enableFileManager),
|
||||
docker: (h) => !!(h.enableSsh && h.enableDocker),
|
||||
tunnel: (h) => !!(h.enableSsh && h.enableTunnel),
|
||||
"host-metrics": (h) => !!h.enableSsh,
|
||||
rdp: (h) => !!h.enableRdp,
|
||||
vnc: (h) => !!h.enableVnc,
|
||||
telnet: (h) => !!h.enableTelnet,
|
||||
};
|
||||
import { useHostActions } from "@/sidebar/host-contributions";
|
||||
import { quickConnectTargets } from "../quick-connect-targets";
|
||||
|
||||
function statusDotClass(host: SSHHostWithStatus): string {
|
||||
if (host.status === "online") return "bg-green-500";
|
||||
@@ -68,6 +36,7 @@ function QuickConnectWidget({
|
||||
const { t } = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
const types =
|
||||
config.connectionTypes.length > 0
|
||||
@@ -109,9 +78,10 @@ function QuickConnectWidget({
|
||||
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
|
||||
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
|
||||
{hosts.map((host) => {
|
||||
const availableTypes = types.filter((type) =>
|
||||
TYPE_ENABLED[type](host),
|
||||
);
|
||||
const availableTypes = types.flatMap((type) => {
|
||||
const target = targets.find((item) => item.type === type);
|
||||
return target && target.enabled(host) ? [target] : [];
|
||||
});
|
||||
if (availableTypes.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -129,18 +99,18 @@ function QuickConnectWidget({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 flex-wrap">
|
||||
{availableTypes.map((type) => (
|
||||
{availableTypes.map((target) => (
|
||||
<button
|
||||
key={type}
|
||||
title={t(`homepage.connType_${type}`, type)}
|
||||
key={target.type}
|
||||
title={t(target.labelKey, target.type)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTab(host, type);
|
||||
openTab(host, target.type);
|
||||
}}
|
||||
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
|
||||
>
|
||||
{TYPE_ICONS[type]}
|
||||
<target.icon size={12} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Container,
|
||||
Wifi,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
import type {
|
||||
RecentActivityConfig,
|
||||
@@ -33,23 +27,8 @@ function relativeTime(ts: string): string {
|
||||
}
|
||||
|
||||
function ActivityIcon({ type }: { type: string }) {
|
||||
const cls = "shrink-0 text-accent-brand";
|
||||
switch (type) {
|
||||
case "terminal":
|
||||
return <Terminal size={10} className={cls} />;
|
||||
case "file_manager":
|
||||
return <FolderOpen size={10} className={cls} />;
|
||||
case "docker":
|
||||
return <Container size={10} className={cls} />;
|
||||
case "tunnel":
|
||||
return <Wifi size={10} className={cls} />;
|
||||
case "rdp":
|
||||
case "vnc":
|
||||
case "telnet":
|
||||
return <Monitor size={10} className={cls} />;
|
||||
default:
|
||||
return <Activity size={10} className={cls} />;
|
||||
}
|
||||
const Icon = activityTarget(type)?.icon ?? Activity;
|
||||
return <Icon className="size-2.5 shrink-0 text-accent-brand" />;
|
||||
}
|
||||
|
||||
function RecentActivityWidget({
|
||||
|
||||
@@ -2,11 +2,13 @@ import type {
|
||||
WidgetTypeDefinition,
|
||||
WidgetTypeId,
|
||||
} from "@/types/homepage-types";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
|
||||
const registry = new Map<WidgetTypeId, WidgetTypeDefinition>();
|
||||
/** Core widgets register on import; plugin widgets while their plugin runs. */
|
||||
const registry = createRegistry<WidgetTypeDefinition>();
|
||||
|
||||
export function registerWidget<C>(def: WidgetTypeDefinition<C>): void {
|
||||
registry.set(def.id, def as unknown as WidgetTypeDefinition);
|
||||
export function registerWidget<C>(def: WidgetTypeDefinition<C>): () => void {
|
||||
return registry.register(def as unknown as WidgetTypeDefinition);
|
||||
}
|
||||
|
||||
export function getWidgetType(
|
||||
@@ -16,5 +18,7 @@ export function getWidgetType(
|
||||
}
|
||||
|
||||
export function getAllWidgetTypes(): WidgetTypeDefinition[] {
|
||||
return Array.from(registry.values());
|
||||
return registry.list();
|
||||
}
|
||||
|
||||
export const useWidgetTypes = registry.useList;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { Pencil, Trash2, GripVertical } from "lucide-react";
|
||||
import type { CanvasWidget } from "@/types/homepage-types";
|
||||
import { getWidgetType } from "./WidgetRegistry";
|
||||
import { getWidgetType, useWidgetTypes } from "./WidgetRegistry";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
|
||||
interface WidgetShellProps {
|
||||
widget: CanvasWidget;
|
||||
@@ -31,6 +32,8 @@ export function WidgetShell({
|
||||
children,
|
||||
}: WidgetShellProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
// A plugin widget appears once its plugin starts.
|
||||
useWidgetTypes();
|
||||
const typeDef = getWidgetType(widget.typeId);
|
||||
const showControls =
|
||||
hovered && !isLocked && !isReadOnly && !isDragging && !isResizing;
|
||||
@@ -87,9 +90,7 @@ export function WidgetShell({
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
|
||||
Unknown widget
|
||||
</div>
|
||||
<PluginViewPlaceholder kind="card" viewId={widget.typeId} compact />
|
||||
))}
|
||||
|
||||
{/* Hover controls */}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Search, Server as ServerIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/input";
|
||||
import type { ProxmoxGuestSummary } from "@/types/proxmox";
|
||||
|
||||
type StatusFilter = "all" | "running" | "stopped";
|
||||
type TypeFilter = "all" | "qemu" | "lxc";
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return "-";
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function UsageCell({
|
||||
percent,
|
||||
usedGiB,
|
||||
totalGiB,
|
||||
}: {
|
||||
percent: number | null;
|
||||
usedGiB?: number | null;
|
||||
totalGiB?: number | null;
|
||||
}) {
|
||||
if (percent === null) {
|
||||
return <span className="text-[11px] text-muted-foreground">-</span>;
|
||||
}
|
||||
const clamped = Math.min(100, Math.max(0, percent));
|
||||
const barColor =
|
||||
clamped >= 90
|
||||
? "bg-red-500"
|
||||
: clamped >= 75
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
return (
|
||||
<div className="flex min-w-[92px] flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold tabular-nums">
|
||||
{clamped.toFixed(0)}%
|
||||
</span>
|
||||
{usedGiB != null && totalGiB != null && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full", barColor)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return guests
|
||||
.filter(
|
||||
(g) =>
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "running") === (g.status === "running"),
|
||||
)
|
||||
.filter((g) => typeFilter === "all" || g.type === typeFilter)
|
||||
.filter(
|
||||
(g) =>
|
||||
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.status === b.status) return a.name.localeCompare(b.name);
|
||||
return a.status === "running" ? -1 : 1;
|
||||
});
|
||||
}, [guests, query, statusFilter, typeFilter]);
|
||||
|
||||
const running = guests.filter((g) => g.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("proxmoxStats.guestsSummary")}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("proxmoxStats.searchGuests")}
|
||||
className="h-7 w-40 pl-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex overflow-hidden rounded-none border border-border">
|
||||
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setTypeFilter(f)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
|
||||
typeFilter === f
|
||||
? "bg-accent-brand text-white"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all"
|
||||
? t("proxmoxStats.filterAll")
|
||||
: f === "qemu"
|
||||
? t("proxmoxStats.filterVm")
|
||||
: t("proxmoxStats.filterLxc")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex overflow-hidden rounded-none border border-border">
|
||||
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
|
||||
statusFilter === f
|
||||
? "bg-accent-brand text-white"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all"
|
||||
? t("proxmoxStats.filterAll")
|
||||
: f === "running"
|
||||
? t("proxmoxStats.running")
|
||||
: t("proxmoxStats.stopped")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{guests.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.noGuests")}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.noGuestsMatch")}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.name")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">ID</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.cpu")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.mem")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.disk")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.uptime")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((guest) => (
|
||||
<tr
|
||||
key={`${guest.type}-${guest.vmid}`}
|
||||
className="transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{guest.type === "lxc" ? (
|
||||
<Box className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-semibold">
|
||||
{guest.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
|
||||
{guest.type === "lxc" ? "LXC" : "VM"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
guest.status === "running"
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
guest.status === "running"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{guest.status === "running"
|
||||
? t("proxmoxStats.running")
|
||||
: t("proxmoxStats.stopped")}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">
|
||||
{guest.vmid}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell percent={guest.cpuPercent} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell
|
||||
percent={guest.memPercent}
|
||||
usedGiB={guest.memUsedGiB}
|
||||
totalGiB={guest.memTotalGiB}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell
|
||||
percent={guest.diskPercent}
|
||||
usedGiB={guest.diskUsedGiB}
|
||||
totalGiB={guest.diskTotalGiB}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{formatUptime(guest.uptimeSeconds)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProxmoxNodeStats } from "@/types/proxmox";
|
||||
|
||||
function Tile({
|
||||
icon,
|
||||
label,
|
||||
percent,
|
||||
detail,
|
||||
sparkline,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
percent: number | null;
|
||||
detail?: string | null;
|
||||
sparkline?: number[];
|
||||
}) {
|
||||
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
|
||||
const barColor =
|
||||
clamped === null
|
||||
? "bg-muted-foreground/30"
|
||||
: clamped >= 90
|
||||
? "bg-red-500"
|
||||
: clamped >= 75
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{icon}
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", barColor)}
|
||||
style={{ width: `${clamped ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniSparkline({ data }: { data: number[] }) {
|
||||
const w = 100;
|
||||
const h = 20;
|
||||
const max = 100;
|
||||
const points = data
|
||||
.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeSummaryStrip({
|
||||
node,
|
||||
histories,
|
||||
}: {
|
||||
node: ProxmoxNodeStats | undefined;
|
||||
histories: { cpu: number[]; memory: number[]; disk: number[] };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tile
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
label={t("proxmoxStats.cpuUsage")}
|
||||
percent={node?.cpu.percent ?? null}
|
||||
detail={
|
||||
node?.cpu.cores != null
|
||||
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.cpu}
|
||||
/>
|
||||
<Tile
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
label={t("proxmoxStats.memoryUsage")}
|
||||
percent={node?.memory.percent ?? null}
|
||||
detail={
|
||||
node?.memory.usedGiB != null && node?.memory.totalGiB != null
|
||||
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.memory}
|
||||
/>
|
||||
<Tile
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
label={t("proxmoxStats.diskUsage")}
|
||||
percent={node?.disk.percent ?? null}
|
||||
detail={
|
||||
node?.disk.usedGiB != null && node?.disk.totalGiB != null
|
||||
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.disk}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="size-3.5" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{t("proxmoxStats.uptime")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">
|
||||
{node?.uptime.formatted ?? "N/A"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{node?.system.hostname ?? "-"}
|
||||
{node?.system.pveVersion ? ` · ${node.system.pveVersion}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProxmoxStatsTab } from "@/features/proxmox-stats/ProxmoxStatsTab.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface ProxmoxStatsAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProxmoxStatsTab
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxmoxStatsApp;
|
||||
@@ -1,385 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
getProxmoxStats,
|
||||
startProxmoxStatsPolling,
|
||||
stopProxmoxStatsPolling,
|
||||
sendProxmoxStatsHeartbeat,
|
||||
getSSHHosts,
|
||||
} from "@/main-axios.ts";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import type { ProxmoxStatsConfig } from "@/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Server } from "lucide-react";
|
||||
import { NodeSummaryStrip } from "./NodeSummaryStrip";
|
||||
import { GuestTable } from "./GuestTable";
|
||||
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
|
||||
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
|
||||
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
|
||||
const bucket = (value: number | null | undefined) =>
|
||||
value == null ? null : Math.round(value / 5) * 5;
|
||||
return JSON.stringify({
|
||||
cpu: bucket(data.node.cpu.percent),
|
||||
memory: bucket(data.node.memory.percent),
|
||||
disk: bucket(data.node.disk.percent),
|
||||
guests: data.guests.counts,
|
||||
cluster: data.cluster,
|
||||
});
|
||||
}
|
||||
const DEFAULT_POLL_INTERVAL = 60;
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
enableProxmoxStats?: boolean;
|
||||
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
|
||||
authType?: string;
|
||||
port?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProxmoxStatsProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
interface ProxmoxStatsHistories {
|
||||
cpu: number[];
|
||||
memory: number[];
|
||||
disk: number[];
|
||||
}
|
||||
|
||||
function parseProxmoxStatsConfig(
|
||||
raw?: string | ProxmoxStatsConfig | null,
|
||||
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
|
||||
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
|
||||
if (!raw) return defaults;
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return { ...defaults, ...parsed };
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ProxmoxStatsInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxmoxStatsInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ProxmoxStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
disk: [],
|
||||
});
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const statsConfig = React.useMemo(
|
||||
() => parseProxmoxStatsConfig(currentHostConfig?.proxmoxStatsConfig),
|
||||
[currentHostConfig?.proxmoxStatsConfig],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onVis = () => setIsPageVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, []);
|
||||
|
||||
const isActuallyVisible = isVisible && isPageVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!viewerSessionId || !isActuallyVisible) return;
|
||||
const interval = setInterval(() => {
|
||||
sendProxmoxStatsHeartbeat(viewerSessionId).catch(() => {});
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [viewerSessionId, isActuallyVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setSnapshot(null);
|
||||
setHistories({ cpu: [], memory: [], disk: [] });
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatest = async () => {
|
||||
if (!hostConfig?.id) return;
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updated = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
|
||||
} catch {
|
||||
// keep previous config
|
||||
}
|
||||
};
|
||||
fetchLatest();
|
||||
const onChanged = () => fetchLatest();
|
||||
window.addEventListener("ssh-hosts:changed", onChanged);
|
||||
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
|
||||
setHistories((prev) => {
|
||||
const add = (arr: number[], v: number | null | undefined) =>
|
||||
[...arr, v ?? 0].slice(-HISTORY_LEN);
|
||||
return {
|
||||
cpu: add(prev.cpu, data.node?.cpu?.percent),
|
||||
memory: add(prev.memory, data.node?.memory?.percent),
|
||||
disk: add(prev.disk, data.node?.disk?.percent),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const notEnabled = currentHostConfig?.enableProxmoxStats !== true;
|
||||
const stopPollingRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: t("proxmoxStats.connecting"),
|
||||
});
|
||||
|
||||
const result = await startProxmoxStatsPolling(currentHostConfig.id);
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_polling",
|
||||
message: t("proxmoxStats.connecting"),
|
||||
});
|
||||
|
||||
const data = await getProxmoxStats(currentHostConfig.id);
|
||||
if (!data) {
|
||||
throw new Error(t("proxmoxStats.connectionFailed"));
|
||||
}
|
||||
|
||||
setSnapshot(data);
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connected",
|
||||
message: t("terminal.connected"),
|
||||
});
|
||||
|
||||
const intervalMs =
|
||||
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
|
||||
let signature = statsChangeKey(data);
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = runAdaptivePolling(
|
||||
async () => {
|
||||
const next = await getProxmoxStats(currentHostConfig.id);
|
||||
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
|
||||
const nextSignature = statsChangeKey(next);
|
||||
const changed = nextSignature !== signature;
|
||||
signature = nextSignature;
|
||||
setSnapshot(next);
|
||||
pushHistory(next);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: intervalMs,
|
||||
maxIntervalMs: Math.min(120_000, intervalMs * 6),
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ runImmediately: false },
|
||||
);
|
||||
}, [currentHostConfig?.id, statsConfig.pollInterval, addLog, t, pushHistory]);
|
||||
|
||||
const retry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchSnapshot();
|
||||
retry.markConnected();
|
||||
} catch (error: unknown) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxmoxStats.connectionFailed"),
|
||||
});
|
||||
retry.markFailed();
|
||||
}
|
||||
},
|
||||
enabled: isPageVisible && !notEnabled && !!currentHostConfig?.id,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const retryRef = React.useRef(retry);
|
||||
retryRef.current = retry;
|
||||
|
||||
// Connects once per host and stays connected while this tab exists, even
|
||||
// when the user switches to another tab and back. Only the browser tab
|
||||
// going into the background (isPageVisible) pauses/resumes it -- switching
|
||||
// between Termix tabs must not tear down and reconnect the session.
|
||||
React.useEffect(() => {
|
||||
if (notEnabled || !currentHostConfig?.id) return;
|
||||
|
||||
let cancelled = false;
|
||||
const debounce = setTimeout(() => {
|
||||
if (isPageVisible && !cancelled) {
|
||||
clearLogs();
|
||||
retryRef.current.reset();
|
||||
retryRef.current.retryNow();
|
||||
} else if (!isPageVisible) {
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
stopProxmoxStatsPolling(currentHostConfig.id, undefined).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
stopProxmoxStatsPolling(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, notEnabled, isPageVisible]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
|
||||
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (retry.status !== "connected") {
|
||||
retry.retryNow();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const data = await getProxmoxStats(currentHostConfig.id);
|
||||
if (data) {
|
||||
setSnapshot(data);
|
||||
pushHistory(data);
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showContent = !notEnabled && retry.status === "connected" && snapshot;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{retry.status === "connected" && !notEnabled && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("proxmoxStats.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showContent && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
|
||||
<div className="shrink-0">
|
||||
<NodeSummaryStrip node={snapshot.node} histories={histories} />
|
||||
</div>
|
||||
|
||||
<GuestTable guests={snapshot.guests.guests} />
|
||||
|
||||
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
|
||||
<NodeNetworkCard snapshot={snapshot} />
|
||||
<StoragePoolsCard snapshot={snapshot} />
|
||||
{snapshot.cluster.clustered && (
|
||||
<ClusterHealthCard snapshot={snapshot} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionScreen
|
||||
status={notEnabled ? "connected" : retry.status}
|
||||
message={t("proxmoxStats.connecting")}
|
||||
attempt={retry.attempt}
|
||||
maxAttempts={retry.maxAttempts}
|
||||
nextRetryInMs={retry.nextRetryInMs}
|
||||
onManualRetry={retry.retryNow}
|
||||
emptyState={
|
||||
notEnabled ? (
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="mx-auto mb-4 size-16" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("proxmoxStats.noHostSelected")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function ClusterHealthCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cluster = snapshot?.cluster;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.clusterHealth")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{!cluster || !cluster.clustered ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.notClustered")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
cluster.quorate ? "bg-green-500" : "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{cluster.quorate
|
||||
? t("proxmoxStats.quorate")
|
||||
: t("proxmoxStats.notQuorate")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{cluster.nodes.map((node) => (
|
||||
<div key={node.name} className="flex items-center gap-2 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
node.online ? "bg-green-500" : "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{node.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{node.online
|
||||
? t("proxmoxStats.nodeOnline")
|
||||
: t("proxmoxStats.nodeOffline")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import { StatRow } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function NodeNetworkCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const interfaces = snapshot?.network.interfaces ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.networkInterfaces")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{interfaces.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{interfaces.map((iface) => (
|
||||
<StatRow
|
||||
key={iface.name}
|
||||
label={iface.name}
|
||||
value={iface.ip ?? iface.state ?? "-"}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Database } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function StoragePoolsCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pools = snapshot?.storage.pools ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.storagePools")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{pools.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{pools.map((pool) => (
|
||||
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{pool.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
|
||||
{pool.type}
|
||||
</span>
|
||||
{!pool.active && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{t("proxmoxStats.inactive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{pool.usedGiB !== null && pool.totalGiB !== null
|
||||
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from "react";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { invokeAction, isActionRegistered } from "@/shell/action-registry";
|
||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||
|
||||
/**
|
||||
* A remote desktop session drawn by whichever plugin provides one.
|
||||
*
|
||||
* Collab rooms and shared-session links show RDP, VNC and Telnet streams
|
||||
* without knowing how they are drawn. The provider contributes a component
|
||||
* to this slot and handles the token action below.
|
||||
*/
|
||||
export const REMOTE_DISPLAY_SLOT = "session.remoteDisplay";
|
||||
export const REMOTE_SESSION_TOKEN_ACTION = "session.remoteDisplay.token";
|
||||
|
||||
export interface RemoteDisplayProps {
|
||||
token: string;
|
||||
protocol: string;
|
||||
isVisible: boolean;
|
||||
onConnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function RemoteDisplay(props: RemoteDisplayProps) {
|
||||
const contributions = useActionSlot(REMOTE_DISPLAY_SLOT);
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
contributions.find(
|
||||
(contribution) =>
|
||||
contribution.kind === "component" && contribution.component,
|
||||
),
|
||||
[contributions],
|
||||
);
|
||||
if (!provider?.component) {
|
||||
return <PluginViewPlaceholder kind="tab" viewId={props.protocol} />;
|
||||
}
|
||||
const Display = provider.component;
|
||||
return <Display {...(props as unknown as Record<string, unknown>)} />;
|
||||
}
|
||||
|
||||
export interface RemoteSessionToken {
|
||||
token: string;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
/** Mints a session token for presenting a host, through the provider. */
|
||||
export async function createRemoteSessionToken(
|
||||
hostId: number,
|
||||
origin: unknown,
|
||||
protocol: string,
|
||||
): Promise<RemoteSessionToken | null> {
|
||||
if (!isActionRegistered(REMOTE_SESSION_TOKEN_ACTION)) return null;
|
||||
return (await invokeAction(
|
||||
REMOTE_SESSION_TOKEN_ACTION,
|
||||
hostId,
|
||||
origin,
|
||||
protocol,
|
||||
)) as RemoteSessionToken | null;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/api/session-sharing-api";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||
import { GuacamoleDisplay } from "../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx";
|
||||
import { RemoteDisplay } from "@/features/remote-display/RemoteDisplay";
|
||||
|
||||
const PING_INTERVAL_MS = 30000;
|
||||
|
||||
@@ -286,12 +286,9 @@ function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{
|
||||
token: share.connectParams.token,
|
||||
protocol: share.protocol as "rdp" | "vnc" | "telnet",
|
||||
type: share.protocol as "rdp" | "vnc" | "telnet",
|
||||
}}
|
||||
<RemoteDisplay
|
||||
token={share.connectParams.token}
|
||||
protocol={share.protocol}
|
||||
isVisible={true}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
@@ -43,7 +44,6 @@ import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
|
||||
import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx";
|
||||
import { TailscaleCheckDialog } from "../../../../plugins/tailscale/src/frontend/TailscaleCheckDialog.tsx";
|
||||
import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx";
|
||||
import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx";
|
||||
import {
|
||||
@@ -74,8 +74,15 @@ import {
|
||||
CommandAutosuggestion,
|
||||
} from "./command-history/CommandAutocomplete.tsx";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { useAiAvailability } from "@/hooks/use-ai-availability.ts";
|
||||
import { TerminalAiPanel } from "./TerminalAiPanel.tsx";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import {
|
||||
TERMINAL_DOCK_SLOT,
|
||||
TERMINAL_OVERLAY_SLOT,
|
||||
type TerminalOverlayProps,
|
||||
type TerminalSessionMessage,
|
||||
type TerminalSlotApi,
|
||||
} from "./terminal-slots";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
@@ -83,7 +90,7 @@ import {
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Bot, Save } from "lucide-react";
|
||||
import { Save } from "lucide-react";
|
||||
import { authApi } from "@/main-axios.ts";
|
||||
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
||||
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||
@@ -326,14 +333,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} | null>(null);
|
||||
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [tailscaleCheckDialog, setTailscaleCheckDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
authUrl: string;
|
||||
message?: string;
|
||||
stage: "prompt" | "waiting";
|
||||
} | null>(null);
|
||||
const tailscaleCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const tailscaleCheckPendingRef = useRef(false);
|
||||
// Overlays contributed by plugins (a Tailscale check, say) hear the
|
||||
// session's messages and may hold the connect timeout while they wait.
|
||||
const sessionListenersRef = useRef(
|
||||
new Set<(message: TerminalSessionMessage) => void>(),
|
||||
);
|
||||
const connectTimeoutHoldsRef = useRef(0);
|
||||
const emitSessionMessage = (message: TerminalSessionMessage) => {
|
||||
for (const listener of sessionListenersRef.current) {
|
||||
try {
|
||||
listener(message);
|
||||
} catch (error) {
|
||||
console.error("[terminal] overlay listener threw", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const opksshFailedRef = useRef(false);
|
||||
const currentHostIdRef = useRef<number | null>(null);
|
||||
@@ -460,11 +474,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
});
|
||||
const [autosuggestionStyle, setAutosuggestionStyle] =
|
||||
useState<React.CSSProperties>({});
|
||||
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
|
||||
const [aiAssistantContext, setAiAssistantContext] = useState("");
|
||||
const { userEnabled: aiAssistantEnabledForUser } = useAiAvailability();
|
||||
const isAiAssistantAvailable =
|
||||
aiAssistantEnabledForUser && host?.enableAiAssistant === true;
|
||||
const [dock, setDock] = useState<{
|
||||
id: string;
|
||||
props: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
const dockContributions = useActionSlot(TERMINAL_DOCK_SLOT, { host });
|
||||
const hasDock = dockContributions.length > 0;
|
||||
const autocompleteHistory = useRef<string[]>([]);
|
||||
const currentAutocompleteCommand = useRef<string>("");
|
||||
const currentAutosuggestionCommand = useRef<string>("");
|
||||
@@ -728,26 +743,16 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return true;
|
||||
}, [clearAutosuggestion]);
|
||||
|
||||
const toggleAiAssistant = useCallback(() => {
|
||||
setAiAssistantOpen((open) => !open);
|
||||
}, []);
|
||||
const firstDockId = dockContributions[0]?.actionId;
|
||||
// Ctrl+Shift+A and the floating button toggle the first docked panel.
|
||||
const toggleDock = useCallback(() => {
|
||||
setDock((open) =>
|
||||
open || !firstDockId ? null : { id: firstDockId, props: {} },
|
||||
);
|
||||
}, [firstDockId]);
|
||||
|
||||
// A plugin-contributed action (the toolbar's AI button) asks for the panel
|
||||
// this way, so the contribution needs no reference into the terminal.
|
||||
useEffect(() => {
|
||||
if (!isFocusedPane) return;
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ context?: string }>).detail;
|
||||
setAiAssistantContext(detail?.context ?? "");
|
||||
setAiAssistantOpen(true);
|
||||
};
|
||||
window.addEventListener("termix:ai:openWithContext", open);
|
||||
return () =>
|
||||
window.removeEventListener("termix:ai:openWithContext", open);
|
||||
}, [isFocusedPane]);
|
||||
|
||||
const closeAiAssistant = useCallback(() => {
|
||||
setAiAssistantOpen(false);
|
||||
const closeDock = useCallback(() => {
|
||||
setDock(null);
|
||||
setTimeout(() => terminal?.focus(), 50);
|
||||
}, [terminal]);
|
||||
|
||||
@@ -771,6 +776,50 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
[clearAutosuggestion, terminal, trackInput],
|
||||
);
|
||||
|
||||
const slotApi = useMemo<TerminalSlotApi>(
|
||||
() => ({
|
||||
host,
|
||||
getBufferText: () => getTerminalBufferText(terminal),
|
||||
openDock: (dockId, props) =>
|
||||
setDock({ id: dockId, props: props ?? {} }),
|
||||
runCommand: handleRunCommandInTerminal,
|
||||
}),
|
||||
[host, terminal, handleRunCommandInTerminal],
|
||||
);
|
||||
|
||||
const overlayProps = useMemo<TerminalOverlayProps>(
|
||||
() => ({
|
||||
host,
|
||||
backgroundColor,
|
||||
subscribe: (listener) => {
|
||||
sessionListenersRef.current.add(listener);
|
||||
return () => {
|
||||
sessionListenersRef.current.delete(listener);
|
||||
};
|
||||
},
|
||||
holdConnectTimeout: (held) => {
|
||||
if (held) {
|
||||
connectTimeoutHoldsRef.current += 1;
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else {
|
||||
connectTimeoutHoldsRef.current = Math.max(
|
||||
0,
|
||||
connectTimeoutHoldsRef.current - 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
fail: (message) => {
|
||||
updateConnectionError(message);
|
||||
webSocketRef.current?.close();
|
||||
},
|
||||
disconnect: () => webSocketRef.current?.close(),
|
||||
}),
|
||||
[host, backgroundColor, updateConnectionError],
|
||||
);
|
||||
|
||||
const activityLoggingRef = useRef(false);
|
||||
const passwordPromptShownRef = useRef(false);
|
||||
const passwordPromptBufferRef = useRef("");
|
||||
@@ -1618,7 +1667,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
!isConnected &&
|
||||
!totpRequired &&
|
||||
!isPasswordPrompt &&
|
||||
!tailscaleCheckPendingRef.current &&
|
||||
connectTimeoutHoldsRef.current === 0 &&
|
||||
!connectionErrorRef.current
|
||||
) {
|
||||
if (terminal) {
|
||||
@@ -1778,6 +1827,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type !== "data" && msg.type !== "pong") {
|
||||
emitSessionMessage(msg);
|
||||
}
|
||||
if (msg.type === "pong") {
|
||||
pongReceivedRef.current = true;
|
||||
return;
|
||||
@@ -2232,42 +2284,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
stage: "error",
|
||||
error: msg.instructions || msg.error,
|
||||
});
|
||||
} else if (msg.type === "tailscale_check_required") {
|
||||
if (connectionErrorRef.current) return;
|
||||
tailscaleCheckPendingRef.current = true;
|
||||
|
||||
// Tailscale holds the connection open while the user authenticates,
|
||||
// so the normal connect timeout must not fire during the wait.
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setTailscaleCheckDialog({
|
||||
isOpen: true,
|
||||
authUrl: msg.url || "",
|
||||
message: msg.message,
|
||||
stage: "prompt",
|
||||
});
|
||||
|
||||
if (tailscaleCheckTimeoutRef.current) {
|
||||
clearTimeout(tailscaleCheckTimeoutRef.current);
|
||||
}
|
||||
tailscaleCheckTimeoutRef.current = setTimeout(() => {
|
||||
tailscaleCheckPendingRef.current = false;
|
||||
setTailscaleCheckDialog(null);
|
||||
updateConnectionError(t("terminal.tailscaleCheckTimeout"));
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
}, 1800000);
|
||||
} else if (msg.type === "tailscale_check_completed") {
|
||||
tailscaleCheckPendingRef.current = false;
|
||||
if (tailscaleCheckTimeoutRef.current) {
|
||||
clearTimeout(tailscaleCheckTimeoutRef.current);
|
||||
tailscaleCheckTimeoutRef.current = null;
|
||||
}
|
||||
setTailscaleCheckDialog(null);
|
||||
} else if (msg.type === "keyboard_interactive_available") {
|
||||
setKeyboardInteractiveDetected(true);
|
||||
setIsConnecting(false);
|
||||
@@ -2467,12 +2483,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
totpTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
tailscaleCheckPendingRef.current = false;
|
||||
if (tailscaleCheckTimeoutRef.current) {
|
||||
clearTimeout(tailscaleCheckTimeoutRef.current);
|
||||
tailscaleCheckTimeoutRef.current = null;
|
||||
}
|
||||
setTailscaleCheckDialog(null);
|
||||
connectTimeoutHoldsRef.current = 0;
|
||||
emitSessionMessage({ type: "session_closed" });
|
||||
|
||||
if (wasSessionExpiredRef.current) {
|
||||
wasSessionExpiredRef.current = false;
|
||||
@@ -3252,7 +3264,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (
|
||||
isAiAssistantAvailable &&
|
||||
hasDock &&
|
||||
e.ctrlKey &&
|
||||
e.shiftKey &&
|
||||
!e.altKey &&
|
||||
@@ -3261,7 +3273,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleAiAssistant();
|
||||
toggleDock();
|
||||
return false;
|
||||
}
|
||||
const macLineNav = getMacLineNavigationSequence(e);
|
||||
@@ -3584,7 +3596,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
};
|
||||
|
||||
terminal.attachCustomKeyEventHandler(handleCustomKey);
|
||||
}, [isAiAssistantAvailable, toggleAiAssistant, terminal]);
|
||||
}, [hasDock, toggleDock, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !hostConfig || !isVisible) return;
|
||||
@@ -3807,19 +3819,33 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
/>
|
||||
|
||||
{isConnected &&
|
||||
isAiAssistantAvailable &&
|
||||
host?.enableTerminalToolbar === false && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={toggleAiAssistant}
|
||||
title={t("ai.assistant") + " (Ctrl+Shift+A)"}
|
||||
className="absolute top-2 right-2 z-[110] size-8 bg-black/60 text-white/75 hover:bg-black/80 hover:text-white"
|
||||
>
|
||||
<Bot className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
host?.enableTerminalToolbar === false &&
|
||||
dockContributions.map((contribution, index) => {
|
||||
const Icon = contribution.icon;
|
||||
return (
|
||||
<Button
|
||||
key={contribution.actionId}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setDock((open) =>
|
||||
open?.id === contribution.actionId
|
||||
? null
|
||||
: { id: contribution.actionId, props: {} },
|
||||
)
|
||||
}
|
||||
title={
|
||||
t(contribution.titleKey) +
|
||||
(index === 0 ? " (Ctrl+Shift+A)" : "")
|
||||
}
|
||||
className="absolute top-2 z-[110] size-8 bg-black/60 text-white/75 hover:bg-black/80 hover:text-white"
|
||||
style={{ right: 8 + index * 40 }}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{host && host.enableTerminalToolbar !== false && (
|
||||
<TerminalToolbar
|
||||
@@ -3845,21 +3871,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}}
|
||||
isFocused={isFocusedPane}
|
||||
actionsEnabled={isAiAssistantAvailable}
|
||||
getBufferText={() => getTerminalBufferText(terminal)}
|
||||
slotApi={slotApi}
|
||||
/>
|
||||
)}
|
||||
|
||||
{aiAssistantOpen && isAiAssistantAvailable && hostConfig.id && (
|
||||
<TerminalAiPanel
|
||||
hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`}
|
||||
hostId={hostConfig.id}
|
||||
activeTab={`terminal:${hostConfig.name || hostConfig.ip}`}
|
||||
initialContext={aiAssistantContext}
|
||||
onClose={closeAiAssistant}
|
||||
onRunInTerminal={handleRunCommandInTerminal}
|
||||
/>
|
||||
)}
|
||||
{dock &&
|
||||
(() => {
|
||||
const contribution = dockContributions.find(
|
||||
(item) => item.actionId === dock.id,
|
||||
);
|
||||
const Panel = contribution?.component;
|
||||
if (!Panel) return null;
|
||||
return (
|
||||
<Panel
|
||||
host={host}
|
||||
hostId={hostConfig.id}
|
||||
hostLabel={`${hostConfig.username}@${hostConfig.name || hostConfig.ip}`}
|
||||
dockProps={dock.props}
|
||||
onClose={closeDock}
|
||||
onRunInTerminal={handleRunCommandInTerminal}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{isQuickConnect &&
|
||||
isConnected &&
|
||||
@@ -4030,32 +4063,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
/>
|
||||
)}
|
||||
|
||||
{tailscaleCheckDialog?.isOpen && (
|
||||
<TailscaleCheckDialog
|
||||
isOpen={tailscaleCheckDialog.isOpen}
|
||||
authUrl={tailscaleCheckDialog.authUrl}
|
||||
message={tailscaleCheckDialog.message}
|
||||
stage={tailscaleCheckDialog.stage}
|
||||
onCancel={() => {
|
||||
tailscaleCheckPendingRef.current = false;
|
||||
if (tailscaleCheckTimeoutRef.current) {
|
||||
clearTimeout(tailscaleCheckTimeoutRef.current);
|
||||
tailscaleCheckTimeoutRef.current = null;
|
||||
}
|
||||
setTailscaleCheckDialog(null);
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
}}
|
||||
onOpenUrl={() => {
|
||||
window.open(tailscaleCheckDialog.authUrl, "_blank");
|
||||
setTailscaleCheckDialog((prev) =>
|
||||
prev ? { ...prev, stage: "waiting" } : null,
|
||||
);
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
)}
|
||||
<ComponentSlot
|
||||
slotId={TERMINAL_OVERLAY_SLOT}
|
||||
when={{ host }}
|
||||
props={overlayProps as unknown as Record<string, unknown>}
|
||||
/>
|
||||
|
||||
{vaultDialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { getAiProviders, type AiProposal, type AiProvider } from "@/api/ai-api";
|
||||
import { AiMessage } from "@/features/ai/AiMessage";
|
||||
import { AiToolCall } from "@/features/ai/AiToolCall";
|
||||
import { useAiStream } from "@/features/ai/use-ai-stream";
|
||||
import { TerminalProposalCard } from "./TerminalProposalCard";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
persistAiPanelPosition,
|
||||
readStoredAiPanelPosition,
|
||||
type ToolbarPosition,
|
||||
} from "./toolbar-geometry";
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
type TimelineItem =
|
||||
| {
|
||||
kind: "message";
|
||||
key: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
kind: "tool";
|
||||
key: string;
|
||||
tool: import("@/features/ai/use-ai-stream").ToolActivity;
|
||||
}
|
||||
| { kind: "proposal"; key: string; proposal: AiProposal };
|
||||
|
||||
interface TerminalAiPanelProps {
|
||||
hostLabel: string;
|
||||
hostId: number;
|
||||
activeTab?: string | null;
|
||||
/** Terminal output to open with, from a contributed action. */
|
||||
initialContext?: string;
|
||||
onClose: () => void;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slimmed-down version of AiPanel docked to a terminal tab. Same backend
|
||||
* (/ai/chat/stream, propose_run_command, ProposalCard) as the main assistant,
|
||||
* so it respects the same admin/user gates and never runs a command without
|
||||
* an explicit approve.
|
||||
*/
|
||||
export function TerminalAiPanel({
|
||||
hostLabel,
|
||||
hostId,
|
||||
activeTab,
|
||||
initialContext,
|
||||
onClose,
|
||||
onRunInTerminal,
|
||||
}: TerminalAiPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, send, stop } = useAiStream();
|
||||
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
const [providerId, setProviderId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [input, setInput] = useState("");
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [resolvedProposals, setResolvedProposals] = useState<
|
||||
Record<number, { status: "applied" | "rejected"; resultSummary?: string }>
|
||||
>({});
|
||||
const conversationIdRef = useRef<number | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Prefilled rather than sent: the user sees exactly what the terminal is
|
||||
// about to hand the model, and can trim it or add a question first.
|
||||
useEffect(() => {
|
||||
if (!initialContext) return;
|
||||
setInput((current) =>
|
||||
current
|
||||
? current
|
||||
: t("ai.terminalContextPrefill", { output: initialContext }),
|
||||
);
|
||||
}, [initialContext, t]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [position, setPosition] = useState<ToolbarPosition>(
|
||||
readStoredAiPanelPosition,
|
||||
);
|
||||
const positionRef = useRef(position);
|
||||
positionRef.current = position;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
basePosition: ToolbarPosition;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
const host = panel?.offsetParent as HTMLElement | null;
|
||||
if (!panel || !host) return;
|
||||
const next = clampToolbarPosition(
|
||||
positionRef.current,
|
||||
panel.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
if (next.x !== positionRef.current.x || next.y !== positionRef.current.y) {
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
}
|
||||
// Only re-clamp when the panel first mounts; drag handles the rest.
|
||||
}, []);
|
||||
|
||||
const handleDragPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 || event.pointerType === "touch") return;
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
basePosition: positionRef.current,
|
||||
};
|
||||
};
|
||||
const handleDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const panel = panelRef.current;
|
||||
const host = panel?.offsetParent as HTMLElement | null;
|
||||
if (!panel || !host) return;
|
||||
const deltaX = event.clientX - drag.startX;
|
||||
const deltaY = event.clientY - drag.startY;
|
||||
const next = clampToolbarPosition(
|
||||
{ x: drag.basePosition.x + deltaX, y: drag.basePosition.y + deltaY },
|
||||
panel.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
};
|
||||
const handleDragPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
persistAiPanelPosition(positionRef.current);
|
||||
dragRef.current = null;
|
||||
if (event.type !== "lostpointercapture") {
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// The browser may have already released capture for this pointer.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getAiProviders()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setProviders(list);
|
||||
setProviderId((current) => current ?? list[0]?.id ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProviders([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [state.assistantText, state.tools.length, history.length]);
|
||||
|
||||
const handleResolved = useCallback(
|
||||
(id: number, status: "applied" | "rejected", resultSummary?: string) => {
|
||||
setResolvedProposals((current) => ({
|
||||
...current,
|
||||
[id]: { status, resultSummary },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const message = input.trim();
|
||||
if (!message || !providerId || state.streaming) return;
|
||||
|
||||
setHistory((current) => [...current, { role: "user", content: message }]);
|
||||
setInput("");
|
||||
|
||||
void send({
|
||||
message,
|
||||
providerId,
|
||||
conversationId: conversationIdRef.current,
|
||||
activeTab: activeTab ?? `terminal:${hostLabel}`,
|
||||
onComplete: (conversationId, reply) => {
|
||||
conversationIdRef.current = conversationId;
|
||||
if (reply) {
|
||||
setHistory((current) => [
|
||||
...current,
|
||||
{ role: "assistant", content: reply },
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [activeTab, hostLabel, input, providerId, send, state.streaming]);
|
||||
|
||||
const proposals: AiProposal[] = state.proposals.map((proposal) => {
|
||||
const resolved = resolvedProposals[proposal.id];
|
||||
if (!resolved) return proposal;
|
||||
return {
|
||||
...proposal,
|
||||
status: resolved.status,
|
||||
resultSummary: resolved.resultSummary ?? proposal.resultSummary,
|
||||
};
|
||||
});
|
||||
|
||||
// The reply exists in two places once a turn finishes: the streamed text in
|
||||
// `state`, and the copy `onComplete` appended to `history`. Show it once,
|
||||
// and keep tool calls spliced in ahead of the reply they produced instead
|
||||
// of in a separate trailing block, so the timeline reads in the order
|
||||
// things actually happened rather than text-then-tools-then-proposals.
|
||||
const last = history[history.length - 1];
|
||||
const historyEndsWithThisReply =
|
||||
state.assistantText.length > 0 &&
|
||||
last?.role === "assistant" &&
|
||||
last.content === state.assistantText;
|
||||
const streamingReply = historyEndsWithThisReply ? "" : state.assistantText;
|
||||
const trailingReply = historyEndsWithThisReply ? history.length - 1 : -1;
|
||||
|
||||
const timeline: TimelineItem[] = [];
|
||||
|
||||
history.forEach((entry, index) => {
|
||||
if (index === trailingReply) return;
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: `history-${index}`,
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
});
|
||||
});
|
||||
|
||||
for (const tool of state.tools) {
|
||||
timeline.push({ kind: "tool", key: tool.id, tool });
|
||||
}
|
||||
|
||||
if (trailingReply >= 0) {
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: `history-${trailingReply}`,
|
||||
role: "assistant",
|
||||
content: history[trailingReply].content,
|
||||
});
|
||||
} else if (streamingReply) {
|
||||
timeline.push({
|
||||
kind: "message",
|
||||
key: "streaming",
|
||||
role: "assistant",
|
||||
content: streamingReply,
|
||||
});
|
||||
}
|
||||
|
||||
for (const proposal of proposals) {
|
||||
timeline.push({
|
||||
kind: "proposal",
|
||||
key: `proposal-${proposal.id}`,
|
||||
proposal,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute right-2 top-12 z-[120] flex max-h-[calc(100%-4rem)] w-[min(420px,calc(100vw-1rem))] flex-col border border-border bg-background shadow-xl"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-grab items-center justify-between gap-2 border-b border-border px-3 py-2 active:cursor-grabbing"
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerEnd}
|
||||
onPointerCancel={handleDragPointerEnd}
|
||||
onLostPointerCapture={handleDragPointerEnd}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<Bot className="size-4 shrink-0 text-accent-brand" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-foreground">
|
||||
{t("ai.assistant")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{hostLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={stop}
|
||||
disabled={!state.streaming}
|
||||
title={t("ai.stop")}
|
||||
>
|
||||
<Square className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3"
|
||||
>
|
||||
{timeline.length === 0 && !loading && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("ai.terminalPanelEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && providers.length === 0 && (
|
||||
<div className="border border-border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t("ai.noProvidersConfigured")}
|
||||
</div>
|
||||
)}
|
||||
{state.error && (
|
||||
<div className="border border-destructive/30 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "message") {
|
||||
return (
|
||||
<AiMessage
|
||||
key={item.key}
|
||||
role={item.role}
|
||||
content={item.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === "tool") {
|
||||
return (
|
||||
<AiToolCall
|
||||
key={item.key}
|
||||
tool={item.tool}
|
||||
streaming={state.streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TerminalProposalCard
|
||||
key={item.key}
|
||||
proposal={item.proposal}
|
||||
hostId={hostId}
|
||||
onRunInTerminal={onRunInTerminal}
|
||||
onResolved={handleResolved}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border p-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("ai.terminalPanelPlaceholder")}
|
||||
rows={3}
|
||||
className="min-h-20 resize-none border border-input bg-background px-2 py-1.5 text-xs text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={state.streaming || providers.length === 0}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={state.streaming || !input.trim() || !providerId}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
{t("ai.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import { getErrorMessage } from "../../lib/error-message.js";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
markAiProposalRunInTerminal,
|
||||
rejectAiProposal,
|
||||
type AiProposal,
|
||||
} from "@/api/ai-api";
|
||||
import { ProposalCard } from "@/features/ai/ProposalCard";
|
||||
|
||||
interface TerminalProposalCardProps {
|
||||
proposal: AiProposal;
|
||||
hostId: number;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
onResolved: (
|
||||
id: number,
|
||||
status: "applied" | "rejected",
|
||||
resultSummary?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A run_command proposal in the terminal-docked assistant runs in the user's
|
||||
* already-open session instead of a hidden pooled connection, so approving it
|
||||
* here is a different action from the main AiPanel's apply button. Every
|
||||
* other proposal kind still goes through the normal apply flow.
|
||||
*/
|
||||
export function TerminalProposalCard({
|
||||
proposal,
|
||||
hostId,
|
||||
onRunInTerminal,
|
||||
onResolved,
|
||||
}: TerminalProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState<"run" | "reject" | null>(null);
|
||||
|
||||
if (
|
||||
proposal.kind !== "propose_run_command" ||
|
||||
proposal.status !== "pending"
|
||||
) {
|
||||
return <ProposalCard proposal={proposal} onResolved={onResolved} />;
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = JSON.parse(proposal.payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
const command = typeof payload.command === "string" ? payload.command : "";
|
||||
const explanation =
|
||||
typeof payload.explanation === "string" ? payload.explanation : "";
|
||||
|
||||
async function handleRun() {
|
||||
if (!command) return;
|
||||
setBusy("run");
|
||||
try {
|
||||
onRunInTerminal(command);
|
||||
const result = await markAiProposalRunInTerminal(
|
||||
proposal.id,
|
||||
hostId,
|
||||
`$ ${command}`,
|
||||
);
|
||||
onResolved(proposal.id, "applied", result.summary);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalApplyFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setBusy("reject");
|
||||
try {
|
||||
await rejectAiProposal(proposal.id);
|
||||
onResolved(proposal.id, "rejected");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, t("ai.proposalRejectFailed")));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-none border border-border bg-muted p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{t("ai.runCommandTitle")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-all border border-border bg-background px-2 py-1.5 font-mono text-[11px] leading-snug">
|
||||
{command}
|
||||
</pre>
|
||||
|
||||
{explanation && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-muted-foreground">
|
||||
{explanation}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<TriangleAlert size={11} className="shrink-0" />
|
||||
{t("ai.runsInOpenTerminal")}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
variant="outline"
|
||||
disabled={busy !== null || !command}
|
||||
onClick={handleRun}
|
||||
>
|
||||
{busy === "run" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)}
|
||||
{t("ai.runInTerminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 flex-1 text-xs"
|
||||
variant="outline"
|
||||
disabled={busy !== null}
|
||||
onClick={handleReject}
|
||||
>
|
||||
{busy === "reject" ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<X size={13} />
|
||||
)}
|
||||
{t("ai.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { TerminalSquare } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
|
||||
import type { TabRenderProps } from "@/shell/tab-registry";
|
||||
|
||||
const CommandHistoryProvider = lazy(() =>
|
||||
import("./command-history/CommandHistoryContext").then((m) => ({
|
||||
default: m.CommandHistoryProvider,
|
||||
})),
|
||||
);
|
||||
export const loadTerminal = () =>
|
||||
import("./Terminal").then((m) => ({ default: m.Terminal }));
|
||||
const TerminalFeature = lazy(loadTerminal);
|
||||
const MobileTerminalKeyboard = lazy(() =>
|
||||
import("./MobileTerminalKeyboard").then((m) => ({
|
||||
default: m.MobileTerminalKeyboard,
|
||||
})),
|
||||
);
|
||||
|
||||
function TabChunkFallback() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH terminal as a tab. Core code, because the terminal's session
|
||||
* manager, split view and file-manager hooks are core until the terminal's
|
||||
* own Phase B step; the ssh-terminal plugin registers it as the "terminal"
|
||||
* tab type, so it comes and goes with that plugin.
|
||||
*/
|
||||
export function TerminalTabContent({
|
||||
tab,
|
||||
host,
|
||||
label,
|
||||
isVisible,
|
||||
isFocusedPane,
|
||||
shell,
|
||||
}: TabRenderProps) {
|
||||
const { t } = useTranslation();
|
||||
const { previewTerminalTheme } = useTabsSafe();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<TerminalSquare className="size-5 text-muted-foreground/30" />
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("terminal.noHostSelected")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<TabChunkFallback />}>
|
||||
<CommandHistoryProvider>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalFeature
|
||||
ref={tab.terminalRef as React.Ref<TerminalHandle>}
|
||||
hostConfig={
|
||||
{
|
||||
...hostToSSHHost(host),
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
joinSharedSessionId: tab.joinSharedSessionId ?? null,
|
||||
joinShareId: tab.joinShareId ?? null,
|
||||
} as TerminalHostConfig
|
||||
}
|
||||
isVisible={isVisible}
|
||||
initialPath={tab.initialFilePath}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => shell.closeTab(tab.id)}
|
||||
onTitleChange={
|
||||
host.terminalConfig?.useSSHTitle
|
||||
? (title) => shell.renameTab(tab.id, title)
|
||||
: undefined
|
||||
}
|
||||
previewTheme={previewTerminalTheme}
|
||||
onOpenFileInEditor={(filePath) =>
|
||||
shell.openFileInEditor(host, filePath)
|
||||
}
|
||||
onOpenFileManager={(path) => shell.openFileManager(host, path)}
|
||||
isQuickConnect={host.id.startsWith("quick-connect-")}
|
||||
onSaveQuickConnect={
|
||||
shell.saveQuickConnect
|
||||
? () => shell.saveQuickConnect!(tab, host)
|
||||
: undefined
|
||||
}
|
||||
host={host}
|
||||
onOpenTab={(type) => shell.openTab(host, type)}
|
||||
isFocusedPane={isFocusedPane}
|
||||
/>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileTerminalKeyboard
|
||||
terminalRef={
|
||||
tab.terminalRef as React.RefObject<TerminalHandle | null>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandHistoryProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from "@/components/select";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TERMINAL_TOOLBAR_SLOT, type TerminalSlotApi } from "./terminal-slots";
|
||||
import { ActionSlot } from "@/shell/ActionSlot";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
@@ -52,7 +54,7 @@ type SelectedToolbarDensity = ToolbarDensity;
|
||||
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
|
||||
|
||||
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
|
||||
const TOOLBAR_SLOT_ID = "terminal.toolbar";
|
||||
const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT;
|
||||
|
||||
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
|
||||
{ value: "icon" },
|
||||
@@ -124,14 +126,10 @@ interface TerminalToolbarProps {
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onOpenFiles?: () => void;
|
||||
isFocused: boolean;
|
||||
/**
|
||||
* Whether this host offers the toolbar's contributed actions at all. The
|
||||
* slot itself is generic; the host-level AI flag is not something a
|
||||
* contributing plugin can know about.
|
||||
*/
|
||||
/** Hides every contributed action, e.g. while the toolbar is measuring. */
|
||||
actionsEnabled?: boolean;
|
||||
/** Read at click time by contributions that want the visible scrollback. */
|
||||
getBufferText?: () => string;
|
||||
/** Handed to contributed actions when they are invoked. */
|
||||
slotApi?: TerminalSlotApi;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
@@ -147,12 +145,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onOpenFiles,
|
||||
isFocused,
|
||||
actionsEnabled = true,
|
||||
getBufferText,
|
||||
slotApi,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Drives both the measurement copy and the separator, so the toolbar sizes
|
||||
// itself correctly whether or not anything contributed.
|
||||
const slotContributions = useActionSlot(TOOLBAR_SLOT_ID);
|
||||
const allSlotContributions = useActionSlot(TOOLBAR_SLOT_ID, { host });
|
||||
const slotContributions = useMemo(
|
||||
() =>
|
||||
allSlotContributions.filter(
|
||||
(contribution) => contribution.kind !== "component",
|
||||
),
|
||||
[allSlotContributions],
|
||||
);
|
||||
const [density, setDensity] =
|
||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||
const [responsiveDensity, setResponsiveDensity] =
|
||||
@@ -855,7 +860,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
className={CONTROL}
|
||||
enabled={actionsEnabled}
|
||||
hideLabels={effectiveDensity === "icon"}
|
||||
context={() => [getBufferText?.() ?? ""]}
|
||||
when={{ host }}
|
||||
context={() => [slotApi]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
/**
|
||||
* The slots the terminal offers plugins, and what it hands them.
|
||||
*
|
||||
* - "terminal.toolbar" (buttons): actions are invoked with a TerminalSlotApi.
|
||||
* - "terminal.dock" (components): a side panel an action opens with
|
||||
* `openDock(id)`, rendered with TerminalDockProps.
|
||||
* - "terminal.overlay" (components): always mounted over the terminal and
|
||||
* given the session's server messages, for connection flows a plugin owns
|
||||
* (a Tailscale check, say).
|
||||
*
|
||||
* Every contribution's `when` sees `{ host }`.
|
||||
*/
|
||||
export const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
|
||||
export const TERMINAL_DOCK_SLOT = "terminal.dock";
|
||||
export const TERMINAL_OVERLAY_SLOT = "terminal.overlay";
|
||||
|
||||
export interface TerminalSlotApi {
|
||||
host: Host | undefined;
|
||||
/** The visible scrollback, read at call time. */
|
||||
getBufferText: () => string;
|
||||
/** Opens a "terminal.dock" contribution by its actionId. */
|
||||
openDock: (dockId: string, props?: Record<string, unknown>) => void;
|
||||
/** Types a command into the session and runs it. */
|
||||
runCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalDockProps {
|
||||
host: Host | undefined;
|
||||
hostId: number | undefined;
|
||||
hostLabel: string;
|
||||
/** Whatever the opening action passed to openDock. */
|
||||
dockProps: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSessionMessage {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TerminalOverlayProps {
|
||||
host: Host | undefined;
|
||||
backgroundColor?: string;
|
||||
/** Server messages for this session, plus a local "session_closed". */
|
||||
subscribe: (
|
||||
listener: (message: TerminalSessionMessage) => void,
|
||||
) => () => void;
|
||||
/**
|
||||
* Holds the connect timeout while the overlay waits on the user, e.g. an
|
||||
* approval in another window. Release it when done.
|
||||
*/
|
||||
holdConnectTimeout: (held: boolean) => void;
|
||||
/** Ends the attempt with an error shown in the terminal. */
|
||||
fail: (message: string) => void;
|
||||
/** Closes the session's connection. */
|
||||
disconnect: () => void;
|
||||
}
|
||||
@@ -70,35 +70,15 @@ export function PanePreview({
|
||||
setAttachNonce((n) => n + 1);
|
||||
}
|
||||
|
||||
// For Tailscale-auth hosts the generic pane-preview attach path can fail
|
||||
// host-reachability checks because the connection is initiated as a plain
|
||||
// TCP/SSH dial rather than through the Tailscale transport that was used for
|
||||
// the interactive terminal. Building the hostConfig with an explicit
|
||||
// authType and a defensively derived SSH port ensures the backend selects
|
||||
// the correct Tailscale-aware PTY path for both initial attach and reattach.
|
||||
let terminalHostConfig: TerminalHostConfig;
|
||||
if (host.authType === "tailscale") {
|
||||
// Prefer host.sshPort when set (Tailscale SSH can be on a non-standard
|
||||
// port); fall back to the general host.port.
|
||||
const resolvedPort = host.sshPort ?? host.port;
|
||||
terminalHostConfig = {
|
||||
...host,
|
||||
port: resolvedPort,
|
||||
sshPort: resolvedPort,
|
||||
// Carry authType explicitly to guard against accidental omission
|
||||
// in the spread (e.g. if host object shape changes upstream).
|
||||
authType: "tailscale",
|
||||
instanceId: instanceIdRef.current,
|
||||
} as TerminalHostConfig;
|
||||
} else {
|
||||
const resolvedPort = host.sshPort ?? host.port;
|
||||
terminalHostConfig = {
|
||||
...host,
|
||||
port: resolvedPort,
|
||||
sshPort: resolvedPort,
|
||||
instanceId: instanceIdRef.current,
|
||||
} as TerminalHostConfig;
|
||||
}
|
||||
// The host's own authType travels in the spread, so a Tailscale host still
|
||||
// gets the Tailscale-aware PTY path. The SSH port may be non-standard.
|
||||
const resolvedPort = host.sshPort ?? host.port;
|
||||
const terminalHostConfig = {
|
||||
...host,
|
||||
port: resolvedPort,
|
||||
sshPort: resolvedPort,
|
||||
instanceId: instanceIdRef.current,
|
||||
} as TerminalHostConfig;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -15,7 +15,10 @@ import { usePermissions } from "@/hooks/use-permissions";
|
||||
* dropped entirely rather than disabled, which is how admin-gated UI already
|
||||
* behaves elsewhere in the app.
|
||||
*/
|
||||
export function useActionSlot(slotId: string): SlotContribution[] {
|
||||
export function useActionSlot(
|
||||
slotId: string,
|
||||
context?: Record<string, unknown>,
|
||||
): SlotContribution[] {
|
||||
const contributions = useSyncExternalStore(
|
||||
subscribeToActionRegistry,
|
||||
() => getSlotContributions(slotId),
|
||||
@@ -31,7 +34,24 @@ export function useActionSlot(slotId: string): SlotContribution[] {
|
||||
return contributions.filter((contribution) => {
|
||||
const permission =
|
||||
getActionPermission(contribution.actionId) ?? undefined;
|
||||
return !permission || has(permission);
|
||||
if (permission && !has(permission)) return false;
|
||||
if (!contribution.when) return true;
|
||||
try {
|
||||
return contribution.when(context ?? {});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, [contributions, has, loaded]);
|
||||
// context is compared by value: slot owners build it inline.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contributions, has, loaded, contextKey(context)]);
|
||||
}
|
||||
|
||||
function contextKey(context: Record<string, unknown> | undefined): string {
|
||||
if (!context) return "";
|
||||
try {
|
||||
return JSON.stringify(context);
|
||||
} catch {
|
||||
return String(Object.keys(context));
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface AiAvailability {
|
||||
/** Admin kill switch. When false the feature does not exist for anyone. */
|
||||
globallyEnabled: boolean;
|
||||
/** Global switch and the user's own opt-in, which is what the rail needs. */
|
||||
userEnabled: boolean;
|
||||
/** False until the status call answers, so nothing flashes in or out. */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/** Fired when the AI status may have changed, so every surface re-reads it. */
|
||||
export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged";
|
||||
|
||||
export function notifyAiStatusChanged(): void {
|
||||
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* One source of truth for whether any AI surface should render.
|
||||
*
|
||||
* The admin global is a hard kill switch: when it is off the assistant is
|
||||
* hidden everywhere for everyone, including the tab bar, command palette,
|
||||
* mobile bar, and the navigation visibility toggles, so nobody can reach or
|
||||
* even see an entry the server would refuse anyway.
|
||||
*/
|
||||
export function useAiAvailability(): AiAvailability {
|
||||
const [state, setState] = useState<AiAvailability>({
|
||||
globallyEnabled: false,
|
||||
userEnabled: false,
|
||||
loaded: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
import("@/api/ai-api")
|
||||
.then(({ getAiStatus }) => getAiStatus())
|
||||
.then((status) => {
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
globallyEnabled: status.globallyEnabled,
|
||||
userEnabled: status.globallyEnabled && status.enabled,
|
||||
loaded: true,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled)
|
||||
setState({
|
||||
globallyEnabled: false,
|
||||
userEnabled: false,
|
||||
loaded: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
refresh();
|
||||
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
// The profile toggle and onboarding still fire this older event.
|
||||
window.addEventListener("hiddenRailTabsChanged", refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("hiddenRailTabsChanged", refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -102,3 +102,13 @@ export function resetPermissionsCache(): void {
|
||||
cache = null;
|
||||
inFlight = null;
|
||||
}
|
||||
|
||||
/** Test seam: answers every hook with these grants, no request made. */
|
||||
export function setPermissionsForTesting(
|
||||
permissions: string[],
|
||||
isAdmin = false,
|
||||
): void {
|
||||
cache = { permissions, isAdmin };
|
||||
inFlight = null;
|
||||
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
|
||||
}
|
||||
+92
-37
@@ -6,46 +6,83 @@ import enTranslation from "../locales/en.json";
|
||||
|
||||
type LocaleModule = { default: ResourceKey };
|
||||
|
||||
const localeLoaders = {
|
||||
af: () => import("../locales/translated/af_ZA.json"),
|
||||
ar: () => import("../locales/translated/ar_SA.json"),
|
||||
bn: () => import("../locales/translated/bn_BD.json"),
|
||||
bg: () => import("../locales/translated/bg_BG.json"),
|
||||
ca: () => import("../locales/translated/ca_ES.json"),
|
||||
cs: () => import("../locales/translated/cs_CZ.json"),
|
||||
da: () => import("../locales/translated/da_DK.json"),
|
||||
de: () => import("../locales/translated/de_DE.json"),
|
||||
el: () => import("../locales/translated/el_GR.json"),
|
||||
"es-ES": () => import("../locales/translated/es_ES.json"),
|
||||
fi: () => import("../locales/translated/fi_FI.json"),
|
||||
fr: () => import("../locales/translated/fr_FR.json"),
|
||||
he: () => import("../locales/translated/he_IL.json"),
|
||||
hi: () => import("../locales/translated/hi_IN.json"),
|
||||
hu: () => import("../locales/translated/hu_HU.json"),
|
||||
id: () => import("../locales/translated/id_ID.json"),
|
||||
it: () => import("../locales/translated/it_IT.json"),
|
||||
ja: () => import("../locales/translated/ja_JP.json"),
|
||||
ko: () => import("../locales/translated/ko_KR.json"),
|
||||
nl: () => import("../locales/translated/nl_NL.json"),
|
||||
no: () => import("../locales/translated/no_NO.json"),
|
||||
pl: () => import("../locales/translated/pl_PL.json"),
|
||||
"pt-PT": () => import("../locales/translated/pt_PT.json"),
|
||||
"pt-BR": () => import("../locales/translated/pt_BR.json"),
|
||||
ro: () => import("../locales/translated/ro_RO.json"),
|
||||
ru: () => import("../locales/translated/ru_RU.json"),
|
||||
sr: () => import("../locales/translated/sr_SP.json"),
|
||||
"sv-SE": () => import("../locales/translated/sv_SE.json"),
|
||||
th: () => import("../locales/translated/th_TH.json"),
|
||||
tr: () => import("../locales/translated/tr_TR.json"),
|
||||
uk: () => import("../locales/translated/uk_UA.json"),
|
||||
vi: () => import("../locales/translated/vi_VN.json"),
|
||||
"zh-CN": () => import("../locales/translated/zh_CN.json"),
|
||||
"zh-TW": () => import("../locales/translated/zh_TW.json"),
|
||||
} satisfies Record<string, () => Promise<LocaleModule>>;
|
||||
/** i18n code -> translated file name, shared by core and plugin locales. */
|
||||
export const LOCALE_FILES = {
|
||||
af: "af_ZA",
|
||||
ar: "ar_SA",
|
||||
bn: "bn_BD",
|
||||
bg: "bg_BG",
|
||||
ca: "ca_ES",
|
||||
cs: "cs_CZ",
|
||||
da: "da_DK",
|
||||
de: "de_DE",
|
||||
el: "el_GR",
|
||||
"es-ES": "es_ES",
|
||||
fi: "fi_FI",
|
||||
fr: "fr_FR",
|
||||
he: "he_IL",
|
||||
hi: "hi_IN",
|
||||
hu: "hu_HU",
|
||||
id: "id_ID",
|
||||
it: "it_IT",
|
||||
ja: "ja_JP",
|
||||
ko: "ko_KR",
|
||||
nl: "nl_NL",
|
||||
no: "no_NO",
|
||||
pl: "pl_PL",
|
||||
"pt-PT": "pt_PT",
|
||||
"pt-BR": "pt_BR",
|
||||
ro: "ro_RO",
|
||||
ru: "ru_RU",
|
||||
sr: "sr_SP",
|
||||
"sv-SE": "sv_SE",
|
||||
th: "th_TH",
|
||||
tr: "tr_TR",
|
||||
uk: "uk_UA",
|
||||
vi: "vi_VN",
|
||||
"zh-CN": "zh_CN",
|
||||
"zh-TW": "zh_TW",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
const translatedFiles = import.meta.glob<LocaleModule>(
|
||||
"../locales/translated/*.json",
|
||||
);
|
||||
|
||||
const localeLoaders: Record<string, () => Promise<LocaleModule>> =
|
||||
Object.fromEntries(
|
||||
Object.entries(LOCALE_FILES).map(([code, file]) => [
|
||||
code,
|
||||
() => {
|
||||
const load = translatedFiles[`../locales/translated/${file}.json`];
|
||||
return load ? load() : Promise.reject(new Error(`no ${file}`));
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads a plugin namespace. Set by the plugin loader; until then a plugin
|
||||
* namespace resolves to nothing and keys fall back to core.
|
||||
*/
|
||||
export type PluginLocaleResolver = (
|
||||
namespace: string,
|
||||
language: string,
|
||||
file: string,
|
||||
) => Promise<ResourceKey | null>;
|
||||
|
||||
let pluginLocaleResolver: PluginLocaleResolver | null = null;
|
||||
|
||||
export function setPluginLocaleResolver(
|
||||
resolver: PluginLocaleResolver | null,
|
||||
): void {
|
||||
pluginLocaleResolver = resolver;
|
||||
}
|
||||
|
||||
export const supportedLngs = ["en", ...Object.keys(localeLoaders)];
|
||||
const PENDING_LOGIN_LANGUAGE_KEY = "termix-pending-login-language";
|
||||
|
||||
/** Core strings. Each plugin gets a namespace named after its id. */
|
||||
export const CORE_NAMESPACE = "translation";
|
||||
|
||||
export function normalizeLanguageCode(language?: string | null): string {
|
||||
if (!language) return "en";
|
||||
|
||||
@@ -64,9 +101,24 @@ export function normalizeLanguageCode(language?: string | null): string {
|
||||
const localeBackend: BackendModule = {
|
||||
type: "backend",
|
||||
init: () => {},
|
||||
read: (language, _namespace, callback) => {
|
||||
read: (language, namespace, callback) => {
|
||||
const normalizedLanguage = normalizeLanguageCode(language);
|
||||
|
||||
if (namespace !== CORE_NAMESPACE) {
|
||||
const file =
|
||||
normalizedLanguage === "en"
|
||||
? "en"
|
||||
: (LOCALE_FILES as Record<string, string>)[normalizedLanguage];
|
||||
if (!pluginLocaleResolver || !file) {
|
||||
callback(null, {});
|
||||
return;
|
||||
}
|
||||
pluginLocaleResolver(namespace, normalizedLanguage, file)
|
||||
.then((resources) => callback(null, resources ?? {}))
|
||||
.catch(() => callback(null, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedLanguage === "en") {
|
||||
callback(null, enTranslation);
|
||||
return;
|
||||
@@ -91,6 +143,9 @@ i18n
|
||||
.init({
|
||||
supportedLngs,
|
||||
fallbackLng: "en",
|
||||
// A plugin's namespace falls back to core, so plugins keep using the
|
||||
// shared strings (common.*, hosts.*) without copying them.
|
||||
fallbackNS: CORE_NAMESPACE,
|
||||
debug: false,
|
||||
|
||||
detection: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { FolderOpen, Network } from "lucide-react";
|
||||
import { tabTypeForActivity } from "@/shell/tab-registry";
|
||||
|
||||
export interface ActivityTarget {
|
||||
icon: ComponentType<{ className?: string }> | undefined;
|
||||
/** Tab type the entry reopens. */
|
||||
tab: string;
|
||||
labelKey: string | undefined;
|
||||
}
|
||||
|
||||
/** Recent-activity types core itself records. */
|
||||
const CORE_ACTIVITY: Record<string, ActivityTarget> = {
|
||||
file_manager: {
|
||||
icon: FolderOpen,
|
||||
tab: "files",
|
||||
labelKey: "dashboard.activityFileManager",
|
||||
},
|
||||
tunnel: {
|
||||
icon: Network,
|
||||
tab: "tunnel",
|
||||
labelKey: "dashboard.activityTunnel",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* What a recent-activity entry opens and how it is labelled. Plugin tabs
|
||||
* claim their activity types through the registry, so an entry whose plugin
|
||||
* is gone simply has no target.
|
||||
*/
|
||||
export function activityTarget(type: string): ActivityTarget | undefined {
|
||||
const core = CORE_ACTIVITY[type];
|
||||
if (core) return core;
|
||||
const def = tabTypeForActivity(type);
|
||||
if (!def) return undefined;
|
||||
return { icon: def.icon, tab: def.id, labelKey: def.titleKey };
|
||||
}
|
||||
@@ -1,40 +1,36 @@
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import {
|
||||
defaultConnectAction,
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
|
||||
type ConnectionTabType = "terminal" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
function isConnectionTabType(type: TabType): type is ConnectionTabType {
|
||||
return (
|
||||
type === "terminal" || type === "rdp" || type === "vnc" || type === "telnet"
|
||||
);
|
||||
}
|
||||
|
||||
function isConnectionEnabled(host: Host, type: ConnectionTabType): boolean {
|
||||
switch (type) {
|
||||
case "terminal":
|
||||
return host.enableSsh;
|
||||
case "rdp":
|
||||
return host.enableRdp;
|
||||
case "vnc":
|
||||
return host.enableVnc;
|
||||
case "telnet":
|
||||
return host.enableTelnet;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultConnectionTab(host: Host): ConnectionTabType {
|
||||
if (host.enableSsh) return "terminal";
|
||||
if (host.enableRdp) return "rdp";
|
||||
if (host.enableVnc) return "vnc";
|
||||
if (host.enableTelnet) return "telnet";
|
||||
return "terminal";
|
||||
/**
|
||||
* Which tab a click on a host opens. The ways to connect (SSH terminal, RDP,
|
||||
* VNC, Telnet) are host actions that plugins register, so the answer is the
|
||||
* highest-priority connect action the host allows.
|
||||
*/
|
||||
export function getDefaultConnectionTab(host: Host): TabType {
|
||||
return defaultConnectAction(listHostActions(), host)?.tabType ?? "terminal";
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferred tab if the host allows it. A connect tab type the host has
|
||||
* switched off falls back to the host's default; any other type is taken as
|
||||
* asked.
|
||||
*/
|
||||
export function resolveHostTabType(
|
||||
host: Host,
|
||||
preferredType?: TabType,
|
||||
): TabType {
|
||||
if (!preferredType) return getDefaultConnectionTab(host);
|
||||
if (!isConnectionTabType(preferredType)) return preferredType;
|
||||
if (isConnectionEnabled(host, preferredType)) return preferredType;
|
||||
return getDefaultConnectionTab(host);
|
||||
const all = listHostActions();
|
||||
const isConnectType = all.some(
|
||||
(action) => action.kind === "connect" && action.tabType === preferredType,
|
||||
);
|
||||
if (!isConnectType) return preferredType;
|
||||
const allowed = hostActionsFor(all, host, "connect").some(
|
||||
(action) => action.tabType === preferredType,
|
||||
);
|
||||
return allowed ? preferredType : getDefaultConnectionTab(host);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
/** The shell's Host in the SSHHost shape the connection surfaces take. */
|
||||
export function hostToSSHHost(h: Host): SSHHost {
|
||||
return {
|
||||
id: parseInt(h.id, 10),
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
username: h.username,
|
||||
folder: h.folder ?? "",
|
||||
tags: h.tags ?? [],
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
hasPassword: h.hasPassword,
|
||||
key: h.key,
|
||||
keyPassword: h.keyPassword,
|
||||
hasKey: h.hasKey,
|
||||
hasKeyPassword: h.hasKeyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
|
||||
terminalConfig: h.terminalConfig,
|
||||
hasSudoPassword: h.hasSudoPassword,
|
||||
enableTerminal: h.enableTerminal ?? false,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
|
||||
enableAiAssistant: h.enableAiAssistant ?? false,
|
||||
dockerConfig: h.dockerConfig ?? null,
|
||||
enableWebUi: h.enableWebUi ?? false,
|
||||
webUiConfig: h.webUiConfig ?? { endpoints: [] },
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: true,
|
||||
showTunnelInSidebar: true,
|
||||
showDockerInSidebar: true,
|
||||
showServerStatsInSidebar: true,
|
||||
defaultPath: h.defaultPath ?? "",
|
||||
tunnelConnections: [],
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: h.connectionOrigin ?? null,
|
||||
isShared: h.isShared ?? false,
|
||||
// Carries the host's identity to a delegated backend. Without it the
|
||||
// remote side resolves our local row id against its own table.
|
||||
syncId: h.syncId ?? null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as unknown as SSHHost;
|
||||
}
|
||||
@@ -2,12 +2,20 @@
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
* A manifest writes plugin-relative keys ("permissions.devices.view.title") so
|
||||
* it never has to know where the strings live. Today they live in core
|
||||
* en.json under `plugins.<pluginId>.`, because the frontend does not load
|
||||
* plugin locale bundles yet. When it does, that subtree moves into
|
||||
* plugins/<id>/locales/en.json as the plugin's own namespace and only this
|
||||
* function changes; no manifest is touched.
|
||||
* it never has to know where the strings live. They live in the plugin's own
|
||||
* locales/en.json, loaded into an i18next namespace named after the plugin,
|
||||
* so the qualified key is "<pluginId>:<key>". A key that already names a
|
||||
* namespace is left alone. Missing keys fall back to core strings.
|
||||
*/
|
||||
export function pluginKey(pluginId: string, key: string): string {
|
||||
return `plugins.${pluginId}.${key}`;
|
||||
export function pluginKey(pluginId: string, key: string): string;
|
||||
export function pluginKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined;
|
||||
export function pluginKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined {
|
||||
if (!key) return key;
|
||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* A keyed, reactive list of contributions.
|
||||
*
|
||||
* Plugins register and unregister at runtime, so anything the shell renders
|
||||
* from a registry has to re-render when it changes. The snapshot array is
|
||||
* cached until the next change because useSyncExternalStore compares by
|
||||
* identity and loops forever on a fresh array.
|
||||
*/
|
||||
export interface Registry<T extends { id: string }> {
|
||||
/** Adds or replaces by id. The disposer only removes this exact entry. */
|
||||
register: (item: T) => () => void;
|
||||
unregister: (id: string) => void;
|
||||
get: (id: string) => T | undefined;
|
||||
list: () => T[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** React hook over list(). */
|
||||
useList: () => T[];
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function createRegistry<T extends { id: string }>(
|
||||
sort?: (a: T, b: T) => number,
|
||||
): Registry<T> {
|
||||
const items = new Map<string, T>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: T[] | null = null;
|
||||
|
||||
const emit = () => {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
const list = () => {
|
||||
if (!snapshot) {
|
||||
snapshot = [...items.values()];
|
||||
if (sort) snapshot.sort(sort);
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
register(item) {
|
||||
items.set(item.id, item);
|
||||
emit();
|
||||
return () => {
|
||||
if (items.get(item.id) === item) {
|
||||
items.delete(item.id);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
},
|
||||
unregister(id) {
|
||||
if (items.delete(id)) emit();
|
||||
},
|
||||
get: (id) => items.get(id),
|
||||
list,
|
||||
subscribe,
|
||||
useList: () => useSyncExternalStore(subscribe, list, list),
|
||||
reset() {
|
||||
items.clear();
|
||||
emit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Orders by `order`, then id, so plugin contributions sort stably. */
|
||||
export function byOrderThenId<T extends { id: string; order?: number }>(
|
||||
a: T,
|
||||
b: T,
|
||||
): number {
|
||||
return (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id);
|
||||
}
|
||||
@@ -36,12 +36,6 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
description: "Feed of recent connection events",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "network_graph",
|
||||
label: "Network Graph",
|
||||
description: "Visual map of host network topology",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "service_links",
|
||||
label: "Service Links",
|
||||
|
||||
+23
-1217
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 721 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user