mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move workspaces into a first-party plugin
This commit is contained in:
1 parent
8742c5d381
commit
6f0dff58a3
19 files changed
+275
-32
No files matched your search
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Workspaces - first-party, in-process plugin.
|
||||
*
|
||||
* Unlike the other first-party plugins (see src/backend/plugins/first-party.ts),
|
||||
* this one does not own a transport or touch the shared SSH pool -- its routes
|
||||
* are plain per-user JSON CRUD against a single repository. It runs in-process
|
||||
* anyway to keep its existing /workspaces URL and its existing dedicated table
|
||||
* (user_workspaces, keyed by userId) rather than moving onto
|
||||
* /plugin-api/workspaces/* and the generic key-value ctx.storage the worker
|
||||
* tier would require, matching how network-topology was migrated in place
|
||||
* rather than reworked around the worker ctx boundary.
|
||||
*
|
||||
* Like network-topology, this plugin does not get its own port -- it
|
||||
* registers its Express router with the always-mounted /workspaces
|
||||
* dispatcher in src/backend/database/routes/workspace-dispatch.ts.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/database/routes/workspaces.ts,
|
||||
* moved here and rewritten from `export default router` into
|
||||
* startWorkspacesService()/stopWorkspacesService(), called from
|
||||
* activate()/deactivate() below. The user_workspaces table and its
|
||||
* repository stay in core (src/backend/database/db/schema.ts,
|
||||
* src/backend/database/repositories/workspace-repository.ts) -- this
|
||||
* plugin system has no owned-table migration mechanism of its own, and the
|
||||
* table carries an FK into core's users table.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for routes.js sits right next to this .mjs. Under vitest/dev nothing
|
||||
* imports this plugin directly (the loader loads in-process plugins with a
|
||||
* plain dynamic import, no tsx loader involved), so the only layout that
|
||||
* needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
routesModule.startWorkspacesService();
|
||||
ctx.log.info("Workspaces router registered at /workspaces");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.stopWorkspacesService();
|
||||
routesModule = null;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { createCurrentWorkspaceRepository } from "../repositories/factory.js";
|
||||
import type { WorkspaceRecord } from "../repositories/workspace-repository.js";
|
||||
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
||||
import { createCurrentWorkspaceRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import type { WorkspaceRecord } from "../../../src/backend/database/repositories/workspace-repository.js";
|
||||
import {
|
||||
registerWorkspacesRouter,
|
||||
unregisterWorkspacesRouter,
|
||||
} from "../../../src/backend/database/routes/workspace-dispatch.js";
|
||||
|
||||
const router = express.Router();
|
||||
export const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
@@ -635,4 +639,10 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
export function startWorkspacesService(): void {
|
||||
registerWorkspacesRouter(router);
|
||||
}
|
||||
|
||||
export function stopWorkspacesService(): void {
|
||||
unregisterWorkspacesRouter();
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
duplicateWorkspace,
|
||||
setDefaultWorkspace,
|
||||
unsetDefaultWorkspace,
|
||||
} from "@/api/workspaces-api";
|
||||
} from "./workspaces-api";
|
||||
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
|
||||
|
||||
function timeAgo(isoStr: string): string {
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Frontend half of the workspaces plugin.
|
||||
*
|
||||
* WorkspacesPanel is a left-sidebar railView panel (opened from the rail,
|
||||
* not a Tab), the same shape as FleetsPanel/HostsPanel/CredentialsPanel.
|
||||
* None of those go through registerRailItem/registerTabComponent either --
|
||||
* per src/ui/shell/pluginLoader.ts's own comment, a rail-view panel like
|
||||
* this is rendered directly by AppShell.tsx rather than registered at
|
||||
* runtime. WorkspacesPanel is reached today through a direct relative
|
||||
* import in AppShell.tsx (../../plugins/workspaces/frontend/WorkspacesPanel),
|
||||
* not through register() below.
|
||||
*
|
||||
* register()/unregister() still follow the same shape every other plugin's
|
||||
* frontend entry does, so this is ready the moment a real frontend plugin
|
||||
* loader exists -- see plugins/ssh-terminal/frontend/index.mjs and
|
||||
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
|
||||
* "there is no plugin loader yet".
|
||||
*/
|
||||
|
||||
export const id = "workspaces";
|
||||
export const railViewId = "workspaces";
|
||||
|
||||
export async function register({ registerRailItem, icons }) {
|
||||
registerRailItem?.({
|
||||
id: railViewId,
|
||||
icon: icons.LayoutTemplate,
|
||||
labelKey: "nav.workspaces",
|
||||
separatorAfter: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregister({ unregisterRailItem }) {
|
||||
unregisterRailItem?.(railViewId);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"name": "Workspaces",
|
||||
"version": "1.0.0",
|
||||
"description": "Save and restore named tab/split layouts per user.",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Productivity",
|
||||
"icon": "LayoutTemplate",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"sidecars": []
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import fleetsDispatch from "./routes/fleet-dispatch.js";
|
||||
import workspaceRoutes from "./routes/workspaces.js";
|
||||
import workspaceDispatch from "./routes/workspace-dispatch.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
@@ -1754,7 +1754,7 @@ app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/fleets", fleetsDispatch);
|
||||
app.use("/workspaces", workspaceRoutes);
|
||||
app.use("/workspaces", workspaceDispatch);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Mounted at /workspaces. Workspaces ships as a first-party in-process
|
||||
// plugin (plugins/workspaces) rather than a route file imported directly
|
||||
// here, so this module exists to keep /workspaces/* working at its original
|
||||
// path without handing the plugin a reference to the shared app instance.
|
||||
// The plugin runtime registers its router via registerWorkspacesRouter when
|
||||
// it activates, and clears it on deactivate, so a disabled plugin falls back
|
||||
// to the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Workspaces plugin's own), and every
|
||||
// route in that router already applies authenticateJWT itself, the same as
|
||||
// every other router mounted alongside it here (/users, /host, /snippets,
|
||||
// ...). /plugin-api gates at the mount point because it forwards to arbitrary
|
||||
// third-party plugin code that cannot be assumed to gate itself; that
|
||||
// reasoning does not apply to a single first-party router that already does.
|
||||
|
||||
import type { Request, Response, NextFunction, Router } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerWorkspacesRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Workspaces plugin router", {
|
||||
operation: "workspaces_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterWorkspacesRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Workspaces plugin router", {
|
||||
operation: "workspaces_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function workspaceDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Workspaces is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -98,6 +98,14 @@
|
||||
* The worker ctx has no fetch primitive at all (ctx.fetch is notImplemented
|
||||
* in SDK v1, see worker-bootstrap.ts), so there is currently no way for a
|
||||
* worker-tier plugin to make an outbound HTTP request.
|
||||
*
|
||||
* workspaces is here for the same reason network-topology is: its routes are
|
||||
* plain per-user JSON CRUD against a single repository, and would fit the
|
||||
* worker ctx fine on their own. It stays in-process to keep its existing
|
||||
* /workspaces URL and its existing dedicated table (user_workspaces, keyed
|
||||
* by userId) rather than moving onto /plugin-api/workspaces/* and the
|
||||
* generic key-value ctx.storage the worker tier would require -- a URL and
|
||||
* storage-model change, not a capability gap.
|
||||
*/
|
||||
export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"ssh-terminal",
|
||||
@@ -110,6 +118,7 @@ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"automations",
|
||||
"network-topology",
|
||||
"tailscale",
|
||||
"workspaces",
|
||||
]);
|
||||
|
||||
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
|
||||
|
||||
+10
-10
@@ -295,16 +295,16 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// ssh-terminal, docker and host-metrics plugins start their own servers,
|
||||
// so disabling any of them stops its WS/HTTP server. See
|
||||
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
|
||||
// AI, Proxmox, Remote Desktop, Fleets, Automations and Network Topology
|
||||
// are also absent for the same reason: each plugin registers its router
|
||||
// with its own dispatcher in database.ts on activate (/ai, /proxmox,
|
||||
// /guacamole, /fleets, /automations, /network-topology), so disabling
|
||||
// any of them makes its routes 404 instead of leaving a dead import
|
||||
// here. Automations' scheduler (due schedules, dwell rechecks,
|
||||
// docker-event polling, history pruning) also starts from its own
|
||||
// activate() rather than here. See plugins/ai, plugins/proxmox,
|
||||
// plugins/remote-desktop, plugins/fleets, plugins/automations and
|
||||
// plugins/network-topology.
|
||||
// AI, Proxmox, Remote Desktop, Fleets, Automations, Network Topology and
|
||||
// Workspaces are also absent for the same reason: each plugin registers
|
||||
// its router with its own dispatcher in database.ts on activate (/ai,
|
||||
// /proxmox, /guacamole, /fleets, /automations, /network-topology,
|
||||
// /workspaces), so disabling any of them makes its routes 404 instead of
|
||||
// leaving a dead import here. Automations' scheduler (due schedules,
|
||||
// dwell rechecks, docker-event polling, history pruning) also starts
|
||||
// from its own activate() rather than here. See plugins/ai,
|
||||
// plugins/proxmox, plugins/remote-desktop, plugins/fleets,
|
||||
// plugins/automations, plugins/network-topology and plugins/workspaces.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
await import("./hosts/file-manager/index.js");
|
||||
await import("./hosts/tmux/index.js");
|
||||
|
||||
@@ -374,3 +374,44 @@ describe("bundled network-topology plugin", () => {
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled workspaces plugin", () => {
|
||||
it("ships a directory with a manifest and both entry points", () => {
|
||||
const dir = path.join(getBundledPluginsDir(), "workspaces");
|
||||
|
||||
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("has a manifest that passes the real validator", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
readBundledManifest("workspaces"),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe("workspaces");
|
||||
expect(manifest?.category).toBe("Productivity");
|
||||
});
|
||||
|
||||
it("declares the transport-owner capability and qualifies for the tier", () => {
|
||||
const { manifest } = parseManifest(readBundledManifest("workspaces"));
|
||||
|
||||
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
|
||||
expect(isFirstParty("workspaces")).toBe(true);
|
||||
expect(runsInProcess("workspaces", manifest!.permissions)).toBe(true);
|
||||
});
|
||||
|
||||
it("exports activate and deactivate from its backend entry", () => {
|
||||
const entry = path.join(
|
||||
getBundledPluginsDir(),
|
||||
"workspaces",
|
||||
"backend",
|
||||
"index.mjs",
|
||||
);
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
|
||||
expect(source).toMatch(/export async function activate/);
|
||||
expect(source).toMatch(/export async function deactivate/);
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,7 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("first-party allowlist", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||
"ai",
|
||||
"automations",
|
||||
@@ -51,10 +51,11 @@ describe("first-party allowlist", () => {
|
||||
"remote-desktop",
|
||||
"ssh-terminal",
|
||||
"tailscale",
|
||||
"workspaces",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology and tailscale and nothing else", () => {
|
||||
it("recognises ssh-terminal, docker, host-metrics, ai, proxmox, remote-desktop, fleets, automations, network-topology, tailscale and workspaces and nothing else", () => {
|
||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(isFirstParty("host-metrics")).toBe(true);
|
||||
@@ -65,6 +66,7 @@ describe("first-party allowlist", () => {
|
||||
expect(isFirstParty("automations")).toBe(true);
|
||||
expect(isFirstParty("network-topology")).toBe(true);
|
||||
expect(isFirstParty("tailscale")).toBe(true);
|
||||
expect(isFirstParty("workspaces")).toBe(true);
|
||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||
expect(isFirstParty("community-plugin")).toBe(false);
|
||||
expect(isFirstParty("")).toBe(false);
|
||||
@@ -93,6 +95,9 @@ describe("runsInProcess", () => {
|
||||
runsInProcess("network-topology", [TRANSPORT_OWNER_CAPABILITY]),
|
||||
).toBe(true);
|
||||
expect(runsInProcess("tailscale", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||
expect(runsInProcess("workspaces", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// On the list but not asking for it: stays in a worker, so the manifest
|
||||
// remains an honest description of what the plugin does.
|
||||
@@ -106,6 +111,7 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("automations", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("network-topology", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("tailscale", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("workspaces", ["hosts.read"])).toBe(false);
|
||||
|
||||
// Asking for it but not on the list.
|
||||
expect(
|
||||
@@ -233,6 +239,18 @@ describe("manifest gate on the reserved capability", () => {
|
||||
expect(parsed?.id).toBe("tailscale");
|
||||
});
|
||||
|
||||
it("allows it for workspaces", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({
|
||||
id: "workspaces",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(parsed?.id).toBe("workspaces");
|
||||
});
|
||||
|
||||
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({ permissions: ["hosts.read"] }),
|
||||
|
||||
+7
-2
@@ -161,8 +161,13 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/workspaces.js");
|
||||
vi.mock("../../../database/routes/workspace-dispatch.js", () => ({
|
||||
registerWorkspacesRouter: vi.fn(),
|
||||
unregisterWorkspacesRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
const { router } =
|
||||
await import("../../../../../plugins/workspaces/backend/routes.js");
|
||||
|
||||
function findLayer(method: string, path: string) {
|
||||
const stack = (router as unknown as Router).stack as Array<{
|
||||
+3
-3
@@ -102,7 +102,7 @@ const TailscaleDevicesPanel = lazy(() =>
|
||||
),
|
||||
);
|
||||
const WorkspacesPanel = lazy(() =>
|
||||
import("@/sidebar/WorkspacesPanel").then((m) => ({
|
||||
import("../../plugins/workspaces/frontend/WorkspacesPanel").then((m) => ({
|
||||
default: m.WorkspacesPanel,
|
||||
})),
|
||||
);
|
||||
@@ -197,12 +197,12 @@ import {
|
||||
listWorkspaces,
|
||||
applyWorkspaceServer,
|
||||
saveLastSessionWorkspace,
|
||||
} from "@/api/workspaces-api";
|
||||
} from "../../plugins/workspaces/frontend/workspaces-api";
|
||||
import {
|
||||
buildWorkspacePayload as buildWorkspacePayloadUtil,
|
||||
remapSlotIds,
|
||||
resolveWorkspaceTabTarget,
|
||||
} from "@/shell/workspaceUtils";
|
||||
} from "../../plugins/workspaces/frontend/workspaceUtils";
|
||||
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
||||
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
||||
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
buildWorkspaceTabSnapshots,
|
||||
remapSlotIds,
|
||||
resolveWorkspaceTabTarget,
|
||||
} from "../../shell/workspaceUtils";
|
||||
} from "../../../../../plugins/workspaces/frontend/workspaceUtils";
|
||||
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
+1
-1
@@ -24,7 +24,7 @@ import {
|
||||
applyWorkspaceServer,
|
||||
getLastSessionWorkspace,
|
||||
saveLastSessionWorkspace,
|
||||
} from "../../api/workspaces-api";
|
||||
} from "../../../../../plugins/workspaces/frontend/workspaces-api";
|
||||
import type { WorkspacePayload } from "@/types/ui-types";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
railItemLabel,
|
||||
visibleRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { WORKSPACE_CAPTURABLE_TYPES } from "@/shell/workspaceUtils";
|
||||
import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/frontend/workspaceUtils";
|
||||
import type { TabType } from "@/types/ui-types";
|
||||
import en from "@/locales/en.json";
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"plugins/remote-desktop/backend/**/*.ts",
|
||||
"plugins/fleets/backend/**/*.ts",
|
||||
"plugins/automations/backend/**/*.ts",
|
||||
"plugins/network-topology/backend/**/*.ts"
|
||||
"plugins/network-topology/backend/**/*.ts",
|
||||
"plugins/workspaces/backend/**/*.ts"
|
||||
],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user