mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move snippets, keybinding actions and macros out of core into plugins
This commit is contained in:
1 parent
d9bba10612
commit
e0c30a4e6f
262 files changed
+9091
-10744
No files matched your search
@@ -4,7 +4,6 @@ import {
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
FakeSwitch,
|
||||
Select2,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -12,33 +11,24 @@ import {
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
HardDrive,
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { HardDrive, LayoutDashboard, Plus, Server, Trash2 } from "lucide-react";
|
||||
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
|
||||
const PLUGIN_ID = "host-metrics";
|
||||
|
||||
type PluginSettingsBag = Record<string, Record<string, unknown>>;
|
||||
type QuickActionRow = { name: string; snippetId: string };
|
||||
|
||||
/**
|
||||
* The Host Metrics section of the host editor. Its values are this plugin's
|
||||
* host settings, carried on form.pluginSettings and saved by the editor after
|
||||
* the host itself. Quick actions are a core host field.
|
||||
* the host itself.
|
||||
*/
|
||||
export function HostStatsTab({
|
||||
form,
|
||||
setField,
|
||||
updateForm,
|
||||
host,
|
||||
snippets,
|
||||
}: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newMount, setNewMount] = useState("");
|
||||
@@ -49,8 +39,6 @@ export function HostStatsTab({
|
||||
];
|
||||
const settings = readHostMetricsSettings(bag);
|
||||
const { excludedMounts, monitoredMounts } = settings;
|
||||
const quickActions = (form.quickActions ?? []) as QuickActionRow[];
|
||||
const snippetOptions = (snippets ?? []) as { id: number; name: string }[];
|
||||
const api = usePluginApi();
|
||||
|
||||
const patch = (values: Record<string, unknown>) =>
|
||||
@@ -288,84 +276,6 @@ export function HostStatsTab({
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.quickActionsLabel")}
|
||||
icon={<Zap className="size-3.5" />}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={() =>
|
||||
setField("quickActions", [
|
||||
...quickActions,
|
||||
{ name: "", snippetId: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addActionBtn")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hosts.quickActionsToolbar")}
|
||||
</p>
|
||||
{quickActions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
|
||||
<Zap className="size-6" />
|
||||
<span className="text-xs">{t("hosts.noQuickActions")}</span>
|
||||
</div>
|
||||
)}
|
||||
{quickActions.map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
|
||||
>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={t("hosts.buttonLabel")}
|
||||
value={a.name}
|
||||
onChange={(e) => {
|
||||
const updated = [...quickActions];
|
||||
updated[i] = { ...updated[i], name: e.target.value };
|
||||
setField("quickActions", updated);
|
||||
}}
|
||||
/>
|
||||
<Select2
|
||||
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={a.snippetId}
|
||||
onChange={(e) => {
|
||||
const updated = [...quickActions];
|
||||
updated[i] = {
|
||||
...updated[i],
|
||||
snippetId: e.target.value,
|
||||
};
|
||||
setField("quickActions", updated);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
|
||||
{snippetOptions.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"quickActions",
|
||||
quickActions.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Separator,
|
||||
Button,
|
||||
useConfirmation,
|
||||
ComponentSlot,
|
||||
TOTPDialog,
|
||||
useTabsSafe,
|
||||
ConnectionLogProvider,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
CardGridCanvas,
|
||||
ColumnCountStepper,
|
||||
type GridCardCatalogEntry,
|
||||
SnippetVariablesDialog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
import {
|
||||
@@ -23,33 +21,12 @@ import type { ServerMetrics } from "../shared/metrics.js";
|
||||
import React from "react";
|
||||
import { useHostMetricsApi } from "./host-metrics-api";
|
||||
|
||||
interface Snippet {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return typeof error === "string" ? error : "";
|
||||
}
|
||||
|
||||
// Mirrors the snippets plugin's own hasSnippetInputs: a pure check for
|
||||
// whether a snippet still has $INPUT_n placeholders once host variables are
|
||||
// substituted, so the quick-action dialog knows to ask before running.
|
||||
const SNIPPET_INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
function hasSnippetInputs(content: string): boolean {
|
||||
SNIPPET_INPUT_PATTERN.lastIndex = 0;
|
||||
return SNIPPET_INPUT_PATTERN.test(content);
|
||||
}
|
||||
import {
|
||||
useTranslation,
|
||||
useSlotContributions,
|
||||
useConnectionRetry,
|
||||
useHost,
|
||||
useHostStatus,
|
||||
invokeAction,
|
||||
logActivity,
|
||||
usePluginUiPreferences,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
@@ -70,11 +47,6 @@ import { metricsChangeKey } from "./metrics-change-key";
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
type ConnectionLogPayload = Parameters<
|
||||
ReturnType<typeof useConnectionLog>["addLog"]
|
||||
>[0];
|
||||
@@ -87,7 +59,6 @@ interface HostConfig {
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
quickActions?: QuickAction[];
|
||||
statusCheckEnabled?: boolean;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
authType?: string;
|
||||
@@ -127,9 +98,6 @@ function HostMetricsInner({
|
||||
});
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
@@ -139,15 +107,9 @@ function HostMetricsInner({
|
||||
null,
|
||||
);
|
||||
const [editMode, setEditMode] = React.useState(false);
|
||||
const [runningAction, setRunningAction] = React.useState<{
|
||||
action: QuickAction;
|
||||
snippet: Snippet;
|
||||
} | null>(null);
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
const snippetsCacheRef = React.useRef(new Map<number, Snippet>());
|
||||
|
||||
const statsConfig = React.useMemo(() => {
|
||||
const settings = readHostMetricsSettings(
|
||||
@@ -532,102 +494,6 @@ function HostMetricsInner({
|
||||
}
|
||||
};
|
||||
|
||||
async function executeQuickAction(
|
||||
action: QuickAction,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
if (!currentHostConfig) return;
|
||||
setExecutingActions((prev) => new Set(prev).add(action.snippetId));
|
||||
toast.loading(
|
||||
t("hostMetrics.executingQuickAction", { name: action.name }),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = (await invokeAction(
|
||||
"snippets.execute",
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
Object.keys(inputValues).length > 0 ? inputValues : undefined,
|
||||
)) as { success: boolean; output?: string; error?: string } | undefined;
|
||||
if (!result) {
|
||||
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: t("hostMetrics.snippetsUnavailable"),
|
||||
duration: 5000,
|
||||
});
|
||||
} else if (result.success) {
|
||||
toast.success(
|
||||
t("hostMetrics.quickActionSuccess", { name: action.name }),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output?.substring(0, 200),
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("hostMetrics.quickActionFailed", { name: action.name }), {
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.error || result.output,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: errorText(error),
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function runQuickActionWithConfirm(
|
||||
action: QuickAction,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
const run = () => void executeQuickAction(action, inputValues);
|
||||
if (!shouldConfirm) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
confirmWithToast(
|
||||
t("newUi.sidebar.snippets.confirmRunMessage", { name: action.name }),
|
||||
run,
|
||||
t("newUi.sidebar.snippets.confirmRunButton"),
|
||||
t("newUi.sidebar.snippets.cancel"),
|
||||
{ confirmOnEnter: true, duration: 6000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runQuickAction(action: QuickAction) {
|
||||
if (!currentHostConfig) return;
|
||||
try {
|
||||
let snippet = snippetsCacheRef.current.get(action.snippetId);
|
||||
if (!snippet) {
|
||||
snippet =
|
||||
((await invokeAction("snippets.get", action.snippetId)) as
|
||||
Snippet | null | undefined) ?? undefined;
|
||||
if (snippet) snippetsCacheRef.current.set(snippet.id, snippet);
|
||||
}
|
||||
if (snippet && hasSnippetInputs(snippet.content)) {
|
||||
setRunningAction({ action, snippet });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through and run with no inputs if the snippet lookup fails
|
||||
}
|
||||
runQuickActionWithConfirm(action, {});
|
||||
}
|
||||
|
||||
const showCards =
|
||||
metricsEnabled && metricsRetry.status === "connected" && metrics;
|
||||
const showOffline =
|
||||
@@ -653,43 +519,13 @@ function HostMetricsInner({
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<>
|
||||
<div className="mr-3 flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map(
|
||||
(action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={() => void runQuickAction(action)}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<RefreshCw className="mr-1 size-3 animate-spin" />
|
||||
{action.name}
|
||||
</>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-3 h-8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ComponentSlot
|
||||
slotId="host-metrics.toolbar"
|
||||
props={{
|
||||
hostId: currentHostConfig?.id,
|
||||
host: currentHostConfig,
|
||||
}}
|
||||
/>
|
||||
{editMode && (
|
||||
<>
|
||||
<ColumnCountStepper
|
||||
@@ -806,32 +642,6 @@ function HostMetricsInner({
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
{runningAction && (
|
||||
<SnippetVariablesDialog
|
||||
snippet={
|
||||
runningAction.snippet as Parameters<
|
||||
typeof SnippetVariablesDialog
|
||||
>[0]["snippet"]
|
||||
}
|
||||
host={
|
||||
currentHostConfig
|
||||
? {
|
||||
ip: currentHostConfig.ip,
|
||||
username: currentHostConfig.username,
|
||||
port: currentHostConfig.port,
|
||||
name: currentHostConfig.name,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onCancel={() => setRunningAction(null)}
|
||||
onConfirm={(_resolvedContent, inputValues) => {
|
||||
const action = runningAction.action;
|
||||
setRunningAction(null);
|
||||
runQuickActionWithConfirm(action, inputValues);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,4 +140,10 @@ export async function activate(app: TermixApp): Promise<void> {
|
||||
id: "host-metrics.managers",
|
||||
accepts: ["component"],
|
||||
});
|
||||
// Buttons before the toolbar's own controls, drawn with { hostId, host }.
|
||||
// The snippets plugin puts a host's quick actions here.
|
||||
app.declareActionSlot({
|
||||
id: "host-metrics.toolbar",
|
||||
accepts: ["component"],
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user