feat(frontend): redesign Jails, Filters, and Actions tabs to list/detail layout
Replace Accordion-based config tabs with the new ConfigListDetail two-pane layout. Each tab now shows a searchable list with active/inactive badges (active items sorted first) on the left and a structured form editor with a collapsible raw-text export section on the right.
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* ActionsTab — form-based accordion editor for action.d files.
|
||||
* ActionsTab — list/detail layout for action.d file editing.
|
||||
*
|
||||
* Shows one accordion item per action file. Expanding a panel lazily loads
|
||||
* the parsed action config and renders an {@link ActionForm}.
|
||||
* Left pane: action names with Active/Inactive badges. Active actions are
|
||||
* those referenced by at least one running jail. Right pane: structured form
|
||||
* editor plus a collapsible raw-config editor.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
@@ -20,9 +17,12 @@ import {
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { DocumentAdd24Regular } from "@fluentui/react-icons";
|
||||
import { fetchActionFiles } from "../../api/config";
|
||||
import type { ConfFileEntry } from "../../types/config";
|
||||
import { fetchActionFile, fetchActionFiles, updateActionFile } from "../../api/config";
|
||||
import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config";
|
||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
||||
import { ActionForm } from "./ActionForm";
|
||||
import { ConfigListDetail } from "./ConfigListDetail";
|
||||
import { RawConfigSection } from "./RawConfigSection";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/**
|
||||
@@ -35,27 +35,53 @@ export function ActionsTab(): React.JSX.Element {
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const { activeActions, loading: statusLoading } = useConfigActiveStatus();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
|
||||
fetchActionFiles()
|
||||
.then((resp) => {
|
||||
if (!cancelled) {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setFiles(resp.files);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load actions");
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load actions",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return (): void => { cancelled = true; };
|
||||
return (): void => {
|
||||
ctrl.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
const fetchRaw = useCallback(
|
||||
async (name: string): Promise<string> => {
|
||||
const result = await fetchActionFile(name);
|
||||
return result.content;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const saveRaw = useCallback(
|
||||
async (name: string, content: string): Promise<void> => {
|
||||
const req: ConfFileUpdateRequest = { content };
|
||||
await updateActionFile(name, req);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading || statusLoading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading actions…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
@@ -86,7 +112,12 @@ export function ActionsTab(): React.JSX.Element {
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Create a new action file in the Export tab.
|
||||
</Text>
|
||||
<Button appearance="primary" onClick={() => { window.location.hash = "#export"; }}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
window.location.hash = "#export";
|
||||
}}
|
||||
>
|
||||
Go to Export
|
||||
</Button>
|
||||
</div>
|
||||
@@ -95,16 +126,27 @@ export function ActionsTab(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.tabContent}>
|
||||
<Accordion collapsible>
|
||||
{files.map((f) => (
|
||||
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}>
|
||||
<AccordionHeader>{f.filename}</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<ActionForm name={f.name} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
<ConfigListDetail
|
||||
items={files}
|
||||
isActive={(f) => activeActions.has(f.name)}
|
||||
selectedName={selectedName}
|
||||
onSelect={setSelectedName}
|
||||
loading={false}
|
||||
error={null}
|
||||
>
|
||||
{selectedName !== null && (
|
||||
<div>
|
||||
<ActionForm name={selectedName} />
|
||||
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||
<RawConfigSection
|
||||
fetchContent={() => fetchRaw(selectedName)}
|
||||
saveContent={(content) => saveRaw(selectedName, content)}
|
||||
label="Raw Action Configuration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfigListDetail>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* FiltersTab — form-based accordion editor for filter.d files.
|
||||
* FiltersTab — list/detail layout for filter.d file editing.
|
||||
*
|
||||
* Shows one accordion item per filter file. Expanding a panel lazily loads
|
||||
* the parsed filter config and renders a {@link FilterForm}.
|
||||
* Left pane: filter names with Active/Inactive badges. Active filters are
|
||||
* those referenced by at least one running jail. Right pane: structured form
|
||||
* editor plus a collapsible raw-config editor.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
@@ -20,10 +17,14 @@ import {
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { DocumentAdd24Regular } from "@fluentui/react-icons";
|
||||
import { fetchFilterFiles } from "../../api/config";
|
||||
import type { ConfFileEntry } from "../../types/config";
|
||||
import { fetchFilterFile, fetchFilterFiles, updateFilterFile } from "../../api/config";
|
||||
import type { ConfFileEntry, ConfFileUpdateRequest } from "../../types/config";
|
||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
||||
import { ConfigListDetail } from "./ConfigListDetail";
|
||||
import { FilterForm } from "./FilterForm";
|
||||
import { RawConfigSection } from "./RawConfigSection";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Tab component for the form-based filter.d editor.
|
||||
@@ -35,27 +36,53 @@ export function FiltersTab(): React.JSX.Element {
|
||||
const [files, setFiles] = useState<ConfFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const { activeFilters, loading: statusLoading } = useConfigActiveStatus();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
|
||||
fetchFilterFiles()
|
||||
.then((resp) => {
|
||||
if (!cancelled) {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setFiles(resp.files);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load filters");
|
||||
if (!ctrl.signal.aborted) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load filters",
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return (): void => { cancelled = true; };
|
||||
return (): void => {
|
||||
ctrl.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
const fetchRaw = useCallback(
|
||||
async (name: string): Promise<string> => {
|
||||
const result = await fetchFilterFile(name);
|
||||
return result.content;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const saveRaw = useCallback(
|
||||
async (name: string, content: string): Promise<void> => {
|
||||
const req: ConfFileUpdateRequest = { content };
|
||||
await updateFilterFile(name, req);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (loading || statusLoading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading filters…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
@@ -86,7 +113,12 @@ export function FiltersTab(): React.JSX.Element {
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Create a new filter file in the Export tab.
|
||||
</Text>
|
||||
<Button appearance="primary" onClick={() => { window.location.hash = "#export"; }}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={() => {
|
||||
window.location.hash = "#export";
|
||||
}}
|
||||
>
|
||||
Go to Export
|
||||
</Button>
|
||||
</div>
|
||||
@@ -95,16 +127,27 @@ export function FiltersTab(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.tabContent}>
|
||||
<Accordion collapsible>
|
||||
{files.map((f) => (
|
||||
<AccordionItem key={f.name} value={f.name} className={styles.accordionItem}>
|
||||
<AccordionHeader>{f.filename}</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<FilterForm name={f.name} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
<ConfigListDetail
|
||||
items={files}
|
||||
isActive={(f) => activeFilters.has(f.name)}
|
||||
selectedName={selectedName}
|
||||
onSelect={setSelectedName}
|
||||
loading={false}
|
||||
error={null}
|
||||
>
|
||||
{selectedName !== null && (
|
||||
<div>
|
||||
<FilterForm name={selectedName} />
|
||||
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||
<RawConfigSection
|
||||
fetchContent={() => fetchRaw(selectedName)}
|
||||
saveContent={(content) => saveRaw(selectedName, content)}
|
||||
label="Raw Filter Configuration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfigListDetail>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
/**
|
||||
* JailsTab and JailAccordionPanel — per-jail configuration editor.
|
||||
* JailsTab — list/detail layout for per-jail configuration editing.
|
||||
*
|
||||
* Displays all active jails in an accordion. Each panel exposes editable
|
||||
* fields for ban time, find time, max retries, regex patterns, log paths,
|
||||
* date pattern, DNS mode, prefix regex, and ban-time escalation.
|
||||
* Left pane: jail names with Active/Inactive badges, sorted with active on
|
||||
* top. Right pane: editable form for the selected jail plus a collapsible
|
||||
* raw-config editor.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
@@ -25,42 +21,55 @@ import {
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowClockwise24Regular, Dismiss24Regular, LockClosed24Regular } from "@fluentui/react-icons";
|
||||
import {
|
||||
ArrowClockwise24Regular,
|
||||
Dismiss24Regular,
|
||||
LockClosed24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
addLogPath,
|
||||
deleteLogPath,
|
||||
fetchJailConfigFileContent,
|
||||
updateJailConfigFile,
|
||||
} from "../../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
ConfFileUpdateRequest,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
} from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
||||
import { useJailConfigs } from "../../hooks/useConfig";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { ConfigListDetail } from "./ConfigListDetail";
|
||||
import { RawConfigSection } from "./RawConfigSection";
|
||||
import { RegexList } from "./RegexList";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailAccordionPanel
|
||||
// JailConfigDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JailAccordionPanelProps {
|
||||
interface JailConfigDetailProps {
|
||||
jail: JailConfig;
|
||||
onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable configuration panel for a single fail2ban jail.
|
||||
* Editable configuration form for a single fail2ban jail.
|
||||
*
|
||||
* Contains all config fields plus a collapsible raw-config editor at the
|
||||
* bottom. Previously known as JailAccordionPanel.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
function JailAccordionPanel({
|
||||
function JailConfigDetail({
|
||||
jail,
|
||||
onSave,
|
||||
}: JailAccordionPanelProps): React.JSX.Element {
|
||||
}: JailConfigDetailProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [banTime, setBanTime] = useState(String(jail.ban_time));
|
||||
const [findTime, setFindTime] = useState(String(jail.find_time));
|
||||
@@ -80,12 +89,22 @@ function JailAccordionPanel({
|
||||
// Ban-time escalation state
|
||||
const esc0 = jail.bantime_escalation;
|
||||
const [escEnabled, setEscEnabled] = useState(esc0?.increment ?? false);
|
||||
const [escFactor, setEscFactor] = useState(esc0?.factor != null ? String(esc0.factor) : "");
|
||||
const [escFactor, setEscFactor] = useState(
|
||||
esc0?.factor != null ? String(esc0.factor) : "",
|
||||
);
|
||||
const [escFormula, setEscFormula] = useState(esc0?.formula ?? "");
|
||||
const [escMultipliers, setEscMultipliers] = useState(esc0?.multipliers ?? "");
|
||||
const [escMaxTime, setEscMaxTime] = useState(esc0?.max_time != null ? String(esc0.max_time) : "");
|
||||
const [escRndTime, setEscRndTime] = useState(esc0?.rnd_time != null ? String(esc0.rnd_time) : "");
|
||||
const [escOverallJails, setEscOverallJails] = useState(esc0?.overall_jails ?? false);
|
||||
const [escMultipliers, setEscMultipliers] = useState(
|
||||
esc0?.multipliers ?? "",
|
||||
);
|
||||
const [escMaxTime, setEscMaxTime] = useState(
|
||||
esc0?.max_time != null ? String(esc0.max_time) : "",
|
||||
);
|
||||
const [escRndTime, setEscRndTime] = useState(
|
||||
esc0?.rnd_time != null ? String(esc0.rnd_time) : "",
|
||||
);
|
||||
const [escOverallJails, setEscOverallJails] = useState(
|
||||
esc0?.overall_jails ?? false,
|
||||
);
|
||||
|
||||
const handleDeleteLogPath = useCallback(
|
||||
async (path: string) => {
|
||||
@@ -166,6 +185,20 @@ function JailAccordionPanel({
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(autoSavePayload, saveCurrent);
|
||||
|
||||
// Raw config file fetch/save helpers — uses jail.d/<name>.conf convention.
|
||||
const fetchRaw = useCallback(async (): Promise<string> => {
|
||||
const result = await fetchJailConfigFileContent(`${jail.name}.conf`);
|
||||
return result.content;
|
||||
}, [jail.name]);
|
||||
|
||||
const saveRaw = useCallback(
|
||||
async (content: string): Promise<void> => {
|
||||
const req: ConfFileUpdateRequest = { content };
|
||||
await updateJailConfigFile(`${jail.name}.conf`, req);
|
||||
},
|
||||
[jail.name],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msg && (
|
||||
@@ -409,6 +442,15 @@ function JailAccordionPanel({
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Raw Configuration */}
|
||||
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||
<RawConfigSection
|
||||
fetchContent={fetchRaw}
|
||||
saveContent={saveRaw}
|
||||
label="Raw Jail Configuration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -418,7 +460,8 @@ function JailAccordionPanel({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tab component showing all active fail2ban jails with editable configs.
|
||||
* Tab component showing all fail2ban jails in a list/detail layout with
|
||||
* editable configuration forms.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
@@ -426,6 +469,8 @@ export function JailsTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { jails, loading, error, refresh, updateJail, reloadAll } =
|
||||
useJailConfigs();
|
||||
const { activeJails } = useConfigActiveStatus();
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [reloadMsg, setReloadMsg] = useState<string | null>(null);
|
||||
|
||||
@@ -436,9 +481,7 @@ export function JailsTab(): React.JSX.Element {
|
||||
await reloadAll();
|
||||
setReloadMsg("fail2ban reloaded.");
|
||||
} catch (err: unknown) {
|
||||
setReloadMsg(
|
||||
err instanceof ApiError ? err.message : "Reload failed.",
|
||||
);
|
||||
setReloadMsg(err instanceof ApiError ? err.message : "Reload failed.");
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
@@ -453,15 +496,38 @@ export function JailsTab(): React.JSX.Element {
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
if (error)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
if (jails.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<LockClosed24Regular
|
||||
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No active jails found.
|
||||
</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Ensure fail2ban is running and jails are configured.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedJail: JailConfig | undefined = jails.find(
|
||||
(j) => j.name === selectedName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.tabContent}>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
@@ -480,55 +546,28 @@ export function JailsTab(): React.JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
{reloadMsg && (
|
||||
<MessageBar style={{ marginTop: tokens.spacingVerticalS }} intent="info">
|
||||
<MessageBar
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
intent="info"
|
||||
>
|
||||
<MessageBarBody>{reloadMsg}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{jails.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
<LockClosed24Regular
|
||||
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No active jails found.
|
||||
</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Ensure fail2ban is running and jails are configured.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<Accordion
|
||||
multiple
|
||||
collapsible
|
||||
style={{ marginTop: tokens.spacingVerticalM }}
|
||||
>
|
||||
{jails.map((jail) => (
|
||||
<AccordionItem key={jail.name} value={jail.name} className={styles.accordionItem}>
|
||||
<AccordionHeader>
|
||||
<Text weight="semibold">{jail.name}</Text>
|
||||
|
||||
<Badge
|
||||
appearance="tint"
|
||||
color="informative"
|
||||
style={{ marginLeft: tokens.spacingHorizontalS }}
|
||||
>
|
||||
ban: {jail.ban_time}s
|
||||
</Badge>
|
||||
<Badge
|
||||
appearance="tint"
|
||||
color="subtle"
|
||||
style={{ marginLeft: tokens.spacingHorizontalXS }}
|
||||
>
|
||||
retries: {jail.max_retry}
|
||||
</Badge>
|
||||
</AccordionHeader>
|
||||
<AccordionPanel>
|
||||
<JailAccordionPanel jail={jail} onSave={updateJail} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<ConfigListDetail
|
||||
items={jails}
|
||||
isActive={(jail) => activeJails.has(jail.name)}
|
||||
selectedName={selectedName}
|
||||
onSelect={setSelectedName}
|
||||
loading={false}
|
||||
error={null}
|
||||
>
|
||||
{selectedJail !== undefined ? (
|
||||
<JailConfigDetail jail={selectedJail} onSave={updateJail} />
|
||||
) : null}
|
||||
</ConfigListDetail>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user