mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 20:13:15 +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,5 +1,7 @@
|
|||
import { Alert, Button, Chip, Modal } from "@heroui/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { connectTestManagedNe } from "../../services/api";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
import type { ManagedNeItem } from "../../types";
|
||||
|
|
@ -12,6 +14,13 @@ export type ManagedNeConnectDetailDialogProps = {
|
|||
onRetestSubmitted?: (rowId: string) => void;
|
||||
};
|
||||
|
||||
function chipColor(level: string): "success" | "danger" | "warning" | "default" {
|
||||
if (level === "up" || level === "pass") return "success";
|
||||
if (level === "down" || level === "fail") return "danger";
|
||||
if (level === "testing") return "warning";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function ManagedNeConnectDetailDialog({
|
||||
row,
|
||||
onClose,
|
||||
|
|
@ -25,47 +34,61 @@ export function ManagedNeConnectDetailDialog({
|
|||
},
|
||||
});
|
||||
|
||||
if (!row) return null;
|
||||
const open = Boolean(row);
|
||||
const level = row ? connectPillLevel(row.connect_status) : "unknown";
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("managedNe.connectDetailTitle")}</h3>
|
||||
<p className="form-hint">
|
||||
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
|
||||
{row.connect_tested_at
|
||||
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
|
||||
: ""}
|
||||
</p>
|
||||
<p>
|
||||
<span className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}>
|
||||
{row.connect_status}
|
||||
</span>
|
||||
{row.connect_message ? (
|
||||
<span className="connect-detail-summary"> — {row.connect_message}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<pre className="connect-log">
|
||||
{row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")}
|
||||
</pre>
|
||||
<div className="modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={connectMutation.isPending}
|
||||
onClick={() => connectMutation.mutate([row.id])}
|
||||
>
|
||||
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{connectMutation.isError ? (
|
||||
<p className="form-hint" role="alert">
|
||||
{String(connectMutation.error)}
|
||||
</p>
|
||||
<AppModalShell open={open} onClose={onClose} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("managedNe.connectDetailTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
{row ? (
|
||||
<>
|
||||
<p className="text-sm text-muted">
|
||||
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
|
||||
{row.connect_tested_at
|
||||
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
|
||||
: ""}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Chip size="sm" color={chipColor(level)} variant="soft">
|
||||
<Chip.Label>{row.connect_status}</Chip.Label>
|
||||
</Chip>
|
||||
{row.connect_message ? (
|
||||
<span className="connect-detail-summary text-sm">— {row.connect_message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="connect-log">
|
||||
{row.connect_detail?.trim() ||
|
||||
row.connect_message?.trim() ||
|
||||
t("managedNe.connectDetailEmpty")}
|
||||
</pre>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{connectMutation.isError ? (
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{String(connectMutation.error)}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={!row || connectMutation.isPending}
|
||||
onPress={() => {
|
||||
if (row) connectMutation.mutate([row.id]);
|
||||
}}
|
||||
>
|
||||
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onPress={onClose}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Alert, Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api";
|
||||
import { HopProxyFields } from "../../components/HopProxyFields";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { patchHopVendorChange } from "../../utils/hopProxy";
|
||||
|
|
@ -14,20 +17,6 @@ import {
|
|||
type ManagedNeFormState,
|
||||
} from "./formState";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export type ManagedNeFormDialogProps = {
|
||||
open: boolean;
|
||||
/** null = create mode */
|
||||
|
|
@ -55,8 +44,6 @@ export function ManagedNeFormDialog({
|
|||
enabled: open,
|
||||
});
|
||||
|
||||
// Reset only when the dialog opens or the edited row changes — avoid clobbering
|
||||
// in-progress edits if the parent re-renders with a new initialValues object identity.
|
||||
const editingId = editing?.id ?? "";
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -94,142 +81,148 @@ export function ManagedNeFormDialog({
|
|||
},
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => {
|
||||
if (saveMutation.isPending) return;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
|
||||
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
|
||||
<AppModalShell open={open} onClose={onClose} dismissible={!saveMutation.isPending} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>
|
||||
{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}
|
||||
</Modal.Heading>
|
||||
<Modal.CloseTrigger isDisabled={saveMutation.isPending} />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">{t("managedNe.form.requiredHint")}</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.name")}</FormLabel>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<TextField
|
||||
fullWidth
|
||||
value={form.name}
|
||||
onChange={(name) => setForm({ ...form, name })}
|
||||
>
|
||||
<Label>{t("managedNe.col.name")}</Label>
|
||||
<Input />
|
||||
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
|
||||
<select
|
||||
required
|
||||
value={form.vendor}
|
||||
onChange={(e) => {
|
||||
const vendor = e.target.value;
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, vendor };
|
||||
const dt = String(prev.device_type || "").trim().toLowerCase();
|
||||
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
|
||||
if (vendor === "ZTE") next.device_type = "zte_zxros";
|
||||
else if (vendor === "Huawei") next.device_type = "huawei";
|
||||
else if (vendor === "Cisco") next.device_type = "cisco_ios";
|
||||
else if (vendor === "Juniper") next.device_type = "juniper_junos";
|
||||
else if (vendor === "Nokia") next.device_type = "nokia_sros";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{vendors.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
|
||||
<select
|
||||
required
|
||||
value={form.device_type}
|
||||
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
|
||||
>
|
||||
{deviceTypes.map((dt) => (
|
||||
<option key={dt} value={dt}>
|
||||
{dt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.ip")}</FormLabel>
|
||||
<input
|
||||
required
|
||||
value={form.ip_address}
|
||||
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.port")}</FormLabel>
|
||||
<input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.protocol")}</FormLabel>
|
||||
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="telnet">telnet</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
|
||||
<input
|
||||
required
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>
|
||||
</TextField>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.vendor")}
|
||||
required
|
||||
value={form.vendor}
|
||||
onChange={(e) => {
|
||||
const vendor = e.target.value;
|
||||
setForm((prev) => {
|
||||
const next = { ...prev, vendor };
|
||||
const dt = String(prev.device_type || "").trim().toLowerCase();
|
||||
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
|
||||
if (vendor === "ZTE") next.device_type = "zte_zxros";
|
||||
else if (vendor === "Huawei") next.device_type = "huawei";
|
||||
else if (vendor === "Cisco") next.device_type = "cisco_ios";
|
||||
else if (vendor === "Juniper") next.device_type = "juniper_junos";
|
||||
else if (vendor === "Nokia") next.device_type = "nokia_sros";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{vendors.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.deviceType")}
|
||||
required
|
||||
value={form.device_type}
|
||||
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
|
||||
>
|
||||
{deviceTypes.map((dt) => (
|
||||
<option key={dt} value={dt}>
|
||||
{dt}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
value={form.ip_address}
|
||||
onChange={(ip_address) => setForm({ ...form, ip_address })}
|
||||
>
|
||||
<Label>{t("managedNe.col.ip")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
value={String(form.port)}
|
||||
onChange={(port) => setForm({ ...form, port: Number(port) || 22 })}
|
||||
>
|
||||
<Label>{t("managedNe.col.port")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<FieldSelect
|
||||
label={t("managedNe.col.protocol")}
|
||||
value={form.protocol}
|
||||
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
|
||||
>
|
||||
<option value="ssh">ssh</option>
|
||||
<option value="telnet">telnet</option>
|
||||
</FieldSelect>
|
||||
<TextField
|
||||
fullWidth
|
||||
isRequired
|
||||
value={form.username}
|
||||
onChange={(username) => setForm({ ...form, username })}
|
||||
>
|
||||
<Label>{t("managedNe.col.user")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(password) => setForm({ ...form, password })}
|
||||
>
|
||||
<Label>
|
||||
{t("managedNe.col.password")}
|
||||
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
|
||||
</FormLabel>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
|
||||
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
|
||||
</label>
|
||||
<label className="form-grid__full">
|
||||
<FormLabel>{t("managedNe.col.remark")}</FormLabel>
|
||||
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} />
|
||||
</label>
|
||||
</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField fullWidth value={form.tags} onChange={(tags) => setForm({ ...form, tags })}>
|
||||
<Label>{t("managedNe.col.tags")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
className="form-grid__full"
|
||||
value={form.remark}
|
||||
onChange={(remark) => setForm({ ...form, remark })}
|
||||
>
|
||||
<Label>{t("managedNe.col.remark")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
<fieldset className="form-fieldset form-grid__full">
|
||||
<legend>{t("managedNe.hop.sectionTitle")}</legend>
|
||||
<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
|
||||
? {
|
||||
...patchHopVendorChange(prev.hop_vendor, prev),
|
||||
...applyHopTemplate(prev, prev.hop_protocol, prev.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
|
||||
? {
|
||||
...patchHopVendorChange(prev.hop_vendor, prev),
|
||||
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Content>{t("managedNe.hop.enable")}</Checkbox.Content>
|
||||
</Checkbox>
|
||||
{form.hop_enabled ? (
|
||||
<HopProxyFields
|
||||
value={{
|
||||
|
|
@ -250,24 +243,26 @@ export function ManagedNeFormDialog({
|
|||
/>
|
||||
) : null}
|
||||
</fieldset>
|
||||
<div className="modal__actions">
|
||||
<button type="button" disabled={saveMutation.isPending} onClick={onClose}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||
</button>
|
||||
</div>
|
||||
{saveMutation.isError ? (
|
||||
<p className="form-hint" role="alert">
|
||||
{String(saveMutation.error)}
|
||||
</p>
|
||||
<Alert status="danger">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{String(saveMutation.error)}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" isDisabled={saveMutation.isPending} onPress={onClose}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={saveMutation.isPending}
|
||||
onPress={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue