feat: convert App Config detail to slide-in DetailPanel
Replaces the separate AppConfigDetailPage route with a 640px-wide DetailPanel that slides in when clicking a row on the App Config overview table. All editing functionality (settings, traces & taps, route recording) is preserved inside the panel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,97 @@
|
||||
/* No custom styles needed — DataTable with badges handles everything */
|
||||
.widePanel {
|
||||
width: 640px !important;
|
||||
}
|
||||
|
||||
.panelSection {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panelSectionHeader {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settingsGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sectionSummary {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tapBadges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.removeBtn:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.panelMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { DataTable, Badge, MonoText } from '@cameleer/design-system';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
DataTable, Badge, MonoText, DetailPanel, SectionHeader, Button, Toggle, Spinner, useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAllApplicationConfigs } from '../../api/queries/commands';
|
||||
import type { ApplicationConfig } from '../../api/queries/commands';
|
||||
import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||
import styles from './AppConfigPage.module.css';
|
||||
|
||||
type ConfigRow = ApplicationConfig & { id: string };
|
||||
|
||||
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto';
|
||||
|
||||
interface TracedTapRow { id: string; processorId: string; captureMode: string | null; taps: TapDefinition[]; }
|
||||
interface RouteRecordingRow { id: string; routeId: string; recording: boolean; }
|
||||
|
||||
function timeAgo(iso?: string): string {
|
||||
if (!iso) return '\u2014';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
@@ -24,120 +29,277 @@ function timeAgo(iso?: string): string {
|
||||
|
||||
function logLevelColor(level?: string): BadgeColor {
|
||||
switch (level?.toUpperCase()) {
|
||||
case 'ERROR': return 'error';
|
||||
case 'WARN': return 'warning';
|
||||
case 'DEBUG': return 'running';
|
||||
default: return 'success';
|
||||
case 'ERROR': return 'error'; case 'WARN': return 'warning'; case 'DEBUG': return 'running'; default: return 'success';
|
||||
}
|
||||
}
|
||||
|
||||
function engineLevelColor(level?: string): BadgeColor {
|
||||
switch (level?.toUpperCase()) {
|
||||
case 'NONE': return 'error';
|
||||
case 'MINIMAL': return 'warning';
|
||||
case 'COMPLETE': return 'running';
|
||||
default: return 'success';
|
||||
case 'NONE': return 'error'; case 'MINIMAL': return 'warning'; case 'COMPLETE': return 'running'; default: return 'success';
|
||||
}
|
||||
}
|
||||
|
||||
function payloadColor(mode?: string): BadgeColor {
|
||||
switch (mode?.toUpperCase()) {
|
||||
case 'INPUT': case 'OUTPUT': return 'warning';
|
||||
case 'BOTH': return 'running';
|
||||
default: return 'auto';
|
||||
case 'INPUT': case 'OUTPUT': return 'warning'; case 'BOTH': return 'running'; default: return 'auto';
|
||||
}
|
||||
}
|
||||
function captureColor(mode: string): BadgeColor {
|
||||
switch (mode?.toUpperCase()) {
|
||||
case 'INPUT': case 'OUTPUT': return 'warning'; case 'BOTH': return 'running'; default: return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: configs } = useAllApplicationConfigs();
|
||||
// ── Table columns (overview) ─────────────────────────────────────────────────
|
||||
|
||||
const columns: Column<ConfigRow>[] = useMemo(() => [
|
||||
{
|
||||
key: 'application',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_val, row) => <MonoText size="sm">{row.application}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'logForwardingLevel',
|
||||
header: 'Log Level',
|
||||
render: (_val, row) => {
|
||||
const val = row.logForwardingLevel ?? 'INFO';
|
||||
return <Badge label={val} color={logLevelColor(val)} variant="filled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'engineLevel',
|
||||
header: 'Engine Level',
|
||||
render: (_val, row) => {
|
||||
const val = row.engineLevel ?? 'REGULAR';
|
||||
return <Badge label={val} color={engineLevelColor(val)} variant="filled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'payloadCaptureMode',
|
||||
header: 'Payload Capture',
|
||||
render: (_val, row) => {
|
||||
const val = row.payloadCaptureMode ?? 'NONE';
|
||||
return <Badge label={val} color={payloadColor(val)} variant="filled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'metricsEnabled',
|
||||
header: 'Metrics',
|
||||
width: '80px',
|
||||
render: (_val, row) => (
|
||||
<Badge label={row.metricsEnabled ? 'On' : 'Off'} color={row.metricsEnabled ? 'success' : 'error'} variant="filled" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tracedProcessors',
|
||||
header: 'Traced',
|
||||
width: '70px',
|
||||
render: (_val, row) => {
|
||||
const count = row.tracedProcessors ? Object.keys(row.tracedProcessors).length : 0;
|
||||
return count > 0
|
||||
? <Badge label={`${count}`} color="running" variant="filled" />
|
||||
: <MonoText size="xs">0</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'taps',
|
||||
header: 'Taps',
|
||||
width: '70px',
|
||||
render: (_val, row) => {
|
||||
const total = row.taps?.length ?? 0;
|
||||
const enabled = row.taps?.filter(t => t.enabled).length ?? 0;
|
||||
if (total === 0) return <MonoText size="xs">0</MonoText>;
|
||||
return <Badge label={`${enabled}/${total}`} color="running" variant="filled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'version',
|
||||
header: 'v',
|
||||
width: '40px',
|
||||
render: (_val, row) => <MonoText size="xs">{row.version}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
header: 'Updated',
|
||||
render: (_val, row) => <MonoText size="xs">{timeAgo(row.updatedAt)}</MonoText>,
|
||||
},
|
||||
], []);
|
||||
function buildColumns(): Column<ConfigRow>[] {
|
||||
return [
|
||||
{ key: 'application', header: 'Application', sortable: true, render: (_v, row) => <MonoText size="sm">{row.application}</MonoText> },
|
||||
{ key: 'logForwardingLevel', header: 'Log Level', render: (_v, row) => { const val = row.logForwardingLevel ?? 'INFO'; return <Badge label={val} color={logLevelColor(val)} variant="filled" />; } },
|
||||
{ key: 'engineLevel', header: 'Engine Level', render: (_v, row) => { const val = row.engineLevel ?? 'REGULAR'; return <Badge label={val} color={engineLevelColor(val)} variant="filled" />; } },
|
||||
{ key: 'payloadCaptureMode', header: 'Payload Capture', render: (_v, row) => { const val = row.payloadCaptureMode ?? 'NONE'; return <Badge label={val} color={payloadColor(val)} variant="filled" />; } },
|
||||
{ key: 'metricsEnabled', header: 'Metrics', width: '80px', render: (_v, row) => <Badge label={row.metricsEnabled ? 'On' : 'Off'} color={row.metricsEnabled ? 'success' : 'error'} variant="filled" /> },
|
||||
{ key: 'tracedProcessors', header: 'Traced', width: '70px', render: (_v, row) => { const c = row.tracedProcessors ? Object.keys(row.tracedProcessors).length : 0; return c > 0 ? <Badge label={`${c}`} color="running" variant="filled" /> : <MonoText size="xs">0</MonoText>; } },
|
||||
{ key: 'taps', header: 'Taps', width: '70px', render: (_v, row) => { const t = row.taps?.length ?? 0; const e = row.taps?.filter(x => x.enabled).length ?? 0; return t === 0 ? <MonoText size="xs">0</MonoText> : <Badge label={`${e}/${t}`} color="running" variant="filled" />; } },
|
||||
{ key: 'version', header: 'v', width: '40px', render: (_v, row) => <MonoText size="xs">{row.version}</MonoText> },
|
||||
{ key: 'updatedAt', header: 'Updated', render: (_v, row) => <MonoText size="xs">{timeAgo(row.updatedAt)}</MonoText> },
|
||||
];
|
||||
}
|
||||
|
||||
function handleRowClick(row: ConfigRow) {
|
||||
navigate(`/admin/appconfig/${row.application}`);
|
||||
// ── Detail Panel Content ─────────────────────────────────────────────────────
|
||||
|
||||
function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { data: config, isLoading } = useApplicationConfig(appId);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<Partial<ApplicationConfig> | null>(null);
|
||||
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
|
||||
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
|
||||
|
||||
const appRoutes: RouteSummary[] = useMemo(() => {
|
||||
if (!catalog || !appId) return [];
|
||||
const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === appId);
|
||||
return entry?.routes ?? [];
|
||||
}, [catalog, appId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setForm({
|
||||
logForwardingLevel: config.logForwardingLevel ?? 'INFO',
|
||||
engineLevel: config.engineLevel ?? 'REGULAR',
|
||||
payloadCaptureMode: config.payloadCaptureMode ?? 'NONE',
|
||||
metricsEnabled: config.metricsEnabled,
|
||||
samplingRate: config.samplingRate,
|
||||
compressSuccess: config.compressSuccess,
|
||||
});
|
||||
setTracedDraft({ ...config.tracedProcessors });
|
||||
setRouteRecordingDraft({ ...config.routeRecording });
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
function startEditing() {
|
||||
if (!config) return;
|
||||
setForm({
|
||||
logForwardingLevel: config.logForwardingLevel ?? 'INFO',
|
||||
engineLevel: config.engineLevel ?? 'REGULAR',
|
||||
payloadCaptureMode: config.payloadCaptureMode ?? 'NONE',
|
||||
metricsEnabled: config.metricsEnabled,
|
||||
samplingRate: config.samplingRate,
|
||||
compressSuccess: config.compressSuccess,
|
||||
});
|
||||
setTracedDraft({ ...config.tracedProcessors });
|
||||
setRouteRecordingDraft({ ...config.routeRecording });
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function updateField<K extends keyof ApplicationConfig>(key: K, value: ApplicationConfig[K]) {
|
||||
setForm((prev) => prev ? { ...prev, [key]: value } : prev);
|
||||
}
|
||||
|
||||
function updateTracedProcessor(processorId: string, mode: string) {
|
||||
setTracedDraft((prev) => {
|
||||
if (mode === 'REMOVE') { const next = { ...prev }; delete next[processorId]; return next; }
|
||||
return { ...prev, [processorId]: mode };
|
||||
});
|
||||
}
|
||||
|
||||
function updateRouteRecording(routeId: string, recording: boolean) {
|
||||
setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording }));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!config || !form) return;
|
||||
const updated = { ...config, ...form, tracedProcessors: tracedDraft, routeRecording: routeRecordingDraft } as ApplicationConfig;
|
||||
updateConfig.mutate(updated, {
|
||||
onSuccess: (saved) => { setEditing(false); toast({ title: 'Config saved', description: `${appId} updated to v${saved.version}`, variant: 'success' }); },
|
||||
onError: () => { toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error' }); },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Traces & Taps merged rows
|
||||
const tracedTapRows: TracedTapRow[] = useMemo(() => {
|
||||
const traced = editing ? tracedDraft : (config?.tracedProcessors ?? {});
|
||||
const taps = config?.taps ?? [];
|
||||
const pids = new Set<string>([...Object.keys(traced), ...taps.map(t => t.processorId)]);
|
||||
return Array.from(pids).sort().map(pid => ({ id: pid, processorId: pid, captureMode: traced[pid] ?? null, taps: taps.filter(t => t.processorId === pid) }));
|
||||
}, [editing, tracedDraft, config?.tracedProcessors, config?.taps]);
|
||||
|
||||
const tracedCount = useMemo(() => Object.keys(editing ? tracedDraft : (config?.tracedProcessors ?? {})).length, [editing, tracedDraft, config?.tracedProcessors]);
|
||||
const tapCount = config?.taps?.length ?? 0;
|
||||
|
||||
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(() => [
|
||||
{ key: 'processorId', header: 'Processor', render: (_v, row) => <MonoText size="xs">{row.processorId}</MonoText> },
|
||||
{
|
||||
key: 'captureMode', header: 'Capture',
|
||||
render: (_v, row) => {
|
||||
if (row.captureMode === null) return <span className={styles.hint}>—</span>;
|
||||
if (editing) return (
|
||||
<select className={styles.select} value={row.captureMode} onChange={(e) => updateTracedProcessor(row.processorId, e.target.value)}>
|
||||
<option value="NONE">None</option><option value="INPUT">Input</option><option value="OUTPUT">Output</option><option value="BOTH">Both</option>
|
||||
</select>
|
||||
);
|
||||
return <Badge label={row.captureMode} color={captureColor(row.captureMode)} variant="filled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'taps', header: 'Taps',
|
||||
render: (_v, row) => row.taps.length === 0
|
||||
? <span className={styles.hint}>—</span>
|
||||
: <div className={styles.tapBadges}>{row.taps.map(t => <Badge key={t.tapId} label={t.attributeName} color={t.enabled ? 'success' : 'auto'} variant="filled" />)}</div>,
|
||||
},
|
||||
...(editing ? [{
|
||||
key: '_remove' as const, header: '', width: '36px',
|
||||
render: (_v: unknown, row: TracedTapRow) => row.captureMode === null ? null : (
|
||||
<button className={styles.removeBtn} title="Remove" onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}>×</button>
|
||||
),
|
||||
}] : []),
|
||||
], [editing]);
|
||||
|
||||
// ── Route Recording rows
|
||||
const routeRecordingRows: RouteRecordingRow[] = useMemo(() => {
|
||||
const rec = editing ? routeRecordingDraft : (config?.routeRecording ?? {});
|
||||
return appRoutes.map(r => ({ id: r.routeId, routeId: r.routeId, recording: rec[r.routeId] !== false }));
|
||||
}, [editing, routeRecordingDraft, config?.routeRecording, appRoutes]);
|
||||
|
||||
const recordingCount = routeRecordingRows.filter(r => r.recording).length;
|
||||
|
||||
const routeRecordingColumns: Column<RouteRecordingRow>[] = useMemo(() => [
|
||||
{ key: 'routeId', header: 'Route', render: (_v, row) => <MonoText size="xs">{row.routeId}</MonoText> },
|
||||
{ key: 'recording', header: 'Recording', width: '100px', render: (_v, row) => <Toggle checked={row.recording} onChange={() => { if (editing) updateRouteRecording(row.routeId, !row.recording); }} disabled={!editing} /> },
|
||||
], [editing, routeRecordingDraft]);
|
||||
|
||||
if (isLoading) return <div style={{ padding: 24, textAlign: 'center' }}><Spinner size="sm" /></div>;
|
||||
if (!config || !form) return <div style={{ padding: 16 }}>No configuration found.</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.panelMeta}>
|
||||
Version <MonoText size="xs">{config.version}</MonoText>
|
||||
{config.updatedAt && <> · Updated <MonoText size="xs">{timeAgo(config.updatedAt)}</MonoText></>}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionHeader}>Settings</div>
|
||||
<div className={styles.settingsGrid}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Log Forwarding</span>
|
||||
{editing
|
||||
? <select className={styles.select} value={String(form.logForwardingLevel)} onChange={(e) => updateField('logForwardingLevel', e.target.value)}><option value="ERROR">ERROR</option><option value="WARN">WARN</option><option value="INFO">INFO</option><option value="DEBUG">DEBUG</option></select>
|
||||
: <Badge label={String(form.logForwardingLevel)} color={logLevelColor(form.logForwardingLevel as string)} variant="filled" />}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Engine Level</span>
|
||||
{editing
|
||||
? <select className={styles.select} value={String(form.engineLevel)} onChange={(e) => updateField('engineLevel', e.target.value)}><option value="NONE">None</option><option value="MINIMAL">Minimal</option><option value="REGULAR">Regular</option><option value="COMPLETE">Complete</option></select>
|
||||
: <Badge label={String(form.engineLevel)} color={engineLevelColor(form.engineLevel as string)} variant="filled" />}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Payload Capture</span>
|
||||
{editing
|
||||
? <select className={styles.select} value={String(form.payloadCaptureMode)} onChange={(e) => updateField('payloadCaptureMode', e.target.value)}><option value="NONE">None</option><option value="INPUT">Input</option><option value="OUTPUT">Output</option><option value="BOTH">Both</option></select>
|
||||
: <Badge label={String(form.payloadCaptureMode)} color={payloadColor(form.payloadCaptureMode as string)} variant="filled" />}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Metrics</span>
|
||||
{editing
|
||||
? <Toggle checked={Boolean(form.metricsEnabled)} onChange={(e) => updateField('metricsEnabled', (e.target as HTMLInputElement).checked)} />
|
||||
: <Badge label={form.metricsEnabled ? 'On' : 'Off'} color={form.metricsEnabled ? 'success' : 'error'} variant="filled" />}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Sampling Rate</span>
|
||||
{editing
|
||||
? <input type="number" className={styles.select} min={0} max={1} step={0.01} value={form.samplingRate ?? 1.0} onChange={(e) => updateField('samplingRate', parseFloat(e.target.value) || 0)} />
|
||||
: <MonoText size="xs">{form.samplingRate}</MonoText>}
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Compress Success</span>
|
||||
{editing
|
||||
? <Toggle checked={Boolean(form.compressSuccess)} onChange={(e) => updateField('compressSuccess', (e.target as HTMLInputElement).checked)} />
|
||||
: <Badge label={form.compressSuccess ? 'On' : 'Off'} color={form.compressSuccess ? 'success' : 'error'} variant="filled" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traces & Taps */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionHeader}>Traces & Taps</div>
|
||||
<span className={styles.sectionSummary}>{tracedCount} traced · {tapCount} taps · manage taps on route pages</span>
|
||||
{tracedTapRows.length > 0
|
||||
? <DataTable<TracedTapRow> columns={tracedTapColumns} data={tracedTapRows} pageSize={20} flush />
|
||||
: <span className={styles.hint}>No processor traces or taps configured.</span>}
|
||||
</div>
|
||||
|
||||
{/* Route Recording */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionHeader}>Route Recording</div>
|
||||
<span className={styles.sectionSummary}>{recordingCount} of {routeRecordingRows.length} routes recording</span>
|
||||
{routeRecordingRows.length > 0
|
||||
? <DataTable<RouteRecordingRow> columns={routeRecordingColumns} data={routeRecordingRows} pageSize={20} flush />
|
||||
: <span className={styles.hint}>No routes found for this application.</span>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppConfigPage() {
|
||||
const { data: configs } = useAllApplicationConfigs();
|
||||
const [selectedApp, setSelectedApp] = useState<string | null>(null);
|
||||
const columns = useMemo(buildColumns, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataTable<ConfigRow>
|
||||
columns={columns}
|
||||
data={(configs ?? []).map(c => ({ ...c, id: c.application }))}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClick={(row) => setSelectedApp(row.application)}
|
||||
selectedId={selectedApp ?? undefined}
|
||||
pageSize={50}
|
||||
/>
|
||||
<DetailPanel
|
||||
open={!!selectedApp}
|
||||
onClose={() => setSelectedApp(null)}
|
||||
title={selectedApp ?? ''}
|
||||
className={styles.widePanel}
|
||||
actions={selectedApp ? <PanelActions appId={selectedApp} onClose={() => setSelectedApp(null)} /> : undefined}
|
||||
>
|
||||
{selectedApp && <AppConfigDetail appId={selectedApp} onClose={() => setSelectedApp(null)} />}
|
||||
</DetailPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelActions({ appId, onClose }: { appId: string; onClose: () => void }) {
|
||||
// Edit/Save/Cancel actions rendered in the panel footer
|
||||
const { data: config } = useApplicationConfig(appId);
|
||||
const updateConfig = useUpdateApplicationConfig();
|
||||
const { toast } = useToast();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
// This is a simplified stub — the actual editing state is managed inside AppConfigDetail.
|
||||
// For now, the actions slot is reserved for future use.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||
const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage'));
|
||||
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
||||
const AppConfigDetailPage = lazy(() => import('./pages/Admin/AppConfigDetailPage'));
|
||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||
|
||||
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
||||
@@ -59,7 +58,6 @@ export const router = createBrowserRouter([
|
||||
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||||
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'appconfig', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'appconfig/:appId', element: <SuspenseWrapper><AppConfigDetailPage /></SuspenseWrapper> },
|
||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user