Polish HeroUI chrome, topology toolbar, and session client IP.

Checkpoint before workbench facade redesign: list defaults, API key quota, toast portal, topology canvas editor toolbar with More menu, and trusted-proxy client IP for sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 15:10:57 +08:00
parent 5096117ad4
commit c4526e36d8
64 changed files with 6316 additions and 4521 deletions

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useI18n } from "../i18n";
/** Compact locale toggle: shows current language, click switches ZH ↔ EN. */
@ -7,14 +8,14 @@ export function HeaderMenu() {
const label = locale === "zh" ? "ZH" : "EN";
return (
<button
type="button"
<Button
size="sm"
variant="ghost"
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--locale"
title={t("layout.switchLanguage")}
aria-label={t("layout.switchLanguage")}
onClick={() => setLocale(next)}
onPress={() => setLocale(next)}
>
{label}
</button>
</Button>
);
}

View file

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Input, Label, TextField } from "@heroui/react";
import { useI18n } from "../i18n";
import { FieldSelect } from "./ui/FieldSelect";
import {
HOP_VENDORS,
defaultHopTemplate,
@ -38,20 +39,6 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_enter_system_view: false,
});
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
function applyHopTemplate(
prev: HopProxyFieldsState,
protocol: string,
@ -111,143 +98,151 @@ export function HopProxyFields({
return (
<div className="form-grid">
<label className="form-grid__full">
<FormLabel required>{t("managedNe.hop.type")}</FormLabel>
<select
value={value.hop_vendor}
onChange={(e) => {
const hop_vendor = e.target.value as HopVendor;
set(patchHopVendorChange(hop_vendor, value));
}}
>
{HOP_VENDORS.map((v) => (
<option key={v} value={v}>
{t(`managedNe.hop.vendor.${v}`)}
</option>
))}
</select>
<span className="form-field-hint">{t(hopHintKey(value.hop_vendor))}</span>
</label>
<label>
<FormLabel required>{t("managedNe.hop.host")}</FormLabel>
<input
required
value={value.hop_host}
placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined}
onChange={(e) => set({ hop_host: e.target.value })}
onBlur={() => {
if (!bastion) return;
const raw = String(value.hop_host || "").trim();
if (!raw.includes("@")) return;
const parsed = expandBastionHopFields(raw, value.hop_username);
if (!parsed.hop_host || parsed.hop_host === raw) return;
set({
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || value.hop_username,
});
}}
/>
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.type")}
required
value={value.hop_vendor}
hint={t(hopHintKey(value.hop_vendor))}
onChange={(e) => {
const hop_vendor = e.target.value as HopVendor;
set(patchHopVendorChange(hop_vendor, value));
}}
>
{HOP_VENDORS.map((v) => (
<option key={v} value={v}>
{t(`managedNe.hop.vendor.${v}`)}
</option>
))}
</FieldSelect>
<TextField
fullWidth
isRequired
value={value.hop_host}
onChange={(hop_host) => set({ hop_host })}
onBlur={() => {
if (!bastion) return;
const raw = String(value.hop_host || "").trim();
if (!raw.includes("@")) return;
const parsed = expandBastionHopFields(raw, value.hop_username);
if (!parsed.hop_host || parsed.hop_host === raw) return;
set({
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || value.hop_username,
});
}}
>
<Label>{t("managedNe.hop.host")}</Label>
<Input placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined} />
{bastion ? <span className="form-field-hint">{t("managedNe.hop.hostHintBastion")}</span> : null}
</label>
<label>
<FormLabel>{t("managedNe.hop.port")}</FormLabel>
<input
type="number"
value={value.hop_port}
onChange={(e) => set({ hop_port: Number(e.target.value) || 22 })}
/>
</label>
</TextField>
<TextField
fullWidth
type="number"
value={String(value.hop_port)}
onChange={(hop_port) => set({ hop_port: Number(hop_port) || 22 })}
>
<Label>{t("managedNe.hop.port")}</Label>
<Input />
</TextField>
{bastion ? (
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.targetAuthMode")}</FormLabel>
<select
value={value.hop_target_auth_mode}
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
>
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
</select>
<span className="form-field-hint">{t("managedNe.hop.targetAuthHint")}</span>
</label>
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.targetAuthMode")}
value={value.hop_target_auth_mode}
hint={t("managedNe.hop.targetAuthHint")}
onChange={(e) => set({ hop_target_auth_mode: e.target.value as HopTargetAuthMode })}
>
<option value="bastion_managed">{t("managedNe.hop.targetAuthBastionManaged")}</option>
<option value="manual">{t("managedNe.hop.targetAuthManual")}</option>
</FieldSelect>
) : null}
{cliHop ? (
<label>
<FormLabel>{t("managedNe.hop.protocol")}</FormLabel>
<select
value={value.hop_protocol}
onChange={(e) => {
const hop_protocol = e.target.value;
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
}}
>
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
<option value="telnet">telnet</option>
</select>
</label>
<FieldSelect
label={t("managedNe.hop.protocol")}
value={value.hop_protocol}
onChange={(e) => {
const hop_protocol = e.target.value;
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
}}
>
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
<option value="telnet">telnet</option>
</FieldSelect>
) : null}
<label>
<FormLabel required>{t("managedNe.hop.username")}</FormLabel>
<input required value={value.hop_username} onChange={(e) => set({ hop_username: e.target.value })} />
</label>
<label>
<FormLabel required={hopPasswordRequired}>
<TextField
fullWidth
isRequired
value={value.hop_username}
onChange={(hop_username) => set({ hop_username })}
>
<Label>{t("managedNe.hop.username")}</Label>
<Input />
</TextField>
<TextField
fullWidth
type="password"
isRequired={hopPasswordRequired}
value={value.hop_password}
onChange={(hop_password) => set({ hop_password })}
>
<Label>
{t("managedNe.hop.password")}
{hopPasswordOptional ? (
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
) : null}
</FormLabel>
<input
type="password"
required={hopPasswordRequired}
value={value.hop_password}
onChange={(e) => set({ hop_password: e.target.value })}
/>
</label>
</Label>
<Input />
</TextField>
{bastion ? (
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.usernameTemplate")}</FormLabel>
<input
value={value.hop_command_template}
onChange={(e) => set({ hop_command_template: e.target.value })}
placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
/>
<TextField
fullWidth
className="form-grid__full"
value={value.hop_command_template}
onChange={(hop_command_template) => set({ hop_command_template })}
>
<Label>{t("managedNe.hop.usernameTemplate")}</Label>
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
</label>
</TextField>
) : null}
{cliHop ? (
<>
<label>
<FormLabel>{t(vrfLabelKey(value.hop_vendor))}</FormLabel>
<input
value={value.hop_vrf}
onChange={(e) => {
const hop_vrf = e.target.value;
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
}}
/>
</label>
<TextField
fullWidth
value={value.hop_vrf}
onChange={(hop_vrf) => {
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
}}
>
<Label>{t(vrfLabelKey(value.hop_vendor))}</Label>
<Input />
</TextField>
{huawei ? (
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.enterSystemView")}</FormLabel>
<select
value={value.hop_enter_system_view ? "yes" : "no"}
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
>
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
</select>
<span className="form-field-hint">{t("managedNe.hop.enterSystemViewHint")}</span>
</label>
<FieldSelect
fullWidth
className="form-grid__full"
label={t("managedNe.hop.enterSystemView")}
value={value.hop_enter_system_view ? "yes" : "no"}
hint={t("managedNe.hop.enterSystemViewHint")}
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
>
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
</FieldSelect>
) : null}
<label className="form-grid__full">
<FormLabel>{t("managedNe.hop.commandTemplate")}</FormLabel>
<input
value={value.hop_command_template}
onChange={(e) => set({ hop_command_template: e.target.value })}
placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
/>
<TextField
fullWidth
className="form-grid__full"
value={value.hop_command_template}
onChange={(hop_command_template) => set({ hop_command_template })}
>
<Label>{t("managedNe.hop.commandTemplate")}</Label>
<Input placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)} />
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
</label>
</TextField>
</>
) : null}
</div>

