mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3
This commit is contained in:
1 parent
7fcda3bb01
commit
cfab6f764c
65 files changed
+1665
-294
No files matched your search
@@ -19,7 +19,13 @@ import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
import { PluginManageError } from "../../plugins/manage.js";
|
||||
import { parsePluginChannel } from "../../plugins/registry-index.js";
|
||||
import { pluginFeatures, youtubeVideoId } from "../../plugins/manifest.js";
|
||||
import {
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
pluginFeatures,
|
||||
youtubeVideoId,
|
||||
type PluginEnvVar,
|
||||
} from "../../plugins/manifest.js";
|
||||
import {
|
||||
isPluginChoice,
|
||||
type PluginChoice,
|
||||
@@ -259,6 +265,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
let repository: string | undefined;
|
||||
let videoId: string | undefined;
|
||||
let features: string[] = [];
|
||||
let docs: string | undefined;
|
||||
let env: PluginEnvVar[] = [];
|
||||
try {
|
||||
const manifest = JSON.parse(record.manifestJson) as {
|
||||
contributes?: unknown;
|
||||
@@ -269,6 +277,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
repository?: unknown;
|
||||
video?: unknown;
|
||||
features?: unknown;
|
||||
docs?: unknown;
|
||||
env?: unknown;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
@@ -292,6 +302,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
: undefined;
|
||||
videoId = youtubeVideoId(manifest?.video) ?? undefined;
|
||||
features = pluginFeatures(manifest?.features);
|
||||
docs = pluginDocsUrl(manifest?.docs) ?? undefined;
|
||||
env = pluginEnvVars(manifest?.env);
|
||||
} catch {
|
||||
contributes = null;
|
||||
}
|
||||
@@ -306,6 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
icon,
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
docs,
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
|
||||
@@ -324,6 +337,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
repository,
|
||||
videoId,
|
||||
features,
|
||||
env,
|
||||
signedBy: loaded?.signedBy ?? null,
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
|
||||
@@ -222,6 +222,7 @@ export interface RegistryListing {
|
||||
icon?: string;
|
||||
videoId?: string;
|
||||
features: string[];
|
||||
docs?: string;
|
||||
versions: Array<{
|
||||
version: string;
|
||||
prerelease: boolean;
|
||||
@@ -290,6 +291,7 @@ export async function listRegistry(
|
||||
icon: plugin.icon,
|
||||
videoId: plugin.videoId,
|
||||
features: plugin.features,
|
||||
docs: plugin.docs,
|
||||
versions: channelReleases(plugin, "beta").map((entry) => ({
|
||||
version: entry.version,
|
||||
prerelease: Boolean(entry.prerelease),
|
||||
|
||||
@@ -19,10 +19,13 @@ export {
|
||||
qualifyPermission,
|
||||
youtubeVideoId,
|
||||
pluginFeatures,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
export type {
|
||||
PluginManifest,
|
||||
PluginEnvVar,
|
||||
PluginAuthor,
|
||||
PluginEngine,
|
||||
PluginContributions,
|
||||
|
||||
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import {
|
||||
pluginDocsUrl,
|
||||
pluginFeatures,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
youtubeVideoId,
|
||||
@@ -67,6 +68,8 @@ export interface RegistryPlugin {
|
||||
/** Only ever a YouTube video id, never a link. */
|
||||
videoId?: string;
|
||||
features: string[];
|
||||
/** https docs link from the index. */
|
||||
docs?: string;
|
||||
/** Stable releases, newest first. */
|
||||
versions: RegistryVersion[];
|
||||
/**
|
||||
@@ -161,6 +164,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
||||
icon: asString(entry.icon),
|
||||
videoId: youtubeVideoId(entry.video) ?? undefined,
|
||||
features: pluginFeatures(entry.features),
|
||||
docs: pluginDocsUrl(entry.docs) ?? undefined,
|
||||
versions,
|
||||
prereleases,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
isTermixCompatible,
|
||||
youtubeVideoId,
|
||||
pluginFeatures,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
} from "../../plugins/manifest.js";
|
||||
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
|
||||
|
||||
function base(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -929,3 +932,73 @@ describe("youtubeVideoId", () => {
|
||||
expect(youtubeVideoId(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("docs and env fields", () => {
|
||||
it("accepts an https docs link and well formed env entries", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
docs: "https://docs.termix.site/plugins/sample-plugin",
|
||||
env: [
|
||||
{ name: "SAMPLE_URL", description: "Where to connect." },
|
||||
{
|
||||
name: "SAMPLE_TOKEN",
|
||||
description: "Token.",
|
||||
secret: true,
|
||||
required: true,
|
||||
default: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a docs link that is not https", () => {
|
||||
const errors = validateManifest(base({ docs: "http://example.com" }));
|
||||
expect(errors.join(" ")).toMatch(/"docs" must be an https URL/);
|
||||
});
|
||||
|
||||
it("refuses bad env entries", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
env: [
|
||||
{ name: "lower_case", description: "x" },
|
||||
{ name: "DUP", description: "x" },
|
||||
{ name: "DUP", description: "x" },
|
||||
{ name: "NO_DESC", description: "" },
|
||||
{ name: "EXTRA", description: "x", other: 1 },
|
||||
{ name: "FLAG", description: "x", secret: "yes" },
|
||||
],
|
||||
}),
|
||||
).join(" ");
|
||||
expect(errors).toMatch(/env\[0\]\.name/);
|
||||
expect(errors).toMatch(/env\[2\]\.name" repeats DUP/);
|
||||
expect(errors).toMatch(/env\[3\]\.description/);
|
||||
expect(errors).toMatch(/Unknown field "other" in env\[4\]/);
|
||||
expect(errors).toMatch(/env\[5\]\.secret/);
|
||||
});
|
||||
|
||||
it("reads docs links and env entries leniently", () => {
|
||||
expect(pluginDocsUrl("https://docs.termix.site/plugins/x/")).toBe(
|
||||
"https://docs.termix.site/plugins/x",
|
||||
);
|
||||
expect(pluginDocsUrl("file:///etc/passwd")).toBeNull();
|
||||
expect(pluginDocsUrl(42)).toBeNull();
|
||||
expect(
|
||||
pluginEnvVars([
|
||||
{ name: "OK_VAR", description: "fine" },
|
||||
{ name: "bad", description: "x" },
|
||||
"nope",
|
||||
]),
|
||||
).toEqual([{ name: "OK_VAR", description: "fine" }]);
|
||||
});
|
||||
|
||||
it("joins pages and anchors onto a docs link", () => {
|
||||
const base = "https://docs.termix.site/plugins/docker";
|
||||
expect(pluginDocsPage(base)).toBe(base);
|
||||
expect(pluginDocsPage(base, "/setup/")).toBe(`${base}/setup`);
|
||||
expect(pluginDocsPage(base, "setup", "#ports")).toBe(`${base}/setup#ports`);
|
||||
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
||||
});
|
||||
});
|
||||
@@ -155,6 +155,23 @@ describe("parseRegistryIndex", () => {
|
||||
expect(index.plugins[1].features).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps only https docs links", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
docs: "https://docs.termix.site/plugins/docker/",
|
||||
versions: [version()],
|
||||
},
|
||||
{ id: "other", docs: "javascript:alert(1)", versions: [version()] },
|
||||
],
|
||||
});
|
||||
expect(index.plugins[0].docs).toBe(
|
||||
"https://docs.termix.site/plugins/docker",
|
||||
);
|
||||
expect(index.plugins[1].docs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses something that is not an index", () => {
|
||||
expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildOpenApi,
|
||||
hasOpenApiPaths,
|
||||
TERMIX_SECURITY_SCHEMES,
|
||||
} from "@termix-ssh/plugin-sdk/openapi";
|
||||
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
function source(files: Record<string, string>): string {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-openapi-"));
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, name), text);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("buildOpenApi", () => {
|
||||
it("reads @openapi blocks from TypeScript sources", async () => {
|
||||
const root = source({
|
||||
"routes.ts": [
|
||||
"/**",
|
||||
" * @openapi",
|
||||
" * /plugin-api/sample/items:",
|
||||
" * get:",
|
||||
" * summary: List items",
|
||||
" * responses:",
|
||||
" * 200:",
|
||||
" * description: The items",
|
||||
" */",
|
||||
'router.get("/items", handler);',
|
||||
].join("\n"),
|
||||
});
|
||||
const spec = await buildOpenApi({
|
||||
title: "Sample API",
|
||||
version: "1.2.3",
|
||||
files: [path.join(root, "*.ts")],
|
||||
tags: [{ name: "Sample" }],
|
||||
});
|
||||
expect(hasOpenApiPaths(spec)).toBe(true);
|
||||
expect(Object.keys(spec.paths ?? {})).toEqual(["/plugin-api/sample/items"]);
|
||||
expect(spec).toMatchObject({
|
||||
openapi: "3.0.3",
|
||||
info: { title: "Sample API", version: "1.2.3" },
|
||||
tags: [{ name: "Sample" }],
|
||||
components: { securitySchemes: TERMIX_SECURITY_SCHEMES },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a spec with no paths when nothing is documented", async () => {
|
||||
const root = source({ "index.ts": "export const x = 1;" });
|
||||
const spec = await buildOpenApi({
|
||||
title: "Empty",
|
||||
version: "1.0.0",
|
||||
files: [path.join(root, "*.ts")],
|
||||
});
|
||||
expect(hasOpenApiPaths(spec)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{ "name": "Auth", "description": "Sign in, sign out and login methods" },
|
||||
{ "name": "Users", "description": "Accounts, profiles and sessions" },
|
||||
{ "name": "API Keys", "description": "API keys for scripts and the CLI" },
|
||||
{ "name": "Encryption", "description": "Data key setup and unlock" },
|
||||
{ "name": "SSH", "description": "Hosts, folders and connections" },
|
||||
{
|
||||
"name": "SSH Auth",
|
||||
"description": "SSH auth types the server can connect with"
|
||||
},
|
||||
{
|
||||
"name": "Host Defaults",
|
||||
"description": "Admin, user and folder host defaults"
|
||||
},
|
||||
{ "name": "Host Enrollment", "description": "Adding hosts from a script" },
|
||||
{ "name": "Credentials", "description": "Saved credentials" },
|
||||
{ "name": "RBAC", "description": "Roles and sharing" },
|
||||
{ "name": "Dashboard", "description": "Dashboard stats and recent activity" },
|
||||
{ "name": "Audit", "description": "Audit log search and export" },
|
||||
{ "name": "Plugins", "description": "Installing and managing plugins" },
|
||||
{ "name": "Sync", "description": "Desktop sync with a linked server" },
|
||||
{ "name": "Database", "description": "Database export, import and health" },
|
||||
{ "name": "General", "description": "Server info and settings" },
|
||||
{ "name": "Open Tabs", "description": "Saved open tabs" },
|
||||
{ "name": "User Preferences", "description": "Per-user preferences" },
|
||||
{ "name": "UI Preferences", "description": "Interface layout preferences" },
|
||||
{ "name": "Host Sidebar", "description": "Host sidebar display options" },
|
||||
{
|
||||
"name": "Credential Sidebar",
|
||||
"description": "Credential sidebar display options"
|
||||
}
|
||||
]
|
||||
@@ -1,176 +0,0 @@
|
||||
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { promises as fs } from "fs";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const swaggerOptions: SwaggerJSDocOptions = {
|
||||
definition: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "Termix API",
|
||||
version: "0.0.0",
|
||||
description: "Termix Backend API Reference",
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "http://localhost:30001",
|
||||
description: "Main database and authentication server",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
description: "Session JWT sent as Authorization: Bearer <JWT>.",
|
||||
},
|
||||
cookieAuth: {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "jwt",
|
||||
description:
|
||||
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
|
||||
},
|
||||
apiKeyAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "tmx_<api key>",
|
||||
description:
|
||||
"API key sent as Authorization: Bearer tmx_<api key>. Permissions follow the key owner's account; encrypted data must be unlocked separately. API keys cannot impersonate another user.",
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
details: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: [],
|
||||
},
|
||||
{
|
||||
cookieAuth: [],
|
||||
},
|
||||
{
|
||||
apiKeyAuth: [],
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Credentials",
|
||||
description: "SSH credential management",
|
||||
},
|
||||
{
|
||||
name: "RBAC",
|
||||
description: "Role-based access control for host sharing",
|
||||
},
|
||||
{
|
||||
name: "Users",
|
||||
description: "User management and authentication",
|
||||
},
|
||||
{
|
||||
name: "Dashboard",
|
||||
description: "Dashboard statistics and activity",
|
||||
},
|
||||
{
|
||||
name: "SSH",
|
||||
description: "SSH host management and configuration",
|
||||
},
|
||||
{
|
||||
name: "Host Enrollment",
|
||||
description: "Host enrollment and onboarding",
|
||||
},
|
||||
{
|
||||
name: "Open Tabs",
|
||||
description: "Per-user open tab state",
|
||||
},
|
||||
{
|
||||
name: "Audit",
|
||||
description: "Audit log querying and export",
|
||||
},
|
||||
{
|
||||
name: "API Keys",
|
||||
description: "API key management",
|
||||
},
|
||||
{
|
||||
name: "Sync",
|
||||
description: "Remote sync between desktop and server",
|
||||
},
|
||||
{
|
||||
name: "User Preferences",
|
||||
description: "Per-user application preferences",
|
||||
},
|
||||
{
|
||||
name: "UI Preferences",
|
||||
description: "Interface layout and display preferences",
|
||||
},
|
||||
{
|
||||
name: "Host Sidebar",
|
||||
description: "Host sidebar display preferences",
|
||||
},
|
||||
{
|
||||
name: "Credential Sidebar",
|
||||
description: "Credential sidebar display preferences",
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
path.join(__dirname, "..", "database", "database.js").replace(/\\/g, "/"),
|
||||
path
|
||||
.join(__dirname, "..", "database", "routes", "*.js")
|
||||
.replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
|
||||
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
|
||||
],
|
||||
};
|
||||
|
||||
async function generateOpenAPISpec() {
|
||||
try {
|
||||
systemLogger.info("Generating OpenAPI specification", {
|
||||
operation: "openapi_generate_start",
|
||||
});
|
||||
|
||||
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
|
||||
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"openapi.json",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
outputPath,
|
||||
JSON.stringify(swaggerSpec, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
systemLogger.success("OpenAPI specification generated", {
|
||||
operation: "openapi_generate_success",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to generate OpenAPI specification", error, {
|
||||
operation: "openapi_generation",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateOpenAPISpec();
|
||||
|
||||
export { swaggerOptions, generateOpenAPISpec };
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ChangelogRelease,
|
||||
ReleaseNotes,
|
||||
} from "@termix-ssh/plugin-sdk/changelog";
|
||||
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
|
||||
|
||||
export type { PluginChoice };
|
||||
|
||||
@@ -255,6 +256,10 @@ export interface PluginSummary {
|
||||
videoId?: string;
|
||||
/** What the plugin does, from the manifest. Admins only. */
|
||||
features?: string[];
|
||||
/** https docs link from the manifest. */
|
||||
docs?: string;
|
||||
/** Environment variables the plugin reads. Admins only. */
|
||||
env?: PluginEnvVar[];
|
||||
signedBy?: string | null;
|
||||
}
|
||||
|
||||
@@ -294,6 +299,7 @@ export interface RegistryPluginEntry {
|
||||
icon?: string;
|
||||
videoId?: string;
|
||||
features?: string[];
|
||||
docs?: string;
|
||||
versions: RegistryPluginVersion[];
|
||||
/** Newest release on the plugin's channel. */
|
||||
latestVersion: string | null;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { BookOpen } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
|
||||
import { docsUrl, type DocsPage } from "@/lib/docs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePluginScope } from "@/plugin-host/scope";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
|
||||
/** A plugin's docs link from its manifest, or null when it has none. */
|
||||
export function usePluginDocsUrl(
|
||||
pluginId: string | null | undefined,
|
||||
page?: string,
|
||||
anchor?: string,
|
||||
): string | null {
|
||||
const { records } = usePluginStore();
|
||||
const base = pluginId ? records.get(pluginId)?.summary.docs : undefined;
|
||||
return base ? pluginDocsPage(base, page, anchor) : null;
|
||||
}
|
||||
|
||||
export interface DocsLinkProps {
|
||||
/** A core docs page. */
|
||||
core?: DocsPage;
|
||||
/**
|
||||
* A page of the current plugin's docs, like "setup". Inside a plugin this
|
||||
* resolves against its manifest docs link; "" is its main page.
|
||||
*/
|
||||
page?: string;
|
||||
anchor?: string;
|
||||
/** A full URL, used as is. */
|
||||
href?: string | null;
|
||||
/** "text" is a small inline link, "icon" a square header button. */
|
||||
variant?: "text" | "icon";
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one way the app links to docs. Renders nothing when there is nowhere
|
||||
* to go, like a community plugin with no docs link.
|
||||
*/
|
||||
export function DocsLink({
|
||||
core,
|
||||
page,
|
||||
anchor,
|
||||
href,
|
||||
variant = "text",
|
||||
children,
|
||||
className,
|
||||
}: DocsLinkProps) {
|
||||
const { t } = useTranslation();
|
||||
const pluginId = usePluginScope();
|
||||
const pluginUrl = usePluginDocsUrl(
|
||||
page !== undefined ? pluginId : null,
|
||||
page,
|
||||
anchor,
|
||||
);
|
||||
const url =
|
||||
href ?? (core ? withAnchor(docsUrl(core), anchor) : null) ?? pluginUrl;
|
||||
if (!url) return null;
|
||||
|
||||
const label = t("common.docs");
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] text-accent-brand hover:underline",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children ?? label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function withAnchor(url: string, anchor?: string): string {
|
||||
if (!anchor) return url;
|
||||
return `${url.split("#")[0]}#${anchor.replace(/^#/, "")}`;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
/** One spacing rhythm for every panel and tab. */
|
||||
export const PANEL = {
|
||||
@@ -30,6 +31,7 @@ export function PanelShell({
|
||||
tabs,
|
||||
footer,
|
||||
scroll = true,
|
||||
docs,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
@@ -49,6 +51,8 @@ export function PanelShell({
|
||||
footer?: React.ReactNode;
|
||||
/** False when the body scrolls itself. */
|
||||
scroll?: boolean;
|
||||
/** Docs URL for this screen, shown as a book icon in the header. */
|
||||
docs?: string | null;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -120,8 +124,11 @@ export function PanelShell({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex shrink-0 items-center gap-1 px-2">{actions}</div>
|
||||
{(actions || docs) && (
|
||||
<div className="flex shrink-0 items-center gap-1 px-2">
|
||||
{actions}
|
||||
{docs && <DocsLink href={docs} variant="icon" />}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{bands}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
type VersionStatus,
|
||||
} from "./dashboard-core-cards";
|
||||
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
|
||||
|
||||
@@ -803,7 +804,7 @@ export function DashboardTab({
|
||||
href: "https://discord.com/invite/jVQGdvHDrf",
|
||||
label: t("dashboard.discord"),
|
||||
},
|
||||
{ href: "https://docs.termix.site/", label: t("dashboard.docs") },
|
||||
{ href: docsUrl(), label: t("dashboard.docs") },
|
||||
{ href: "https://donate.termix.site/", label: t("dashboard.donate") },
|
||||
];
|
||||
|
||||
@@ -827,6 +828,7 @@ export function DashboardTab({
|
||||
title={title}
|
||||
status={isDashboardView ? todayLabel : undefined}
|
||||
scroll={false}
|
||||
docs={docsUrl("dashboard")}
|
||||
actions={
|
||||
<>
|
||||
{isDashboardView && !viewPending && (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"home": "",
|
||||
"install": "install",
|
||||
"updating": "install/updating",
|
||||
"betas": "configure/betas",
|
||||
"envVars": "configure/environment-variables",
|
||||
"https": "configure/https",
|
||||
"database": "configure/database",
|
||||
"reverseProxy": "configure/reverse-proxy",
|
||||
"desktopSync": "configure/desktop-sync",
|
||||
"interface": "guide/interface",
|
||||
"dashboard": "guide/dashboard",
|
||||
"manage": "guide/manage",
|
||||
"hosts": "guide/hosts",
|
||||
"portKnocking": "guide/hosts#port-knocking",
|
||||
"hostDefaults": "guide/host-defaults",
|
||||
"credentials": "guide/credentials",
|
||||
"sharing": "guide/sharing",
|
||||
"account": "guide/account",
|
||||
"accountSecurity": "guide/account#security",
|
||||
"accountData": "guide/account#your-data",
|
||||
"apiKeys": "guide/api-keys",
|
||||
"commandPalette": "guide/command-palette",
|
||||
"settings": "guide/settings",
|
||||
"interfaceSettings": "guide/settings#interface",
|
||||
"appearance": "guide/settings#appearance",
|
||||
"keybindings": "guide/settings#keybindings",
|
||||
"users": "guide/users",
|
||||
"sessions": "guide/users#sessions",
|
||||
"roles": "guide/roles",
|
||||
"adminGeneral": "guide/admin-settings",
|
||||
"branding": "guide/admin-settings#branding",
|
||||
"adminDatabase": "guide/admin-settings#database",
|
||||
"adminSsl": "configure/https",
|
||||
"auditLog": "guide/audit-log",
|
||||
"importExport": "guide/import-export",
|
||||
"plugins": "guide/plugins",
|
||||
"pluginCatalog": "plugins",
|
||||
"cli": "cli",
|
||||
"develop": "develop"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import DOCS_PAGES from "./docs-pages.json";
|
||||
|
||||
export const DOCS_URL = "https://docs.termix.site";
|
||||
|
||||
/**
|
||||
* Every core docs page the app links to. The docs site checks each one
|
||||
* exists, so add new links here rather than writing URLs inline.
|
||||
*/
|
||||
export type DocsPage = keyof typeof DOCS_PAGES;
|
||||
|
||||
/** Full URL of a core docs page. */
|
||||
export function docsUrl(page: DocsPage = "home"): string {
|
||||
const path = DOCS_PAGES[page] ?? "";
|
||||
return path ? `${DOCS_URL}/${path}` : `${DOCS_URL}/`;
|
||||
}
|
||||
|
||||
export { DOCS_PAGES };
|
||||
+11
-4
@@ -208,7 +208,8 @@
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search...",
|
||||
"new": "New"
|
||||
"new": "New",
|
||||
"docs": "Docs"
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Step {{current}} of {{total}}",
|
||||
@@ -252,7 +253,8 @@
|
||||
"desktopLocalBody": "Everything is stored here. Nothing to set up.",
|
||||
"desktopLinkTitle": "With my Termix server",
|
||||
"desktopLinkBody": "Sign in to your server to use the same hosts and settings on every device.",
|
||||
"desktopLinked": "Linked to {{server}}"
|
||||
"desktopLinked": "Linked to {{server}}",
|
||||
"pluginsDocsLink": "Read about each one"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -930,7 +932,8 @@
|
||||
"select": "Select",
|
||||
"toggleWith": "Toggle with",
|
||||
"pluginActions": "More actions",
|
||||
"noTargetTab": "No terminal tab is open"
|
||||
"noTargetTab": "No terminal tab is open",
|
||||
"documentation": "Documentation"
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Split screen",
|
||||
@@ -2185,7 +2188,11 @@
|
||||
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
|
||||
"versionLabel": "{{version}} (beta)",
|
||||
"sendFeedback": "Send beta feedback"
|
||||
}
|
||||
},
|
||||
"documentation": "Documentation",
|
||||
"envVars": "Environment variables",
|
||||
"envRequired": "Required",
|
||||
"envDefault": "Default: {{value}}"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type ManageMode,
|
||||
type ManageRequest,
|
||||
} from "./manage-requests";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
type Editing =
|
||||
| {
|
||||
@@ -440,6 +441,13 @@ export function ManageTab() {
|
||||
title={t("nav.manage")}
|
||||
status={status}
|
||||
scroll={false}
|
||||
docs={docsUrl(
|
||||
mode === "credentials"
|
||||
? "credentials"
|
||||
: mode === "hosts"
|
||||
? "hosts"
|
||||
: "hostDefaults",
|
||||
)}
|
||||
leading={
|
||||
editing ? (
|
||||
<span className="flex h-full md:hidden">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
defaultChoices,
|
||||
recommendedChoices,
|
||||
} from "../plugin-choices";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"Terminal",
|
||||
@@ -173,7 +174,10 @@ export function PluginsStep({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("onboarding.pluginsIntro")}
|
||||
{t("onboarding.pluginsIntro")}{" "}
|
||||
<DocsLink core="pluginCatalog" className="text-xs">
|
||||
{t("onboarding.pluginsDocsLink")}
|
||||
</DocsLink>
|
||||
</p>
|
||||
|
||||
<div className="flex items-start gap-2 border border-border bg-muted/30 p-2.5 text-[11px] leading-snug text-muted-foreground">
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
} from "./host-scope";
|
||||
import { manifestDeclares, type ViewKind } from "./view-ownership";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { pluginDocsPage, pluginDocsUrl } from "@termix-ssh/plugin-sdk/docs";
|
||||
import { hasPermission } from "@/hooks/use-permissions";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
@@ -83,6 +84,20 @@ export interface PluginAppHandle {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
function createDocsApi(manifest: PluginManifest): TermixApp["docs"] {
|
||||
const url = pluginDocsUrl(manifest?.docs);
|
||||
const page = (name?: string, anchor?: string) =>
|
||||
url ? pluginDocsPage(url, name, anchor) : null;
|
||||
return {
|
||||
url,
|
||||
page,
|
||||
open(name, anchor) {
|
||||
const target = page(name, anchor);
|
||||
if (target) window.open(target, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the object a plugin's `activate(app)` receives.
|
||||
*
|
||||
@@ -602,6 +617,8 @@ export function createPluginApp(
|
||||
|
||||
confirm: (options) => shell.confirm!(options),
|
||||
|
||||
docs: createDocsApi(manifest),
|
||||
|
||||
desktop: {
|
||||
available: isElectron(),
|
||||
remoteServerUrl,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/api/open-tabs-api";
|
||||
import { runKeybindingAction } from "@/shell/keybinding-registry";
|
||||
import { usePluginScope } from "./scope";
|
||||
import { usePluginDocsUrl } from "@/components/docs-link";
|
||||
import { knownPluginIds, usePluginStore } from "./plugin-store";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import type { UiPluginPresets } from "@/types/ui-preferences";
|
||||
@@ -225,6 +226,10 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
return pluginId;
|
||||
},
|
||||
|
||||
useDocsUrl(pluginId, page, anchor) {
|
||||
return usePluginDocsUrl(pluginId, page, anchor);
|
||||
},
|
||||
|
||||
useTranslation(pluginId) {
|
||||
const { t, i18n } = useI18nTranslation(pluginId);
|
||||
return {
|
||||
|
||||
@@ -289,6 +289,8 @@ export {
|
||||
type PanelDensity,
|
||||
type PanelViewMode,
|
||||
} from "@/components/panel-layout";
|
||||
// A link to docs. Inside a plugin, page="setup" opens that page of its docs.
|
||||
export { DocsLink, type DocsLinkProps } from "@/components/docs-link";
|
||||
export {
|
||||
AddButton,
|
||||
FormFooter,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
Check,
|
||||
Database,
|
||||
@@ -262,6 +263,40 @@ export function PluginDetail({
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{plugin.env.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.envVars")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
{plugin.env.map((v) => (
|
||||
<div
|
||||
key={v.name}
|
||||
className="flex flex-col gap-0.5 px-4 py-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="font-mono text-xs font-semibold">
|
||||
{v.name}
|
||||
</code>
|
||||
{v.required && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("plugins.manager.envRequired")}
|
||||
</span>
|
||||
)}
|
||||
{v.default !== undefined && (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{t("plugins.manager.envDefault", {
|
||||
value: v.default,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{v.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<PluginReleaseNotes plugin={plugin} />
|
||||
|
||||
{plugin.installed && (
|
||||
@@ -419,6 +454,20 @@ export function PluginDetail({
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{plugin.docs && (
|
||||
<a
|
||||
href={plugin.docs}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={LINK_ROW}
|
||||
>
|
||||
<BookOpen className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("plugins.manager.documentation")}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{hasSettings && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
type SortKey,
|
||||
} from "./plugin-model";
|
||||
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type Section = "installed" | "browse" | "updates";
|
||||
|
||||
@@ -102,6 +104,7 @@ function PluginsTabBody() {
|
||||
<PanelShell
|
||||
icon={<Puzzle className="size-4" />}
|
||||
title={t("nav.plugins")}
|
||||
docs={docsUrl("plugins")}
|
||||
status={t("plugins.manager.status.summary", {
|
||||
installed: installed.length,
|
||||
running,
|
||||
@@ -550,13 +553,21 @@ function InstalledCard({
|
||||
: t("plugins.manager.enable")}
|
||||
</Button>
|
||||
)}
|
||||
<DocsLink
|
||||
href={plugin.docs}
|
||||
variant="icon"
|
||||
className="ml-auto size-6"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className="ml-auto text-muted-foreground hover:text-destructive"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-destructive",
|
||||
!plugin.docs && "ml-auto",
|
||||
)}
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
@@ -690,6 +701,7 @@ function BrowseCard({
|
||||
})
|
||||
: t("plugins.manager.noServerAccess")}
|
||||
</span>
|
||||
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
|
||||
{plugin.installed ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
|
||||
<Check className="size-3" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getCapabilityInfo,
|
||||
type CapabilityRisk,
|
||||
} from "@termix-ssh/plugin-sdk/capabilities";
|
||||
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
|
||||
import semver from "semver";
|
||||
import {
|
||||
CHANGE_TYPES,
|
||||
@@ -36,6 +37,9 @@ export interface PluginEntry {
|
||||
repository?: string;
|
||||
videoId?: string;
|
||||
features: string[];
|
||||
/** Docs link: the installed manifest's, else the registry's. */
|
||||
docs?: string;
|
||||
env: PluginEnvVar[];
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
latestVersion: string | null;
|
||||
@@ -124,6 +128,8 @@ export function mergePlugins(
|
||||
features: summary.features?.length
|
||||
? summary.features
|
||||
: (entry?.features ?? []),
|
||||
docs: summary.docs ?? entry?.docs,
|
||||
env: summary.env ?? [],
|
||||
installed: true,
|
||||
version: summary.version,
|
||||
latestVersion: entry?.latestVersion ?? null,
|
||||
@@ -164,6 +170,8 @@ export function mergePlugins(
|
||||
repository: entry.repository,
|
||||
videoId: entry.videoId,
|
||||
features: entry.features ?? [],
|
||||
docs: entry.docs,
|
||||
env: [],
|
||||
installed: false,
|
||||
version: null,
|
||||
latestVersion: entry.latestVersion,
|
||||
@@ -201,6 +209,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
description: preview.description,
|
||||
author: preview.author,
|
||||
features: preview.features ?? [],
|
||||
env: [],
|
||||
category: "",
|
||||
installed: preview.replaces !== null,
|
||||
version: preview.replaces,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { type PluginSummary } from "@/api/plugins-api";
|
||||
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
||||
@@ -142,6 +143,7 @@ function HostPluginSection({
|
||||
<SectionCard
|
||||
title={plugin.name}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
action={<DocsLink href={plugin.docs} />}
|
||||
>
|
||||
{!running && (
|
||||
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
type SettingsBand,
|
||||
type SettingsPage,
|
||||
} from "./settings-pages";
|
||||
import { usePluginDocsUrl } from "@/components/docs-link";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
export interface SettingsTabProps {
|
||||
/** The page to open, from the tab's data. */
|
||||
@@ -195,6 +197,13 @@ export function SettingsTab({
|
||||
window.setTimeout(attempt, 60);
|
||||
};
|
||||
|
||||
const pluginDocs = usePluginDocsUrl(page.pluginId);
|
||||
const pageDocs = page.pluginId
|
||||
? pluginDocs
|
||||
: page.docs
|
||||
? docsUrl(page.docs)
|
||||
: null;
|
||||
|
||||
const pluginFeature = page.pluginId
|
||||
? {
|
||||
user: userFeatures.find((p) => p.id === page.pluginId),
|
||||
@@ -306,6 +315,7 @@ export function SettingsTab({
|
||||
title={t("nav.settings")}
|
||||
status={pageLabel(page)}
|
||||
scroll={false}
|
||||
docs={pageDocs}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -21,10 +21,9 @@ import { reportBetaFeedbackUrl } from "@/lib/issue-url";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isPrerelease } from "@/plugins/plugin-model";
|
||||
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
|
||||
const BETA_DOCS_URL =
|
||||
"https://docs.termix.site/install/server/docker#beta-builds";
|
||||
|
||||
function onBeta(plugin: PluginSummary): boolean {
|
||||
return plugin.channel === "beta" || isPrerelease(plugin.version);
|
||||
@@ -196,7 +195,7 @@ export function UpdatesSettings() {
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href={BETA_DOCS_URL}
|
||||
href={docsUrl("betas")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-accent-brand hover:underline"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { AdminSection } from "@/types/ui-types";
|
||||
import type { DocsPage } from "@/lib/docs";
|
||||
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
|
||||
|
||||
export type SettingsBand = "you" | "app" | "plugins" | "instance";
|
||||
@@ -45,6 +46,8 @@ export interface SettingsPage {
|
||||
adminSection?: AdminSection;
|
||||
/** A plugin's settings page. */
|
||||
pluginId?: string;
|
||||
/** The docs page for this settings page. Plugin pages use the plugin's docs. */
|
||||
docs?: DocsPage;
|
||||
/** i18n keys whose text search matches. */
|
||||
searchKeys: string[];
|
||||
}
|
||||
@@ -61,6 +64,7 @@ function core(
|
||||
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
core({
|
||||
id: "account",
|
||||
docs: "account",
|
||||
band: "you",
|
||||
labelKey: "settings.account",
|
||||
icon: User,
|
||||
@@ -68,6 +72,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "security",
|
||||
docs: "accountSecurity",
|
||||
band: "you",
|
||||
labelKey: "settings.security",
|
||||
icon: Shield,
|
||||
@@ -76,6 +81,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "api-keys",
|
||||
docs: "apiKeys",
|
||||
band: "you",
|
||||
labelKey: "settings.apiKeys",
|
||||
icon: KeyRound,
|
||||
@@ -83,6 +89,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "data",
|
||||
docs: "accountData",
|
||||
band: "you",
|
||||
labelKey: "settings.data",
|
||||
icon: Database,
|
||||
@@ -90,6 +97,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "interface",
|
||||
docs: "interfaceSettings",
|
||||
band: "app",
|
||||
labelKey: "settings.interface",
|
||||
icon: SlidersHorizontal,
|
||||
@@ -97,6 +105,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "appearance",
|
||||
docs: "appearance",
|
||||
band: "app",
|
||||
labelKey: "settings.appearance",
|
||||
icon: Palette,
|
||||
@@ -104,6 +113,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "keybindings",
|
||||
docs: "keybindings",
|
||||
band: "app",
|
||||
labelKey: "settings.keybindings",
|
||||
icon: Keyboard,
|
||||
@@ -111,6 +121,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-general",
|
||||
docs: "adminGeneral",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminGeneral",
|
||||
icon: Settings,
|
||||
@@ -119,6 +130,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "updates",
|
||||
docs: "updating",
|
||||
band: "instance",
|
||||
labelKey: "settings.updates.title",
|
||||
icon: FlaskConical,
|
||||
@@ -126,6 +138,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-users",
|
||||
docs: "users",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminUsers",
|
||||
icon: Users,
|
||||
@@ -134,6 +147,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-sessions",
|
||||
docs: "sessions",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminSessions",
|
||||
icon: Monitor,
|
||||
@@ -142,6 +156,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-roles",
|
||||
docs: "roles",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminRoles",
|
||||
icon: Lock,
|
||||
@@ -150,6 +165,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-host-defaults",
|
||||
docs: "hostDefaults",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminHostDefaults",
|
||||
icon: Server,
|
||||
@@ -158,6 +174,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-branding",
|
||||
docs: "branding",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminBranding",
|
||||
icon: Palette,
|
||||
@@ -166,6 +183,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-database",
|
||||
docs: "adminDatabase",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminDatabase",
|
||||
icon: Database,
|
||||
@@ -174,6 +192,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-ssl",
|
||||
docs: "adminSsl",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminSsl",
|
||||
icon: Globe,
|
||||
@@ -182,6 +201,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-api-keys",
|
||||
docs: "apiKeys",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminApiKeys",
|
||||
icon: KeyRound,
|
||||
@@ -190,6 +210,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
|
||||
}),
|
||||
core({
|
||||
id: "admin-audit-log",
|
||||
docs: "auditLog",
|
||||
band: "instance",
|
||||
labelKey: "settings.adminAuditLog",
|
||||
icon: FileText,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getSavedServerUrls,
|
||||
rememberServerUrl,
|
||||
} from "./saved-servers";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type Step = "server" | "signin" | "choose" | "finishing";
|
||||
|
||||
@@ -454,7 +455,9 @@ export function LinkServerDialog({
|
||||
scroll={!wide}
|
||||
>
|
||||
{!wide && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{description} {step === "server" && <DocsLink core="desktopSync" />}
|
||||
</p>
|
||||
)}
|
||||
{content}
|
||||
</InlineView>
|
||||
@@ -474,7 +477,9 @@ export function LinkServerDialog({
|
||||
>
|
||||
<DialogHeader className={wide ? "sr-only" : undefined}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{description} {step === "server" && <DocsLink core="desktopSync" />}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{content}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CommandEmpty,
|
||||
} from "@/components/command";
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
Clock,
|
||||
FlaskConical,
|
||||
@@ -48,6 +49,7 @@ import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
|
||||
import { isPrerelease } from "@/plugins/plugin-model";
|
||||
import { shell } from "@/plugin-host/shell-bridge";
|
||||
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
/** Opens settings, optionally at one section. */
|
||||
@@ -218,6 +220,12 @@ export function CommandPalette({
|
||||
);
|
||||
|
||||
const links = [
|
||||
{
|
||||
id: "link-docs",
|
||||
label: t("commandPalette.documentation"),
|
||||
icon: BookOpen,
|
||||
url: docsUrl(),
|
||||
},
|
||||
{
|
||||
id: "link-github",
|
||||
label: "GitHub",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { toast } from "sonner";
|
||||
import { AccordionSection } from "./AdminSettingsShared";
|
||||
import type { AdminUser } from "./AdminManagementSections";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type AdminApiKeysSectionProps = {
|
||||
open: boolean;
|
||||
@@ -66,24 +67,10 @@ export function AdminApiKeysSection({
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/api/api-keys"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.apiKeysDocsLink")}
|
||||
</a>
|
||||
<DocsLink core="apiKeys">{t("admin.apiKeysDocsLink")}</DocsLink>
|
||||
<br />
|
||||
{t("admin.apiKeysCliHint")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/cli"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.apiKeysCliLink")}
|
||||
</a>
|
||||
<DocsLink core="cli">{t("admin.apiKeysCliLink")}</DocsLink>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
import { AdminHostTags } from "./AdminHostTags";
|
||||
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
|
||||
import type { TlsStatus } from "@/api/tls-api";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type GeneralSettingsSectionProps = {
|
||||
open: boolean;
|
||||
@@ -423,14 +424,7 @@ export function AdminSSLSection({
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslDescription")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/ssl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.sslDocsLink")}
|
||||
</a>
|
||||
<DocsLink core="https">{t("admin.sslDocsLink")}</DocsLink>
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { AdminSession, AdminUser } from "./AdminManagementSections";
|
||||
import { TabStrip } from "./HostManagerTabs";
|
||||
import { BackButton, PanelShell } from "@/components/panel-layout";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
@@ -338,6 +339,7 @@ export function AdminUserManagePanel({
|
||||
<PanelShell
|
||||
leading={<BackButton onClick={close} />}
|
||||
title={t("admin.manageEditorBack", { username: user.username })}
|
||||
docs={docsUrl("users")}
|
||||
status={
|
||||
editor.kind === "host"
|
||||
? (editor.host?.name ?? t("manage.newHost"))
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { getUserList, type AccessRecord } from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
|
||||
|
||||
@@ -268,14 +269,7 @@ export function CredentialShareModal({
|
||||
<Users className="size-3.5" />
|
||||
{t("hosts.sharing.shareWithSection")}
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/rbac"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline shrink-0"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Filter,
|
||||
GripVertical,
|
||||
Plus,
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
export function CredentialsPanel({
|
||||
active: _active = true,
|
||||
@@ -269,15 +269,7 @@ export function CredentialsPanel({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="w-px self-stretch bg-border" />
|
||||
<a
|
||||
href="https://docs.termix.site/features/files-and-hosts/credentials"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("hosts.docsLink")}
|
||||
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
<DocsLink core="credentials" variant="icon" />
|
||||
</div>
|
||||
<div className="flex items-center border border-border shrink-0">
|
||||
<button
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
HostDefaultBadge,
|
||||
HostOnly,
|
||||
} from "@/lib/host-defaults-context";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
@@ -402,14 +403,7 @@ export function HostEditorGeneralTab({
|
||||
{t("hosts.portKnockingSequence")}
|
||||
</span>
|
||||
<HostDefaultBadge settingKey="core.portKnockSequence" />
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/port-knocking"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<DocsLink core="portKnocking">{t("hosts.docsLink")}</DocsLink>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
const PERMISSION_LEVELS: SharePermissionLevel[] = [
|
||||
"connect",
|
||||
@@ -307,14 +308,7 @@ export function HostShareModal({
|
||||
<Users className="size-3.5" />
|
||||
{t("hosts.sharing.shareWithSection")}
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/rbac"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline shrink-0"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
useFeatureSettings,
|
||||
type FeatureSectionId,
|
||||
} from "@/settings/FeatureSettingsSections";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
|
||||
type UserProfileSection =
|
||||
| "account"
|
||||
@@ -1345,7 +1346,7 @@ export function UserProfilePanel({
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.termix.site/cli"
|
||||
href={docsUrl("cli")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DOCS_PAGES, DOCS_URL, docsUrl } from "@/lib/docs";
|
||||
|
||||
describe("docsUrl", () => {
|
||||
it("builds full links to core docs pages", () => {
|
||||
expect(docsUrl()).toBe(`${DOCS_URL}/`);
|
||||
expect(docsUrl("hosts")).toBe(`${DOCS_URL}/guide/hosts`);
|
||||
expect(docsUrl("portKnocking")).toBe(
|
||||
`${DOCS_URL}/guide/hosts#port-knocking`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every page a relative path", () => {
|
||||
for (const path of Object.values(DOCS_PAGES)) {
|
||||
expect(path).not.toMatch(/^\/|^https?:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,23 @@ describe("mergePlugins", () => {
|
||||
expect(mergePlugins([summary()], null)[0].features).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the installed docs link and falls back to the registry", () => {
|
||||
const registry = entry({ docs: "https://docs.example.com/registry" });
|
||||
expect(
|
||||
mergePlugins(
|
||||
[summary({ docs: "https://docs.example.com/installed" })],
|
||||
[registry],
|
||||
)[0].docs,
|
||||
).toBe("https://docs.example.com/installed");
|
||||
expect(mergePlugins([summary()], [registry])[0].docs).toBe(
|
||||
"https://docs.example.com/registry",
|
||||
);
|
||||
expect(mergePlugins([summary()], null)[0].docs).toBeUndefined();
|
||||
expect(mergePlugins([], [registry])[0].docs).toBe(
|
||||
"https://docs.example.com/registry",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps installed plugins when the registry is unreachable", () => {
|
||||
const rows = mergePlugins([summary()], null);
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
Reference in new issue
Block a user