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,5 +1,6 @@
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import { Button } from "@heroui/react";
import type { Edge, Node } from "@xyflow/react";
import { useI18n } from "../../i18n";
import type { CtxMenu } from "./pageTypes";
@ -372,16 +373,16 @@ export function TopologyCtxMenu({
))}
</ul>
{!expandPhysicalLinks ? (
<button
type="button"
className="btn btn--sm"
onClick={() => {
<Button
size="sm"
variant="secondary"
onPress={() => {
onExpandPhysicalLinks();
onClose();
}}
>
{t("topology.expandPhysicalLinks")}
</button>
</Button>
) : null}
</div>
) : null}

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import type { TopologyDiscoverOut } from "../../types";
import { useI18n } from "../../i18n";
import { openOrFocusModule } from "../../utils/moduleWindows";
@ -48,10 +49,10 @@ export function TopologyDiscoverPanel({
<div className="topo-discover">
<div className="topo-discover__head">
<strong>{t("topology.discoverReport")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
<Button
size="sm"
variant="ghost"
onPress={() => {
if (discovering) {
void onCancelDiscover();
return;
@ -60,7 +61,7 @@ export function TopologyDiscoverPanel({
}}
>
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
</button>
</Button>
</div>
{discovering && discoverProgress.total > 0 ? (
<div className="topo-discover__progress" aria-live="polite">
@ -96,10 +97,10 @@ export function TopologyDiscoverPanel({
.replace("{{failed}}", String(discoverSummary.failed))}
</p>
<p className="panel__hint">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
<Button
size="sm"
variant="ghost"
onPress={() => {
const jobId = String(discoverReport?.job_id || "").trim();
const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : "";
openOrFocusModule({
@ -109,7 +110,7 @@ export function TopologyDiscoverPanel({
}}
>
{t("topology.discoverGoLldp")}
</button>
</Button>
</p>
</>
) : null}

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { Fragment } from "react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
import { useI18n } from "../../i18n";
@ -125,17 +126,17 @@ export function TopologyHexBrowser({
<p>{t("topology.treeLoadFailed")}</p>
{treeError ? <span className="muted"> ({String(treeError)})</span> : null}
<div className="topo-browser__empty-actions">
<button type="button" className="btn btn--sm btn--ghost" onClick={onTreeRetry}>
<Button size="sm" variant="ghost" onPress={onTreeRetry}>
{t("topology.treeRetry")}
</button>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionPending}
</Button>
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionPending}
>
{t("topology.newRegion")}
</button>
</Button>
</div>
</div>
) : regions.length === 0 ? (
@ -144,14 +145,14 @@ export function TopologyHexBrowser({
<RegionGlyph size={36} />
</span>
<p>{t("topology.emptyMaps")}</p>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionPending}
<Button
size="sm"
variant="secondary"
onPress={promptNewRegion}
isDisabled={createRegionPending}
>
{t("topology.newRegion")}
</button>
</Button>
</div>
) : (
<div className="topo-browser__grid topo-browser__grid--regions">

View file

@ -1,4 +1,5 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
import { Button, Input } from "@heroui/react";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
import { useI18n } from "../../i18n";
import { WORLD_MAP_ENABLED } from "./constants";
@ -71,24 +72,24 @@ export function TopologySidebar({
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
{collapsed ? (
<div className="topo-sidebar__rail-wrap">
<button
type="button"
<Button
isIconOnly
variant="ghost"
className="topo-sidebar__rail"
title={t("topology.expandSidebar")}
aria-label={t("topology.expandSidebar")}
onClick={onExpand}
onPress={onExpand}
>
<span className="topo-sidebar__rail-icon" aria-hidden="true">
<SidebarFoldIcon expand />
</span>
</button>
</Button>
</div>
) : (
<>
<div className="topo-sidebar__section">
<div className="topo-tree-search" ref={treeSearchRef}>
<div className="topo-tree-search__bar">
<input
<Input
className="input"
type="search"
value={treeNeQuery}
@ -100,25 +101,27 @@ export function TopologySidebar({
}}
onFocus={() => onTreeSearchOpen(true)}
/>
<button
type="button"
<Button
isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn"
onClick={promptNewRegion}
disabled={createRegionPending}
title={t("topology.newRegion")}
onPress={promptNewRegion}
isDisabled={createRegionPending}
aria-label={t("topology.newRegion")}
>
<PlusIcon />
</button>
<button
type="button"
</Button>
<Button
isIconOnly
size="sm"
variant="ghost"
className="topo-sidebar__icon-btn"
title={t("topology.collapseSidebar")}
aria-label={t("topology.collapseSidebar")}
onClick={onCollapse}
onPress={onCollapse}
>
<SidebarFoldIcon />
</button>
</Button>
</div>
{treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox">
@ -206,9 +209,9 @@ export function TopologySidebar({
<>
{t("topology.treeLoadFailed")}
{treeError ? <span className="muted"> ({String(treeError)})</span> : null}{" "}
<button type="button" className="btn btn--sm btn--ghost" onClick={onTreeRetry}>
<Button size="sm" variant="ghost" onPress={onTreeRetry}>
{t("topology.treeRetry")}
</button>
</Button>
</>
) : (
t("topology.emptyMaps")

View file

@ -1,4 +1,5 @@
import { Fragment, type RefObject } from "react";
import { Button, Dropdown } from "@heroui/react";
import { Fragment, type Key, type RefObject, useMemo } from "react";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
import { HelpHint } from "../../components/HelpHint";
@ -31,7 +32,6 @@ export type TopologyToolbarProps = {
savePending: boolean;
onSave: () => void;
onFit: () => void;
exportMenuRef: RefObject<HTMLDetailsElement | null>;
exporting: boolean;
onExport: (kind: "svg" | "xml") => void;
mapId: string;
@ -51,6 +51,16 @@ export type TopologyToolbarProps = {
) => TopologyTreeViewItem | null | undefined;
};
type MoreActionKey =
| "live-sync"
| "back"
| "undo"
| "redo"
| "fit"
| "export-svg"
| "export-xml"
| "remove-stale";
export function TopologyToolbar({
readOnly = false,
breadcrumbFolders,
@ -75,7 +85,6 @@ export function TopologyToolbar({
savePending,
onSave,
onFit,
exportMenuRef,
exporting,
onExport,
mapId,
@ -94,6 +103,27 @@ export function TopologyToolbar({
}: TopologyToolbarProps) {
const { t } = useI18n();
const canvasEmpty = !isWorldFlatCanvas && nodes.length === 0;
const moreDisabledKeys = useMemo(() => {
const keys: MoreActionKey[] = [];
if (readOnly || !canUndo) keys.push("undo");
if (readOnly || !canRedo) keys.push("redo");
if (canvasEmpty) keys.push("fit");
if (exporting || canvasEmpty) keys.push("export-svg");
if (exporting || !mapId) keys.push("export-xml");
if (readOnly || !staleEdgeCount) keys.push("remove-stale");
return keys;
}, [
readOnly,
canUndo,
canRedo,
canvasEmpty,
exporting,
mapId,
staleEdgeCount,
]);
const handleCreateNe = () => {
let flowX = 80 + nodes.length * 24;
let flowY = 80 + nodes.length * 24;
@ -112,14 +142,51 @@ export function TopologyToolbar({
onCreateNe(flowX, flowY);
};
const handleMoreAction = (key: Key) => {
switch (key as MoreActionKey) {
case "live-sync":
onLiveSyncToggle();
break;
case "back":
onBackUp();
break;
case "undo":
onUndo();
break;
case "redo":
onRedo();
break;
case "fit":
onFit();
break;
case "export-svg":
void onExport("svg");
break;
case "export-xml":
void onExport("xml");
break;
case "remove-stale":
void onRemoveStale();
break;
default:
break;
}
};
return (
<div className="topo-toolbar">
<div className="topo-toolbar__row">
<div className="topo-toolbar__title">
<div className="topo-breadcrumb">
<button type="button" className="topo-breadcrumb__link" onClick={() => goRoot()}>
<Button
type="button"
variant="ghost"
size="sm"
className="topo-breadcrumb__link"
onPress={() => goRoot()}
>
{t("topology.rootName")}
</button>
</Button>
{breadcrumbFolders.map((folder, idx) => {
const isLast = idx === breadcrumbFolders.length - 1;
const primaryView = primaryViewOfFolder(folder);
@ -140,13 +207,15 @@ export function TopologyToolbar({
{dirty ? " *" : ""}
</span>
) : (
<button
<Button
type="button"
variant="ghost"
size="sm"
className="topo-breadcrumb__link"
onClick={() => goRegion(folder.id)}
onPress={() => goRegion(folder.id)}
>
{regionDisplayName(folder, t)}
</button>
</Button>
)}
</Fragment>
);
@ -175,116 +244,81 @@ export function TopologyToolbar({
) : null}
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
</div>
<div className="topo-toolbar__actions">
<button
<div className="topo-toolbar__actions topo-toolbar__primary">
<Button
type="button"
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
onClick={onLiveSyncToggle}
>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={onAddNe}
size="sm"
variant="secondary"
isDisabled={readOnly || isWorldFlatCanvas}
onPress={onAddNe}
>
{t("topology.addNe")}
</button>
<button
</Button>
<Button
type="button"
className="btn btn--sm"
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={handleCreateNe}
size="sm"
variant="secondary"
isDisabled={readOnly || isWorldFlatCanvas}
onPress={handleCreateNe}
>
{t("topology.createNe")}
</button>
<button type="button" className="btn btn--sm btn--ghost" onClick={onBackUp}>
{t("topology.backUp")}
</button>
<button
</Button>
<Button
type="button"
className="btn btn--sm btn--ghost"
disabled={readOnly || !canUndo}
onClick={onUndo}
title="Ctrl+Z"
>
{t("topology.undo")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={readOnly || !canRedo}
onClick={onRedo}
title="Ctrl+Y"
>
{t("topology.redo")}
</button>
<button
type="button"
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
disabled={readOnly || savePending || !dirty}
onClick={onSave}
title={readOnly ? t("topology.readOnlyHint") : "Ctrl+S"}
size="sm"
className={dirty ? "topo-toolbar__save topo-toolbar__save--dirty" : "topo-toolbar__save"}
variant={dirty ? "primary" : "ghost"}
isDisabled={readOnly || savePending || !dirty}
onPress={onSave}
>
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!isWorldFlatCanvas && nodes.length === 0}
onClick={onFit}
>
{t("topology.fit")}
</button>
<details className="topo-toolbar__export" ref={exportMenuRef}>
<summary>{exporting ? t("topology.exporting") : t("topology.export")}</summary>
<div className="topo-export-menu" role="menu" aria-label={t("topology.export")}>
<button
type="button"
className="topo-export-menu__btn"
role="menuitem"
disabled={exporting || (!isWorldFlatCanvas && nodes.length === 0)}
onClick={() => void onExport("svg")}
</Button>
<Dropdown>
<Button type="button" size="sm" variant="ghost" aria-label={t("topology.more")}>
{t("topology.more")}
</Button>
<Dropdown.Popover className="topo-toolbar__more-popover" placement="bottom end">
<Dropdown.Menu
aria-label={t("topology.more")}
disabledKeys={moreDisabledKeys}
onAction={handleMoreAction}
>
{t("topology.exportSvg")}
</button>
<button
type="button"
className="topo-export-menu__btn"
role="menuitem"
disabled={exporting || !mapId}
onClick={() => void onExport("xml")}
>
{t("topology.exportXml")}
</button>
</div>
</details>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={readOnly || !staleEdgeCount}
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
onClick={() => void onRemoveStale()}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</button>
<Dropdown.Item id="live-sync" textValue={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</Dropdown.Item>
<Dropdown.Item id="back" textValue={t("topology.backUp")}>
{t("topology.backUp")}
</Dropdown.Item>
<Dropdown.Item id="undo" textValue={t("topology.undo")}>
{t("topology.undo")}
</Dropdown.Item>
<Dropdown.Item id="redo" textValue={t("topology.redo")}>
{t("topology.redo")}
</Dropdown.Item>
<Dropdown.Item id="fit" textValue={t("topology.fit")}>
{t("topology.fit")}
</Dropdown.Item>
<Dropdown.Item
id="export-svg"
textValue={exporting ? t("topology.exporting") : t("topology.exportSvg")}
>
{exporting ? t("topology.exporting") : t("topology.exportSvg")}
</Dropdown.Item>
<Dropdown.Item
id="export-xml"
textValue={exporting ? t("topology.exporting") : t("topology.exportXml")}
>
{exporting ? t("topology.exporting") : t("topology.exportXml")}
</Dropdown.Item>
<Dropdown.Item
id="remove-stale"
textValue={t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</div>
</div>
<div className="topo-toolbar__row topo-toolbar__row--tools">
@ -295,23 +329,27 @@ export function TopologyToolbar({
["pan", t("topology.toolPan"), "H"],
["connect", t("topology.toolConnect"), "C"],
] as const
).map(([mode, label, key]) => (
<button
key={mode}
type="button"
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`}
aria-pressed={toolMode === mode}
disabled={readOnly && mode === "connect"}
onClick={() => {
onToolModeChange(mode);
onConnectClickReset();
}}
>
<span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd>
</button>
))}
).map(([mode, label, key]) => {
const active = toolMode === mode;
return (
<Button
key={mode}
type="button"
className={`topo-tools__btn${active ? " is-active" : ""}`}
variant={active ? "primary" : "ghost"}
size="sm"
aria-pressed={active}
isDisabled={readOnly && mode === "connect"}
onPress={() => {
onToolModeChange(mode);
onConnectClickReset();
}}
>
<span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd>
</Button>
);
})}
</div>
{!fullscreen ? (
<div
@ -322,14 +360,15 @@ export function TopologyToolbar({
/>
) : null}
{outsidePeerCount > 0 ? (
<button
<Button
type="button"
className="btn btn--sm btn--ghost topo-toolbar__outside-peers"
title={t("topology.outsidePeers").replace("{{count}}", String(outsidePeerCount))}
onClick={onOpenOutsidePeers}
size="sm"
variant="ghost"
className="topo-toolbar__outside-peers"
onPress={onOpenOutsidePeers}
>
{t("topology.outsidePeersView").replace("{{count}}", String(outsidePeerCount))}
</button>
</Button>
) : null}
</div>
</div>

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "react";
import type { Node } from "@xyflow/react";
@ -299,16 +300,17 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
<strong>{t("topology.canvasBg")}</strong>
<button
<Button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
size="sm"
variant="ghost"
onPress={() => {
onCanvasBgChange(DEFAULT_CANVAS_BG);
persistCanvasBg(DEFAULT_CANVAS_BG);
}}
>
{t("topology.canvasBgReset")}
</button>
</Button>
</div>
<div className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{t("topology.canvasBgColor")}</span>
@ -342,16 +344,17 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
<strong>{t("topology.textColors")}</strong>
<button
<Button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
size="sm"
variant="ghost"
onPress={() => {
onLabelColorsChange({ ...DEFAULT_LABEL_COLORS });
persistLabelColors({ ...DEFAULT_LABEL_COLORS });
}}
>
{t("topology.textColorsReset")}
</button>
</Button>
</div>
{(
[
@ -393,16 +396,17 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
<div className="topo-display-defaults__head">
<strong>{t("topology.vendorColors")}</strong>
<button
<Button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
size="sm"
variant="ghost"
onPress={() => {
onVendorColorsChange({ ...DEFAULT_VENDOR_COLORS });
persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
}}
>
{t("topology.vendorColorsReset")}
</button>
</Button>
</div>
{VENDOR_TONE_KEYS.map((key) => {
const label = t(`topology.vendorTone.${key}`);
@ -444,9 +448,9 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-display-defaults">
<div className="topo-display-defaults__head">
<strong>{t("topology.edgeDefaults")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onResetEdgeDefaults}>
<Button type="button" size="sm" variant="ghost" onPress={onResetEdgeDefaults}>
{t("topology.edgeDefaultsReset")}
</button>
</Button>
</div>
{(
[
@ -528,7 +532,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
aria-label={t("topology.findNode")}
ref={findBoxRef}
>
<input
<Input
className="topo-toolbar__find"
value={canvasQuery}
onChange={(e) => onCanvasQueryChange(e.target.value)}
@ -571,20 +575,20 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
) : (
canvasHits.slice(0, 12).map((n, idx) => (
<button
<Button
key={n.id}
type="button"
role="option"
aria-selected={idx === findActiveIdx}
variant="ghost"
size="sm"
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
onMouseEnter={() => onFindActiveIdxChange(idx)}
onClick={() => onFindOnCanvas(n.id)}
onHoverStart={() => onFindActiveIdxChange(idx)}
onPress={() => onFindOnCanvas(n.id)}
>
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
<span className="topo-find-suggest__meta">
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(SEP)}
</span>
</button>
</Button>
))
)}
{canvasHits.length > 12 ? (

View file

@ -25,7 +25,7 @@ import type { ManagedNeFormState } from "../../managedNe/formState";
import { isWorldFlatViewName } from "../treeUtils";
import type { NeNodeData } from "../TopologyReactFlowView";
type FocusNode = (fabricNodeId: string, fit?: boolean) => void;
type FocusNode = (fabricNodeId: string, fit: boolean) => void;
type Args = {
mapId: string | null | undefined;

View file

@ -1,4 +1,6 @@
import type { DragEvent } from "react";
import { Button, Chip, Input, Modal } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n";
import type { PaletteItem, PaletteSource } from "../pageTypes";
@ -34,54 +36,37 @@ export function AddNePaletteDialog({
onAddSelected,
}: AddNePaletteDialogProps) {
const { t } = useI18n();
if (!open) return null;
return (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div
className="topo-modal__backdrop"
onClick={() => {
if (paletteAdding) return;
onClose();
}}
/>
<div className="topo-modal__panel">
<div className="topo-modal__head">
<strong>{t("topology.addNe")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("managed")}
<TopoModalShell open={open} onClose={onClose} dismissible={!paletteAdding} size="md">
<Modal.Header>
<Modal.Heading>{t("topology.addNe")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={paletteAdding} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.paletteHint")}</p>
<div className="topo-palette-source flex gap-2" role="tablist">
<Button
size="sm"
variant={paletteSource === "managed" ? "primary" : "secondary"}
isDisabled={paletteAdding}
className="flex-1"
onPress={() => onPaletteSourceChange("managed")}
>
{t("topology.paletteManaged")}
</button>
<button
type="button"
role="tab"
aria-selected={paletteSource === "ume"}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("ume")}
</Button>
<Button
size="sm"
variant={paletteSource === "ume" ? "primary" : "secondary"}
isDisabled={paletteAdding}
className="flex-1"
onPress={() => onPaletteSourceChange("ume")}
>
{t("topology.paletteUme")}
</button>
</Button>
</div>
<input
className="input"
<Input
className="w-full"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder={t("topology.filterPh")}
@ -109,9 +94,11 @@ export function AddNePaletteDialog({
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</span>
<Chip size="sm" variant="soft">
<Chip.Label>
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</Chip.Label>
</Chip>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
@ -168,27 +155,22 @@ export function AddNePaletteDialog({
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={() => void onAddSelected()}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</button>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" size="sm" isDisabled={paletteAdding} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button
variant="primary"
size="sm"
isDisabled={paletteSelectedKeys.length === 0 || paletteAdding}
onPress={() => void onAddSelected()}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -1,3 +1,5 @@
import { Button, Modal } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n";
export type CreateNeModeDialogProps = {
@ -15,34 +17,34 @@ export function CreateNeModeDialog({
onPickPlaceholder,
}: CreateNeModeDialogProps) {
const { t } = useI18n();
if (!open) return null;
return (
<div className="topo-modal" role="dialog" aria-modal="true" aria-labelledby="topo-create-ne-mode-title">
<div className="topo-modal__backdrop" onClick={onClose} />
<div className="topo-modal__panel" style={{ maxWidth: 440 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-mode-title">{t("topology.createNe")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
<TopoModalShell open={open} onClose={onClose} size="sm">
<Modal.Header>
<Modal.Heading>{t("topology.createNe")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.createNeModeHint")}</p>
<div className="flex flex-col gap-2.5">
<Button
variant="primary"
className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
onPress={onPickManaged}
>
<span className="font-semibold">{t("topology.createNeManaged")}</span>
<span className="text-sm font-normal opacity-90">{t("topology.createNeManagedHint")}</span>
</Button>
<Button
variant="secondary"
className="h-auto flex-col items-start gap-1 whitespace-normal px-4 py-3 text-left"
onPress={onPickPlaceholder}
>
<span className="font-semibold">{t("topology.createNePlaceholder")}</span>
<span className="text-sm font-normal text-muted">{t("topology.createNePlaceholderHint")}</span>
</Button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeModeHint")}</p>
<div className="topo-create-ne-mode" style={{ padding: "0 16px 16px", display: "grid", gap: 10 }}>
<button type="button" className="btn" onClick={onPickManaged}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNeManaged")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNeManagedHint")}
</span>
</button>
<button type="button" className="btn btn--ghost" onClick={onPickPlaceholder}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNePlaceholder")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNePlaceholderHint")}
</span>
</button>
</div>
</div>
</div>
</Modal.Body>
</TopoModalShell>
);
}

View file

@ -1,3 +1,5 @@
import { Button, Input, Label, Modal, TextField } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n";
export type NewRootDialogProps = {
@ -10,57 +12,43 @@ export type NewRootDialogProps = {
export function NewRootDialog({ dialog, pending, onNameChange, onClose, onSubmit }: NewRootDialogProps) {
const { t } = useI18n();
if (!dialog) return null;
const open = Boolean(dialog);
return (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-new-root-title"
>
<div className="topo-modal__backdrop" onClick={onClose} />
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">{t("topology.newRegionPrompt")}</span>
<input
autoFocus
value={dialog.name}
disabled={pending}
onChange={(e) => onNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={pending}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={pending || !String(dialog.name || "").trim()}
onClick={onSubmit}
>
{pending ? "…" : t("topology.newRegion")}
</button>
</div>
</div>
</div>
<TopoModalShell open={open} onClose={onClose} dismissible={!pending} size="sm">
<Modal.Header>
<Modal.Heading>{t("topology.newRegion")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={pending} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.folderHint")}</p>
<TextField
fullWidth
autoFocus
value={dialog?.name ?? ""}
onChange={onNameChange}
isDisabled={pending}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit();
}}
>
<Label>{t("topology.newRegionPrompt")}</Label>
<Input />
</TextField>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" size="sm" isDisabled={pending} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button
variant="primary"
size="sm"
isDisabled={pending || !String(dialog?.name || "").trim()}
onPress={onSubmit}
>
{pending ? "…" : t("topology.newRegion")}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -1,3 +1,5 @@
import { Button, Chip, Input, Modal } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import type { TopologyOutsidePeer } from "../../../types";
import { useI18n } from "../../../i18n";
@ -29,40 +31,20 @@ export function OutsidePeersDialog({
onAddOutsidePeers,
}: OutsidePeersDialogProps) {
const { t } = useI18n();
if (!open) return null;
return (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-label={t("topology.outsidePeersTitle")}
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (outsidePeersAdding) return;
onClose();
}}
/>
<div className="topo-modal__panel topo-modal__panel--wide">
<div className="topo-modal__head">
<strong>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
<input
className="input"
<TopoModalShell open={open} onClose={onClose} dismissible={!outsidePeersAdding} size="lg">
<Modal.Header>
<Modal.Heading>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</Modal.Heading>
<Modal.CloseTrigger isDisabled={outsidePeersAdding} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.outsidePeersHint")}</p>
<Input
className="w-full"
value={outsidePeerQuery}
onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
placeholder={t("topology.outsidePeersFilterPh")}
@ -95,12 +77,14 @@ export function OutsidePeersDialog({
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</span>
<Chip size="sm" variant="soft">
<Chip.Label>
{t("topology.selectedCount").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</Chip.Label>
</Chip>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
@ -157,30 +141,25 @@ export function OutsidePeersDialog({
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onClick={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
>
{outsidePeersAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</button>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" size="sm" isDisabled={outsidePeersAdding} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button
variant="primary"
size="sm"
isDisabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onPress={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
>
{outsidePeersAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -1,3 +1,5 @@
import { Button, Input, Label, Modal, TextField } from "@heroui/react";
import { TopoModalShell } from "../../../components/ui/TopoModalShell";
import { useI18n } from "../../../i18n";
export type PlaceholderCreateDialogState = {
@ -23,82 +25,56 @@ export function PlaceholderCreateDialog({
onSubmit,
}: PlaceholderCreateDialogProps) {
const { t } = useI18n();
if (!dialog) return null;
const open = Boolean(dialog);
return (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-create-ne-title"
<TopoModalShell
open={open}
onClose={onClose}
dismissible={!busy}
size="sm"
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (busy) return;
onClose();
}}
/>
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={busy}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">
{t("topology.createNeName")}
<span className="form-label__required" aria-hidden="true">
{" "}
*
</span>
</span>
<input
autoFocus
value={dialog.name}
placeholder={t("topology.createNeNamePh")}
disabled={busy}
onChange={(e) => onChange({ name: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
/>
</label>
<label>
<span className="form-label">{t("topology.createNeIp")}</span>
<input
value={dialog.ip_address}
placeholder={t("topology.createNeIpPh")}
disabled={busy}
onChange={(e) => onChange({ ip_address: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button type="button" className="btn btn--ghost" disabled={busy} onClick={onClose}>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--primary"
disabled={busy}
onClick={() => void onSubmit()}
>
{busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</button>
</div>
</div>
</div>
<Modal.Header>
<Modal.Heading>{t("topology.createNeTitle")}</Modal.Heading>
<Modal.CloseTrigger isDisabled={busy} />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="text-sm text-muted">{t("topology.createNeHint")}</p>
<TextField
fullWidth
autoFocus
isRequired
value={dialog?.name ?? ""}
onChange={(name) => onChange({ name })}
isDisabled={busy}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
>
<Label>{t("topology.createNeName")}</Label>
<Input placeholder={t("topology.createNeNamePh")} />
</TextField>
<TextField
fullWidth
value={dialog?.ip_address ?? ""}
onChange={(ip_address) => onChange({ ip_address })}
isDisabled={busy}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
>
<Label>{t("topology.createNeIp")}</Label>
<Input placeholder={t("topology.createNeIpPh")} />
</TextField>
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={busy} onPress={onClose}>
{t("topology.discoverClose")}
</Button>
<Button variant="primary" isDisabled={busy} onPress={() => void onSubmit()}>
{busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</Button>
</Modal.Footer>
</TopoModalShell>
);
}

View file

@ -1,7 +1,7 @@
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
export const TOPO_NODE_W = 80;
export const TOPO_NODE_H = 25;
export const TOPO_ICON = 25;
export const TOPO_NODE_W = 88;
export const TOPO_NODE_H = 28;
export const TOPO_ICON = 28;
/** region-building.png is 195×133 — keep aspect so icon center == handle. */
export const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195);
export const TOPO_HANDLE_X = TOPO_NODE_W / 2;