mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 21:50:47 +08:00
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:
parent
5096117ad4
commit
c4526e36d8
64 changed files with 6316 additions and 4521 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
51
web/src/components/ui/AppModalShell.tsx
Normal file
51
web/src/components/ui/AppModalShell.tsx
Normal 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} />;
|
||||
}
|
||||
34
web/src/components/ui/FieldSelect.tsx
Normal file
34
web/src/components/ui/FieldSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
web/src/components/ui/TopoModalShell.tsx
Normal file
1
web/src/components/ui/TopoModalShell.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { AppModalShell, TopoModalShell, type AppModalShellProps } from "./AppModalShell";
|
||||
Loading…
Add table
Add a link
Reference in a new issue