View file

@ -1,5 +1,7 @@
import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import { FieldSelect } from "./ui/FieldSelect";
export type ListPagerProps = {
page: number;
@ -19,7 +21,7 @@ export function ListPager({
pages,
total,
pageSize,
pageSizeOptions = [20, 50, 100, 200],
pageSizeOptions = [10, 20, 50, 100, 200],
onPageChange,
onPageSizeChange,
disabled = false,
@ -54,17 +56,18 @@ export function ListPager({
})}
</div>
<div className="pager__controls btn-row">
<button
<Button
size="sm"
variant="secondary"
className="pager__btn"
type="button"
disabled={disabled || page <= 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
isDisabled={disabled || page <= 1}
onPress={() => onPageChange(Math.max(1, page - 1))}
>
{t("common.prevPage")}
</button>
</Button>
<label className="pager__jump">
<span className="visually-hidden">{t("common.jumpPage")}</span>
<input
<Input
className="pager__jump-input"
type="number"
min={1}
@ -72,7 +75,6 @@ export function ListPager({
value={jumpDraft}
disabled={disabled}
aria-label={t("common.jumpPage")}
title={t("common.jumpPage")}
onChange={(e) => setJumpDraft(e.target.value)}
onBlur={commitJump}
onKeyDown={(e) => {
@ -84,20 +86,21 @@ export function ListPager({
/>
<span className="muted">/ {safePages}</span>
</label>
<button
<Button
size="sm"
variant="secondary"
className="pager__btn"
type="button"
disabled={disabled || page >= safePages}
onClick={() => onPageChange(Math.min(safePages, page + 1))}
isDisabled={disabled || page >= safePages}
onPress={() => onPageChange(Math.min(safePages, page + 1))}
>
{t("common.nextPage")}
</button>
</Button>
{onPageSizeChange ? (
<select
className="pager__size"
<FieldSelect
className="pager__size-field"
aria-label={t("common.pageSize")}
value={String(pageSize)}
disabled={disabled}
aria-label={t("common.pageSize")}
onChange={(e) => {
const next = Number(e.target.value) || pageSizeOptions[0] || 50;
onPageSizeChange(next);
@ -108,7 +111,7 @@ export function ListPager({
{t("common.perPage", { n: String(n) })}
</option>
))}
</select>
</FieldSelect>
) : null}
</div>
</div>

View file

@ -1,3 +1,4 @@
import { Button, Checkbox, Input, Label, TextField } from "@heroui/react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -12,6 +13,7 @@ import {
} from "../services/api";
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "./HopProxyFields";
import { HelpHint } from "./HelpHint";
import { FieldSelect } from "./ui/FieldSelect";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
@ -214,77 +216,76 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
{cliReady ? t("ume.cli.statusReady") : t("ume.cli.statusNotReady")}
</p>
<div className="form-grid">
<label>
<span className="form-label">{t("ume.cli.profileName")}</span>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label>
<label>
<span className="form-label">{t("ume.cli.targetUsername")}</span>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
placeholder="ca-oper"
/>
</label>
<label>
<span className="form-label">{t("ume.cli.targetPassword")}</span>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
placeholder={form.id ? t("ume.cli.passwordKeep") : ""}
/>
</label>
<label>
<span className="form-label">{t("managedNe.form.deviceType")}</span>
<select
value={form.device_type_default}
onChange={(e) => setForm({ ...form, device_type_default: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</select>
</label>
<label>
<span className="form-label">{t("managedNe.form.vendor")}</span>
<select value={form.vendor_default} onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<TextField fullWidth value={form.name} onChange={(name) => setForm({ ...form, name })}>
<Label>{t("ume.cli.profileName")}</Label>
<Input />
</TextField>
<TextField
fullWidth
isRequired
value={form.username}
onChange={(username) => setForm({ ...form, username })}
>
<Label>{t("ume.cli.targetUsername")}</Label>
<Input placeholder="ca-oper" />
</TextField>
<TextField
fullWidth
type="password"
value={form.password}
onChange={(password) => setForm({ ...form, password })}
>
<Label>{t("ume.cli.targetPassword")}</Label>
<Input placeholder={form.id ? t("ume.cli.passwordKeep") : ""} />
</TextField>
<FieldSelect
label={t("managedNe.form.deviceType")}
value={form.device_type_default}
onChange={(e) => setForm({ ...form, device_type_default: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("managedNe.form.vendor")}
value={form.vendor_default}
onChange={(e) => setForm({ ...form, vendor_default: e.target.value })}
>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</FieldSelect>
</div>
<div className="form-fieldset" style={{ marginTop: 16 }}>
<div className="form-fieldset__title">{t("managedNe.hop.sectionTitle")}</div>
<label className="form-check">
<input
type="checkbox"
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
hop: {
...prev.hop,
...patchHopVendorChange(prev.hop.hop_vendor, prev.hop),
...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true),
},
}
: {}),
}));
}}
/>
<span className="form-check__text">{t("managedNe.hop.enable")}</span>
</label>
<Checkbox
isSelected={form.hop_enabled}
onChange={(hop_enabled) => {
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
hop: {
...prev.hop,
...patchHopVendorChange(prev.hop.hop_vendor, prev.hop),
...applyHopTemplate(prev.hop, prev.hop.hop_protocol, prev.hop.hop_vrf, true),
},
}
: {}),
}));
}}
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
</Checkbox>
{form.hop_enabled ? (
<div className="hop-proxy-fields">
<HopProxyFields
@ -297,50 +298,57 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
) : null}
</div>
<div className="actions-row actions-row--inline" style={{ marginTop: 16 }}>
<button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
<button
type="button"
onClick={() => {
setForm(emptyForm());
}}
<Button
size="sm"
variant="primary"
isDisabled={saveMutation.isPending}
onPress={() => saveMutation.mutate()}
>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</Button>
<Button size="sm" variant="secondary" onPress={() => setForm(emptyForm())}>
{t("ume.cli.newProfile")}
</button>
</Button>
{form.id ? (
<button
type="button"
className="danger-btn"
onClick={async () => {
if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
try {
await apiDelete(`/v1/cli/profiles/${form.id}`);
setForm(emptyForm());
await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
showOk(t("ume.cli.deleted"));
} catch (e) {
showError(e instanceof Error ? e.message : String(e));
}
<Button
size="sm"
variant="danger"
onPress={() => {
void (async () => {
if (!window.confirm(t("ume.cli.deleteConfirm"))) return;
try {
await apiDelete(`/v1/cli/profiles/${form.id}`);
setForm(emptyForm());
await queryClient.invalidateQueries({ queryKey: queryKeys.cliProfiles });
showOk(t("ume.cli.deleted"));
} catch (e) {
showError(e instanceof Error ? e.message : String(e));
}
})();
}}
>
{t("managedNe.delete")}
</button>
</Button>
) : null}
</div>
<div className={embedded ? undefined : "panel"} style={{ marginTop: 20 }}>
<h3 className={embedded ? "card__section-title" : undefined}>{t("ume.cli.connectTestTitle")}</h3>
<div className="cli-connect-test-row">
<input
<Input
value={sampleUmeNeId}
onChange={(e) => setSampleUmeNeId(e.target.value)}
placeholder={t("ume.cli.sampleNePh")}
/>
<button type="button" onClick={() => connectTestMutation.mutate()} disabled={connectTestMutation.isPending}>
<Button
size="sm"
variant="secondary"
isDisabled={connectTestMutation.isPending}
onPress={() => connectTestMutation.mutate()}
>
{connectTestMutation.isPending || overrideTesting
? t("managedNe.connect.running")
: t("managedNe.connect.run")}
</button>
</Button>
</div>
{testNeId ? (
<div style={{ marginTop: 12 }}>
@ -385,10 +393,10 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
<span className="form-label">{t("ume.cli.existingProfiles")}</span>
<div className="actions-row actions-row--inline">
{(profilesQuery.data?.items || []).map((p) => (
<button key={p.id} type="button" className="link-btn" onClick={() => setForm(profileToForm(p))}>
<Button key={p.id} size="sm" variant="ghost" onPress={() => setForm(profileToForm(p))}>
{p.name}
{p.is_default ? ` (${t("ume.cli.default")})` : ""}
</button>
</Button>
))}
</div>
</div>

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@ -1031,9 +1032,10 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}}
/>
<div className="webcrt-paste-bridge__actions">
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="primary"
onPress={() => {
const text = pasteBridgeRef.current?.value || "";
setPasteBridgeOpen(false);
if (text) sendStdinThrottled(text);
@ -1041,23 +1043,24 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}}
>
{t("webcrt.term.paste")}
</button>
<button
type="button"
onClick={() => {
</Button>
<Button
size="sm"
variant="tertiary"
onPress={() => {
setPasteBridgeOpen(false);
focusTerminal();
}}
>
{t("webcrt.term.pasteBridgeCancel")}
</button>
</Button>
</div>
</div>
</div>
) : null}
{findOpen ? (
<div className="webcrt-find">
<input
<Input
ref={findInputRef}
type="search"
value={findQuery}
@ -1088,11 +1091,11 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}
}}
/>
<button
type="button"
<Button
size="sm"
variant="secondary"
aria-label={t("webcrt.term.findPrev")}
title={t("webcrt.term.findPrev")}
onClick={() => {
onPress={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const hit = findBufferLine(term, findQuery, Math.max(0, findLineRef.current) - 1, -1);
@ -1103,12 +1106,12 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}}
>
↑
</button>
<button
type="button"
</Button>
<Button
size="sm"
variant="secondary"
aria-label={t("webcrt.term.findNext")}
title={t("webcrt.term.findNext")}
onClick={() => {
onPress={() => {
const term = termRef.current;
if (!term || !findQuery) return;
const hit = findBufferLine(term, findQuery, findLineRef.current + 1, 1);
@ -1119,18 +1122,18 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
}}
>
↓
</button>
<button
type="button"
</Button>
<Button
size="sm"
variant="ghost"
aria-label={t("webcrt.term.findClose")}
title={t("webcrt.term.findClose")}
onClick={() => {
onPress={() => {
setFindOpen(false);
focusTerminal();
}}
>
×
</button>
</Button>
</div>
) : null}
<div
@ -1147,22 +1150,23 @@ export const WebTerminal = forwardRef<WebTerminalHandle, Props>(function WebTerm
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onMouseDown={(e) => e.stopPropagation()}
>
<button type="button" onClick={() => void copySelection()}>
<Button size="sm" variant="ghost" onPress={() => void copySelection()}>
{t("webcrt.term.copy")}
</button>
<button type="button" onClick={() => void pasteFromClipboard()}>
</Button>
<Button size="sm" variant="ghost" onPress={() => void pasteFromClipboard()}>
{t("webcrt.term.paste")}
</button>
<button
type="button"
onClick={() => {
</Button>
<Button
size="sm"
variant="ghost"
onPress={() => {
termRef.current?.clear();
termRef.current?.write("\x1b[H\x1b[2J");
setCtxMenu(null);
}}
>
{t("webcrt.term.clear")}
</button>
</Button>
</div>
) : null}
</div>

View file

@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { Modal, useOverlayState } from "@heroui/react";
export type AppModalShellProps = {
open: boolean;
onClose: () => void;
/** When false, backdrop click / Escape won't dismiss (e.g. while saving). */
dismissible?: boolean;
size?: "sm" | "md" | "lg" | "cover" | "full" | "xs";
className?: string;
children: ReactNode;
};
/**
* Controlled HeroUI Modal chrome for NetX dialogs.
* Keeps call sites on open/onClose while using v3 compound Modal.
*/
export function AppModalShell({
open,
onClose,
dismissible = true,
size = "md",
className = "",
children,
}: AppModalShellProps) {
const state = useOverlayState({
isOpen: open,
onOpenChange: (next) => {
if (!next) onClose();
},
});
return (
<Modal state={state}>
<Modal.Backdrop isDismissable={dismissible} className="app-heroui-modal-backdrop">
<Modal.Container
size={size}
placement="center"
className={`app-heroui-modal${className ? ` ${className}` : ""}`}
>
<Modal.Dialog className="app-heroui-modal__dialog">{children}</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}
/** @deprecated Prefer AppModalShell — kept for topology imports. */
export function TopoModalShell(props: AppModalShellProps) {
return <AppModalShell {...props} />;
}

View file

@ -0,0 +1,34 @@
import type { ReactNode, SelectHTMLAttributes } from "react";
import { Label } from "@heroui/react";
/** Compact labeled native select — HeroUI Select is heavy for dense ops forms. */
export function FieldSelect({
label,
hint,
required,
fullWidth,
className = "",
children,
...props
}: {
label?: ReactNode;
hint?: ReactNode;
required?: boolean;
fullWidth?: boolean;
className?: string;
children: ReactNode;
} & SelectHTMLAttributes<HTMLSelectElement>) {
return (
<label className={`ui-field${fullWidth ? " ui-field--full" : ""}${className ? ` ${className}` : ""}`}>
{label ? (
<Label isRequired={required} className="ui-field__label">
{label}
</Label>
) : null}
<select className="ui-field__select" required={required} {...props}>
{children}
</select>
{hint ? <span className="ui-field__hint">{hint}</span> : null}
</label>
);
}

View file

@ -0,0 +1 @@
export { AppModalShell, TopoModalShell, type AppModalShellProps } from "./AppModalShell";