mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +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";
|
||||
|
|
@ -59,8 +59,19 @@ export const queryKeys = {
|
|||
linkStatus: string,
|
||||
page: number,
|
||||
levelMajor?: string,
|
||||
pageSize = 10,
|
||||
) =>
|
||||
["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page, levelMajor || ""] as const,
|
||||
[
|
||||
"fabricNodeInventory",
|
||||
keyword,
|
||||
role,
|
||||
regionFolderId,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
levelMajor || "",
|
||||
pageSize,
|
||||
] as const,
|
||||
fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const,
|
||||
topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||
fabricSummary: ["fabricSummary"] as const,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type ToastState = { type: "ok" | "error"; text: string } | null;
|
||||
|
||||
|
|
@ -27,12 +36,22 @@ function useToastState(autoHideMs: number): ToastApi {
|
|||
return useMemo(() => ({ toast, showOk, showError, clear }), [toast, showOk, showError, clear]);
|
||||
}
|
||||
|
||||
function ToastViewport({ toast }: { toast: NonNullable<ToastState> }) {
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div className={`app-toast app-toast--${toast.type}`} role="status" aria-live="polite">
|
||||
{toast.text}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastProvider({ children, autoHideMs = 2600 }: { children: ReactNode; autoHideMs?: number }) {
|
||||
const api = useToastState(autoHideMs);
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
{api.toast ? <div className={`toast toast--${api.toast.type}`}>{api.toast.text}</div> : null}
|
||||
{api.toast ? <ToastViewport toast={api.toast} /> : null}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -562,7 +562,8 @@ const en = {
|
|||
loginFailed: "Login failed",
|
||||
logout: "Sign out",
|
||||
sessionsTitle: "Login sessions",
|
||||
sessionsHint: "Manage browser/device logins for this account. Revoked sessions must sign in again.",
|
||||
sessionsHint:
|
||||
"Manage browser/device logins for this account. Behind a reverse proxy, IP is taken from trusted forwarded headers; use the Client column to tell browsers apart on the same public IP. Revoked sessions must sign in again.",
|
||||
sessionsEmpty: "No active sessions.",
|
||||
revokeOtherSessions: "Revoke other sessions",
|
||||
revokeSession: "Revoke",
|
||||
|
|
@ -571,6 +572,7 @@ const en = {
|
|||
sessionsRevokedOthers: "Revoked {{count}} other session(s)",
|
||||
revokeCurrentConfirm: "This is your current session; revoking it requires signing in again. Continue?",
|
||||
colSession: "Session",
|
||||
colClient: "Client",
|
||||
colLastSeen: "Last seen",
|
||||
colCreated: "Created",
|
||||
usersTitle: "User management",
|
||||
|
|
@ -603,7 +605,9 @@ const en = {
|
|||
colIp: "IP",
|
||||
apiKeysTitle: "API Key management",
|
||||
apiKeysHelp:
|
||||
"The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools.",
|
||||
"The web UI uses login sessions, not API keys — keys are for MCP/scripts. Defaults on create: alarms:read + ne:read + ne:write + ne:exec; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved for future MCP tools. System-wide cap: {{max}} non-revoked keys (revoke to free a slot).",
|
||||
tokenQuota: "Active {{active}} / {{max}}",
|
||||
tokenLimitReached: "API key limit reached ({{max}}). Revoke unused keys first.",
|
||||
tokenName: "Name",
|
||||
expiresIn: "Expiry",
|
||||
expire7d: "7 days",
|
||||
|
|
@ -1511,6 +1515,7 @@ const en = {
|
|||
browserRegionsSub: "{{count}} item(s)",
|
||||
browserEmptyMaps: "No root map under this root yet.",
|
||||
addNe: "Add NE",
|
||||
more: "More",
|
||||
backUp: "Back",
|
||||
newRegion: "New root",
|
||||
newRegionName: "New root",
|
||||
|
|
|
|||
|
|
@ -558,7 +558,8 @@ const zh = {
|
|||
loginFailed: "登录失败",
|
||||
logout: "退出",
|
||||
sessionsTitle: "登录会话",
|
||||
sessionsHint: "管理当前账号在各浏览器/设备上的登录。踢掉会话后对方需重新登录。",
|
||||
sessionsHint:
|
||||
"管理当前账号在各浏览器/设备上的登录。IP 经反代时会取真实客户端地址;同公网出口可用「客户端」列区分浏览器/系统。踢掉会话后对方需重新登录。",
|
||||
sessionsEmpty: "当前没有活跃会话。",
|
||||
revokeOtherSessions: "踢掉其他会话",
|
||||
revokeSession: "踢掉",
|
||||
|
|
@ -567,6 +568,7 @@ const zh = {
|
|||
sessionsRevokedOthers: "已踢掉 {{count}} 个其他会话",
|
||||
revokeCurrentConfirm: "这是当前会话,踢掉后需要重新登录。继续?",
|
||||
colSession: "会话",
|
||||
colClient: "客户端",
|
||||
colLastSeen: "最近活动",
|
||||
colCreated: "创建时间",
|
||||
usersTitle: "用户管理",
|
||||
|
|
@ -599,7 +601,9 @@ const zh = {
|
|||
colIp: "IP",
|
||||
apiKeysTitle: "API Key 管理",
|
||||
apiKeysHelp:
|
||||
"网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。",
|
||||
"网页登录不用 API Key,Key 给 MCP/脚本用。生成时默认 alarms:read + ne:read + ne:write + ne:exec,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接 MCP 的项为预留。全站未吊销 Key 上限 {{max}} 把(吊销后可再建)。",
|
||||
tokenQuota: "未吊销 {{active}} / {{max}}",
|
||||
tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销不用的 Key",
|
||||
tokenName: "名称",
|
||||
expiresIn: "有效期",
|
||||
expire7d: "7 天",
|
||||
|
|
@ -1500,6 +1504,7 @@ const zh = {
|
|||
browserRegionsSub: "共 {{count}} 个",
|
||||
browserEmptyMaps: "此根下还没有根图。",
|
||||
addNe: "添加网元",
|
||||
more: "更多",
|
||||
backUp: "返回",
|
||||
newRegion: "新建根",
|
||||
newRegionName: "新根",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
import { Link, useLocation } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Chip } from "@heroui/react";
|
||||
import { AppsGridIcon } from "../components/AppsGridIcon";
|
||||
import { HeaderMenu } from "../components/HeaderMenu";
|
||||
import { getPageTitleKey, isWorkbenchPath } from "../config/modules";
|
||||
|
|
@ -48,15 +49,16 @@ export function AppLayout({ children }: Props) {
|
|||
{!onWorkbench ? (
|
||||
<>
|
||||
<span className="app-brand__sep" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="app-brand__apps"
|
||||
onClick={returnToWorkbench}
|
||||
title={t("workbench.backToWorkbench")}
|
||||
onPress={returnToWorkbench}
|
||||
aria-label={t("workbench.backToWorkbench")}
|
||||
>
|
||||
<AppsGridIcon />
|
||||
</button>
|
||||
</Button>
|
||||
<span className="app-brand__module-title">{pageTitle}</span>
|
||||
</>
|
||||
) : null}
|
||||
|
|
@ -64,24 +66,26 @@ export function AppLayout({ children }: Props) {
|
|||
<div className="app-brand__actions">
|
||||
{user ? (
|
||||
<div className="app-brand__actions-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`conn-pill conn-pill--on-brand conn-pill--tasks conn-pill--${activeTasks > 0 ? "up" : "unknown"}`}
|
||||
title={t("layout.activeTasksHint")}
|
||||
<Chip
|
||||
size="sm"
|
||||
color={activeTasks > 0 ? "success" : "default"}
|
||||
variant="soft"
|
||||
className="conn-pill conn-pill--on-brand conn-pill--tasks cursor-pointer"
|
||||
onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
|
||||
>
|
||||
{t("layout.activeTasks", { count: activeTasks })}
|
||||
</button>
|
||||
<span className="conn-pill conn-pill--on-brand conn-pill--user" title={user.role}>
|
||||
{user.username}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
<Chip.Label>{t("layout.activeTasks", { count: activeTasks })}</Chip.Label>
|
||||
</Chip>
|
||||
<Chip size="sm" variant="soft" className="conn-pill conn-pill--on-brand conn-pill--user" title={user.role}>
|
||||
<Chip.Label>{user.username}</Chip.Label>
|
||||
</Chip>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="header-menu__trigger header-menu__trigger--on-brand"
|
||||
onClick={() => void logout()}
|
||||
onPress={() => void logout()}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</Button>
|
||||
<HeaderMenu />
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Button, Checkbox, Input, Modal } from "@heroui/react";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { AppModalShell } from "../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../components/ui/FieldSelect";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "../services/api";
|
||||
|
|
@ -86,7 +89,8 @@ export function ApiTokensPage() {
|
|||
|
||||
const tokensQuery = useQuery({
|
||||
queryKey: ["apiTokens"],
|
||||
queryFn: () => apiGet<{ items: TokenRow[] }>("/v1/api-tokens"),
|
||||
queryFn: () =>
|
||||
apiGet<{ items: TokenRow[]; active_count?: number; max_count?: number }>("/v1/api-tokens"),
|
||||
enabled: ready,
|
||||
});
|
||||
|
||||
|
|
@ -98,6 +102,9 @@ export function ApiTokensPage() {
|
|||
|
||||
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
|
||||
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
|
||||
const tokenMax = Number(tokensQuery.data?.max_count ?? 20);
|
||||
const tokenActive = Number(tokensQuery.data?.active_count ?? items.filter((r) => !r.revoked).length);
|
||||
const atTokenLimit = tokenMax > 0 && tokenActive >= tokenMax;
|
||||
|
||||
const availableForCreate = useMemo(() => {
|
||||
if (ownerUserId) {
|
||||
|
|
@ -203,40 +210,51 @@ export function ApiTokensPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2 className="token-page__title">
|
||||
{t("auth.apiKeysTitle")}
|
||||
<span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp")}>
|
||||
<span className="help-q" tabIndex={0} aria-label={t("auth.apiKeysHelp", { max: tokenMax || 20 })}>
|
||||
?
|
||||
<span className="help-q__tip" role="tooltip">
|
||||
{t("auth.apiKeysHelp")}
|
||||
{t("auth.apiKeysHelp", { max: tokenMax || 20 })}
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
{tokenMax > 0 ? (
|
||||
<span className="muted" style={{ fontSize: 13 }}>
|
||||
{t("auth.tokenQuota", { active: tokenActive, max: tokenMax })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
{atTokenLimit ? (
|
||||
<p className="panel__hint panel__hint--live">{t("auth.tokenLimitReached", { max: tokenMax })}</p>
|
||||
) : null}
|
||||
<form className="token-create" onSubmit={onCreate}>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
<Input
|
||||
placeholder={t("auth.tokenName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
isDisabled={atTokenLimit}
|
||||
/>
|
||||
<select
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
<FieldSelect
|
||||
aria-label={t("auth.expiresIn")}
|
||||
value={String(expiresInDays)}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
disabled={atTokenLimit}
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
<FieldSelect
|
||||
aria-label={t("auth.tokenOwner")}
|
||||
value={ownerUserId}
|
||||
onChange={(e) => setOwnerUserId(e.target.value)}
|
||||
aria-label={t("auth.tokenOwner")}
|
||||
disabled={atTokenLimit}
|
||||
>
|
||||
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
|
||||
{users
|
||||
|
|
@ -246,11 +264,16 @@ export function ApiTokensPage() {
|
|||
{u.username} ({u.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
) : null}
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={createMut.isPending || atTokenLimit}
|
||||
>
|
||||
{t("auth.createToken")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
|
@ -260,12 +283,12 @@ export function ApiTokensPage() {
|
|||
{t("auth.tokenOnceHint")}
|
||||
</div>
|
||||
<code>{createdPlain}</code>
|
||||
<button type="button" onClick={() => void copyToken()}>
|
||||
<Button size="sm" variant="primary" onPress={() => void copyToken()}>
|
||||
{t("auth.copyToken")}
|
||||
</button>
|
||||
<button type="button" onClick={dismissCreatedPlain}>
|
||||
</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={dismissCreatedPlain}>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -318,24 +341,24 @@ export function ApiTokensPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn--ghost"
|
||||
disabled={row.revoked || row.expired}
|
||||
onClick={() => startEdit(row)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={row.revoked || row.expired}
|
||||
onPress={() => startEdit(row)}
|
||||
>
|
||||
{t("auth.editScopes")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={row.revoked || revokeMut.isPending}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={row.revoked || revokeMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("auth.revokeConfirm"))) revokeMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.revokeToken")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -347,59 +370,52 @@ export function ApiTokensPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{editingRow ? (
|
||||
<div
|
||||
className="token-scopes-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("auth.editScopes")}
|
||||
>
|
||||
<div className="token-scopes-modal__backdrop" onClick={() => setEditingRow(null)} />
|
||||
<div className="token-scopes-modal__panel">
|
||||
<div className="token-scopes-modal__head">
|
||||
<div>
|
||||
<h3>{t("auth.editScopes")}</h3>
|
||||
<p className="panel__hint" style={{ margin: "4px 0 0" }}>
|
||||
{editingRow.name}
|
||||
{editingRow.username ? ` · ${editingRow.username}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="token-scopes-modal__list">
|
||||
{ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
|
||||
<li key={scope}>
|
||||
<label className="token-scopes-modal__row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editScopes.includes(scope)}
|
||||
onChange={() => toggleEditScope(scope)}
|
||||
/>
|
||||
<span className="token-scopes-modal__label">{t(SCOPE_LABEL_KEYS[scope])}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{!editAvailable.length ? (
|
||||
<li className="muted">{t("auth.scopesNoneAvailable")}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
<div className="token-scopes-modal__foot">
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateMut.isPending || editScopes.length === 0}
|
||||
onClick={() => updateMut.mutate({ id: editingRow.id, scopes: editScopes })}
|
||||
>
|
||||
{t("auth.saveScopes")}
|
||||
</button>
|
||||
<button type="button" className="btn--ghost" onClick={() => setEditingRow(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<AppModalShell open={Boolean(editingRow)} onClose={() => setEditingRow(null)} size="md">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("auth.editScopes")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
{editingRow ? (
|
||||
<p className="text-sm text-muted">
|
||||
{editingRow.name}
|
||||
{editingRow.username ? ` · ${editingRow.username}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="token-scopes-modal__list">
|
||||
{ALL_SCOPE_KEYS.filter((s) => editAvailable.includes(s)).map((scope) => (
|
||||
<li key={scope}>
|
||||
<Checkbox
|
||||
isSelected={editScopes.includes(scope)}
|
||||
onChange={() => toggleEditScope(scope)}
|
||||
>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
<Checkbox.Content>{t(SCOPE_LABEL_KEYS[scope])}</Checkbox.Content>
|
||||
</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
{!editAvailable.length ? (
|
||||
<li className="muted">{t("auth.scopesNoneAvailable")}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={updateMut.isPending || editScopes.length === 0 || !editingRow}
|
||||
onPress={() => {
|
||||
if (editingRow) updateMut.mutate({ id: editingRow.id, scopes: editScopes });
|
||||
}}
|
||||
>
|
||||
{t("auth.saveScopes")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onPress={() => setEditingRow(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
|
@ -132,7 +133,7 @@ export function AuditPage() {
|
|||
queryFn: () => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(page));
|
||||
p.set("page_size", "50");
|
||||
p.set("page_size", "10");
|
||||
p.set("exclude_noise", excludeNoise ? "true" : "false");
|
||||
if (username.trim()) p.set("username", username.trim());
|
||||
if (effectiveAction) p.set("action", effectiveAction);
|
||||
|
|
@ -145,7 +146,7 @@ export function AuditPage() {
|
|||
|
||||
const items = useMemo(() => query.data?.items || [], [query.data]);
|
||||
const total = query.data?.total || 0;
|
||||
const pages = Math.max(1, Math.ceil(total / 50));
|
||||
const pages = Math.max(1, Math.ceil(total / 10));
|
||||
const hasFilters = Boolean(username.trim() || action.trim() || quick !== "business");
|
||||
|
||||
const setQuickFilter = (next: QuickFilter) => {
|
||||
|
|
@ -174,20 +175,20 @@ export function AuditPage() {
|
|||
["all", "audit.filterAll"],
|
||||
] as const
|
||||
).map(([value, labelKey]) => (
|
||||
<button
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
className={quick === value && !action.trim() ? "is-active" : undefined}
|
||||
onClick={() => setQuickFilter(value)}
|
||||
size="sm"
|
||||
variant={quick === value && !action.trim() ? "primary" : "secondary"}
|
||||
onPress={() => setQuickFilter(value)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="filter-inline">
|
||||
{isAdmin ? (
|
||||
<input
|
||||
<Input
|
||||
placeholder={t("auth.filterUsername")}
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
|
|
@ -196,7 +197,7 @@ export function AuditPage() {
|
|||
}}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
<Input
|
||||
placeholder={t("auth.filterAction")}
|
||||
value={action}
|
||||
onChange={(e) => {
|
||||
|
|
@ -205,13 +206,14 @@ export function AuditPage() {
|
|||
if (e.target.value.trim()) setQuick("all");
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
|
||||
<Button size="sm" variant="secondary" onPress={() => void query.refetch()} isDisabled={query.isFetching}>
|
||||
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasFilters}
|
||||
onPress={() => {
|
||||
setUsername("");
|
||||
setAction("");
|
||||
setQuick("business");
|
||||
|
|
@ -219,7 +221,7 @@ export function AuditPage() {
|
|||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -266,13 +268,14 @@ export function AuditPage() {
|
|||
</td>
|
||||
<td className="pt-list-num">{row.client_ip || t("common.empty")}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="linkish"
|
||||
onClick={() => setExpanded(open ? null : row.id)}
|
||||
onPress={() => setExpanded(open ? null : row.id)}
|
||||
>
|
||||
{open ? t("audit.hideDetail") : t("audit.showDetail")}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{open ? (
|
||||
|
|
@ -304,16 +307,22 @@ export function AuditPage() {
|
|||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={page <= 1}
|
||||
onPress={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={page >= pages}
|
||||
onPress={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../components/ListPager";
|
||||
import { AppModalShell } from "../components/ui/AppModalShell";
|
||||
import {
|
||||
createNeCollection,
|
||||
deleteCollectionJob,
|
||||
|
|
@ -29,8 +31,8 @@ import { pageCount } from "../utils/display";
|
|||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
const POLL_MS = 2000;
|
||||
const ELIGIBLE_PAGE_SIZE = 20;
|
||||
const POLICY_TARGET_PAGE_SIZE = 20;
|
||||
const ELIGIBLE_PAGE_SIZE = 10;
|
||||
const POLICY_TARGET_PAGE_SIZE = 10;
|
||||
const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
|
||||
const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
|
@ -71,7 +73,7 @@ export function CollectPage() {
|
|||
const [neKeyword, setNeKeyword] = useState("");
|
||||
const [nePage, setNePage] = useState(1);
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [jobPageSize, setJobPageSize] = useState(20);
|
||||
const [jobPageSize, setJobPageSize] = useState(10);
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [jobKeyword, setJobKeyword] = useState("");
|
||||
const [exportingJobs, setExportingJobs] = useState(false);
|
||||
|
|
@ -468,35 +470,42 @@ export function CollectPage() {
|
|||
) : null}
|
||||
</div>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingJobs || jobTotal === 0}
|
||||
onClick={() => void exportJobsCsv()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingJobs || jobTotal === 0}
|
||||
onPress={() => void exportJobsCsv()}
|
||||
>
|
||||
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={jobsQuery.isFetching || dashQuery.isFetching}
|
||||
onPress={refreshAll}
|
||||
>
|
||||
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={startFromPolicyMutation.isPending || jobActive}
|
||||
onClick={() => startFromPolicyMutation.mutate()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={startFromPolicyMutation.isPending || jobActive}
|
||||
onPress={() => startFromPolicyMutation.mutate()}
|
||||
>
|
||||
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setCreateOpen(true)}>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => setCreateOpen(true)}>
|
||||
{t("collect.create.expand")}
|
||||
</button>
|
||||
</Button>
|
||||
{running?.status === "running" || running?.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pauseMutation.mutate(running.id)}
|
||||
disabled={pauseMutation.isPending}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={pauseMutation.isPending}
|
||||
onPress={() => pauseMutation.mutate(running.id)}
|
||||
>
|
||||
{t("collect.jobs.pause")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -600,14 +609,13 @@ export function CollectPage() {
|
|||
<option value="selected">{t("collect.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMutation.isPending}
|
||||
onClick={() => savePolicyMutation.mutate()}
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={savePolicyMutation.isPending}
|
||||
onPress={() => savePolicyMutation.mutate()}
|
||||
>
|
||||
{t("collect.savePolicy")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--single" style={{ marginTop: 12 }}>
|
||||
|
|
@ -726,17 +734,18 @@ export function CollectPage() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasJobFilters}
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasJobFilters}
|
||||
onPress={() => {
|
||||
setJobKeyword("");
|
||||
setJobStatus("");
|
||||
setJobPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -795,324 +804,312 @@ export function CollectPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{createOpen ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setCreateOpen(false)}>
|
||||
<div
|
||||
className="modal modal--wide ops-detail-modal ops-detail-modal--xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("collect.create.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="ops-detail-modal__head">
|
||||
<div className="ops-detail-modal__title">
|
||||
<h3>{t("collect.create.title")}</h3>
|
||||
<p className="muted">{t("collect.create.hint")}</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button type="button" onClick={() => setCreateOpen(false)}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
<AppModalShell
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
dismissible={!createMutation.isPending}
|
||||
size="lg"
|
||||
className="ops-detail-modal"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("collect.create.title")}</Modal.Heading>
|
||||
<Modal.CloseTrigger isDisabled={createMutation.isPending} />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="muted">{t("collect.create.hint")}</p>
|
||||
<div className="collect-create-modal">
|
||||
<div className="form-grid form-grid--single">
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.jobTitle")}
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("collect.form.jobTitlePh")}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.commands")}
|
||||
<textarea
|
||||
className="collect-commands"
|
||||
rows={6}
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
|
||||
<div className="collect-selected-block">
|
||||
<div className="collect-selected-block__head">
|
||||
<h3>{t("collect.create.selectedTitle")}</h3>
|
||||
<span className="collect-meta">
|
||||
{t("collect.create.selectedCount", { count: selectedList.length })}
|
||||
</span>
|
||||
{selectedList.length > 0 ? (
|
||||
<Button size="sm" variant="ghost" className="link-btn" onPress={clearSelected}>
|
||||
{t("collect.create.clearSelected")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedList.length === 0 ? (
|
||||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||
) : (
|
||||
<div className="collect-selected-list">
|
||||
{selectedList.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<div key={key} className="collect-selected-chip">
|
||||
<span className="collect-selected-chip__main">
|
||||
<strong>{row.name || row.ip_address}</strong>
|
||||
<span className="collect-selected-chip__meta">
|
||||
{src}
|
||||
{row.ip_address ? ` · ${row.ip_address}` : ""}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" className="link-btn" onPress={() => removeNe(key)}>
|
||||
{t("collect.create.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--pad collect-create-modal">
|
||||
<div className="form-grid form-grid--single">
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.jobTitle")}
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("collect.form.jobTitlePh")}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.commands")}
|
||||
<textarea
|
||||
className="collect-commands"
|
||||
rows={6}
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
|
||||
<div className="collect-selected-block">
|
||||
<div className="collect-selected-block__head">
|
||||
<h3>{t("collect.create.selectedTitle")}</h3>
|
||||
<span className="collect-meta">
|
||||
{t("collect.create.selectedCount", { count: selectedList.length })}
|
||||
</span>
|
||||
{selectedList.length > 0 ? (
|
||||
<button type="button" className="link-btn" onClick={clearSelected}>
|
||||
{t("collect.create.clearSelected")}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="collect-pick-block">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h3>{t("collect.create.pickTitle")}</h3>
|
||||
<p className="panel__hint">{t("collect.create.pickHint")}</p>
|
||||
</div>
|
||||
{selectedList.length === 0 ? (
|
||||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||
) : (
|
||||
<div className="collect-selected-list">
|
||||
{selectedList.map((row) => {
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn"
|
||||
isDisabled={batchPickCount === 0}
|
||||
onPress={() => {
|
||||
const rows = eligibleItems.filter(
|
||||
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
|
||||
);
|
||||
addBatchNe(rows);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("collect.create.addBatch", { count: batchPickCount })}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={eligibleQuery.isFetching}
|
||||
onPress={() => void eligibleQuery.refetch()}
|
||||
>
|
||||
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-detail-modal__toolbar filter-inline">
|
||||
<Input
|
||||
type="search"
|
||||
value={neKeyword}
|
||||
placeholder={t("collect.create.filterKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setNeKeyword(e.target.value);
|
||||
setNePage(1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{eligibleQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPickSelected}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
|
||||
return;
|
||||
}
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
aria-label="pick all"
|
||||
/>
|
||||
</th>
|
||||
<th>{t("managedNe.col.source")}</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eligibleItems.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const picked = Boolean(selectedMap[key]);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<div key={key} className="collect-selected-chip">
|
||||
<span className="collect-selected-chip__main">
|
||||
<strong>{row.name || row.ip_address}</strong>
|
||||
<span className="collect-selected-chip__meta">
|
||||
{src}
|
||||
{row.ip_address ? ` · ${row.ip_address}` : ""}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="link-btn" onClick={() => removeNe(key)}>
|
||||
{t("collect.create.remove")}
|
||||
</button>
|
||||
</div>
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pickSelectedIds.includes(key)}
|
||||
disabled={picked}
|
||||
onChange={(e) => {
|
||||
if (picked) return;
|
||||
setPickSelectedIds((prev) =>
|
||||
e.target.checked
|
||||
? [...new Set([...prev, key])]
|
||||
: prev.filter((id) => id !== key),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className="table-tag">{src}</span>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>
|
||||
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
|
||||
</td>
|
||||
<td className="table-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn"
|
||||
isDisabled={picked}
|
||||
onPress={() => {
|
||||
addNe(row);
|
||||
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
|
||||
}}
|
||||
>
|
||||
{picked ? t("collect.create.added") : t("collect.create.add")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("collect.create.pickEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="collect-pick-block">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h3>{t("collect.create.pickTitle")}</h3>
|
||||
<p className="panel__hint">{t("collect.create.pickHint")}</p>
|
||||
</div>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={batchPickCount === 0}
|
||||
onClick={() => {
|
||||
const rows = eligibleItems.filter(
|
||||
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
|
||||
);
|
||||
addBatchNe(rows);
|
||||
{neTotal > 0 ? (
|
||||
<div className="ops-detail-modal__foot" style={{ marginTop: 10 }}>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(neTotal),
|
||||
page: String(nePage),
|
||||
pages: String(nePages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={nePage <= 1}
|
||||
onPress={() => {
|
||||
setNePage(nePage - 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("collect.create.addBatch", { count: batchPickCount })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => eligibleQuery.refetch()}
|
||||
disabled={eligibleQuery.isFetching}
|
||||
{t("common.prevPage")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={nePage >= nePages}
|
||||
onPress={() => {
|
||||
setNePage(nePage + 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
{t("common.nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-detail-modal__toolbar filter-inline">
|
||||
<input
|
||||
type="search"
|
||||
value={neKeyword}
|
||||
placeholder={t("collect.create.filterKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setNeKeyword(e.target.value);
|
||||
setNePage(1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{eligibleQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPickSelected}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
|
||||
return;
|
||||
}
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
aria-label="pick all"
|
||||
/>
|
||||
</th>
|
||||
<th>{t("managedNe.col.source")}</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eligibleItems.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const picked = Boolean(selectedMap[key]);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pickSelectedIds.includes(key)}
|
||||
disabled={picked}
|
||||
onChange={(e) => {
|
||||
if (picked) return;
|
||||
setPickSelectedIds((prev) =>
|
||||
e.target.checked
|
||||
? [...new Set([...prev, key])]
|
||||
: prev.filter((id) => id !== key),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className="table-tag">{src}</span>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>
|
||||
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
|
||||
</td>
|
||||
<td className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={picked}
|
||||
onClick={() => {
|
||||
addNe(row);
|
||||
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
|
||||
}}
|
||||
>
|
||||
{picked ? t("collect.create.added") : t("collect.create.add")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("collect.create.pickEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{neTotal > 0 ? (
|
||||
<div className="ops-detail-modal__foot" style={{ marginTop: 10 }}>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(neTotal),
|
||||
page: String(nePage),
|
||||
pages: String(nePages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={nePage <= 1}
|
||||
onClick={() => {
|
||||
setNePage(nePage - 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={nePage >= nePages}
|
||||
onClick={() => {
|
||||
setNePage(nePage + 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => setCreateOpen(false)}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{expandedJobId ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setExpandedJobId("")}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide ops-detail-modal ops-detail-modal--xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("collect.jobs.detailTitle")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<span className="muted">
|
||||
{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
|
||||
</span>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
isDisabled={createMutation.isPending}
|
||||
onPress={() => setCreateOpen(false)}
|
||||
>
|
||||
<div className="ops-detail-modal__head">
|
||||
<div className="ops-detail-modal__title">
|
||||
<h3>{t("collect.jobs.detailTitle")}</h3>
|
||||
<p className="muted">
|
||||
{(() => {
|
||||
const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
|
||||
return job
|
||||
? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
|
||||
: expandedJobId.slice(0, 8);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button type="button" onClick={() => setExpandedJobId("")}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
<JobRunsPanel
|
||||
jobId={expandedJobId}
|
||||
jobStatus={
|
||||
jobs.find((j) => j.id === expandedJobId)?.status ||
|
||||
detailQuery.data?.job.status ||
|
||||
""
|
||||
}
|
||||
failCount={
|
||||
jobs.find((j) => j.id === expandedJobId)?.fail_count ??
|
||||
detailQuery.data?.job.fail_count ??
|
||||
0
|
||||
}
|
||||
commands={detailQuery.data?.job.commands ?? ""}
|
||||
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
|
||||
retryPending={actionPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{t("networkConfigs.close")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
|
||||
onPress={() => createMutation.mutate()}
|
||||
>
|
||||
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
<AppModalShell
|
||||
open={Boolean(expandedJobId)}
|
||||
onClose={() => setExpandedJobId("")}
|
||||
size="lg"
|
||||
className="ops-detail-modal"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("collect.jobs.detailTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="muted">
|
||||
{(() => {
|
||||
const job = jobs.find((j) => j.id === expandedJobId) || detailQuery.data?.job;
|
||||
return job
|
||||
? `${job.title} · ${job.status} · ${job.success_count}/${job.ne_count}`
|
||||
: expandedJobId.slice(0, 8);
|
||||
})()}
|
||||
</p>
|
||||
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
<JobRunsPanel
|
||||
jobId={expandedJobId}
|
||||
jobStatus={
|
||||
jobs.find((j) => j.id === expandedJobId)?.status ||
|
||||
detailQuery.data?.job.status ||
|
||||
""
|
||||
}
|
||||
failCount={
|
||||
jobs.find((j) => j.id === expandedJobId)?.fail_count ??
|
||||
detailQuery.data?.job.fail_count ??
|
||||
0
|
||||
}
|
||||
commands={detailQuery.data?.job.commands ?? ""}
|
||||
onRetryFailed={() => retryFailedMutation.mutate(expandedJobId)}
|
||||
retryPending={actionPending}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={() => setExpandedJobId("")}>
|
||||
{t("networkConfigs.close")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1156,36 +1153,49 @@ function JobRow({
|
|||
<td>{formatSystemTime(job.created_at)}</td>
|
||||
<td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td>
|
||||
<td className="table-actions">
|
||||
<button type="button" className="link-btn" onClick={onOpenDetail}>
|
||||
<Button size="sm" variant="ghost" className="link-btn" onPress={onOpenDetail}>
|
||||
{t("collect.jobs.expand")}
|
||||
</button>
|
||||
</Button>
|
||||
{canPause ? (
|
||||
<button type="button" className="link-btn" disabled={actionPending} onClick={onPause}>
|
||||
<Button size="sm" variant="ghost" className="link-btn" isDisabled={actionPending} onPress={onPause}>
|
||||
{t("collect.jobs.pause")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{canStart ? (
|
||||
<button type="button" className="link-btn" disabled={actionPending} onClick={onStart}>
|
||||
<Button size="sm" variant="ghost" className="link-btn" isDisabled={actionPending} onPress={onStart}>
|
||||
{startPending ? t("collect.jobs.starting") : t("collect.jobs.start")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{canRetryFailed ? (
|
||||
<button type="button" className="link-btn" disabled={actionPending} onClick={onRetryFailed}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn"
|
||||
isDisabled={actionPending}
|
||||
onPress={onRetryFailed}
|
||||
>
|
||||
{t("collect.jobs.retryFailed")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn"
|
||||
disabled={actionPending || !hasResults}
|
||||
onClick={downloadResults}
|
||||
isDisabled={actionPending || !hasResults}
|
||||
onPress={downloadResults}
|
||||
>
|
||||
{t("collect.jobs.downloadResults")}
|
||||
</button>
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<button type="button" className="link-btn link-btn--danger" disabled={actionPending} onClick={onDelete}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
className="link-btn link-btn--danger"
|
||||
isDisabled={actionPending}
|
||||
onPress={onDelete}
|
||||
>
|
||||
{t("collect.jobs.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -1212,7 +1222,7 @@ function JobRunsPanel({
|
|||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [runPage, setRunPage] = useState(1);
|
||||
const [runPageSize, setRunPageSize] = useState(20);
|
||||
const [runPageSize, setRunPageSize] = useState(10);
|
||||
const [runStatus, setRunStatus] = useState("");
|
||||
const [runKeyword, setRunKeyword] = useState("");
|
||||
const [exportingRuns, setExportingRuns] = useState(false);
|
||||
|
|
@ -1322,17 +1332,22 @@ function JobRunsPanel({
|
|||
))}
|
||||
</select>
|
||||
{hasRunFilters ? (
|
||||
<button type="button" onClick={clearRunFilters}>
|
||||
<Button size="sm" variant="tertiary" onPress={clearRunFilters}>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
<button type="button" disabled={exportingRuns || runTotal === 0} onClick={() => void exportRunsCsv()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingRuns || runTotal === 0}
|
||||
onPress={() => void exportRunsCsv()}
|
||||
>
|
||||
{exportingRuns ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</Button>
|
||||
{jobStatus !== "running" && failCount > 0 ? (
|
||||
<button type="button" disabled={retryPending} onClick={onRetryFailed}>
|
||||
<Button size="sm" variant="secondary" isDisabled={retryPending} onPress={onRetryFailed}>
|
||||
{t("collect.jobs.retryFailed")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{runsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../components/ListPager";
|
||||
import { AppModalShell } from "../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../hooks/useDebouncedValue";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
|
|
@ -24,7 +27,7 @@ import { pageCount } from "../utils/display";
|
|||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
const POLL_MS = 2500;
|
||||
const TARGET_PAGE_SIZE = 20;
|
||||
const TARGET_PAGE_SIZE = 10;
|
||||
|
||||
export function ConfigSyncPage() {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -33,13 +36,13 @@ export function ConfigSyncPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [cyclePage, setCyclePage] = useState(1);
|
||||
const [cyclePageSize, setCyclePageSize] = useState(20);
|
||||
const [cyclePageSize, setCyclePageSize] = useState(10);
|
||||
const [cycleStatus, setCycleStatus] = useState("");
|
||||
const [cycleKeyword, setCycleKeyword] = useState("");
|
||||
const [exportingCycles, setExportingCycles] = useState(false);
|
||||
const [expandedCycleId, setExpandedCycleId] = useState("");
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
const [taskPageSize, setTaskPageSize] = useState(20);
|
||||
const [taskPageSize, setTaskPageSize] = useState(10);
|
||||
const [taskStatus, setTaskStatus] = useState("");
|
||||
const [taskKeyword, setTaskKeyword] = useState("");
|
||||
const [exportingTasks, setExportingTasks] = useState(false);
|
||||
|
|
@ -354,44 +357,61 @@ export function ConfigSyncPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("configSync.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={dashQuery.isFetching}
|
||||
onPress={() => void refresh()}
|
||||
>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={Boolean(running) || startMut.isPending}
|
||||
onClick={() => startMut.mutate("full")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={Boolean(running) || startMut.isPending}
|
||||
onPress={() => startMut.mutate("full")}
|
||||
>
|
||||
{t("configSync.syncNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)}
|
||||
onClick={() => startMut.mutate("retry_failed")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)}
|
||||
onPress={() => startMut.mutate("retry_failed")}
|
||||
>
|
||||
{t("configSync.retryFailed")}
|
||||
</button>
|
||||
</Button>
|
||||
{running?.status === "running" || running?.status === "pending" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={pauseMut.isPending}
|
||||
onPress={() => pauseMut.mutate(running.id)}
|
||||
>
|
||||
{t("configSync.pause")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{running?.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(running.id)} disabled={resumeMut.isPending}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={resumeMut.isPending}
|
||||
onPress={() => resumeMut.mutate(running.id)}
|
||||
>
|
||||
{t("configSync.resume")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={stopMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(running.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
>
|
||||
{t("configSync.stop")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -436,41 +456,41 @@ export function ConfigSyncPage() {
|
|||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("configSync.intervalDays")}</span>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={intervalDays}
|
||||
value={String(intervalDays)}
|
||||
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("configSync.concurrency")}</span>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={concurrency}
|
||||
value={String(concurrency)}
|
||||
onChange={(e) => setConcurrency(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("configSync.historyKeep")}</span>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={historyKeep}
|
||||
value={String(historyKeep)}
|
||||
onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("configSync.cycleKeep")}</span>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={200}
|
||||
value={cycleKeep}
|
||||
value={String(cycleKeep)}
|
||||
onChange={(e) => setCycleKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -484,14 +504,13 @@ export function ConfigSyncPage() {
|
|||
<option value="selected">{t("configSync.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMut.isPending}
|
||||
onClick={() => savePolicyMut.mutate()}
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={savePolicyMut.isPending}
|
||||
onPress={() => savePolicyMut.mutate()}
|
||||
>
|
||||
{t("configSync.savePolicy")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scopeMode === "selected" ? (
|
||||
|
|
@ -540,9 +559,14 @@ export function ConfigSyncPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={targetPage <= 1}
|
||||
onPress={() => setTargetPage((p) => p - 1)}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(targetsQuery.data?.total ?? 0),
|
||||
|
|
@ -550,13 +574,14 @@ export function ConfigSyncPage() {
|
|||
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
|
||||
onClick={() => setTargetPage((p) => p + 1)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
|
||||
onPress={() => setTargetPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -565,7 +590,7 @@ export function ConfigSyncPage() {
|
|||
<h3>{t("configSync.cyclesTitle")}</h3>
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
<Input
|
||||
value={cycleKeyword}
|
||||
placeholder={t("configSync.keywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -573,7 +598,7 @@ export function ConfigSyncPage() {
|
|||
setCyclePage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={cycleStatus}
|
||||
onChange={(e) => {
|
||||
setCycleStatus(e.target.value);
|
||||
|
|
@ -588,25 +613,27 @@ export function ConfigSyncPage() {
|
|||
<option value="completed">completed</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="stopped">stopped</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasCycleFilters}
|
||||
onClick={() => {
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasCycleFilters}
|
||||
onPress={() => {
|
||||
setCycleKeyword("");
|
||||
setCycleStatus("");
|
||||
setCyclePage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingCycles || cycleTotal === 0}
|
||||
onClick={() => void exportCyclesCsv()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingCycles || cycleTotal === 0}
|
||||
onPress={() => void exportCyclesCsv()}
|
||||
>
|
||||
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -630,13 +657,9 @@ export function ConfigSyncPage() {
|
|||
{cycles.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => openCycleTasks(c.id)}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onPress={() => openCycleTasks(c.id)}>
|
||||
{t("configSync.expand")}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
|
||||
<td>{c.trigger_mode}</td>
|
||||
|
|
@ -665,25 +688,36 @@ export function ConfigSyncPage() {
|
|||
<td>
|
||||
<div className="btn-row">
|
||||
{c.status === "running" || c.status === "pending" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={pauseMut.isPending}
|
||||
onPress={() => pauseMut.mutate(c.id)}
|
||||
>
|
||||
{t("configSync.pause")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{c.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={resumeMut.isPending}
|
||||
onPress={() => resumeMut.mutate(c.id)}
|
||||
>
|
||||
{t("configSync.resume")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={stopMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
>
|
||||
{t("configSync.stop")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -704,7 +738,7 @@ export function ConfigSyncPage() {
|
|||
pages={cyclePages}
|
||||
total={cycleTotal}
|
||||
pageSize={cyclePageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={setCyclePage}
|
||||
onPageSizeChange={(size) => {
|
||||
setCyclePageSize(size);
|
||||
|
|
@ -714,130 +748,117 @@ export function ConfigSyncPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{expandedCycleId ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setExpandedCycleId("")}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide ops-detail-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("configSync.tasksTitle")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="ops-detail-modal__head">
|
||||
<div className="ops-detail-modal__title">
|
||||
<h3>{t("configSync.tasksTitle")}</h3>
|
||||
<p className="muted">
|
||||
{expandedCycleId.slice(0, 8)}
|
||||
{(() => {
|
||||
const c = cycles.find((x) => x.id === expandedCycleId);
|
||||
return c
|
||||
? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
|
||||
: "";
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingTasks || taskTotal === 0}
|
||||
onClick={() => void exportTasksCsv()}
|
||||
>
|
||||
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setExpandedCycleId("")}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-detail-modal__toolbar filter-inline">
|
||||
<input
|
||||
value={taskKeyword}
|
||||
placeholder={t("configSync.taskKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setTaskKeyword(e.target.value);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={taskStatus}
|
||||
onChange={(e) => {
|
||||
setTaskStatus(e.target.value);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("configSync.statusAll")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
<option value="fail">fail</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{tasksQuery.isError ? (
|
||||
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="ops-detail-modal__scroll">
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("configSync.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("configSync.col.vendor")}</th>
|
||||
<th>{t("configSync.col.source")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.message")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(tasksQuery.data?.items ?? []).map((task) => (
|
||||
<tr key={task.id}>
|
||||
<td>{task.ne_name || task.target_id}</td>
|
||||
<td>{task.ne_ip}</td>
|
||||
<td>{task.vendor || "-"}</td>
|
||||
<td>{task.source}</td>
|
||||
<td>{task.status}</td>
|
||||
<td title={task.message}>{task.message || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<ListPager
|
||||
page={taskPage}
|
||||
pages={taskPages}
|
||||
total={taskTotal}
|
||||
pageSize={taskPageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
onPageChange={setTaskPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setTaskPageSize(size);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
disabled={tasksQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<AppModalShell
|
||||
open={Boolean(expandedCycleId)}
|
||||
onClose={() => setExpandedCycleId("")}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("configSync.tasksTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">
|
||||
{expandedCycleId.slice(0, 8)}
|
||||
{(() => {
|
||||
const c = cycles.find((x) => x.id === expandedCycleId);
|
||||
return c
|
||||
? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}`
|
||||
: "";
|
||||
})()}
|
||||
</p>
|
||||
<div className="filter-inline">
|
||||
<Input
|
||||
value={taskKeyword}
|
||||
placeholder={t("configSync.taskKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setTaskKeyword(e.target.value);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
/>
|
||||
<FieldSelect
|
||||
value={taskStatus}
|
||||
onChange={(e) => {
|
||||
setTaskStatus(e.target.value);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("configSync.statusAll")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
<option value="fail">fail</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingTasks || taskTotal === 0}
|
||||
onPress={() => void exportTasksCsv()}
|
||||
>
|
||||
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{tasksQuery.isError ? (
|
||||
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("configSync.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("configSync.col.vendor")}</th>
|
||||
<th>{t("configSync.col.source")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.message")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(tasksQuery.data?.items ?? []).map((task) => (
|
||||
<tr key={task.id}>
|
||||
<td>{task.ne_name || task.target_id}</td>
|
||||
<td>{task.ne_ip}</td>
|
||||
<td>{task.vendor || "-"}</td>
|
||||
<td>{task.source}</td>
|
||||
<td>{task.status}</td>
|
||||
<td title={task.message}>{task.message || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ListPager
|
||||
page={taskPage}
|
||||
pages={taskPages}
|
||||
total={taskTotal}
|
||||
pageSize={taskPageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100, 200]}
|
||||
onPageChange={setTaskPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setTaskPageSize(size);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
disabled={tasksQuery.isLoading}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={() => setExpandedCycleId("")}>
|
||||
{t("networkConfigs.close")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Alert, Button, Card, Form, Input, Label, TextField } from "@heroui/react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -44,72 +45,79 @@ export function ForceChangePasswordPage() {
|
|||
|
||||
return (
|
||||
<LoginShell>
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__head">
|
||||
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
|
||||
<Card className="login-card login-card--heroui" variant="transparent">
|
||||
<Card.Header className="login-card__head">
|
||||
<Card.Title className="login-card__title">{t("auth.forceChangeTitle")}</Card.Title>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
</div>
|
||||
</div>
|
||||
<p className="login-card__hint">
|
||||
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
|
||||
</p>
|
||||
<label className="login-card__label">
|
||||
<span className="login-card__sr">{t("auth.oldPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
placeholder={t("auth.oldPassword")}
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
<span className="login-card__sr">{t("auth.newPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.newPassword")}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
<span className="login-card__sr">{t("auth.confirmPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t("auth.confirmPassword")}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="login-card__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button type="submit" className="login-card__submit" disabled={busy}>
|
||||
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__submit login-card__submit--ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</form>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p className="login-card__hint">
|
||||
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
|
||||
</p>
|
||||
<Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={oldPassword}
|
||||
onChange={setOldPassword}
|
||||
isDisabled={busy}
|
||||
isRequired
|
||||
>
|
||||
<Label className="login-card__sr">{t("auth.oldPassword")}</Label>
|
||||
<Input placeholder={t("auth.oldPassword")} />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
isDisabled={busy}
|
||||
isRequired
|
||||
minLength={8}
|
||||
>
|
||||
<Label className="login-card__sr">{t("auth.newPassword")}</Label>
|
||||
<Input placeholder={t("auth.newPassword")} />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
isDisabled={busy}
|
||||
isRequired
|
||||
minLength={8}
|
||||
>
|
||||
<Label className="login-card__sr">{t("auth.confirmPassword")}</Label>
|
||||
<Input placeholder={t("auth.confirmPassword")} />
|
||||
</TextField>
|
||||
{error ? (
|
||||
<Alert status="danger" className="login-card__error">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" variant="primary" fullWidth isDisabled={busy}>
|
||||
{busy ? t("auth.savingPassword") : t("auth.savePassword")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
isDisabled={busy}
|
||||
onPress={() => void logout()}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Alert, Button, Card, Form, Input, InputGroup, Label, TextField } from "@heroui/react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
|
@ -34,75 +35,96 @@ export function LoginPage() {
|
|||
|
||||
return (
|
||||
<LoginShell>
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__head">
|
||||
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
|
||||
<Card className="login-card login-card--heroui" variant="transparent">
|
||||
<Card.Header className="login-card__head">
|
||||
<Card.Title className="login-card__title">{t("auth.loginTitle")}</Card.Title>
|
||||
<div className="login-card__brand" aria-label="NETX">
|
||||
NETX
|
||||
</div>
|
||||
</div>
|
||||
<label className="login-card__label">
|
||||
<span className="login-card__sr">{t("auth.username")}</span>
|
||||
<input
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-card__label">
|
||||
<span className="login-card__sr">{t("auth.password")}</span>
|
||||
<span className="login-card__password">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy || !ready}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-card__eye"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Form className="login-card__form" onSubmit={(e) => void onSubmit(e)}>
|
||||
<TextField
|
||||
fullWidth
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
isDisabled={busy || !ready}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 3l18 18M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5M9.9 5.1A10 10 0 0 1 12 4.8c5.5 0 9.2 5.3 10.2 6.7a1.2 1.2 0 0 1 0 1.4c-.4.6-1.3 1.8-2.7 3M6.1 6.1C4.2 7.5 2.9 9.3 2 11.1a1.2 1.2 0 0 0 0 1.4C3 13.9 6.7 19.2 12 19.2c1.4 0 2.7-.3 3.9-.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12.2C3 10.5 6.7 4.8 12 4.8s9 5.7 10 7.4a1.2 1.2 0 0 1 0 1.2C21 15.1 17.3 20.8 12 20.8S3 15.1 2 13.4a1.2 1.2 0 0 1 0-1.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="login-card__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button type="submit" className="login-card__submit" disabled={busy || !ready || !password}>
|
||||
{busy ? t("auth.loggingIn") : t("auth.login")}
|
||||
</button>
|
||||
</form>
|
||||
<Label className="login-card__sr">{t("auth.username")}</Label>
|
||||
<Input placeholder={t("auth.username")} />
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
isDisabled={busy || !ready}
|
||||
autoComplete="current-password"
|
||||
>
|
||||
<Label className="login-card__sr">{t("auth.password")}</Label>
|
||||
<InputGroup fullWidth>
|
||||
<InputGroup.Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t("auth.password")}
|
||||
/>
|
||||
<InputGroup.Suffix>
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
onPress={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M3 3l18 18M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5M9.9 5.1A10 10 0 0 1 12 4.8c5.5 0 9.2 5.3 10.2 6.7a1.2 1.2 0 0 1 0 1.4c-.4.6-1.3 1.8-2.7 3M6.1 6.1C4.2 7.5 2.9 9.3 2 11.1a1.2 1.2 0 0 0 0 1.4C3 13.9 6.7 19.2 12 19.2c1.4 0 2.7-.3 3.9-.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12.2C3 10.5 6.7 4.8 12 4.8s9 5.7 10 7.4a1.2 1.2 0 0 1 0 1.2C21 15.1 17.3 20.8 12 20.8S3 15.1 2 13.4a1.2 1.2 0 0 1 0-1.2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</InputGroup.Suffix>
|
||||
</InputGroup>
|
||||
</TextField>
|
||||
|
||||
{error ? (
|
||||
<Alert status="danger" className="login-card__error">
|
||||
<Alert.Content>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
className="login-card__submit"
|
||||
isDisabled={busy || !ready || !password}
|
||||
>
|
||||
{busy ? t("auth.loggingIn") : t("auth.login")}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react";
|
||||
import {
|
||||
batchApplyAccountManagedNe,
|
||||
batchApplyHopManagedNe,
|
||||
|
|
@ -20,6 +21,8 @@ import {
|
|||
} from "../services/api";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } 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 { useToast } from "../hooks/useToast";
|
||||
|
|
@ -51,20 +54,6 @@ const emptyAccount = (): AccountState => ({
|
|||
password: "",
|
||||
});
|
||||
|
||||
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 function NePage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
|
|
@ -79,7 +68,7 @@ export function NePage() {
|
|||
const [vendorFilter, setVendorFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [batchHopOpen, setBatchHopOpen] = useState(false);
|
||||
|
|
@ -438,38 +427,41 @@ export function NePage() {
|
|||
<h2>{t("managedNe.stats.title")}</h2>
|
||||
<div className="panel__toolbar-end">
|
||||
<div className="panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkByTagMutation.isPending}
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={bulkByTagMutation.isPending}
|
||||
onPress={() => {
|
||||
setBulkTagAction("proxy");
|
||||
setBulkHop(emptyHopProxyFields());
|
||||
setBulkTagModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("managedNe.stats.batchProxy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkByTagMutation.isPending}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={bulkByTagMutation.isPending}
|
||||
onPress={() => {
|
||||
setBulkTagAction("account");
|
||||
setBulkAccount(emptyAccount());
|
||||
setBulkTagModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("managedNe.account.batchByTag")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkByTagMutation.isPending}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={bulkByTagMutation.isPending}
|
||||
onPress={() => {
|
||||
setBulkTagAction("test");
|
||||
setBulkTagModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("managedNe.stats.batchTest")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -499,50 +491,53 @@ export function NePage() {
|
|||
<h2>{t("managedNe.title")}</h2>
|
||||
<div className="panel__toolbar-end">
|
||||
<div className="panel__actions">
|
||||
<button type="button" onClick={openCreate} disabled={!credsOk || !canWriteNe}>
|
||||
<Button size="sm" variant="primary" onPress={openCreate} isDisabled={!credsOk || !canWriteNe}>
|
||||
{t("managedNe.add")}
|
||||
</button>
|
||||
</Button>
|
||||
{SHOW_UME_MANAGED_SYNC ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => umeSyncMutation.mutate()}
|
||||
disabled={umeSyncMutation.isPending}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => umeSyncMutation.mutate()}
|
||||
isDisabled={umeSyncMutation.isPending}
|
||||
>
|
||||
{umeSyncMutation.isPending
|
||||
? t("managedNe.umeSync.syncing")
|
||||
: t("managedNe.umeSync.sync")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onPress={() => {
|
||||
if (!window.confirm(t("managedNe.umeSync.deleteConfirm"))) return;
|
||||
umeDeleteMutation.mutate();
|
||||
}}
|
||||
disabled={umeDeleteMutation.isPending}
|
||||
isDisabled={umeDeleteMutation.isPending}
|
||||
>
|
||||
{umeDeleteMutation.isPending
|
||||
? t("managedNe.umeSync.deleting")
|
||||
: t("managedNe.umeSync.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
void downloadManagedNeImportTemplate("xlsx").catch((err) => showError(String(err)));
|
||||
}}
|
||||
>
|
||||
{t("managedNe.downloadTemplate")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importRef.current?.click()}
|
||||
disabled={!credsOk || importMutation.isPending || !canWriteNe}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => importRef.current?.click()}
|
||||
isDisabled={!credsOk || importMutation.isPending || !canWriteNe}
|
||||
>
|
||||
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
|
||||
</button>
|
||||
</Button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
|
|
@ -554,17 +549,19 @@ export function NePage() {
|
|||
if (file) importMutation.mutate(file);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0 || connectMutation.isPending}
|
||||
onClick={() => connectMutation.mutate(selected)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={selected.length === 0 || connectMutation.isPending}
|
||||
onPress={() => connectMutation.mutate(selected)}
|
||||
>
|
||||
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0 || batchHopMutation.isPending || !canWriteNe}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={selected.length === 0 || batchHopMutation.isPending || !canWriteNe}
|
||||
onPress={() => {
|
||||
if (selected.length === 0) {
|
||||
showError(t("managedNe.hop.selectRequired"));
|
||||
return;
|
||||
|
|
@ -574,11 +571,12 @@ export function NePage() {
|
|||
}}
|
||||
>
|
||||
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.batchAdd")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0 || batchAccountMutation.isPending || !canWriteNe}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={selected.length === 0 || batchAccountMutation.isPending || !canWriteNe}
|
||||
onPress={() => {
|
||||
if (selected.length === 0) {
|
||||
showError(t("managedNe.account.selectRequired"));
|
||||
return;
|
||||
|
|
@ -588,12 +586,12 @@ export function NePage() {
|
|||
}}
|
||||
>
|
||||
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.batchAdd")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
disabled={selected.length === 0 || batchDeleteMutation.isPending || !canWriteNe}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={selected.length === 0 || batchDeleteMutation.isPending || !canWriteNe}
|
||||
onPress={() => {
|
||||
if (selected.length === 0) {
|
||||
showError(t("managedNe.batchDeleteSelectRequired"));
|
||||
return;
|
||||
|
|
@ -603,10 +601,10 @@ export function NePage() {
|
|||
}}
|
||||
>
|
||||
{batchDeleteMutation.isPending ? t("managedNe.batchDeleting") : t("managedNe.batchDelete")}
|
||||
</button>
|
||||
<button type="button" onClick={() => invalidateList()}>
|
||||
</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => invalidateList()}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<HelpHint text={t("managedNe.help")} ariaLabel={t("common.help")} align="end" />
|
||||
</div>
|
||||
|
|
@ -614,7 +612,7 @@ export function NePage() {
|
|||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("managedNe.keywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -622,7 +620,7 @@ export function NePage() {
|
|||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={vendorFilter}
|
||||
onChange={(e) => {
|
||||
setVendorFilter(e.target.value);
|
||||
|
|
@ -635,8 +633,8 @@ export function NePage() {
|
|||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
|
|
@ -648,11 +646,12 @@ export function NePage() {
|
|||
<option value="testing">testing</option>
|
||||
<option value="pass">pass</option>
|
||||
<option value="fail">fail</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => {
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasFilters}
|
||||
onPress={() => {
|
||||
setKeyword("");
|
||||
setVendorFilter("");
|
||||
setStatusFilter("");
|
||||
|
|
@ -660,7 +659,7 @@ export function NePage() {
|
|||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -675,12 +674,15 @@ export function NePage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
<Checkbox
|
||||
isSelected={allSelected}
|
||||
onChange={toggleSelectAll}
|
||||
aria-label="select all"
|
||||
/>
|
||||
>
|
||||
<Checkbox.Control>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox.Control>
|
||||
</Checkbox>
|
||||
</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
|
|
@ -753,38 +755,44 @@ export function NePage() {
|
|||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
|
||||
})
|
||||
}
|
||||
title={t("managedNe.openTerminal")}
|
||||
>
|
||||
{t("managedNe.openTerminal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConnectDetailRow(row)}
|
||||
disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
|
||||
onPress={() => setConnectDetailRow(row)}
|
||||
>
|
||||
{t("managedNe.connectDetail")}
|
||||
</button>
|
||||
<button type="button" onClick={() => openEdit(row)} disabled={!canWriteNe}>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={!canWriteNe}
|
||||
onPress={() => openEdit(row)}
|
||||
>
|
||||
{t("managedNe.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={!canWriteNe}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={!canWriteNe}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("managedNe.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -804,23 +812,34 @@ export function NePage() {
|
|||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage(Math.max(1, page - 1))}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={page <= 1}
|
||||
onPress={() => setPage(Math.max(1, page - 1))}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button type="button" disabled={page >= pages} onClick={() => setPage(page + 1)}>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={page >= pages}
|
||||
onPress={() => setPage(page + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
<select
|
||||
</Button>
|
||||
<FieldSelect
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value) || 50);
|
||||
setPageSize(Number(e.target.value) || 10);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="10">{perPage(10)}</option>
|
||||
<option value="20">{perPage(20)}</option>
|
||||
<option value="50">{perPage(50)}</option>
|
||||
<option value="100">{perPage(100)}</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -845,185 +864,213 @@ export function NePage() {
|
|||
}}
|
||||
/>
|
||||
|
||||
{batchHopOpen ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setBatchHopOpen(false)}>
|
||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("managedNe.hop.batchTitle")}</h3>
|
||||
<p className="form-hint">{t("managedNe.hop.batchHint", { n: selected.length })}</p>
|
||||
<HopProxyFields value={batchHop} onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))} />
|
||||
<div className="modal__actions">
|
||||
<button type="button" onClick={() => setBatchHopOpen(false)}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchHopMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!batchHop.hop_host.trim()) {
|
||||
showError(t("managedNe.hop.hostRequired"));
|
||||
return;
|
||||
}
|
||||
if (!batchHop.hop_username.trim()) {
|
||||
showError(t("managedNe.hop.userRequired"));
|
||||
return;
|
||||
}
|
||||
if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
|
||||
showError(t("managedNe.hop.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
batchHopMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<AppModalShell
|
||||
open={batchHopOpen}
|
||||
onClose={() => setBatchHopOpen(false)}
|
||||
dismissible={!batchHopMutation.isPending}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("managedNe.hop.batchTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger isDisabled={batchHopMutation.isPending} />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: selected.length })}</p>
|
||||
<HopProxyFields value={batchHop} onChange={(patch) => setBatchHop((prev) => ({ ...prev, ...patch }))} />
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={() => setBatchHopOpen(false)}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={batchHopMutation.isPending}
|
||||
onPress={() => {
|
||||
if (!batchHop.hop_host.trim()) {
|
||||
showError(t("managedNe.hop.hostRequired"));
|
||||
return;
|
||||
}
|
||||
if (!batchHop.hop_username.trim()) {
|
||||
showError(t("managedNe.hop.userRequired"));
|
||||
return;
|
||||
}
|
||||
if (!batchHop.hop_password && batchHop.hop_target_auth_mode !== "bastion_managed") {
|
||||
showError(t("managedNe.hop.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
batchHopMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{batchHopMutation.isPending ? t("managedNe.hop.applying") : t("managedNe.hop.apply")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
{batchAccountOpen ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setBatchAccountOpen(false)}>
|
||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("managedNe.account.batchTitle")}</h3>
|
||||
<p className="form-hint">{t("managedNe.account.batchHint", { n: selected.length })}</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.user")}</FormLabel>
|
||||
<input
|
||||
value={batchAccount.username}
|
||||
onChange={(e) => setBatchAccount((prev) => ({ ...prev, username: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>
|
||||
{t("managedNe.col.password")}
|
||||
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
|
||||
</FormLabel>
|
||||
<input
|
||||
<AppModalShell
|
||||
open={batchAccountOpen}
|
||||
onClose={() => setBatchAccountOpen(false)}
|
||||
dismissible={!batchAccountMutation.isPending}
|
||||
size="md"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("managedNe.account.batchTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger isDisabled={batchAccountMutation.isPending} />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted">{t("managedNe.account.batchHint", { n: selected.length })}</p>
|
||||
<div className="form-grid">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={batchAccount.username}
|
||||
onChange={(username) => setBatchAccount((prev) => ({ ...prev, username }))}
|
||||
>
|
||||
<Label>{t("managedNe.col.user")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
value={batchAccount.password}
|
||||
onChange={(password) => setBatchAccount((prev) => ({ ...prev, password }))}
|
||||
>
|
||||
<Label>
|
||||
{t("managedNe.col.password")}
|
||||
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
|
||||
</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={() => setBatchAccountOpen(false)}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={batchAccountMutation.isPending}
|
||||
onPress={() => {
|
||||
if (!batchAccount.username.trim() && !batchAccount.password) {
|
||||
showError(t("managedNe.account.usernameOrPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
batchAccountMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.apply")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
<AppModalShell
|
||||
open={bulkTagModalOpen}
|
||||
onClose={() => setBulkTagModalOpen(false)}
|
||||
dismissible={!bulkByTagMutation.isPending}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>
|
||||
{bulkTagAction === "proxy"
|
||||
? t("managedNe.stats.batchProxy")
|
||||
: bulkTagAction === "account"
|
||||
? t("managedNe.account.batchByTag")
|
||||
: t("managedNe.stats.batchTest")}
|
||||
{bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""}
|
||||
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
|
||||
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
|
||||
</Modal.Heading>
|
||||
<Modal.CloseTrigger isDisabled={bulkByTagMutation.isPending} />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<FieldSelect
|
||||
label={t("managedNe.stats.tagFilter")}
|
||||
fullWidth
|
||||
value={bulkTagSelected}
|
||||
onChange={(e) => setBulkTagSelected(e.target.value)}
|
||||
>
|
||||
<option value="">{t("managedNe.stats.allTag")}</option>
|
||||
<option value="__no_tag__">{t("managedNe.stats.noTag")}</option>
|
||||
{statTags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
{bulkTagAction === "proxy" ? (
|
||||
<>
|
||||
<p className="text-sm text-muted">{t("managedNe.hop.batchHint", { n: "?" })}</p>
|
||||
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
|
||||
</>
|
||||
) : bulkTagAction === "account" ? (
|
||||
<>
|
||||
<p className="text-sm text-muted">{t("managedNe.account.batchByTagHint")}</p>
|
||||
<div className="form-grid">
|
||||
<TextField
|
||||
fullWidth
|
||||
value={bulkAccount.username}
|
||||
onChange={(username) => setBulkAccount((prev) => ({ ...prev, username }))}
|
||||
>
|
||||
<Label>{t("managedNe.col.user")}</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
value={batchAccount.password}
|
||||
onChange={(e) => setBatchAccount((prev) => ({ ...prev, password: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal__actions">
|
||||
<button type="button" onClick={() => setBatchAccountOpen(false)}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchAccountMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!batchAccount.username.trim() && !batchAccount.password) {
|
||||
showError(t("managedNe.account.usernameOrPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
batchAccountMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{batchAccountMutation.isPending ? t("managedNe.account.applying") : t("managedNe.account.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bulkTagModalOpen ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setBulkTagModalOpen(false)}>
|
||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>
|
||||
{bulkTagAction === "proxy"
|
||||
? t("managedNe.stats.batchProxy")
|
||||
value={bulkAccount.password}
|
||||
onChange={(password) => setBulkAccount((prev) => ({ ...prev, password }))}
|
||||
>
|
||||
<Label>
|
||||
{t("managedNe.col.password")}
|
||||
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
|
||||
</Label>
|
||||
<Input />
|
||||
</TextField>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("managedNe.stats.confirm", { n: "?" })}</p>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={() => setBulkTagModalOpen(false)}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={bulkByTagMutation.isPending}
|
||||
onPress={() => {
|
||||
if (bulkTagAction === "proxy") {
|
||||
if (!bulkHop.hop_host.trim()) {
|
||||
showError(t("managedNe.hop.hostRequired"));
|
||||
return;
|
||||
}
|
||||
if (!bulkHop.hop_username.trim()) {
|
||||
showError(t("managedNe.hop.userRequired"));
|
||||
return;
|
||||
}
|
||||
if (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") {
|
||||
showError(t("managedNe.hop.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (bulkTagAction === "account") {
|
||||
if (!bulkAccount.username.trim() && !bulkAccount.password) {
|
||||
showError(t("managedNe.account.usernameOrPasswordRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
|
||||
}}
|
||||
>
|
||||
{bulkByTagMutation.isPending
|
||||
? t("managedNe.stats.loadingIds")
|
||||
: bulkTagAction === "proxy"
|
||||
? t("managedNe.hop.apply")
|
||||
: bulkTagAction === "account"
|
||||
? t("managedNe.account.batchByTag")
|
||||
: t("managedNe.stats.batchTest")}
|
||||
{bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""}
|
||||
{bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""}
|
||||
{bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}
|
||||
</h3>
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.stats.tagFilter")}</FormLabel>
|
||||
<select
|
||||
className="ne-stats-card__tag-select"
|
||||
value={bulkTagSelected}
|
||||
onChange={(e) => setBulkTagSelected(e.target.value)}
|
||||
>
|
||||
<option value="">{t("managedNe.stats.allTag")}</option>
|
||||
<option value="__no_tag__">{t("managedNe.stats.noTag")}</option>
|
||||
{statTags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{bulkTagAction === "proxy" ? (
|
||||
<>
|
||||
<p className="form-hint">{t("managedNe.hop.batchHint", { n: "?" })}</p>
|
||||
<HopProxyFields value={bulkHop} onChange={(patch) => setBulkHop((prev) => ({ ...prev, ...patch }))} />
|
||||
</>
|
||||
) : bulkTagAction === "account" ? (
|
||||
<>
|
||||
<p className="form-hint">{t("managedNe.account.batchByTagHint")}</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<FormLabel>{t("managedNe.col.user")}</FormLabel>
|
||||
<input
|
||||
value={bulkAccount.username}
|
||||
onChange={(e) => setBulkAccount((prev) => ({ ...prev, username: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FormLabel>
|
||||
{t("managedNe.col.password")}
|
||||
<span className="form-label__optional"> ({t("managedNe.account.passwordOptionalBatch")})</span>
|
||||
</FormLabel>
|
||||
<input
|
||||
type="password"
|
||||
value={bulkAccount.password}
|
||||
onChange={(e) => setBulkAccount((prev) => ({ ...prev, password: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="form-hint">{t("managedNe.stats.confirm", { n: "?" })}</p>
|
||||
)}
|
||||
<div className="modal__actions">
|
||||
<button type="button" onClick={() => setBulkTagModalOpen(false)}>
|
||||
{t("managedNe.form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={bulkByTagMutation.isPending}
|
||||
onClick={() => {
|
||||
if (bulkTagAction === "proxy") {
|
||||
if (!bulkHop.hop_host.trim()) { showError(t("managedNe.hop.hostRequired")); return; }
|
||||
if (!bulkHop.hop_username.trim()) { showError(t("managedNe.hop.userRequired")); return; }
|
||||
if (!bulkHop.hop_password && bulkHop.hop_target_auth_mode !== "bastion_managed") {
|
||||
showError(t("managedNe.hop.passwordRequired")); return;
|
||||
}
|
||||
}
|
||||
if (bulkTagAction === "account") {
|
||||
if (!bulkAccount.username.trim() && !bulkAccount.password) {
|
||||
showError(t("managedNe.account.usernameOrPasswordRequired")); return;
|
||||
}
|
||||
}
|
||||
bulkByTagMutation.mutate({ action: bulkTagAction, tag: bulkTagSelected });
|
||||
}}
|
||||
>
|
||||
{bulkByTagMutation.isPending
|
||||
? t("managedNe.stats.loadingIds")
|
||||
: bulkTagAction === "proxy"
|
||||
? t("managedNe.hop.apply")
|
||||
: bulkTagAction === "account"
|
||||
? t("managedNe.account.apply")
|
||||
: t("managedNe.connect.run")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
? t("managedNe.account.apply")
|
||||
: t("managedNe.connect.run")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
<ManagedNeConnectDetailDialog
|
||||
row={connectDetailRow}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
|
|
@ -15,6 +16,37 @@ type SessionRow = {
|
|||
current: boolean;
|
||||
};
|
||||
|
||||
/** Short browser/OS label so same NAT IP can still be told apart. */
|
||||
function summarizeUa(ua: string): string {
|
||||
const s = String(ua || "").trim();
|
||||
if (!s) return "—";
|
||||
const browser = /Edg\//.test(s)
|
||||
? "Edge"
|
||||
: /Chrome\//.test(s)
|
||||
? "Chrome"
|
||||
: /Firefox\//.test(s)
|
||||
? "Firefox"
|
||||
: /Safari\//.test(s) && !/Chrome\//.test(s)
|
||||
? "Safari"
|
||||
: /curl\//i.test(s)
|
||||
? "curl"
|
||||
: /python/i.test(s)
|
||||
? "python"
|
||||
: "Client";
|
||||
const os = /Windows NT/i.test(s)
|
||||
? "Windows"
|
||||
: /Android/i.test(s)
|
||||
? "Android"
|
||||
: /iPhone|iPad/i.test(s)
|
||||
? "iOS"
|
||||
: /Mac OS X|Macintosh/i.test(s)
|
||||
? "macOS"
|
||||
: /Linux/i.test(s)
|
||||
? "Linux"
|
||||
: "";
|
||||
return os ? `${browser} · ${os}` : browser;
|
||||
}
|
||||
|
||||
export function SessionsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
|
|
@ -54,16 +86,22 @@ export function SessionsPage() {
|
|||
|
||||
<div className="panel">
|
||||
<div className="filter-inline" style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
|
||||
onClick={() => revokeOthersMut.mutate()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
|
||||
onPress={() => revokeOthersMut.mutate()}
|
||||
>
|
||||
{t("auth.revokeOtherSessions")}
|
||||
</button>
|
||||
<button type="button" onClick={() => void sessionsQuery.refetch()} disabled={sessionsQuery.isFetching}>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => void sessionsQuery.refetch()}
|
||||
isDisabled={sessionsQuery.isFetching}
|
||||
>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -76,6 +114,7 @@ export function SessionsPage() {
|
|||
<tr>
|
||||
<th>{t("auth.colSession")}</th>
|
||||
<th>{t("auth.colIp")}</th>
|
||||
<th>{t("auth.colClient")}</th>
|
||||
<th>{t("auth.colLastSeen")}</th>
|
||||
<th>{t("auth.colCreated")}</th>
|
||||
<th>{t("auth.actions")}</th>
|
||||
|
|
@ -85,27 +124,31 @@ export function SessionsPage() {
|
|||
{items.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<code title={row.user_agent}>{row.id.slice(0, 10)}…</code>
|
||||
<code title={row.id}>{row.id.slice(0, 10)}…</code>
|
||||
{row.current ? (
|
||||
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
|
||||
{t("auth.sessionCurrent")}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{row.client_ip || "—"}</td>
|
||||
<td>
|
||||
<code>{row.client_ip || "—"}</code>
|
||||
</td>
|
||||
<td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td>
|
||||
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
|
||||
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
disabled={revokeMut.isPending}
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={revokeMut.isPending}
|
||||
onPress={() => {
|
||||
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
|
||||
revokeMut.mutate(row.id);
|
||||
}}
|
||||
>
|
||||
{t("auth.revokeSession")}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import {
|
||||
DISCOVER_AUTO_ADD_KEY,
|
||||
DISCOVER_PROJECT_NEIGHBORS_KEY,
|
||||
|
|
@ -189,7 +190,6 @@ export function TopologyPage() {
|
|||
const searchHitTimerRef = useRef<number | null>(null);
|
||||
const findBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
|
||||
const exportMenuRef = useRef<HTMLDetailsElement | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null);
|
||||
const findJustLocatedRef = useRef(false);
|
||||
|
|
@ -1780,7 +1780,6 @@ export function TopologyPage() {
|
|||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
exportMenuRef.current?.removeAttribute("open");
|
||||
const filenameBase = activeLeafName || view.name || "topology";
|
||||
try {
|
||||
if (format === "svg") {
|
||||
|
|
@ -2027,7 +2026,6 @@ export function TopologyPage() {
|
|||
savePending={saveMut.isPending}
|
||||
onSave={() => saveMut.mutate()}
|
||||
onFit={fitCanvas}
|
||||
exportMenuRef={exportMenuRef}
|
||||
exporting={exporting}
|
||||
onExport={runTopologyExport}
|
||||
mapId={mapId}
|
||||
|
|
@ -2304,34 +2302,34 @@ export function TopologyPage() {
|
|||
<div className="topo-canvas__empty">
|
||||
<p className="muted">{t("topology.treeLoadFailed")}</p>
|
||||
<div className="topo-browser__empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => void treeQuery.refetch()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => void treeQuery.refetch()}
|
||||
>
|
||||
{t("topology.treeRetry")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
onClick={promptNewRegion}
|
||||
disabled={createRegionMut.isPending}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={promptNewRegion}
|
||||
isDisabled={createRegionMut.isPending}
|
||||
>
|
||||
{t("topology.newRegion")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="topo-canvas__empty">
|
||||
<p className="muted">{t("topology.emptyMaps")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
onClick={promptNewRegion}
|
||||
disabled={createRegionMut.isPending}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={promptNewRegion}
|
||||
isDisabled={createRegionMut.isPending}
|
||||
>
|
||||
{t("topology.newRegion")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{treeRoot && canvasGraphError ? (
|
||||
|
|
@ -2341,13 +2339,13 @@ export function TopologyPage() {
|
|||
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
|
||||
) : null}
|
||||
<div className="topo-browser__empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => void graphQuery.refetch()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => void graphQuery.refetch()}
|
||||
>
|
||||
{t("topology.treeRetry")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -2374,16 +2372,16 @@ export function TopologyPage() {
|
|||
</p>
|
||||
{worldNeedsApply ? (
|
||||
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={applyWorldMut.isPending}
|
||||
onClick={() => applyWorldMut.mutate()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={applyWorldMut.isPending}
|
||||
onPress={() => applyWorldMut.mutate()}
|
||||
>
|
||||
{applyWorldMut.isPending
|
||||
? t("topology.worldApplying")
|
||||
: t("topology.worldApply")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,9 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { FieldSelect } from "../components/ui/FieldSelect";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { apiGet, apiPatch, apiPost } from "../services/api";
|
||||
|
|
@ -71,13 +73,13 @@ export function UsersPage() {
|
|||
|
||||
<div className="pt-list">
|
||||
<form className="filter-inline" onSubmit={onCreate}>
|
||||
<input
|
||||
<Input
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
|
|
@ -85,13 +87,13 @@ export function UsersPage() {
|
|||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<FieldSelect value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">{t("auth.roleUser")}</option>
|
||||
<option value="admin">{t("auth.roleAdmin")}</option>
|
||||
</select>
|
||||
<button type="submit" disabled={createMut.isPending}>
|
||||
</FieldSelect>
|
||||
<Button type="submit" size="sm" variant="primary" isDisabled={createMut.isPending}>
|
||||
{t("auth.addUser")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{usersQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -127,15 +129,16 @@ export function UsersPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
patchMut.mutate({ id: u.id, body: { is_active: !u.is_active } })
|
||||
}
|
||||
>
|
||||
{u.is_active ? t("auth.disable") : t("auth.enable")}
|
||||
</button>
|
||||
<select
|
||||
</Button>
|
||||
<FieldSelect
|
||||
value={u.role}
|
||||
onChange={(e) =>
|
||||
patchMut.mutate({ id: u.id, body: { role: e.target.value } })
|
||||
|
|
@ -143,8 +146,8 @@ export function UsersPage() {
|
|||
>
|
||||
<option value="user">{t("auth.roleUser")}</option>
|
||||
<option value="admin">{t("auth.roleAdmin")}</option>
|
||||
</select>
|
||||
<input
|
||||
</FieldSelect>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("auth.newPassword")}
|
||||
value={resetPwd[u.id] || ""}
|
||||
|
|
@ -153,17 +156,18 @@ export function UsersPage() {
|
|||
}
|
||||
style={{ width: 140, minWidth: 140, flex: "0 0 auto" }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={!resetPwd[u.id] || resetPwd[u.id].length < 8}
|
||||
onPress={() => {
|
||||
const pwd = resetPwd[u.id];
|
||||
patchMut.mutate({ id: u.id, body: { password: pwd } });
|
||||
setResetPwd((m) => ({ ...m, [u.id]: "" }));
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPassword")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@heroui/react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
||||
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
|
||||
|
|
@ -250,16 +251,16 @@ export function WorkbenchPage() {
|
|||
</div>
|
||||
<div className="wb-grid">
|
||||
{mods.map((mod) => (
|
||||
<button
|
||||
<Button
|
||||
key={mod.moduleId}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={`wb-card wb-card--${mod.iconTone}`}
|
||||
title={t("workbench.openModule")}
|
||||
onClick={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
|
||||
aria-label={t("workbench.openModule")}
|
||||
onPress={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
|
||||
>
|
||||
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||
<span className="wb-card__label">{t(mod.labelKey)}</span>
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
|
@ -87,9 +88,14 @@ export function TaskOverviewPage() {
|
|||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("audit.tasks.title")}</h2>
|
||||
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => void query.refetch()}
|
||||
isDisabled={query.isFetching}
|
||||
>
|
||||
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="panel__hint">{t("audit.tasks.hint")}</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { HelpHint } from "../../components/HelpHint";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
|
|
@ -32,7 +35,7 @@ import { pageCount } from "../../utils/display";
|
|||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
const POLL_MS = 2500;
|
||||
const TARGET_PAGE_SIZE = 20;
|
||||
const TARGET_PAGE_SIZE = 10;
|
||||
const SEP = " · ";
|
||||
|
||||
function lldpJobStatusLabel(t: (k: string) => string, status: string): string {
|
||||
|
|
@ -99,14 +102,14 @@ export function LldpLinksPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [jobPageSize, setJobPageSize] = useState(20);
|
||||
const [jobPageSize, setJobPageSize] = useState(10);
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [jobKeyword, setJobKeyword] = useState("");
|
||||
const [exportingJobs, setExportingJobs] = useState(false);
|
||||
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
|
||||
const [expandedJobId, setExpandedJobId] = useState("");
|
||||
const [itemPage, setItemPage] = useState(1);
|
||||
const [itemPageSize, setItemPageSize] = useState(20);
|
||||
const [itemPageSize, setItemPageSize] = useState(10);
|
||||
const [itemStatus, setItemStatus] = useState("");
|
||||
const [itemKeyword, setItemKeyword] = useState("");
|
||||
const [exportingItems, setExportingItems] = useState(false);
|
||||
|
|
@ -117,7 +120,7 @@ export function LldpLinksPage() {
|
|||
const [edgeSource, setEdgeSource] = useState("");
|
||||
const [edgeKeyword, setEdgeKeyword] = useState("");
|
||||
const [edgePage, setEdgePage] = useState(1);
|
||||
const [edgePageSize, setEdgePageSize] = useState(50);
|
||||
const [edgePageSize, setEdgePageSize] = useState(10);
|
||||
const [exportingEdges, setExportingEdges] = useState(false);
|
||||
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
|
||||
|
||||
|
|
@ -554,45 +557,62 @@ export function LldpLinksPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("lldpLinks.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={dashQuery.isFetching}
|
||||
onPress={() => void refresh()}
|
||||
>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={Boolean(running) || startMut.isPending}
|
||||
onClick={() => startMut.mutate("full")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={Boolean(running) || startMut.isPending}
|
||||
onPress={() => startMut.mutate("full")}
|
||||
>
|
||||
{t("lldpLinks.collectNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)}
|
||||
onClick={() => startMut.mutate("retry_failed")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)}
|
||||
onPress={() => startMut.mutate("retry_failed")}
|
||||
>
|
||||
{t("lldpLinks.retryFailed")}
|
||||
</button>
|
||||
</Button>
|
||||
{running?.status === "running" || running?.status === "pending" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={pauseMut.isPending}
|
||||
onPress={() => pauseMut.mutate(running.id)}
|
||||
>
|
||||
{t("lldpLinks.pause")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{running?.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(running.id)} disabled={resumeMut.isPending}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={resumeMut.isPending}
|
||||
onPress={() => resumeMut.mutate(running.id)}
|
||||
>
|
||||
{t("lldpLinks.resume")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{running &&
|
||||
(running.status === "running" || running.status === "paused" || running.status === "pending") ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={stopMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(running.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
>
|
||||
{t("lldpLinks.stop")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -654,11 +674,11 @@ export function LldpLinksPage() {
|
|||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
<span>{t("lldpLinks.interval")}</span>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={intervalUnit === "days" ? 365 : 8760}
|
||||
value={intervalValue}
|
||||
value={String(intervalValue)}
|
||||
onChange={(e) => {
|
||||
const max = intervalUnit === "days" ? 365 : 8760;
|
||||
setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
|
||||
|
|
@ -711,14 +731,13 @@ export function LldpLinksPage() {
|
|||
<option value="selected">{t("lldpLinks.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMut.isPending}
|
||||
onClick={() => savePolicyMut.mutate()}
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={savePolicyMut.isPending}
|
||||
onPress={() => savePolicyMut.mutate()}
|
||||
>
|
||||
{t("lldpLinks.savePolicy")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{scopeMode === "selected" ? (
|
||||
|
|
@ -767,9 +786,14 @@ export function LldpLinksPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={targetPage <= 1}
|
||||
onPress={() => setTargetPage((p) => p - 1)}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(targetsQuery.data?.total ?? 0),
|
||||
|
|
@ -777,13 +801,14 @@ export function LldpLinksPage() {
|
|||
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
|
||||
onClick={() => setTargetPage((p) => p + 1)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
|
||||
onPress={() => setTargetPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -791,7 +816,7 @@ export function LldpLinksPage() {
|
|||
|
||||
<h3>{t("lldpLinks.edgesTitle")}</h3>
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={edgeStatus}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
|
|
@ -802,8 +827,8 @@ export function LldpLinksPage() {
|
|||
<option value="all">{t("lldpLinks.edgeStatusAll")}</option>
|
||||
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
|
||||
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
|
||||
</select>
|
||||
<select
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
value={edgeSource}
|
||||
onChange={(e) => {
|
||||
setEdgeSource(e.target.value);
|
||||
|
|
@ -813,8 +838,8 @@ export function LldpLinksPage() {
|
|||
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
|
||||
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
|
||||
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
|
||||
</select>
|
||||
<input
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={edgeKeyword}
|
||||
placeholder={t("lldpLinks.edgeKeywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -822,13 +847,14 @@ export function LldpLinksPage() {
|
|||
setEdgePage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingEdges || edgeTotal === 0}
|
||||
onClick={() => void exportEdgesCsv()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingEdges || edgeTotal === 0}
|
||||
onPress={() => void exportEdgesCsv()}
|
||||
>
|
||||
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
|
|
@ -893,7 +919,7 @@ export function LldpLinksPage() {
|
|||
pages={edgePages}
|
||||
total={edgeTotal}
|
||||
pageSize={edgePageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
pageSizeOptions={[10, 20, 50, 100, 200]}
|
||||
onPageChange={setEdgePage}
|
||||
onPageSizeChange={(size) => {
|
||||
setEdgePageSize(size);
|
||||
|
|
@ -904,7 +930,7 @@ export function LldpLinksPage() {
|
|||
|
||||
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={jobStatus}
|
||||
onChange={(e) => {
|
||||
setJobStatus(e.target.value);
|
||||
|
|
@ -919,8 +945,8 @@ export function LldpLinksPage() {
|
|||
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
|
||||
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
|
||||
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
|
||||
</select>
|
||||
<input
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={jobKeyword}
|
||||
placeholder={t("lldpLinks.jobKeywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -928,13 +954,14 @@ export function LldpLinksPage() {
|
|||
setJobPage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingJobs || jobTotal === 0}
|
||||
onClick={() => void exportJobsCsv()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingJobs || jobTotal === 0}
|
||||
onPress={() => void exportJobsCsv()}
|
||||
>
|
||||
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
|
|
@ -958,10 +985,10 @@ export function LldpLinksPage() {
|
|||
return (
|
||||
<tr key={job.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
|
|
@ -970,7 +997,7 @@ export function LldpLinksPage() {
|
|||
}}
|
||||
>
|
||||
{t("lldpLinks.expand")}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
<td className="mono">{job.id.slice(0, 8)}</td>
|
||||
<td>{lldpTriggerLabel(t, job.trigger_mode)}</td>
|
||||
|
|
@ -1005,33 +1032,36 @@ export function LldpLinksPage() {
|
|||
<td>
|
||||
<div className="btn-row">
|
||||
{job.status === "running" || job.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pauseMut.mutate(job.id)}
|
||||
disabled={pauseMut.isPending}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={pauseMut.isPending}
|
||||
onPress={() => pauseMut.mutate(job.id)}
|
||||
>
|
||||
{t("lldpLinks.pause")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{job.status === "paused" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resumeMut.mutate(job.id)}
|
||||
disabled={resumeMut.isPending}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={resumeMut.isPending}
|
||||
onPress={() => resumeMut.mutate(job.id)}
|
||||
>
|
||||
{t("lldpLinks.resume")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{job.status === "running" || job.status === "paused" || job.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={stopMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(job.id);
|
||||
}}
|
||||
disabled={stopMut.isPending}
|
||||
>
|
||||
{t("lldpLinks.stop")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -1062,265 +1092,238 @@ export function LldpLinksPage() {
|
|||
disabled={jobsQuery.isLoading}
|
||||
/>
|
||||
|
||||
{expandedJobId ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide ops-detail-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("lldpLinks.jobDetailTitle")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="ops-detail-modal__head">
|
||||
<div className="ops-detail-modal__title">
|
||||
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
|
||||
<p className="muted">
|
||||
{(() => {
|
||||
const job = jobDetailQuery.data;
|
||||
if (job) {
|
||||
return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
|
||||
}
|
||||
if (jobDetailQuery.isLoading) {
|
||||
return `${expandedJobId.slice(0, 8)} · …`;
|
||||
}
|
||||
return expandedJobId.slice(0, 8);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingItems || itemTotal === 0}
|
||||
onClick={() => void exportItemsCsv()}
|
||||
>
|
||||
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AppModalShell
|
||||
open={Boolean(expandedJobId)}
|
||||
onClose={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setItemPage(1);
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("lldpLinks.jobDetailTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="muted">
|
||||
{(() => {
|
||||
const job = jobDetailQuery.data;
|
||||
if (job) {
|
||||
return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
|
||||
}
|
||||
if (jobDetailQuery.isLoading) {
|
||||
return `${expandedJobId.slice(0, 8)} · …`;
|
||||
}
|
||||
return expandedJobId.slice(0, 8);
|
||||
})()}
|
||||
</p>
|
||||
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<select
|
||||
value={itemStatus}
|
||||
onChange={(e) => {
|
||||
setItemStatus(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.itemResultAll")}</option>
|
||||
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
|
||||
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
|
||||
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
|
||||
</select>
|
||||
<input
|
||||
value={itemKeyword}
|
||||
placeholder={t("lldpLinks.itemKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setItemKeyword(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{jobDetailQuery.data?.error ? (
|
||||
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="ops-detail-modal__scroll">
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.neighbors")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.error")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailItems.map((it) => {
|
||||
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<td>{it.ne_name || it.ne_id || "—"}</td>
|
||||
<td>{it.ne_ip || "—"}</td>
|
||||
<td>
|
||||
{lldpItemResultLabel(t, it)}
|
||||
</td>
|
||||
<td>{it.neighbors}</td>
|
||||
<td>
|
||||
+{it.edges_added} / ~{it.edges_updated}
|
||||
{unmatchedCount > 0
|
||||
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
|
||||
: ""}
|
||||
</td>
|
||||
<td className="muted">{it.error || "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setItemDetail(it)}
|
||||
>
|
||||
{t("topology.discoverViewDetail")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!detailItems.length && !jobDetailQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<ListPager
|
||||
page={itemPage}
|
||||
pages={itemPages}
|
||||
total={itemTotal}
|
||||
pageSize={itemPageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
onPageChange={setItemPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemPageSize(size);
|
||||
setItemPage(1);
|
||||
}}
|
||||
disabled={jobDetailQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-inline">
|
||||
<FieldSelect
|
||||
value={itemStatus}
|
||||
onChange={(e) => {
|
||||
setItemStatus(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("lldpLinks.itemResultAll")}</option>
|
||||
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
|
||||
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
|
||||
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={itemKeyword}
|
||||
placeholder={t("lldpLinks.itemKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setItemKeyword(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{itemDetail ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}>
|
||||
<div
|
||||
className="modal modal--wide topo-discover-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="lldp-item-detail-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="topo-discover-modal__head">
|
||||
<div>
|
||||
<h3 id="lldp-item-detail-title">
|
||||
{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}
|
||||
</h3>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setItemDetail(null)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{jobDetailQuery.data?.error ? (
|
||||
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
|
||||
) : null}
|
||||
|
||||
{!itemDetail.ok ? (
|
||||
<p className="topo-discover__error">
|
||||
{itemDetail.error === "vendor_or_device_type_required"
|
||||
? t("topology.discoverVendorRequired")
|
||||
: itemDetail.error || t("topology.discoverNeFail")}
|
||||
</p>
|
||||
) : null}
|
||||
{itemDetail.parser_stub ? (
|
||||
<p className="topo-discover__item-warn">
|
||||
{t("topology.discoverParserStub").replace(
|
||||
"{{parser}}",
|
||||
itemDetail.parser_key || "unknown",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="topo-discover-modal__stats">
|
||||
<span>
|
||||
{t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
|
||||
.replace("{{added}}", String(itemDetail.edges_added || 0))
|
||||
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverUnmatchedTitle").replace(
|
||||
"{{count}}",
|
||||
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
|
||||
)}
|
||||
</h4>
|
||||
{(itemDetail.unmatched || []).length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColRemote")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemDetail.unmatched || []).map((u, idx) => (
|
||||
<tr key={`${itemDetail.id}-u-${idx}`}>
|
||||
<div className="ops-detail-modal__scroll">
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.neighbors")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.error")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detailItems.map((it) => {
|
||||
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<td>{it.ne_name || it.ne_id || "—"}</td>
|
||||
<td>{it.ne_ip || "—"}</td>
|
||||
<td>{lldpItemResultLabel(t, it)}</td>
|
||||
<td>{it.neighbors}</td>
|
||||
<td>
|
||||
{(u.remote_name || u.remote_ip || "?").trim()}
|
||||
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
|
||||
+{it.edges_added} / ~{it.edges_updated}
|
||||
{unmatchedCount > 0
|
||||
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
|
||||
: ""}
|
||||
</td>
|
||||
<td className="muted">{it.error || "—"}</td>
|
||||
<td>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setItemDetail(it)}>
|
||||
{t("topology.discoverViewDetail")}
|
||||
</Button>
|
||||
</td>
|
||||
<td>{u.local_port || "?"}</td>
|
||||
<td>{u.remote_port || "?"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemDetail.raw_preview ? (
|
||||
<>
|
||||
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
|
||||
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="modal__actions">
|
||||
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!detailItems.length && !jobDetailQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("common.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ListPager
|
||||
page={itemPage}
|
||||
pages={itemPages}
|
||||
total={itemTotal}
|
||||
pageSize={itemPageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={setItemPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemPageSize(size);
|
||||
setItemPage(1);
|
||||
}}
|
||||
disabled={jobDetailQuery.isLoading}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={exportingItems || itemTotal === 0}
|
||||
onPress={() => void exportItemsCsv()}
|
||||
>
|
||||
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onPress={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
{t("networkConfigs.close")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
<AppModalShell open={Boolean(itemDetail)} onClose={() => setItemDetail(null)} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>
|
||||
{itemDetail?.ne_name || itemDetail?.ne_ip || itemDetail?.ne_id}
|
||||
</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
{itemDetail ? (
|
||||
<>
|
||||
<p className="topo-discover-modal__sub">
|
||||
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
|
||||
</p>
|
||||
|
||||
{!itemDetail.ok ? (
|
||||
<p className="topo-discover__error">
|
||||
{itemDetail.error === "vendor_or_device_type_required"
|
||||
? t("topology.discoverVendorRequired")
|
||||
: itemDetail.error || t("topology.discoverNeFail")}
|
||||
</p>
|
||||
) : null}
|
||||
{itemDetail.parser_stub ? (
|
||||
<p className="topo-discover__item-warn">
|
||||
{t("topology.discoverParserStub").replace(
|
||||
"{{parser}}",
|
||||
itemDetail.parser_key || "unknown",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="topo-discover-modal__stats">
|
||||
<span>
|
||||
{t("topology.discoverNeOk")
|
||||
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
|
||||
.replace("{{added}}", String(itemDetail.edges_added || 0))
|
||||
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="topo-discover-modal__section">
|
||||
{t("topology.discoverUnmatchedTitle").replace(
|
||||
"{{count}}",
|
||||
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
|
||||
)}
|
||||
</h4>
|
||||
{(itemDetail.unmatched || []).length === 0 ? (
|
||||
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColRemote")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemDetail.unmatched || []).map((u, idx) => (
|
||||
<tr key={`${itemDetail.id}-u-${idx}`}>
|
||||
<td>
|
||||
{(u.remote_name || u.remote_ip || "?").trim()}
|
||||
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
|
||||
</td>
|
||||
<td>{u.local_port || "?"}</td>
|
||||
<td>{u.remote_port || "?"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{itemDetail.raw_preview ? (
|
||||
<>
|
||||
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
|
||||
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={() => setItemDetail(null)}>
|
||||
{t("topology.discoverClose")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
|
|
@ -32,7 +34,7 @@ export function NetworkAlarmsPage() {
|
|||
const [curHostName, setCurHostName] = useState("");
|
||||
const [curKeyword, setCurKeyword] = useState("");
|
||||
const [curPage, setCurPage] = useState(1);
|
||||
const [curPageSize, setCurPageSize] = useState(50);
|
||||
const [curPageSize, setCurPageSize] = useState(10);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
|
||||
|
|
@ -135,15 +137,20 @@ export function NetworkAlarmsPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("ume.alarms.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={exporting || curTotal === 0} onClick={() => void exportCsv()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exporting || curTotal === 0}
|
||||
onPress={() => void exportCsv()}
|
||||
>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
<Input
|
||||
value={curKeyword}
|
||||
placeholder={t("ume.alarms.keywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -151,7 +158,7 @@ export function NetworkAlarmsPage() {
|
|||
setCurPage(1);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
value={curHostName}
|
||||
placeholder={t("ume.alarms.hostNamePh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -159,7 +166,7 @@ export function NetworkAlarmsPage() {
|
|||
setCurPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={curSeverity}
|
||||
onChange={(e) => {
|
||||
setCurSeverity(e.target.value);
|
||||
|
|
@ -172,8 +179,8 @@ export function NetworkAlarmsPage() {
|
|||
<option value="minor">minor</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
<select
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
value={curCleared}
|
||||
onChange={(e) => {
|
||||
setCurCleared(e.target.value);
|
||||
|
|
@ -183,21 +190,22 @@ export function NetworkAlarmsPage() {
|
|||
<option value="">{t("ume.alarms.clearedAll")}</option>
|
||||
<option value="false">{t("ume.alarms.clearedNo")}</option>
|
||||
<option value="true">{t("ume.alarms.clearedYes")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
title={t("ume.alarms.clearTitle")}
|
||||
onClick={() => {
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
aria-label={t("ume.alarms.clearTitle")}
|
||||
onPress={() => {
|
||||
setCurKeyword("");
|
||||
setCurHostName("");
|
||||
setCurSeverity("");
|
||||
setCurCleared("");
|
||||
setCurPage(1);
|
||||
}}
|
||||
disabled={!hasFilters}
|
||||
isDisabled={!hasFilters}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -232,18 +240,19 @@ export function NetworkAlarmsPage() {
|
|||
<td className="pt-list-num">{x.ne_id}</td>
|
||||
<td>
|
||||
{(x.host_name || "").trim() ? (
|
||||
<button
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn pt-list-task-name"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPress={() => {
|
||||
setCurHostName(x.host_name || "");
|
||||
setCurKeyword("");
|
||||
setCurPage(1);
|
||||
}}
|
||||
title={t("ume.alarms.filterByHost")}
|
||||
aria-label={t("ume.alarms.filterByHost")}
|
||||
>
|
||||
{x.host_name}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="muted" title={t("ume.alarms.noHostName")}>
|
||||
{t("common.empty")}
|
||||
|
|
@ -264,7 +273,7 @@ export function NetworkAlarmsPage() {
|
|||
pages={curPages}
|
||||
total={curTotal}
|
||||
pageSize={curPageSize}
|
||||
pageSizeOptions={[50, 100, 200, 500]}
|
||||
pageSizeOptions={[10, 20, 50, 100, 200]}
|
||||
onPageChange={setCurPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setCurPageSize(size);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
|
|
@ -30,7 +33,7 @@ export function NetworkConfigsPage() {
|
|||
const { showOk, showError } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
|
||||
const [source, setSource] = useState("");
|
||||
const [vendor, setVendor] = useState("");
|
||||
|
|
@ -150,19 +153,20 @@ export function NetworkConfigsPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("networkConfigs.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingList || total === 0}
|
||||
onClick={() => void exportListCsv()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingList || total === 0}
|
||||
onPress={() => void exportListCsv()}
|
||||
>
|
||||
{exportingList ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("networkConfigs.keywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -170,7 +174,7 @@ export function NetworkConfigsPage() {
|
|||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
|
|
@ -180,8 +184,8 @@ export function NetworkConfigsPage() {
|
|||
<option value="">{t("networkConfigs.allSource")}</option>
|
||||
<option value="managed">managed</option>
|
||||
<option value="ume">ume</option>
|
||||
</select>
|
||||
<input
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={vendor}
|
||||
placeholder={t("networkConfigs.vendorPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -189,10 +193,11 @@ export function NetworkConfigsPage() {
|
|||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasFilters}
|
||||
onPress={() => {
|
||||
setKeyword("");
|
||||
setSource("");
|
||||
setVendor("");
|
||||
|
|
@ -200,7 +205,7 @@ export function NetworkConfigsPage() {
|
|||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -239,22 +244,24 @@ export function NetworkConfigsPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
setSelected({ source: row.source, id: row.target_id });
|
||||
setTab("primary");
|
||||
}}
|
||||
>
|
||||
{t("networkConfigs.view")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={
|
||||
exporting === exportKey ||
|
||||
exporting.startsWith(`${row.source}:${row.target_id}:`)
|
||||
}
|
||||
onClick={() =>
|
||||
onPress={() =>
|
||||
void exportConfig(
|
||||
row.source,
|
||||
row.target_id,
|
||||
|
|
@ -263,10 +270,11 @@ export function NetworkConfigsPage() {
|
|||
}
|
||||
>
|
||||
{t("networkConfigs.export")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
const path =
|
||||
row.source === "ume"
|
||||
? `/webcrt?ne_id=${encodeURIComponent(row.target_id)}&source=ume`
|
||||
|
|
@ -275,7 +283,7 @@ export function NetworkConfigsPage() {
|
|||
}}
|
||||
>
|
||||
WebCRT
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -291,7 +299,7 @@ export function NetworkConfigsPage() {
|
|||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
|
|
@ -301,89 +309,84 @@ export function NetworkConfigsPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<div
|
||||
className="modal modal--wide nm-config-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("networkConfigs.view")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="nm-config-modal__head">
|
||||
<div className="nm-config-modal__title">
|
||||
<h3>{detail?.ne_name || selected.id}</h3>
|
||||
<p className="muted">
|
||||
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="btn-row nm-config-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(exporting)}
|
||||
onClick={() =>
|
||||
void exportConfig(
|
||||
selected.source,
|
||||
selected.id,
|
||||
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("networkConfigs.export")}
|
||||
</button>
|
||||
{showAlt ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(exporting)}
|
||||
onClick={() => void exportConfig(selected.source, selected.id, "both")}
|
||||
>
|
||||
{t("networkConfigs.exportBoth")}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setSelected(null)}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
||||
{showAlt ? (
|
||||
<div className="btn-row nm-config-modal__tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "primary" ? "btn-primary" : undefined}
|
||||
onClick={() => setTab("primary")}
|
||||
>
|
||||
{t("networkConfigs.tabSet")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "alt" ? "btn-primary" : undefined}
|
||||
onClick={() => setTab("alt")}
|
||||
>
|
||||
{t("networkConfigs.tabHier")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<textarea
|
||||
className="nm-config-modal__body"
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
|
||||
/>
|
||||
<p className="muted nm-config-modal__meta">
|
||||
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
|
||||
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
|
||||
<AppModalShell
|
||||
open={Boolean(selected)}
|
||||
onClose={() => setSelected(null)}
|
||||
size="lg"
|
||||
className="nm-config-modal"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{detail?.ne_name || selected?.id || t("networkConfigs.view")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
{selected ? (
|
||||
<p className="muted">
|
||||
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{showAlt ? (
|
||||
<div className="btn-row nm-config-modal__tabs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tab === "primary" ? "primary" : "secondary"}
|
||||
onPress={() => setTab("primary")}
|
||||
>
|
||||
{t("networkConfigs.tabSet")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tab === "alt" ? "primary" : "secondary"}
|
||||
onPress={() => setTab("alt")}
|
||||
>
|
||||
{t("networkConfigs.tabHier")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
className="nm-config-modal__body"
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
|
||||
/>
|
||||
<p className="muted nm-config-modal__meta">
|
||||
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
|
||||
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{selected ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={Boolean(exporting)}
|
||||
onPress={() =>
|
||||
void exportConfig(
|
||||
selected.source,
|
||||
selected.id,
|
||||
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("networkConfigs.export")}
|
||||
</Button>
|
||||
{showAlt ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={Boolean(exporting)}
|
||||
onPress={() => void exportConfig(selected.source, selected.id, "both")}
|
||||
>
|
||||
{t("networkConfigs.exportBoth")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="tertiary" onPress={() => setSelected(null)}>
|
||||
{t("networkConfigs.close")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
|
|
@ -36,7 +38,7 @@ export function NetworkDevicesPage() {
|
|||
const [keyword, setKeyword] = useState("");
|
||||
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
|
@ -91,15 +93,20 @@ export function NetworkDevicesPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("networkDevices.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" disabled={exporting || total === 0} onClick={() => void exportCsv()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exporting || total === 0}
|
||||
onPress={() => void exportCsv()}
|
||||
>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("networkDevices.keywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -107,7 +114,7 @@ export function NetworkDevicesPage() {
|
|||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value as "all" | "managed" | "ume");
|
||||
|
|
@ -117,18 +124,19 @@ export function NetworkDevicesPage() {
|
|||
<option value="all">{t("networkDevices.allSource")}</option>
|
||||
<option value="managed">managed</option>
|
||||
<option value="ume">ume</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasFilters}
|
||||
onClick={() => {
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasFilters}
|
||||
onPress={() => {
|
||||
setKeyword("");
|
||||
setSource("all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -167,12 +175,13 @@ export function NetworkDevicesPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<button type="button" onClick={() => openWebcrt(row)}>
|
||||
<Button size="sm" variant="secondary" onPress={() => openWebcrt(row)}>
|
||||
WebCRT
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
openNewModuleWindow({
|
||||
moduleId: "network",
|
||||
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
|
||||
|
|
@ -180,7 +189,7 @@ export function NetworkDevicesPage() {
|
|||
}
|
||||
>
|
||||
{t("network.nav.configs")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -195,7 +204,7 @@ export function NetworkDevicesPage() {
|
|||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[20, 50, 100, 200]}
|
||||
pageSizeOptions={[10, 20, 50, 100, 200]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createPortTrafficBoard,
|
||||
|
|
@ -52,90 +53,91 @@ export function PortTrafficBoardListPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("portTraffic.wallTitle")}</h2>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={createMut.isPending}
|
||||
onClick={() => createMut.mutate()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={createMut.isPending}
|
||||
onPress={() => createMut.mutate()}
|
||||
>
|
||||
{t("portTraffic.boardCreate")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
{boardsQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : boardsQuery.isError ? (
|
||||
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
|
||||
) : !boards.length ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("portTraffic.boardEmpty")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={createMut.isPending}
|
||||
onClick={() => createMut.mutate()}
|
||||
>
|
||||
{t("portTraffic.boardCreate")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("portTraffic.boardName")}</th>
|
||||
<th>{t("portTraffic.boardPanelCount")}</th>
|
||||
<th>{t("portTraffic.boardCols")}</th>
|
||||
<th>{t("portTraffic.boardUpdated")}</th>
|
||||
<th>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{boards.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn pt-list-task-name"
|
||||
onClick={() => openPortTrafficBoardWindow(b.id)}
|
||||
>
|
||||
{b.name || t("portTraffic.boardDefaultName")}
|
||||
</button>
|
||||
</td>
|
||||
<td>{b.panel_count}</td>
|
||||
<td>{b.cols}</td>
|
||||
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
|
||||
<td>
|
||||
<div className="btn-row table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openPortTrafficBoardWindow(b.id)}
|
||||
>
|
||||
{t("portTraffic.boardOpen")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
|
||||
deleteMut.mutate(b.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{boardsQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : boardsQuery.isError ? (
|
||||
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
|
||||
) : !boards.length ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("portTraffic.boardEmpty")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={createMut.isPending}
|
||||
onPress={() => createMut.mutate()}
|
||||
>
|
||||
{t("portTraffic.boardCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("portTraffic.boardName")}</th>
|
||||
<th>{t("portTraffic.boardPanelCount")}</th>
|
||||
<th>{t("portTraffic.boardCols")}</th>
|
||||
<th>{t("portTraffic.boardUpdated")}</th>
|
||||
<th>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{boards.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn pt-list-task-name"
|
||||
onPress={() => openPortTrafficBoardWindow(b.id)}
|
||||
>
|
||||
{b.name || t("portTraffic.boardDefaultName")}
|
||||
</Button>
|
||||
</td>
|
||||
<td>{b.panel_count}</td>
|
||||
<td>{b.cols}</td>
|
||||
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
|
||||
<td>
|
||||
<div className="btn-row table-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onPress={() => openPortTrafficBoardWindow(b.id)}
|
||||
>
|
||||
{t("portTraffic.boardOpen")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
|
||||
deleteMut.mutate(b.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button } from "@heroui/react";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import { fetchPortTrafficCompare } from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
|
|
@ -96,18 +97,18 @@ export function PortTrafficBoardPanelCell({
|
|||
{title}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => onMove?.(-1)}>
|
||||
<Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(-1)}>
|
||||
↑
|
||||
</button>
|
||||
<button type="button" onClick={() => onMove?.(1)}>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(1)}>
|
||||
↓
|
||||
</button>
|
||||
<button type="button" onClick={() => onEdit?.()}>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => onEdit?.()}>
|
||||
{t("portTraffic.edit")}
|
||||
</button>
|
||||
<button type="button" className="btn--danger" onClick={() => onRemove?.()}>
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onPress={() => onRemove?.()}>
|
||||
{t("portTraffic.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -115,9 +116,9 @@ export function PortTrafficBoardPanelCell({
|
|||
<div className="pt-board-panel__stale">
|
||||
<p>{t("portTraffic.boardPanelStale")}</p>
|
||||
{editing ? (
|
||||
<button type="button" className="btn-primary" onClick={() => onEdit?.()}>
|
||||
<Button variant="primary" onPress={() => onEdit?.()}>
|
||||
{t("portTraffic.boardRetarget")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import {
|
||||
createPortTrafficDevice,
|
||||
deletePortTrafficDevice,
|
||||
|
|
@ -35,7 +38,7 @@ import { pageCount } from "../../utils/display";
|
|||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const TARGET_PAGE_SIZE = 20;
|
||||
const TARGET_PAGE_SIZE = 10;
|
||||
|
||||
type ViewMode = "list" | "wizard" | "edit";
|
||||
|
||||
|
|
@ -128,7 +131,7 @@ export function PortTrafficPage() {
|
|||
|
||||
const [view, setView] = useState<ViewMode>("list");
|
||||
const [listPage, setListPage] = useState(1);
|
||||
const [listPageSize, setListPageSize] = useState(50);
|
||||
const [listPageSize, setListPageSize] = useState(10);
|
||||
const [listKeyword, setListKeyword] = useState("");
|
||||
const [listStatus, setListStatus] = useState("");
|
||||
const [exportingDevices, setExportingDevices] = useState(false);
|
||||
|
|
@ -697,30 +700,32 @@ export function PortTrafficPage() {
|
|||
<h2>{t("portTraffic.title")}</h2>
|
||||
<div className="btn-row">
|
||||
{view !== "list" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={() => {
|
||||
resetWizard();
|
||||
setView("list");
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.backList")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}>
|
||||
<Button size="sm" variant="secondary" onPress={() => navigate("/network/tasks/port-traffic/wall")}>
|
||||
{t("portTraffic.wall")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingDevices || listTotal === 0}
|
||||
onClick={() => void exportDevicesCsv()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingDevices || listTotal === 0}
|
||||
onPress={() => void exportDevicesCsv()}
|
||||
>
|
||||
{exportingDevices ? t("common.exporting") : t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onPress={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -752,7 +757,7 @@ export function PortTrafficPage() {
|
|||
</div>
|
||||
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
<Input
|
||||
value={listKeyword}
|
||||
placeholder={t("portTraffic.keywordPh")}
|
||||
onChange={(e) => {
|
||||
|
|
@ -760,7 +765,7 @@ export function PortTrafficPage() {
|
|||
setListPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
<FieldSelect
|
||||
value={listStatus}
|
||||
onChange={(e) => {
|
||||
setListStatus(e.target.value);
|
||||
|
|
@ -772,18 +777,19 @@ export function PortTrafficPage() {
|
|||
<option value="paused">{t("portTraffic.statusPaused")}</option>
|
||||
<option value="stopped">{t("portTraffic.statusStopped")}</option>
|
||||
<option value="draft">{t("portTraffic.statusDraft")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasListFilters}
|
||||
onClick={() => {
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasListFilters}
|
||||
onPress={() => {
|
||||
setListKeyword("");
|
||||
setListStatus("");
|
||||
setListPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
|
@ -793,9 +799,9 @@ export function PortTrafficPage() {
|
|||
<div className="pt-list-empty">
|
||||
<p>{t("portTraffic.empty")}</p>
|
||||
{!hasListFilters ? (
|
||||
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||
<Button variant="primary" onPress={openWizard}>
|
||||
{t("portTraffic.create")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : devices.length ? (
|
||||
|
|
@ -843,74 +849,70 @@ export function PortTrafficPage() {
|
|||
</td>
|
||||
<td>
|
||||
<div className="pt-list-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => openWallList()}
|
||||
>
|
||||
<Button size="sm" variant="primary" onPress={() => openWallList()}>
|
||||
{t("portTraffic.wall")}
|
||||
</button>
|
||||
<button type="button" onClick={() => openEdit(row)}>
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => openEdit(row)}>
|
||||
{t("portTraffic.edit")}
|
||||
</button>
|
||||
</Button>
|
||||
{needsNeRebind(row) ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t("portTraffic.rebindHint")}
|
||||
onClick={() => openRebind(row)}
|
||||
>
|
||||
<Button size="sm" variant="secondary" onPress={() => openRebind(row)}>
|
||||
{t("portTraffic.rebind")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status !== "running" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={startMut.isPending}
|
||||
onClick={() => startMut.mutate(row.id)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={startMut.isPending}
|
||||
onPress={() => startMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.start")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pauseMut.isPending}
|
||||
onClick={() => pauseMut.mutate(row.id)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={pauseMut.isPending}
|
||||
onPress={() => pauseMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.pause")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "stopped" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={stopMut.isPending}
|
||||
onClick={() => stopMut.mutate(row.id)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={stopMut.isPending}
|
||||
onPress={() => stopMut.mutate(row.id)}
|
||||
>
|
||||
{t("portTraffic.stop")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={row.last_error ? "pt-list-log-btn--error" : undefined}
|
||||
onClick={() => {
|
||||
onPress={() => {
|
||||
setLogDeviceId(row.id);
|
||||
setLogDeviceLabel(deviceLabel(row));
|
||||
setLogKeyword("");
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.log")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("portTraffic.confirmDelete"))) {
|
||||
deleteMut.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -926,7 +928,7 @@ export function PortTrafficPage() {
|
|||
pages={pages}
|
||||
total={listTotal}
|
||||
pageSize={listPageSize}
|
||||
pageSizeOptions={[20, 50, 100]}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={setListPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setListPageSize(size);
|
||||
|
|
@ -1021,9 +1023,14 @@ export function PortTrafficPage() {
|
|||
<strong>
|
||||
{selectedNe.name} ({selectedNe.ip_address})
|
||||
</strong>
|
||||
<button type="button" disabled={portsLoading} onClick={() => void discoverSelected()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={portsLoading}
|
||||
onPress={() => void discoverSelected()}
|
||||
>
|
||||
{portsLoading ? "…" : t("portTraffic.discover")}
|
||||
</button>
|
||||
</Button>
|
||||
{portsError ? <span className="muted">{portsError}</span> : null}
|
||||
</div>
|
||||
{discoverMeta ? (
|
||||
|
|
@ -1165,24 +1172,24 @@ export function PortTrafficPage() {
|
|||
})}
|
||||
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
|
||||
</ul>
|
||||
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}>
|
||||
<Button variant="primary" isDisabled={createMut.isPending} onPress={submitWizard}>
|
||||
{t("portTraffic.confirmCreate")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="btn-row" style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={wizardStep <= 1}
|
||||
onClick={() => setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={wizardStep <= 1}
|
||||
onPress={() => setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))}
|
||||
>
|
||||
{t("portTraffic.prev")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={wizardStep >= 3}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={wizardStep >= 3}
|
||||
onPress={() => {
|
||||
if (wizardStep === 1 && !selectedNe) {
|
||||
showError(t("portTraffic.pickNeFirst"));
|
||||
return;
|
||||
|
|
@ -1195,7 +1202,7 @@ export function PortTrafficPage() {
|
|||
}}
|
||||
>
|
||||
{t("portTraffic.next")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1228,23 +1235,28 @@ export function PortTrafficPage() {
|
|||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
{t("portTraffic.concurrency")}
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
value={concurrency}
|
||||
value={String(concurrency)}
|
||||
onChange={(e) => setConcurrency(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
{t("portTraffic.note")}
|
||||
<input value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="btn-row" style={{ margin: "12px 0" }}>
|
||||
<button type="button" disabled={portsLoading} onClick={() => void discoverSelected()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={portsLoading}
|
||||
onPress={() => void discoverSelected()}
|
||||
>
|
||||
{portsLoading ? "…" : t("portTraffic.discover")}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span>
|
||||
</div>
|
||||
{portsError ? <p className="muted">{portsError}</p> : null}
|
||||
|
|
@ -1315,233 +1327,222 @@ export function PortTrafficPage() {
|
|||
</ul>
|
||||
)}
|
||||
<div className="btn-row" style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={updateMut.isPending || putIfacesMut.isPending}
|
||||
onClick={() => void saveEdit()}
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={updateMut.isPending || putIfacesMut.isPending}
|
||||
onPress={() => void saveEdit()}
|
||||
>
|
||||
{t("portTraffic.save")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{logDeviceId ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeLog}>
|
||||
<div
|
||||
className="modal modal--wide pt-log-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pt-log-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="pt-log-modal__head">
|
||||
<div>
|
||||
<h3 id="pt-log-title">{t("portTraffic.logTitle")}</h3>
|
||||
<p className="muted pt-log-modal__sub">
|
||||
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!filteredLogEvents.length}
|
||||
onClick={exportEventsCsv}
|
||||
>
|
||||
{t("common.exportCsv")}
|
||||
</button>
|
||||
<button type="button" onClick={closeLog}>
|
||||
{t("portTraffic.logClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-inline" style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
value={logKeyword}
|
||||
placeholder={t("portTraffic.keywordPh")}
|
||||
onChange={(e) => setLogKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{logQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : !logEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : !filteredLogEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : (
|
||||
<div className="pt-log-modal__list">
|
||||
{filteredLogEvents.map((ev) => {
|
||||
const level = String(ev.level || "error").toLowerCase();
|
||||
const tone =
|
||||
level === "warn" || level === "warning"
|
||||
? "warn"
|
||||
: level === "info"
|
||||
? "info"
|
||||
: "error";
|
||||
return (
|
||||
<article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
|
||||
<div className="pt-log-item__meta">
|
||||
<span className="pt-log-item__level">{level}</span>
|
||||
<span className="pt-log-item__time">
|
||||
{formatSystemTime(ev.created_at) || "—"}
|
||||
<AppModalShell open={Boolean(logDeviceId)} onClose={closeLog} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("portTraffic.logTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<p className="muted pt-log-modal__sub">
|
||||
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
|
||||
</p>
|
||||
<div className="filter-inline">
|
||||
<Input
|
||||
value={logKeyword}
|
||||
placeholder={t("portTraffic.keywordPh")}
|
||||
onChange={(e) => setLogKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{logQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : !logEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : !filteredLogEvents.length ? (
|
||||
<p className="muted">{t("portTraffic.logEmpty")}</p>
|
||||
) : (
|
||||
<div className="pt-log-modal__list">
|
||||
{filteredLogEvents.map((ev) => {
|
||||
const level = String(ev.level || "error").toLowerCase();
|
||||
const tone =
|
||||
level === "warn" || level === "warning"
|
||||
? "warn"
|
||||
: level === "info"
|
||||
? "info"
|
||||
: "error";
|
||||
return (
|
||||
<article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
|
||||
<div className="pt-log-item__meta">
|
||||
<span className="pt-log-item__level">{level}</span>
|
||||
<span className="pt-log-item__time">
|
||||
{formatSystemTime(ev.created_at) || "—"}
|
||||
</span>
|
||||
{ev.ifname ? (
|
||||
<span className="pt-log-item__if" title={ev.ifname}>
|
||||
{ev.ifname}
|
||||
</span>
|
||||
{ev.ifname ? (
|
||||
<span className="pt-log-item__if" title={ev.ifname}>
|
||||
{ev.ifname}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rebindDevice ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={closeRebind}>
|
||||
<div
|
||||
className="modal modal--wide pt-rebind-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pt-rebind-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={!filteredLogEvents.length}
|
||||
onPress={exportEventsCsv}
|
||||
>
|
||||
<div className="pt-log-modal__head">
|
||||
<div>
|
||||
<h3 id="pt-rebind-title">{t("portTraffic.rebindTitle")}</h3>
|
||||
<p className="muted pt-log-modal__sub">
|
||||
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
|
||||
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
|
||||
{` · ${rebindSource}`}
|
||||
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
|
||||
</p>
|
||||
<p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p>
|
||||
</div>
|
||||
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
|
||||
{t("portTraffic.logClose")}
|
||||
</button>
|
||||
</div>
|
||||
{t("common.exportCsv")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onPress={closeLog}>
|
||||
{t("portTraffic.logClose")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
|
||||
<div className="filter-inline" style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
value={rebindKeyword}
|
||||
onChange={(e) => {
|
||||
setRebindKeyword(e.target.value);
|
||||
setRebindPage(1);
|
||||
}}
|
||||
placeholder={t("portTraffic.neKeywordPh")}
|
||||
/>
|
||||
{rebindSelectedNe ? (
|
||||
<AppModalShell
|
||||
open={Boolean(rebindDevice)}
|
||||
onClose={closeRebind}
|
||||
dismissible={!rebindMut.isPending}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("portTraffic.rebindTitle")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
{rebindDevice ? (
|
||||
<>
|
||||
<p className="muted pt-log-modal__sub">
|
||||
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
|
||||
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
|
||||
{` · ${rebindSource}`}
|
||||
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
|
||||
</p>
|
||||
<p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p>
|
||||
|
||||
<div className="filter-inline">
|
||||
<Input
|
||||
value={rebindKeyword}
|
||||
onChange={(e) => {
|
||||
setRebindKeyword(e.target.value);
|
||||
setRebindPage(1);
|
||||
}}
|
||||
placeholder={t("portTraffic.neKeywordPh")}
|
||||
/>
|
||||
{rebindSelectedNe ? (
|
||||
<span className="muted">
|
||||
{t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
|
||||
{rebindSelectedNe.ip_address})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rebindNesQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : !(rebindNesQuery.data?.items || []).length ? (
|
||||
<p className="muted">{t("portTraffic.rebindEmpty")}</p>
|
||||
) : (
|
||||
<div className="pt-rebind-modal__table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("portTraffic.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("portTraffic.col.vendor")}</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(rebindNesQuery.data?.items || []).map((row) => {
|
||||
const checked = rebindSelectedNe?.id === row.id;
|
||||
const sameIp =
|
||||
Boolean(rebindDevice.ne_ip) &&
|
||||
String(row.ip_address || "").trim() ===
|
||||
String(rebindDevice.ne_ip || "").trim();
|
||||
return (
|
||||
<tr
|
||||
key={`${row.source}:${row.id}`}
|
||||
className={sameIp ? "pt-rebind-row--same-ip" : undefined}
|
||||
title={sameIp ? t("portTraffic.rebindSameIpHint") : undefined}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="radio"
|
||||
name="pt-rebind-ne"
|
||||
checked={checked}
|
||||
onChange={() => setRebindSelectedNe(row)}
|
||||
/>
|
||||
</td>
|
||||
<td>{row.name}</td>
|
||||
<td>
|
||||
{row.ip_address}
|
||||
{sameIp ? (
|
||||
<span className="pt-rebind-same-ip">{t("portTraffic.rebindSameIp")}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{row.vendor || "—"}</td>
|
||||
<td className="muted" style={{ fontSize: 12 }}>
|
||||
{row.id.slice(0, 8)}…
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isIconOnly
|
||||
className="pager__btn"
|
||||
isDisabled={rebindPage <= 1}
|
||||
onPress={() => setRebindPage((p) => p - 1)}
|
||||
>
|
||||
‹
|
||||
</Button>
|
||||
<span className="muted">
|
||||
{t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
|
||||
{rebindSelectedNe.ip_address})
|
||||
{rebindPage}/
|
||||
{Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rebindNesQuery.isLoading ? (
|
||||
<p className="muted">…</p>
|
||||
) : !(rebindNesQuery.data?.items || []).length ? (
|
||||
<p className="muted">{t("portTraffic.rebindEmpty")}</p>
|
||||
) : (
|
||||
<div className="pt-rebind-modal__table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("portTraffic.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("portTraffic.col.vendor")}</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(rebindNesQuery.data?.items || []).map((row) => {
|
||||
const checked = rebindSelectedNe?.id === row.id;
|
||||
const sameIp =
|
||||
Boolean(rebindDevice.ne_ip) &&
|
||||
String(row.ip_address || "").trim() ===
|
||||
String(rebindDevice.ne_ip || "").trim();
|
||||
return (
|
||||
<tr
|
||||
key={`${row.source}:${row.id}`}
|
||||
className={sameIp ? "pt-rebind-row--same-ip" : undefined}
|
||||
title={sameIp ? t("portTraffic.rebindSameIpHint") : undefined}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="radio"
|
||||
name="pt-rebind-ne"
|
||||
checked={checked}
|
||||
onChange={() => setRebindSelectedNe(row)}
|
||||
/>
|
||||
</td>
|
||||
<td>{row.name}</td>
|
||||
<td>
|
||||
{row.ip_address}
|
||||
{sameIp ? (
|
||||
<span className="pt-rebind-same-ip">{t("portTraffic.rebindSameIp")}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{row.vendor || "—"}</td>
|
||||
<td className="muted" style={{ fontSize: 12 }}>
|
||||
{row.id.slice(0, 8)}…
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isIconOnly
|
||||
className="pager__btn"
|
||||
isDisabled={
|
||||
rebindPage >=
|
||||
Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
|
||||
}
|
||||
onPress={() => setRebindPage((p) => p + 1)}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pager pt-list-pager" style={{ marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="pager__btn"
|
||||
disabled={rebindPage <= 1}
|
||||
onClick={() => setRebindPage((p) => p - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="muted">
|
||||
{rebindPage}/
|
||||
{Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pager__btn"
|
||||
disabled={
|
||||
rebindPage >=
|
||||
Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
|
||||
}
|
||||
onClick={() => setRebindPage((p) => p + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal__actions" style={{ marginTop: 12 }}>
|
||||
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
|
||||
{t("portTraffic.boardCancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={!rebindSelectedNe || rebindMut.isPending}
|
||||
onClick={confirmRebind}
|
||||
>
|
||||
{rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" isDisabled={rebindMut.isPending} onPress={closeRebind}>
|
||||
{t("portTraffic.boardCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={!rebindSelectedNe || rebindMut.isPending}
|
||||
onPress={confirmRebind}
|
||||
>
|
||||
{rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
|
@ -9,6 +10,8 @@ import {
|
|||
putPortTrafficBoardPanels,
|
||||
updatePortTrafficBoard,
|
||||
} from "../../services/api";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
|
|
@ -318,7 +321,7 @@ export function PortTrafficWallPage() {
|
|||
<>
|
||||
<label className="pt-board-toolbar__field">
|
||||
{t("portTraffic.boardName")}
|
||||
<input
|
||||
<Input
|
||||
value={draftName}
|
||||
onChange={(e) => {
|
||||
setDraftName(e.target.value);
|
||||
|
|
@ -326,21 +329,20 @@ export function PortTrafficWallPage() {
|
|||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="pt-board-toolbar__field pt-board-toolbar__field--sm">
|
||||
{t("portTraffic.boardCols")}
|
||||
<select
|
||||
value={draftCols}
|
||||
onChange={(e) => {
|
||||
setDraftCols(Number(e.target.value) || 2);
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
</select>
|
||||
</label>
|
||||
<FieldSelect
|
||||
className="pt-board-toolbar__field pt-board-toolbar__field--sm"
|
||||
label={t("portTraffic.boardCols")}
|
||||
value={draftCols}
|
||||
onChange={(e) => {
|
||||
setDraftCols(Number(e.target.value) || 2);
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
</FieldSelect>
|
||||
</>
|
||||
) : (
|
||||
<div className="pt-board-toolbar__title">
|
||||
|
|
@ -351,47 +353,45 @@ export function PortTrafficWallPage() {
|
|||
</div>
|
||||
<div className="btn-row pt-board-toolbar__actions">
|
||||
{panels.length && !editing ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
|
||||
onClick={() => void toggleFullscreen()}
|
||||
title={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
|
||||
onPress={() => void toggleFullscreen()}
|
||||
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
<span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span>
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{!editing ? (
|
||||
<button type="button" className="btn-primary" onClick={startEdit} disabled={!board}>
|
||||
<Button variant="primary" onPress={startEdit} isDisabled={!board}>
|
||||
{t("portTraffic.boardEdit")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePanelsMut.isPending}
|
||||
onClick={() => savePanelsMut.mutate()}
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={savePanelsMut.isPending}
|
||||
onPress={() => savePanelsMut.mutate()}
|
||||
>
|
||||
{t("portTraffic.boardSave")}
|
||||
</button>
|
||||
<button type="button" onClick={cancelEdit}>
|
||||
</Button>
|
||||
<Button variant="tertiary" onPress={cancelEdit}>
|
||||
{t("portTraffic.boardCancel")}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn--danger"
|
||||
disabled={deleteMut.isPending || !board}
|
||||
onClick={() => {
|
||||
<Button
|
||||
variant="danger"
|
||||
isDisabled={deleteMut.isPending || !board}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate();
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -436,9 +436,9 @@ export function PortTrafficWallPage() {
|
|||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn-primary" onClick={addPanel}>
|
||||
<Button variant="primary" onPress={addPanel}>
|
||||
{t("portTraffic.boardAddPanel")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -446,9 +446,9 @@ export function PortTrafficWallPage() {
|
|||
<div className="pt-wall-shell__empty">
|
||||
<p>{t("portTraffic.boardNoPanels")}</p>
|
||||
{!editing ? (
|
||||
<button type="button" className="btn-primary" onClick={startEdit}>
|
||||
<Button variant="primary" onPress={startEdit}>
|
||||
{t("portTraffic.boardEdit")}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -499,25 +499,23 @@ export function PortTrafficWallPage() {
|
|||
)}
|
||||
|
||||
{editing && editPanel ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={closePanelSettings}>
|
||||
<div
|
||||
className="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3>{t("portTraffic.boardPanelSettings")}</h3>
|
||||
<div className="form-grid" style={{ marginTop: 12 }}>
|
||||
<AppModalShell open onClose={closePanelSettings} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("portTraffic.boardPanelSettings")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
{t("portTraffic.boardPanelTitle")}
|
||||
<input
|
||||
<Input
|
||||
value={editPanel.title}
|
||||
onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.boardPickDevice")}
|
||||
<select
|
||||
<FieldSelect
|
||||
value={editTargetsDeviceId}
|
||||
onChange={(e) => {
|
||||
setEditPickDeviceId(e.target.value);
|
||||
|
|
@ -530,11 +528,11 @@ export function PortTrafficWallPage() {
|
|||
{d.ne_name || d.ne_ip || d.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.wallPort")}
|
||||
<select
|
||||
<FieldSelect
|
||||
value={editPanel.target_id}
|
||||
disabled={!editTargetsDeviceId}
|
||||
onChange={(e) => {
|
||||
|
|
@ -558,12 +556,12 @@ export function PortTrafficWallPage() {
|
|||
{tgt.ifname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.range")}
|
||||
<select
|
||||
value={editPanel.range_hours}
|
||||
<FieldSelect
|
||||
value={String(editPanel.range_hours)}
|
||||
onChange={(e) =>
|
||||
updatePanel(editPanel.id, { range_hours: Number(e.target.value) || 24 })
|
||||
}
|
||||
|
|
@ -571,11 +569,11 @@ export function PortTrafficWallPage() {
|
|||
<option value={1}>1h</option>
|
||||
<option value={6}>6h</option>
|
||||
<option value={24}>24h</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.compare")}
|
||||
<select
|
||||
<FieldSelect
|
||||
value={editPanel.baseline}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as BaselineMode;
|
||||
|
|
@ -592,16 +590,16 @@ export function PortTrafficWallPage() {
|
|||
<option value="week">{t("portTraffic.compareWeek")}</option>
|
||||
<option value="shift">{t("portTraffic.compareShift")}</option>
|
||||
<option value="custom">{t("portTraffic.compareCustom")}</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
{editPanel.baseline === "custom" ? (
|
||||
<label>
|
||||
{t("portTraffic.offsetHours")}
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24 * 90}
|
||||
value={editPanel.offset_hours || 48}
|
||||
value={String(editPanel.offset_hours || 48)}
|
||||
onChange={(e) =>
|
||||
updatePanel(editPanel.id, {
|
||||
offset_hours: Number(e.target.value) || 24,
|
||||
|
|
@ -612,7 +610,7 @@ export function PortTrafficWallPage() {
|
|||
) : null}
|
||||
<label>
|
||||
{t("portTraffic.yMode")}
|
||||
<select
|
||||
<FieldSelect
|
||||
value={editPanel.y_mode}
|
||||
onChange={(e) =>
|
||||
updatePanel(editPanel.id, { y_mode: e.target.value as WallYMode })
|
||||
|
|
@ -621,12 +619,12 @@ export function PortTrafficWallPage() {
|
|||
<option value="auto">{t("portTraffic.yModeAuto")}</option>
|
||||
<option value="current">{t("portTraffic.yModeCurrent")}</option>
|
||||
<option value="util">{t("portTraffic.yModeUtil")}</option>
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.boardColSpan")}
|
||||
<select
|
||||
value={editPanel.col_span || 1}
|
||||
<FieldSelect
|
||||
value={String(editPanel.col_span || 1)}
|
||||
onChange={(e) =>
|
||||
updatePanel(editPanel.id, {
|
||||
col_span: Math.min(cols, Number(e.target.value) || 1),
|
||||
|
|
@ -638,11 +636,11 @@ export function PortTrafficWallPage() {
|
|||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
|
||||
<select
|
||||
<FieldSelect
|
||||
value={editMapBaselineDeviceId}
|
||||
disabled={!editPanel.target_id}
|
||||
onChange={(e) => {
|
||||
|
|
@ -659,11 +657,11 @@ export function PortTrafficWallPage() {
|
|||
{d.ne_name || d.ne_ip || d.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.mapBaselinePort")}
|
||||
<select
|
||||
<FieldSelect
|
||||
value={editPanel.baseline_target_id || ""}
|
||||
disabled={!editMapBaselineDeviceId}
|
||||
onChange={(e) => {
|
||||
|
|
@ -682,18 +680,18 @@ export function PortTrafficWallPage() {
|
|||
{tgt.ifname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
{editPanel.baseline !== "off" || editPanel.baseline_target_id ? (
|
||||
<label>
|
||||
{t("portTraffic.aheadHours")}
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={24}
|
||||
step={1}
|
||||
title={t("portTraffic.aheadHoursHint")}
|
||||
value={editPanel.ahead_hours ?? 1}
|
||||
aria-label={t("portTraffic.aheadHoursHint")}
|
||||
value={String(editPanel.ahead_hours ?? 1)}
|
||||
onChange={(e) =>
|
||||
updatePanel(editPanel.id, {
|
||||
ahead_hours: Math.max(0, Math.min(24, Number(e.target.value) || 0)),
|
||||
|
|
@ -704,22 +702,18 @@ export function PortTrafficWallPage() {
|
|||
) : null}
|
||||
</div>
|
||||
{editPanel.baseline === "week" ? (
|
||||
<p className="muted" style={{ marginTop: 10 }}>
|
||||
{t("portTraffic.retentionHint")}
|
||||
</p>
|
||||
<p className="muted">{t("portTraffic.retentionHint")}</p>
|
||||
) : null}
|
||||
{editPanel.baseline_target_id ? (
|
||||
<p className="muted" style={{ marginTop: 10 }}>
|
||||
{t("portTraffic.mapBaselineHint")}
|
||||
</p>
|
||||
<p className="muted">{t("portTraffic.mapBaselineHint")}</p>
|
||||
) : null}
|
||||
<div className="modal__actions">
|
||||
<button type="button" className="btn-primary" onClick={closePanelSettings}>
|
||||
{t("portTraffic.logClose")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="primary" onPress={closePanelSettings}>
|
||||
{t("portTraffic.logClose")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -9,13 +10,15 @@ import {
|
|||
matchFabricNodes,
|
||||
patchFabricNodeTags,
|
||||
} from "../../services/api";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
|
||||
import { pageCount } from "../../utils/display";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
|
||||
if (!root) return [];
|
||||
|
|
@ -95,6 +98,7 @@ export function TopologyClassifyPage() {
|
|||
const [unmatched, setUnmatched] = useState("");
|
||||
const [linkStatus, setLinkStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const linkLabel = (status: string | undefined) => {
|
||||
switch (String(status || "").toLowerCase()) {
|
||||
|
|
@ -144,6 +148,7 @@ export function TopologyClassifyPage() {
|
|||
linkStatus,
|
||||
page,
|
||||
filterMajor,
|
||||
pageSize,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricNodes({
|
||||
|
|
@ -153,7 +158,7 @@ export function TopologyClassifyPage() {
|
|||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageSize,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -246,7 +251,7 @@ export function TopologyClassifyPage() {
|
|||
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
|
||||
.map((n) => n.id);
|
||||
const total = listQuery.data?.total || 0;
|
||||
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const pages = pageCount(total, pageSize);
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
|
||||
|
||||
|
|
@ -273,10 +278,9 @@ export function TopologyClassifyPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("topoClassify.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => {
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
const regionId = filterRegion || assignRegion || "";
|
||||
const folder = regions.find((r) => r.id === regionId);
|
||||
const views = folder?.views || [];
|
||||
|
|
@ -289,15 +293,14 @@ export function TopologyClassifyPage() {
|
|||
}}
|
||||
>
|
||||
{t("topoClassify.openTopo")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="topo-classify__section topo-classify__section--first">
|
||||
<h3>{t("topoClassify.inventory")}</h3>
|
||||
<div className="topo-classify__draft">
|
||||
<input
|
||||
className="input"
|
||||
<Input
|
||||
placeholder={t("topoClassify.keywordPh")}
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
|
|
@ -305,8 +308,7 @@ export function TopologyClassifyPage() {
|
|||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
<select className="input"
|
||||
value={filterMajor}
|
||||
onChange={(e) => {
|
||||
setFilterMajor(e.target.value);
|
||||
|
|
@ -319,8 +321,7 @@ export function TopologyClassifyPage() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
<select className="input"
|
||||
value={filterRegion}
|
||||
onChange={(e) => {
|
||||
setFilterRegion(e.target.value);
|
||||
|
|
@ -334,8 +335,7 @@ export function TopologyClassifyPage() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
<select className="input"
|
||||
value={unmatched}
|
||||
onChange={(e) => {
|
||||
setUnmatched(e.target.value);
|
||||
|
|
@ -347,8 +347,7 @@ export function TopologyClassifyPage() {
|
|||
<option value="level">{t("topoClassify.kindLevel")}</option>
|
||||
<option value="region">{t("topoClassify.kindRegion")}</option>
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
<select className="input"
|
||||
value={linkStatus}
|
||||
onChange={(e) => {
|
||||
setLinkStatus(e.target.value);
|
||||
|
|
@ -363,14 +362,13 @@ export function TopologyClassifyPage() {
|
|||
|
||||
<div className="topo-classify__regex-bar">
|
||||
<strong>{t("topoClassify.regexTitle")}</strong>
|
||||
<input
|
||||
className="input topo-classify__regex-input"
|
||||
<Input
|
||||
className="topo-classify__regex-input"
|
||||
placeholder={t("topoClassify.patternPh")}
|
||||
value={regex}
|
||||
onChange={(e) => setRegex(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
<select className="input"
|
||||
value={matchField}
|
||||
onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")}
|
||||
>
|
||||
|
|
@ -378,14 +376,14 @@ export function TopologyClassifyPage() {
|
|||
<option value="ip">IP</option>
|
||||
<option value="name_ip">{t("topoClassify.fieldNameIp")}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={!regex.trim() || matchMut.isPending}
|
||||
onClick={() => matchMut.mutate()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={!regex.trim() || matchMut.isPending}
|
||||
onPress={() => matchMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.findMatches")}
|
||||
</button>
|
||||
</Button>
|
||||
{matchTotal > 0 ? (
|
||||
<span className="panel__hint">
|
||||
{t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))}
|
||||
|
|
@ -394,8 +392,7 @@ export function TopologyClassifyPage() {
|
|||
</div>
|
||||
|
||||
<div className="topo-classify__assign-bar">
|
||||
<select
|
||||
className="input"
|
||||
<select className="input"
|
||||
value={assignWhat}
|
||||
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
|
||||
>
|
||||
|
|
@ -404,11 +401,7 @@ export function TopologyClassifyPage() {
|
|||
<option value="both">{t("topoClassify.assignBoth")}</option>
|
||||
</select>
|
||||
{assignWhat !== "region" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignLevel}
|
||||
onChange={(e) => setAssignLevel(e.target.value)}
|
||||
>
|
||||
<select className="input" value={assignLevel} onChange={(e) => setAssignLevel(e.target.value)}>
|
||||
{LEVEL_PRESETS.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{levelPresetLabel(t, r.value)}
|
||||
|
|
@ -417,11 +410,7 @@ export function TopologyClassifyPage() {
|
|||
</select>
|
||||
) : null}
|
||||
{assignWhat !== "level" ? (
|
||||
<select
|
||||
className="input"
|
||||
value={assignRegion}
|
||||
onChange={(e) => setAssignRegion(e.target.value)}
|
||||
>
|
||||
<select className="input" value={assignRegion} onChange={(e) => setAssignRegion(e.target.value)}>
|
||||
<option value="">{t("topoClassify.clearRegion")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
|
|
@ -430,24 +419,23 @@ export function TopologyClassifyPage() {
|
|||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
|
||||
onClick={() => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
|
||||
onPress={() => {
|
||||
const n = selectedCount || matchedIds.length;
|
||||
const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n));
|
||||
if (window.confirm(msg)) bulkMut.mutate();
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.confirmAssign")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--danger"
|
||||
disabled={deleteBulkMut.isPending || deletableSelected.length === 0}
|
||||
title={t("topoClassify.deleteHint")}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteBulkMut.isPending || deletableSelected.length === 0}
|
||||
onPress={() => {
|
||||
const msg = t("topoClassify.deleteConfirm").replace(
|
||||
"{{count}}",
|
||||
String(deletableSelected.length),
|
||||
|
|
@ -459,7 +447,7 @@ export function TopologyClassifyPage() {
|
|||
"{{count}}",
|
||||
String(deletableSelected.length),
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="topo-classify__help" tabIndex={0} aria-label={t("topoClassify.deleteHint")}>
|
||||
?
|
||||
<span className="topo-classify__help-tip" role="tooltip">
|
||||
|
|
@ -510,8 +498,7 @@ export function TopologyClassifyPage() {
|
|||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<input
|
||||
className="input"
|
||||
<Input
|
||||
value={editLevel}
|
||||
placeholder="1 / 1.1 / 2.1"
|
||||
onChange={(e) => setEditLevel(e.target.value)}
|
||||
|
|
@ -522,11 +509,7 @@ export function TopologyClassifyPage() {
|
|||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<select
|
||||
className="input"
|
||||
value={editRegion}
|
||||
onChange={(e) => setEditRegion(e.target.value)}
|
||||
>
|
||||
<select className="input" value={editRegion} onChange={(e) => setEditRegion(e.target.value)}>
|
||||
<option value="">-</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
|
|
@ -543,41 +526,28 @@ export function TopologyClassifyPage() {
|
|||
<td>
|
||||
{editing ? (
|
||||
<div className="topo-classify__row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={patchMut.isPending}
|
||||
onClick={() => patchMut.mutate()}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={patchMut.isPending}
|
||||
onPress={() => patchMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => setEditingId("")}
|
||||
>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onPress={() => setEditingId("")}>
|
||||
{t("topoClassify.cancel")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="topo-classify__row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => startEdit(n)}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onPress={() => startEdit(n)}>
|
||||
{t("topoClassify.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--danger"
|
||||
disabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
|
||||
title={
|
||||
isFabricNodeDeletable(n)
|
||||
? t("topoClassify.deleteHint")
|
||||
: t("topoClassify.deleteBlocked")
|
||||
}
|
||||
onClick={() => {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
|
||||
onPress={() => {
|
||||
if (!isFabricNodeDeletable(n)) return;
|
||||
const msg = t("topoClassify.deleteConfirm").replace(
|
||||
"{{count}}",
|
||||
|
|
@ -587,7 +557,7 @@ export function TopologyClassifyPage() {
|
|||
}}
|
||||
>
|
||||
{t("topoClassify.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -603,30 +573,19 @@ export function TopologyClassifyPage() {
|
|||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="topo-classify__pager">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span>
|
||||
{t("common.pagerMeta")
|
||||
.replace("{{total}}", String(total))
|
||||
.replace("{{page}}", String(page))
|
||||
.replace("{{pages}}", String(pages))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue